Share point-in-time signals and a cash-funded order ledger across backtests and trading. Correct eligibility, missing-price fills, quality factors, regime inputs, warmup windows, and cash-injection accounting. Add reproducible registered-strategy comparisons and paired forward candidates with a 20% resize band. Validation: 125 Python tests, 23 web tests, TypeScript/Vite build, 87 strategy causality checks, and real-data target/order/equity parity for 10 strategies.
155 lines
7.2 KiB
Python
155 lines
7.2 KiB
Python
"""
|
|
Final evaluation of candidate strategies against the existing champions,
|
|
under identical rules (PIT universe, next-open execution, costs, phase
|
|
averaging). Writes data/research/results/final_{market}.json.
|
|
|
|
PYTHONPATH=. uv run python -m research.lab.evaluate --market us
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import warnings
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
|
|
from research.lab import overlays as ov
|
|
from research.lab.construction import combo_score
|
|
from research.lab.engine import Costs, simulate
|
|
from research.lab.factor_scan import RESULTS, load
|
|
from research.lab.report import deflated_sharpe, stats, yearly
|
|
from research.lab.stepwise import SUBPERIODS
|
|
from research.lab.trials import count_trials
|
|
|
|
warnings.filterwarnings("ignore")
|
|
|
|
# name -> (combo, construction). "baseline:" combos are the current factor_combo champions.
|
|
CANDIDATES = {
|
|
"us": {
|
|
"champion rec_mfilt+deep_upvol monthly": ("baseline:rec_mfilt+deep_upvol", dict(top_n=10, every=21)),
|
|
"champion mom7m+rec126 monthly": ("baseline:mom7m+rec126", dict(top_n=10, every=21)),
|
|
"champion mom7m+rec126 weekly": ("baseline:mom7m+rec126", dict(top_n=10, every=5)),
|
|
"EW S&P 500 (PIT)": ("ew", dict(top_n=10000, every=5)),
|
|
"new overnight+rec": ("overnight_mom+rec_126", dict(top_n=10, every=10, buffer=2.0)),
|
|
"new overnight6+rec": ("overnight_mom_6+rec_126", dict(top_n=10, every=10, buffer=2.0)),
|
|
"new smooth-mom4": ("overnight_mom+overnight_mom_6+amp_cut_mom+rec_126", dict(top_n=10, every=10, buffer=2.0)),
|
|
"new overnight+ampcut+rec": ("overnight_mom+amp_cut_mom+rec_126", dict(top_n=10, every=10, buffer=2.0)),
|
|
},
|
|
"cn": {
|
|
"champion up_cap+quality_mom weekly": ("baseline:up_cap+quality_mom", dict(top_n=10, every=5)),
|
|
"champion up_cap+mom_gap weekly": ("baseline:up_cap+mom_gap", dict(top_n=10, every=5)),
|
|
"champion down_resil+qual_mom biweekly": ("baseline:down_resil+qual_mom", dict(top_n=10, every=10)),
|
|
"EW CSI 300 (PIT)": ("ew", dict(top_n=10000, every=5)),
|
|
},
|
|
}
|
|
LIVE_FEES = {"us": (2.0, 10_000.0), "cn": (5.0, 70_000.0)}
|
|
WINDOW = ("2016-09-27", "2026-09-24") # the web UI's 10-year window
|
|
|
|
|
|
def score_for(p, combo, market):
|
|
if combo == "ew":
|
|
return p.close.notna().astype(float).where(p.member)
|
|
return combo_score(p, combo, market)
|
|
|
|
|
|
def phase_runs(p, score, a, b, exposure=None, **kw):
|
|
every = kw.get("every", 5)
|
|
w0 = p.dates.searchsorted(pd.Timestamp(a))
|
|
sp = p.slice(p.dates[max(w0 - 5, 0)], b)
|
|
runs = []
|
|
for off in range(every if kw.get("top_n", 10) < 1000 else 1):
|
|
r = simulate(sp, score, offset=off, warmup=5, exposure=exposure, **kw)
|
|
runs.append(r)
|
|
return runs
|
|
|
|
|
|
def summarize(runs, a, b) -> dict:
|
|
sts = [stats(r.equity.loc[a:b], r.turnover) for r in runs]
|
|
df = pd.DataFrame(sts)
|
|
rets = pd.concat([r.returns.loc[a:b] for r in runs], axis=1).mean(axis=1)
|
|
eq = (1 + rets).cumprod()
|
|
out = {k: float(df[k].mean()) for k in df.columns}
|
|
out["cagr_phase_min"] = float(df["cagr"].min())
|
|
out["cagr_phase_max"] = float(df["cagr"].max())
|
|
out["yearly"] = {str(k.year): float(v) for k, v in yearly(eq).items()}
|
|
out["_rets"] = rets
|
|
return out
|
|
|
|
|
|
def main() -> None:
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--market", default="us")
|
|
ap.add_argument("--add", nargs="*", default=[], help="extra 'name=combo|top|every|buffer'")
|
|
ap.add_argument("--n-trials", type=int, default=0, help="override trial count for DSR")
|
|
ap.add_argument("--registered", nargs="+", help="Validate registered strategies using the production ledger")
|
|
ap.add_argument("--start")
|
|
ap.add_argument("--end")
|
|
ap.add_argument("--output")
|
|
args = ap.parse_args()
|
|
if args.registered:
|
|
from research.lab.registered import evaluate_registered
|
|
evaluate_registered(args)
|
|
return
|
|
mk = "us" if args.market == "us" else "cn"
|
|
p, _ = load(args.market)
|
|
cands = dict(CANDIDATES[mk])
|
|
for spec in args.add:
|
|
name, rest = spec.split("=", 1)
|
|
combo, top, every, buf = rest.split("|")
|
|
cands[name] = (combo, dict(top_n=int(top), every=int(every), buffer=float(buf)))
|
|
n_trials = args.n_trials or count_trials(RESULTS, args.market)
|
|
if n_trials < 1:
|
|
raise ValueError("DSR needs a trial ledger or an explicit --n-trials count")
|
|
full = (SUBPERIODS[mk][0][0], WINDOW[1])
|
|
out = {}
|
|
fee, cap = LIVE_FEES[mk]
|
|
for name, (combo, kw) in cands.items():
|
|
s = score_for(p, combo, args.market)
|
|
res = {"combo": combo, "construction": kw}
|
|
base_runs = phase_runs(p, s, *WINDOW, **kw)
|
|
res["window"] = summarize(base_runs, *WINDOW)
|
|
res["full"] = summarize(phase_runs(p, s, *full, **kw), *full)
|
|
res["blocks"] = [summarize(phase_runs(p, s, a, b, **kw), a, b) for a, b in SUBPERIODS[mk]]
|
|
# live-book fixed fee
|
|
pf = p.slice(); pf.costs = Costs(p.costs.buy, p.costs.sell, p.costs.sell_tax, fee, cap)
|
|
res["window_livefee"] = summarize(phase_runs(pf, s, *WINDOW, **kw), *WINDOW)
|
|
# double proportional cost
|
|
p2 = p.slice(); p2.costs = Costs(p.costs.buy * 2, p.costs.sell * 2, p.costs.sell_tax, 0.0, cap)
|
|
res["window_2xcost"] = summarize(phase_runs(p2, s, *WINDOW, **kw), *WINDOW)
|
|
res["window_exec_close"] = summarize(phase_runs(p, s, *WINDOW, exec_at="close", **kw), *WINDOW)
|
|
# drawdown-control overlay: slow/fast market trend blend on the EW index
|
|
ew = ov.ew_index(p)
|
|
slow = (ew / ew.shift(252) > 1).astype(float)
|
|
fast = (ew / ew.shift(21) > 1).astype(float)
|
|
expo = (0.5 + 0.25 * slow + 0.25 * fast).where(slow + fast < 2, 1.0)
|
|
res["window_trend_overlay"] = summarize(phase_runs(p, s, *WINDOW, exposure=expo, **kw), *WINDOW)
|
|
dsr = deflated_sharpe(res["full"]["_rets"], n_trials)
|
|
res["dsr_full"] = {**dsr, "observed_trials_lower_bound": n_trials,
|
|
"evidence_role": "development, not independent OOS"}
|
|
out[name] = res
|
|
w, f = res["window"], res["full"]
|
|
print(f"{name:42s} 2016-26: CAGR {w['cagr']*100:5.1f}% [{w['cagr_phase_min']*100:4.1f},{w['cagr_phase_max']*100:4.1f}] "
|
|
f"Sh {w['sharpe']:.2f} MDD {w['mdd']*100:5.1f}% | full: CAGR {f['cagr']*100:5.1f}% Sh {f['sharpe']:.2f} "
|
|
f"MDD {f['mdd']*100:5.1f}% | blocks Sh {' '.join(f'{b_['sharpe']:.2f}' for b_ in res['blocks'])} "
|
|
f"| livefee {res['window_livefee']['cagr']*100:5.1f}% 2x {res['window_2xcost']['cagr']*100:5.1f}% "
|
|
f"close {res['window_exec_close']['cagr']*100:5.1f}% trend {res['window_trend_overlay']['cagr']*100:5.1f}%/"
|
|
f"{res['window_trend_overlay']['sharpe']:.2f}/{res['window_trend_overlay']['mdd']*100:.0f}% "
|
|
f"| DSR {dsr['dsr']:.2f} (N={n_trials})", flush=True)
|
|
|
|
def clean(o):
|
|
if isinstance(o, dict):
|
|
return {k: clean(v) for k, v in o.items() if not k.startswith("_")}
|
|
if isinstance(o, list):
|
|
return [clean(v) for v in o]
|
|
if isinstance(o, (np.floating, np.integer)):
|
|
return float(o)
|
|
return o
|
|
os.makedirs(RESULTS, exist_ok=True)
|
|
with open(os.path.join(RESULTS, f"final_{args.market}.json"), "w") as fh:
|
|
json.dump(clean(out), fh, indent=1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|