research: point-in-time factor research harness (research/lab)
Event-driven long-only simulator with next-open fills, CN tradability (halts, limit-up/down, stamp duty), PIT S&P 500 / CSI 300 membership, rebalance-phase averaging, factor IC scans, maximin stepwise composite search across 5-year blocks, walk-forward LightGBM/ridge combiners, overlays, and deflated Sharpe. Fetchers pull 20y US OHLCV from Yahoo and CSI 300/500 ever-members (membership snapshot from baostock, bars from Yahoo). Research data lives in data/research/ (ignored). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
143
research/lab/evaluate.py
Normal file
143
research/lab/evaluate.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
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
|
||||
|
||||
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")
|
||||
args = ap.parse_args()
|
||||
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)))
|
||||
trials_path = os.path.join(RESULTS, f"trials_{args.market}.csv")
|
||||
n_trials = args.n_trials or (len(pd.read_csv(trials_path)) if os.path.exists(trials_path) else 100)
|
||||
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
|
||||
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()
|
||||
Reference in New Issue
Block a user