| """
|
| AFML part A (see AFML_PLAN.md): is the dip-z book a product of the search?
|
|
|
| Three tests on the 1,200-config neighbourhood the book was picked from:
|
| 1. PBO by CSCV (Bailey/Borwein/Lopez de Prado/Zhu 2015)
|
| 2. Deflated Sharpe Ratio (Bailey & Lopez de Prado 2014) of the chosen config
|
| 3. CPCV backtest paths (AFML ch.12) -> distribution of path Sharpe and maxDD
|
|
|
| Equity is exit-based at 0.25% risk (applies each trade's R at exit), which
|
| understates mark-to-market DD by ~0.6pp on this book - read maxDD as a floor.
|
| """
|
|
|
| from __future__ import annotations
|
|
|
| import itertools
|
| import sys
|
| from concurrent.futures import ProcessPoolExecutor
|
|
|
| import numpy as np
|
| from scipy.stats import norm, skew, kurtosis
|
|
|
| sys.path.insert(0, __file__.rsplit("\\", 1)[0] if "\\" in __file__ else ".")
|
|
|
| import backtest as bt # noqa: E402
|
| from vol_filter_test import vol_pctile # noqa: E402
|
|
|
| IDX = ["SP500", "NAS100", "US30", "DAX40"]
|
| PERIOD = "PERIOD_H4"
|
| RISK = 0.0025
|
| Z_TH = [-1.0, -1.25, -1.5, -1.75, -2.0]
|
| Z_N = [10, 15, 20, 30, 40]
|
| MAX_BARS = [5, 10, 15, 20]
|
| GATE = [None, 0.30, 0.50, 0.70]
|
| STOP = [2.0, 3.0, 4.0]
|
| BOOK = (-1.5, 20, 10, 0.50, 3.0)
|
| CONFIGS = list(itertools.product(Z_TH, Z_N, MAX_BARS, GATE, STOP))
|
|
|
| _D, _P = {}, {}
|
|
|
|
|
| def _init():
|
| for s in IDX:
|
| _D[s] = bt.load(s, PERIOD)
|
| _P[s] = vol_pctile(_D[s])
|
|
|
|
|
| def run_config(cfg):
|
| zt, zn, mb, g, st = cfg
|
| out = []
|
| for s in IDX:
|
| d = _D[s]
|
| c = d["c"]
|
| m = bt.sma(c, zn)
|
| sd = bt.rolling_std(c, zn)
|
| z = (c - m) / np.where(sd > 0, sd, np.nan)
|
| e = np.nan_to_num(z <= zt, nan=0).astype(bool)
|
| if g is not None:
|
| e &= np.nan_to_num(_P[s] >= g, nan=0).astype(bool)
|
| tr = bt.simulate(d, e, side=1, exit_ma=m, max_bars=mb, stop_atr=st)
|
| tr = bt.add_r(tr, d, stop_atr=st)
|
| for t in tr:
|
| out.append((d["ts"][t["exit_i"]], t["r"], s))
|
| out.sort(key=lambda x: x[0])
|
| return out
|
|
|
|
|
| def weekly_matrix(all_trades, weeks):
|
| """T x N matrix of weekly additive returns at RISK."""
|
| M = np.zeros((len(weeks), len(all_trades)))
|
| w0 = weeks[0]
|
| for j, tr in enumerate(all_trades):
|
| for t, r, _ in tr:
|
| k = int((t - w0) / np.timedelta64(7, "D"))
|
| if 0 <= k < len(weeks):
|
| M[k, j] += RISK * r
|
| return M
|
|
|
|
|
| def curve_stats(rs):
|
| if len(rs) == 0:
|
| return 0.0, 0.0, np.nan
|
| eq = np.concatenate([[1.0], np.cumprod(1.0 + RISK * np.asarray(rs))])
|
| peak = np.maximum.accumulate(eq)
|
| dd = ((peak - eq) / peak).max()
|
| tot = eq[-1] - 1.0
|
| return tot, dd, (tot / dd if dd > 1e-12 else np.nan)
|
|
|
|
|
| def sharpe(x, axis=0):
|
| sd = x.std(axis=axis, ddof=1)
|
| return np.where(sd > 0, x.mean(axis=axis) / np.where(sd > 0, sd, 1), 0.0)
|
|
|
|
|
| # ------------------------------------------------------------------ CSCV / PBO
|
| def pbo_cscv(M, S=16):
|
| T, N = M.shape
|
| edges = np.linspace(0, T, S + 1).astype(int)
|
| bs = np.array([M[edges[i]:edges[i + 1]].sum(0) for i in range(S)])
|
| bq = np.array([(M[edges[i]:edges[i + 1]] ** 2).sum(0) for i in range(S)])
|
| bn = np.diff(edges)
|
| logits, deg = [], []
|
| for combo in itertools.combinations(range(S), S // 2):
|
| isb = np.zeros(S, bool)
|
| isb[list(combo)] = True
|
|
|
| def sr(mask):
|
| n = bn[mask].sum()
|
| mu = bs[mask].sum(0) / n
|
| var = bq[mask].sum(0) / n - mu * mu
|
| return mu / np.sqrt(np.maximum(var, 1e-18))
|
| s_is, s_oos = sr(isb), sr(~isb)
|
| best = int(np.argmax(s_is))
|
| rank = (s_oos < s_oos[best]).sum() + 0.5 * ((s_oos == s_oos[best]).sum() - 1)
|
| w = (rank + 0.5) / N # relative rank in (0,1)
|
| logits.append(np.log(w / (1 - w)))
|
| deg.append((s_is[best], s_oos[best]))
|
| logits = np.array(logits)
|
| deg = np.array(deg)
|
| return (logits <= 0).mean(), logits, deg
|
|
|
|
|
| # ------------------------------------------------------------------ DSR
|
| def expected_max_sr(var_sr, N):
|
| g = 0.5772156649
|
| return np.sqrt(var_sr) * ((1 - g) * norm.ppf(1 - 1.0 / N) + g * norm.ppf(1 - 1.0 / (N * np.e)))
|
|
|
|
|
| def dsr(x, sr0):
|
| T = len(x)
|
| sr = x.mean() / x.std(ddof=1)
|
| g3, g4 = skew(x), kurtosis(x, fisher=False)
|
| z = (sr - sr0) * np.sqrt(T - 1) / np.sqrt(1 - g3 * sr + (g4 - 1) / 4 * sr * sr)
|
| return norm.cdf(z), sr, g3, g4
|
|
|
|
|
| # ------------------------------------------------------------------ CPCV
|
| def cpcv(all_trades, t0, t1, n_groups=10, k=2, purge_days=5, embargo_frac=0.01):
|
| edges = [t0 + (t1 - t0) * i / n_groups for i in range(n_groups + 1)]
|
| emb = (t1 - t0) * embargo_frac
|
| purge = np.timedelta64(purge_days, "D")
|
| combos = list(itertools.combinations(range(n_groups), k))
|
| pre = [] # per config: r, group, purge zones
|
| for tr in all_trades:
|
| ts = np.array([t for t, _, _ in tr], dtype="datetime64[s]")
|
| r = np.array([x for _, x, _ in tr])
|
| g = np.minimum(((ts - t0) / (t1 - t0) * n_groups).astype(int), n_groups - 1)
|
| zone = np.array([(ts >= edges[tg] - purge) & (ts < edges[tg + 1] + purge + emb)
|
| for tg in range(n_groups)]) if len(ts) else np.zeros((n_groups, 0), bool)
|
| pre.append((r, g, zone))
|
| chosen = {} # (combo) -> config index
|
| for cb in combos:
|
| best, best_score = None, -np.inf
|
| for j, (r, g, zone) in enumerate(pre):
|
| # train = outside the test groups, minus purge before / purge+embargo after
|
| keep = ~zone[list(cb)].any(0) if len(r) else np.zeros(0, bool)
|
| tot, dd, rdd = curve_stats(r[keep])
|
| score = rdd if np.isfinite(rdd) else -np.inf
|
| if score > best_score:
|
| best, best_score = j, score
|
| chosen[cb] = best
|
| # assemble paths: each group appears in (n_groups-1 choose k-1) combos
|
| n_paths = len(combos) * k // n_groups
|
| use = {g: [cb for cb in combos if g in cb] for g in range(n_groups)}
|
| paths = []
|
| for p in range(n_paths):
|
| rs, cfgs = [], []
|
| for g in range(n_groups):
|
| cb = use[g][p]
|
| j = chosen[cb]
|
| cfgs.append(j)
|
| rs += [r for t, r, _ in all_trades[j]
|
| if min(int((t - t0) / (t1 - t0) * n_groups), n_groups - 1) == g]
|
| paths.append((rs, cfgs))
|
| return chosen, paths
|
|
|
|
|
| if __name__ == "__main__":
|
| print(f"running {len(CONFIGS)} configs x {len(IDX)} indices ...", flush=True)
|
| with ProcessPoolExecutor(max_workers=10, initializer=_init) as ex:
|
| all_trades = list(ex.map(run_config, CONFIGS, chunksize=20))
|
| book_j = CONFIGS.index(BOOK)
|
| t0 = min(tr[0][0] for tr in all_trades if tr)
|
| t1 = max(tr[-1][0] for tr in all_trades if tr)
|
| weeks = np.arange(t0.astype("datetime64[D]"), t1.astype("datetime64[D]") + 7, 7)
|
| M = weekly_matrix(all_trades, weeks)
|
| months = (t1 - t0) / np.timedelta64(30, "D")
|
|
|
| # --- family overview
|
| stats = [curve_stats([r for _, r, _ in tr]) for tr in all_trades]
|
| rdd = np.array([s[2] for s in stats])
|
| dd = np.array([s[1] for s in stats])
|
| permo = np.array([len(tr) / months for tr in all_trades])
|
| passes = (permo >= 2) & (dd <= 0.05) & (rdd >= 2)
|
| srw = sharpe(M)
|
| print(f"\nweeks T={M.shape[0]} configs N={M.shape[1]}")
|
| print(f"prop screen passes: {passes.sum()} / {len(CONFIGS)} "
|
| f"median ret/DD {np.nanmedian(rdd):.2f} book ret/DD {rdd[book_j]:.2f} "
|
| f"(rank {int((rdd > rdd[book_j]).sum()) + 1} of {len(CONFIGS)})")
|
| print(f"weekly Sharpe: family median {np.median(srw):.3f}, sd {srw.std(ddof=1):.3f}, "
|
| f"max {srw.max():.3f}; book {srw[book_j]:.3f} "
|
| f"(ann {srw[book_j]*np.sqrt(52):.2f})")
|
| print(f"configs with positive total: {(np.array([s[0] for s in stats]) > 0).mean():.1%}")
|
|
|
| # --- 1. PBO
|
| pbo, logits, deg = pbo_cscv(M, 16)
|
| slope = np.polyfit(deg[:, 0], deg[:, 1], 1)[0]
|
| print(f"\n[1] PBO (CSCV, S=16, {len(logits)} splits): {pbo:.3f} "
|
| f"-> {'PASS' if pbo <= 0.20 else 'FAIL'} (<= 0.20)")
|
| print(f" OOS Sharpe of the IS winner: median {np.median(deg[:,1]):.3f}, "
|
| f"P(OOS SR<0) {(deg[:,1] < 0).mean():.3f}; IS->OOS slope {slope:.2f}")
|
|
|
| # --- 2. DSR
|
| x = M[:, book_j]
|
| var_sr = srw.var(ddof=1)
|
| for N in (1200, 5000):
|
| sr0 = expected_max_sr(var_sr, N)
|
| p, sr, g3, g4 = dsr(x, sr0)
|
| print(f"[2] DSR N={N:>5}: SR_book {sr:.4f}/wk vs SR0 {sr0:.4f} skew {g3:.2f} "
|
| f"kurt {g4:.2f} DSR {p:.3f} -> {'PASS' if p >= 0.95 else 'FAIL'}")
|
| p1, _, _, _ = dsr(x, 0.0)
|
| print(f" PSR vs 0 (no deflation): {p1:.4f}")
|
| # the family's best, deflated - what a naive search would have shipped
|
| jb = int(np.argmax(srw))
|
| pbest, _, _, _ = dsr(M[:, jb], expected_max_sr(var_sr, 1200))
|
| print(f" family-best config {CONFIGS[jb]}: SR {srw[jb]:.4f}, DSR(1200) {pbest:.3f}")
|
|
|
| # --- 3. CPCV
|
| print("\n[3] CPCV (10 groups, 2 test, purge 5d, embargo 1%) ... ", flush=True)
|
| chosen, paths = cpcv(all_trades, t0, t1)
|
| picks = {}
|
| for j in chosen.values():
|
| picks[CONFIGS[j]] = picks.get(CONFIGS[j], 0) + 1
|
| print(" configs chosen on train:", sorted(picks.items(), key=lambda kv: -kv[1])[:6])
|
| res = []
|
| for rs, cfgs in paths:
|
| tot, pdd, prdd = curve_stats(rs)
|
| res.append((tot, pdd, prdd, len(rs) / months))
|
| print(f" path: n {len(rs):>4} ({len(rs)/months:4.1f}/mo) total {tot:6.1%} "
|
| f"maxDD {pdd:5.2%} ret/DD {prdd:5.2f}")
|
| res = np.array(res)
|
| ok = (res[:, 1] <= 0.05).all() and np.median(res[:, 2]) >= 2
|
| print(f" maxDD range {res[:,1].min():.2%}..{res[:,1].max():.2%} median ret/DD "
|
| f"{np.median(res[:,2]):.2f} -> {'PASS' if ok else 'FAIL'}")
|
| bt_ = curve_stats([r for _, r, _ in all_trades[book_j]])
|
| print(f" book single path: total {bt_[0]:.1%} maxDD {bt_[1]:.2%} ret/DD {bt_[2]:.2f}")
|
| np.savez(r"C:\Users\admin\AppData\Local\Temp\2\claude\c--Users-admin-Documents-Workspaces-Warrior-EA"
|
| r"\e314e8b8-08b3-4745-9acc-2063a9c30b23\scratchpad\afml_M.npz",
|
| M=M, rdd=rdd, dd=dd, permo=permo, srw=srw)
|