Warrior_EA/research/afml_overfit.py

248 lines
10 KiB
Python

"""
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)