"""Build the landing page's sample "Real or luck?" report. The story: a trader tries every SMA crossover on BTC-USD daily over 2017-2021, picks the best one, and asks whether it's real. We grade it the way the product will: 1. in-sample vs out-of-sample (2022-2025, never seen during the search), 2. deflated Sharpe ratio for the number of versions tried, 3. probability of backtest overfitting (CSCV), 4. cost stress, and 5. a Monte Carlo of the out-of-sample trade order. Signals use the previous day's close (no look-ahead). Long or flat only. Writes data/sample_report.json for the landing page. Usage: python tools/sample_report.py data/btc-usd-1d.csv data/sample_report.json """ import itertools import json import math import sys import numpy as np import pandas as pd from scipy import stats DAYS_PER_YEAR = 365 # crypto trades every day FEE_PER_SIDE = 0.001 # 0.10% per side: a typical exchange taker fee plus spread IS_START, IS_END = "2017-01-01", "2021-12-31" OOS_START, OOS_END = "2022-01-01", "2025-12-31" FAST = range(5, 55, 5) SLOW = range(20, 220, 20) EULER_GAMMA = 0.5772156649 def load(path): df = pd.read_csv(path, parse_dates=["date"]).set_index("date").sort_index() full = pd.date_range(df.index.min(), df.index.max(), freq="D") missing = full.difference(df.index) if len(missing): print(f"warning: {len(missing)} missing days, forward-filled") return df.reindex(full).ffill() def strategy_returns(close, fast, slow, fee=FEE_PER_SIDE): """Daily returns of a long/flat SMA crossover; position decided on yesterday's close.""" ret = close.pct_change().fillna(0.0) signal = (close.rolling(fast).mean() > close.rolling(slow).mean()).astype(float) pos = signal.shift(1).fillna(0.0) # trade at today's open-ish, using yesterday's info turnover = pos.diff().abs().fillna(pos.iloc[0]) return pos * ret - turnover * fee, pos def sharpe(r): sd = r.std(ddof=1) return 0.0 if sd == 0 else r.mean() / sd * math.sqrt(DAYS_PER_YEAR) def cagr(r): years = len(r) / DAYS_PER_YEAR growth = float((1 + r).prod()) return growth ** (1 / years) - 1 if growth > 0 else -1.0 def max_drawdown(r): eq = (1 + r).cumprod() return float((eq / eq.cummax() - 1).min()) def trades_from(r, pos): """Compound return of each trade (entry to exit).""" out, cur, open_ = [], 1.0, False for ri, p in zip(r.values, pos.values): if p > 0: cur *= 1 + ri open_ = True elif open_: cur *= 1 + ri # exit-day fee lands on the first flat day out.append(cur - 1) cur, open_ = 1.0, False if open_: out.append(cur - 1) return np.array(out) def deflated_sharpe(r_best, all_period_sharpes, n_trials): """Bailey & Lopez de Prado (2014), per-period Sharpe ratios.""" t = len(r_best) sr = r_best.mean() / r_best.std(ddof=1) skew = stats.skew(r_best) kurt = stats.kurtosis(r_best, fisher=False) var_sr = np.var(all_period_sharpes, ddof=1) sr0 = math.sqrt(var_sr) * ( (1 - EULER_GAMMA) * stats.norm.ppf(1 - 1 / n_trials) + EULER_GAMMA * stats.norm.ppf(1 - 1 / (n_trials * math.e)) ) z = (sr - sr0) * math.sqrt(t - 1) / math.sqrt(1 - skew * sr + (kurt - 1) / 4 * sr**2) return float(stats.norm.cdf(z)), float(sr0 * math.sqrt(DAYS_PER_YEAR)) def pbo_cscv(returns_matrix, blocks=16): """Probability of backtest overfitting via combinatorially symmetric CV.""" m = returns_matrix.values splits = np.array_split(np.arange(len(m)), blocks) logits = [] for train_ids in itertools.combinations(range(blocks), blocks // 2): train = np.concatenate([splits[i] for i in train_ids]) test = np.concatenate([splits[i] for i in range(blocks) if i not in train_ids]) def sr(x): sd = x.std(axis=0, ddof=1) return np.divide(x.mean(axis=0), sd, out=np.zeros_like(sd), where=sd > 0) best = int(np.argmax(sr(m[train]))) test_sr = sr(m[test]) rank = stats.rankdata(test_sr)[best] / (len(test_sr) + 1) logits.append(math.log(rank / (1 - rank))) return float(np.mean(np.array(logits) <= 0)) def monte_carlo_drawdown(trades, runs=10_000, seed=7): rng = np.random.default_rng(seed) dds = [] for _ in range(runs): eq = np.cumprod(1 + rng.permutation(trades)) dds.append((eq / np.maximum.accumulate(np.concatenate([[1.0], eq]))[1:] - 1).min()) dds = np.array(dds) return float(np.percentile(dds, 50)), float(np.percentile(dds, 5)) def grade(is_sharpe, oos_sharpe, dsr, pbo): keep = oos_sharpe / is_sharpe if is_sharpe > 0 else 0.0 score = 0 score += 2 if dsr >= 0.95 else 1 if dsr >= 0.80 else 0 score += 2 if pbo <= 0.10 else 1 if pbo <= 0.30 else 0 score += 2 if keep >= 0.7 else 1 if keep >= 0.4 else 0 score += 1 if oos_sharpe >= 0.5 else 0 # 0..7 letter = "A" if score >= 6 else "B" if score == 5 else "C" if score == 4 else "D" if score >= 2 else "F" return letter, keep def main(src, out): close = load(src)["close"] combos = [(f, s) for f in FAST for s in SLOW if s > f] is_rets = {} for f, s in combos: r, _ = strategy_returns(close, f, s) is_rets[(f, s)] = r[IS_START:IS_END] is_matrix = pd.DataFrame(is_rets) is_sharpes = is_matrix.apply(sharpe) best = is_sharpes.idxmax() f, s = best r_full, pos_full = strategy_returns(close, f, s) r_is, r_oos = r_full[IS_START:IS_END], r_full[OOS_START:OOS_END] pos_oos = pos_full[OOS_START:OOS_END] bh_oos = close.pct_change()[OOS_START:OOS_END] period_sharpes = (is_matrix.mean() / is_matrix.std(ddof=1)).values dsr, sr0_annual = deflated_sharpe(r_is, period_sharpes, len(combos)) pbo = pbo_cscv(is_matrix) r_oos_2x, _ = strategy_returns(close, f, s, fee=FEE_PER_SIDE * 2.5) r_oos_2x = r_oos_2x[OOS_START:OOS_END] oos_trades = trades_from(r_oos, pos_oos) mc_median, mc_bad = monte_carlo_drawdown(oos_trades) letter, keep = grade(sharpe(r_is), sharpe(r_oos), dsr, pbo) report = { "strategy": f"Buy BTC when the {f}-day average crosses above the {s}-day average; sell when it crosses back", "fast": int(f), "slow": int(s), "versions_tried": len(combos), "in_sample": { "period": "2017-2021", "sharpe": round(sharpe(r_is), 2), "cagr": round(cagr(r_is), 3), "max_drawdown": round(max_drawdown(r_is), 3), }, "out_of_sample": { "period": "2022-2025", "sharpe": round(sharpe(r_oos), 2), "cagr": round(cagr(r_oos), 3), "max_drawdown": round(max_drawdown(r_oos), 3), "trades": int(len(oos_trades)), "win_rate": round(float((oos_trades > 0).mean()), 2) if len(oos_trades) else None, }, "buy_and_hold_oos": { "sharpe": round(sharpe(bh_oos), 2), "cagr": round(cagr(bh_oos), 3), "max_drawdown": round(max_drawdown(bh_oos), 3), }, "luck_bar_sharpe": round(sr0_annual, 2), "deflated_sharpe_prob": round(dsr, 3), "pbo": round(pbo, 3), "sharpe_kept": round(keep, 2), "oos_sharpe_at_2_5x_costs": round(sharpe(r_oos_2x), 2), "mc_drawdown_median": round(mc_median, 3), "mc_drawdown_bad_case": round(mc_bad, 3), "grade": letter, "fee_per_side": FEE_PER_SIDE, "data": "Coinbase BTC-USD daily", } with open(out, "w") as fh: json.dump(report, fh, indent=2, default=float) print(json.dumps(report, indent=2)) if __name__ == "__main__": main(*sys.argv[1:3])