daily panel sweep runner (66 trials, per-config CPCV)
Browse files
sweep2.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Panel sweep v2: daily multi-asset strategies through the eval gates.
|
| 2 |
+
|
| 3 |
+
Every configuration is a counted trial. Reports PBO across the grid,
|
| 4 |
+
DSR (n_trials = grid size) on the best config, and CPCV OOS for the top-K.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import argparse
|
| 9 |
+
import json
|
| 10 |
+
import sys
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
import numpy as np
|
| 14 |
+
import pandas as pd
|
| 15 |
+
|
| 16 |
+
from crypto_eval.engine2 import run_panel_backtest
|
| 17 |
+
from crypto_eval.grid2 import (
|
| 18 |
+
buy_and_hold_btc, donchian_panel, xs_momentum,
|
| 19 |
+
xs_momentum_tsmom_filter, tsmom_panel,
|
| 20 |
+
)
|
| 21 |
+
from crypto_eval.cv import make_cpcv
|
| 22 |
+
from crypto_eval.stats import eval_backtest, eval_trial_set
|
| 23 |
+
|
| 24 |
+
PANEL_META_DEFAULT_ADV = {
|
| 25 |
+
"BTC": 20e9, "ETH": 10e9, "LTC": 1.0e9, "XRP": 1.5e9,
|
| 26 |
+
"ADA": 0.5e9, "DOGE": 1.0e9, "ETC": 0.3e9, "DASH": 0.1e9,
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def build_grid() -> dict:
|
| 31 |
+
"""Returns {name: (fn, kwargs, subset_cols or None)}. subset restricts
|
| 32 |
+
the panel to one asset (single-asset configs count as trials too)."""
|
| 33 |
+
g: dict = {}
|
| 34 |
+
for lb in (10, 20, 50, 100, 200, 400):
|
| 35 |
+
for lo in (False, True):
|
| 36 |
+
for vt in (None, 0.30):
|
| 37 |
+
g[f"tsmom_lb{lb}_{'lo' if lo else 'ls'}_vt{vt}"] = (
|
| 38 |
+
tsmom_panel, dict(lookback=lb, long_only=lo, vol_target=vt), None)
|
| 39 |
+
for w in (20, 55):
|
| 40 |
+
for lo in (False, True):
|
| 41 |
+
for vt in (None, 0.30):
|
| 42 |
+
g[f"donchian_w{w}_{'lo' if lo else 'ls'}_vt{vt}"] = (
|
| 43 |
+
donchian_panel, dict(window=w, long_only=lo, vol_target=vt), None)
|
| 44 |
+
for lb in (30, 90, 180):
|
| 45 |
+
for k in (2, 3):
|
| 46 |
+
for sb in (False, True):
|
| 47 |
+
g[f"xs_lb{lb}_k{k}_{'sb' if sb else 'lo'}"] = (
|
| 48 |
+
xs_momentum, dict(lookback=lb, top_k=k, short_bottom=sb), None)
|
| 49 |
+
g[f"xs_lb{lb}_k{k}_tfilt"] = (
|
| 50 |
+
xs_momentum_tsmom_filter, dict(lookback=lb, top_k=k), None)
|
| 51 |
+
# single-asset long-only trend (era-stability family)
|
| 52 |
+
for sym in ("BTC", "ETH"):
|
| 53 |
+
for lb in (20, 50, 100, 200):
|
| 54 |
+
for fam, fn in (("donch", donchian_panel), ("tsmom", tsmom_panel)):
|
| 55 |
+
kw = ({"window": lb} if fam == "donch" else {"lookback": lb})
|
| 56 |
+
kw["long_only"] = True
|
| 57 |
+
g[f"{sym}_{fam}_lb{lb}_lo"] = (fn, kw, [sym])
|
| 58 |
+
return g
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def main() -> int:
|
| 62 |
+
ap = argparse.ArgumentParser()
|
| 63 |
+
ap.add_argument("--panel", required=True, help="panel_daily_close.parquet")
|
| 64 |
+
ap.add_argument("--out", default="sweep2_report.json")
|
| 65 |
+
ap.add_argument("--top-k", type=int, default=5)
|
| 66 |
+
ap.add_argument("--max-configs", type=int, default=0)
|
| 67 |
+
args = ap.parse_args()
|
| 68 |
+
|
| 69 |
+
panel = pd.read_parquet(args.panel)
|
| 70 |
+
panel.index = pd.to_datetime(panel.index, utc=True)
|
| 71 |
+
panel = panel.sort_index()
|
| 72 |
+
start = "2014-01-01" # >= 4 assets listed
|
| 73 |
+
panel = panel[panel.index >= start]
|
| 74 |
+
print(f"[sweep2] panel {panel.shape[0]:,} days x {panel.shape[1]} assets "
|
| 75 |
+
f"from {panel.index[0].date()}", flush=True)
|
| 76 |
+
|
| 77 |
+
grid = build_grid()
|
| 78 |
+
if args.max_configs:
|
| 79 |
+
grid = dict(list(grid.items())[: args.max_configs])
|
| 80 |
+
print(f"[sweep2] {len(grid)} configurations", flush=True)
|
| 81 |
+
|
| 82 |
+
returns, failures = {}, {}
|
| 83 |
+
for i, (name, (fn, kw, subset)) in enumerate(grid.items()):
|
| 84 |
+
try:
|
| 85 |
+
p = panel[subset] if subset else panel
|
| 86 |
+
adv = {s: PANEL_META_DEFAULT_ADV[s] for s in p.columns}
|
| 87 |
+
pos = fn(p, **kw)
|
| 88 |
+
bt = run_panel_backtest(p, pos, adv)
|
| 89 |
+
returns[name] = bt["portfolio_return"].to_numpy()
|
| 90 |
+
except Exception as e:
|
| 91 |
+
failures[name] = repr(e)
|
| 92 |
+
print(f"[sweep2] CONFIG FAILED {name}: {e}", flush=True)
|
| 93 |
+
if (i + 1) % 10 == 0:
|
| 94 |
+
print(f"[sweep2] {i+1}/{len(grid)} done", flush=True)
|
| 95 |
+
if failures:
|
| 96 |
+
print(f"[sweep2] {len(failures)} configs failed", flush=True)
|
| 97 |
+
n_trials = len(returns)
|
| 98 |
+
|
| 99 |
+
sr = {n: float(np.nanmean(r) / (np.nanstd(r, ddof=1) + 1e-12))
|
| 100 |
+
for n, r in returns.items()}
|
| 101 |
+
ranked = sorted(sr, key=sr.get, reverse=True)
|
| 102 |
+
print(f"[sweep2] best per-day SR: {ranked[0]} = {sr[ranked[0]]:.5f} "
|
| 103 |
+
f"(ann {sr[ranked[0]]*np.sqrt(365):.2f})", flush=True)
|
| 104 |
+
|
| 105 |
+
print("[sweep2] computing PBO ...", flush=True)
|
| 106 |
+
trials = eval_trial_set([returns[n] for n in ranked])
|
| 107 |
+
best = ranked[0]
|
| 108 |
+
best_stats = eval_backtest(
|
| 109 |
+
returns[best], n_trials=n_trials,
|
| 110 |
+
trial_sr_variance=trials["trial_sr_variance"],
|
| 111 |
+
periods_per_year=365,
|
| 112 |
+
)
|
| 113 |
+
print(f"[sweep2] PBO={trials['probability_of_backtest_overfitting']:.3f} "
|
| 114 |
+
f"DSR={best_stats['deflated_sharpe']:.4f}", flush=True)
|
| 115 |
+
|
| 116 |
+
# CPCV OOS for top-K (daily bars, horizon 1, embargo 5 days)
|
| 117 |
+
# CPCV audit on the full panel (per-config combos built on demand)
|
| 118 |
+
_, _, audit = make_cpcv(panel.index, horizon=1, embargo=5, freq="1D")
|
| 119 |
+
print(f"[sweep2] CPCV audit {'OK' if audit.ok else 'FAILED'}", flush=True)
|
| 120 |
+
oos_top = {}
|
| 121 |
+
for name in ranked[: args.top_k]:
|
| 122 |
+
fn, kw, subset = grid[name]
|
| 123 |
+
p = panel[subset] if subset else panel
|
| 124 |
+
adv = {s: PANEL_META_DEFAULT_ADV[s] for s in p.columns}
|
| 125 |
+
pos = fn(p, **kw)
|
| 126 |
+
bt = run_panel_backtest(p, pos, adv)
|
| 127 |
+
ret_series = bt["portfolio_return"]
|
| 128 |
+
rets = ret_series.to_numpy()
|
| 129 |
+
# CPCV on the config's OWN history (single-asset configs start at
|
| 130 |
+
# the asset's listing date, shorter than the panel)
|
| 131 |
+
_, combos_k, audit_k = make_cpcv(ret_series.index, horizon=1,
|
| 132 |
+
embargo=5, freq="1D")
|
| 133 |
+
if not audit_k.ok:
|
| 134 |
+
raise RuntimeError(f"OOS audit failed for {name}")
|
| 135 |
+
per = [eval_backtest(rets[t], n_trials=1, trial_sr_variance=0.0,
|
| 136 |
+
periods_per_year=365) for _, t in combos_k]
|
| 137 |
+
srs = [c["sharpe_annualized"] for c in per]
|
| 138 |
+
oos_top[name] = {
|
| 139 |
+
"oos_mean_sharpe_annualized": float(np.mean(srs)),
|
| 140 |
+
"n_combos_positive": int(sum(1 for s in srs if s > 0)),
|
| 141 |
+
"net_total_pnl": float(np.nansum(rets) * 1_000_000),
|
| 142 |
+
}
|
| 143 |
+
print(f"[sweep2] OOS {name}: mean SR {np.mean(srs):.2f}", flush=True)
|
| 144 |
+
|
| 145 |
+
report = {
|
| 146 |
+
"panel_start": str(panel.index[0].date()),
|
| 147 |
+
"n_days": len(panel), "n_assets": panel.shape[1],
|
| 148 |
+
"n_trials": n_trials, "n_configs_failed": len(failures),
|
| 149 |
+
"config_failures": failures,
|
| 150 |
+
"grid": trials,
|
| 151 |
+
"best_config": best,
|
| 152 |
+
"best_config_stats": best_stats,
|
| 153 |
+
"top_k_oos": oos_top,
|
| 154 |
+
"all_configs_ranked": {n: {"sharpe_per_day": sr[n],
|
| 155 |
+
"sharpe_annualized": sr[n] * np.sqrt(365)}
|
| 156 |
+
for n in ranked},
|
| 157 |
+
"decision_rule": "accept only if DSR > 0.95 (n_trials=grid) and "
|
| 158 |
+
"PBO <= 0.5 and leakage audit OK",
|
| 159 |
+
}
|
| 160 |
+
Path(args.out).write_text(json.dumps(report, indent=2, default=str))
|
| 161 |
+
passed = (best_stats["deflated_sharpe"] is not None
|
| 162 |
+
and best_stats["deflated_sharpe"] > 0.95
|
| 163 |
+
and trials["probability_of_backtest_overfitting"] <= 0.5)
|
| 164 |
+
print(f"[sweep2] VERDICT: {'PASS' if passed else 'FAIL'} "
|
| 165 |
+
f"(DSR={best_stats['deflated_sharpe']:.4f}, "
|
| 166 |
+
f"PBO={trials['probability_of_backtest_overfitting']:.3f})", flush=True)
|
| 167 |
+
return 0
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
if __name__ == "__main__":
|
| 171 |
+
sys.exit(main())
|