Files
quant-trading/research/lab/us_sweep.py
Gahow Wang 722cade974 fix: unify backtest execution and add forward validation candidates
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.
2026-10-01 02:24:01 -07:00

184 lines
7.3 KiB
Python

"""
US construction and overlay sweep for a few fixed scores, under one set of
rules (PIT S&P 500 members, next-open fills, 10 bp costs, phase averaging).
PYTHONPATH=. uv run --with numba python -m research.lab.us_sweep --part construction
PYTHONPATH=. uv run --with numba python -m research.lab.us_sweep --part overlays
Every configuration is appended to data/research/results/trials_us_sweep.jsonl
so the deflated Sharpe of the final pick can count them.
"""
from __future__ import annotations
import argparse
import os
import pickle
import warnings
import numpy as np
import pandas as pd
from research.lab import fundamentals as fu
from research.lab import overlays as ov
from research.lab.engine import Costs, simulate
from research.lab.factor_scan import RESULTS, load
from research.lab.report import stats
from research.lab.rotation import factor_ranks
warnings.filterwarnings("ignore")
# 2006-2026 includes the 2008 crash; fundamentals start in 2011
LONG = [("2006-09-27", "2011-09-26"), ("2011-09-27", "2016-09-26"),
("2016-09-27", "2021-09-26"), ("2021-09-27", "2026-09-24")]
SHORT = [("2011-01-01", "2014-12-31"), ("2015-01-01", "2018-12-31"),
("2019-01-01", "2022-12-31"), ("2023-01-01", "2026-09-24")]
def _blend(*dfs: pd.DataFrame) -> pd.DataFrame:
stack = np.stack([d.values for d in dfs])
with np.errstate(invalid="ignore"):
m = np.nanmean(stack, axis=0)
return pd.DataFrame(m, index=dfs[0].index, columns=dfs[0].columns)
def sector_rank(df: pd.DataFrame, p) -> pd.DataFrame:
df = df.where(p.member)
sec = p.extra["sector"]
out = pd.DataFrame(np.nan, index=df.index, columns=df.columns)
for s in sec.dropna().unique():
cols = [c for c in sec.index[sec == s] if c in df.columns]
out[cols] = df[cols].rank(axis=1, pct=True)
return out
def scores(p) -> dict[str, tuple[pd.DataFrame, list]]:
pr = factor_ranks(p, "us")
F = fu.load(p)
Q = _blend(*(sector_rank(F[k], p) for k in ("gp_a", "cfo_a", "roe", "accruals", "leverage")))
mom = _blend(pr["mom_12_1"], pr["resid_mom"])
low = _blend(pr["ivol"], pr["low_beta"])
return {
"champ": (_blend(pr["overnight_mom"], pr["rec_126"]).where(p.member), LONG),
"mom_qual_low": (_blend(mom, Q, low).where(p.member), SHORT),
}
def run(p, score, subs, exposure=None, **kw) -> dict:
"""Phase-averaged daily returns over the whole span plus block stats."""
a, b = subs[0][0], subs[-1][1]
every = kw.get("every", 10)
w0 = p.dates.searchsorted(pd.Timestamp(a))
sp = p.slice(p.dates[max(w0 - 5, 0)], b)
rets = []
for off in range(every):
r = simulate(sp, score, offset=off, warmup=5, exposure=exposure, **kw)
rets.append(r.returns.loc[a:b])
if off == 0:
turn = r.turnover.loc[a:b].sum() / ((pd.Timestamp(b) - pd.Timestamp(a)).days / 365.25)
R = pd.concat(rets, axis=1)
per_phase = [stats((1 + R[c]).cumprod()) for c in R.columns]
avg = R.mean(axis=1) # = a book split into ``every`` tranches
out = {"sharpe": np.mean([s["sharpe"] for s in per_phase]),
"cagr": np.mean([s["cagr"] for s in per_phase]),
"mdd": np.mean([s["mdd"] for s in per_phase]),
"sharpe_min_phase": min(s["sharpe"] for s in per_phase),
"tranche_sharpe": stats((1 + avg).cumprod())["sharpe"], "turn": turn,
"_rets": avg}
blocks = []
for sa, sb in subs:
blocks.append(np.mean([stats((1 + R[c].loc[sa:sb]).cumprod())["sharpe"] for c in R.columns]))
out["blocks"] = blocks
out["min_block"] = min(blocks)
return out
def fmt(name, st) -> str:
return (f"{name:48s} sh {st['sharpe']:.2f} [{st['sharpe_min_phase']:.2f}] tr {st['tranche_sharpe']:.2f} "
f"cagr {st['cagr']*100:5.1f}% mdd {st['mdd']*100:5.1f}% turn {st['turn']:4.1f}x "
f"min {st['min_block']:.2f} [{' '.join(f'{x:.2f}' for x in st['blocks'])}]")
def log_trials(rows, part):
from research.lab.trials import append_trials
records = [{k: v for k, v in r.items() if not k.startswith("_") and k != "blocks"} for r in rows]
append_trials(os.path.join(RESULTS, "trials_us_sweep.jsonl"), records,
{"part": part, "role": "development; historical data already examined"})
def construction(p, S) -> list[dict]:
rows = []
grid = []
for top in (10, 20, 30):
for buf in (0.0, 1.0, 2.0):
grid.append((f"top{top} buf{buf:g} equal", dict(top_n=top, buffer=buf, weighting="equal")))
for top in (10, 20, 30):
grid.append((f"top{top} buf1 zvol", dict(top_n=top, buffer=1.0, weighting="zvol")))
grid.append((f"top{top} buf1 invvol", dict(top_n=top, buffer=1.0, weighting="invvol")))
grid.append(("dyn 10-30 thr0.80 buf1 equal", dict(top_n=30, buffer=1.0, dyn_n=(10, 0.80))))
grid.append(("dyn 10-30 thr0.75 buf1 equal", dict(top_n=30, buffer=1.0, dyn_n=(10, 0.75))))
grid.append(("top20 buf1 every5", dict(top_n=20, buffer=1.0, every=5)))
grid.append(("top20 buf1 every21", dict(top_n=20, buffer=1.0, every=21)))
for sname, (score, subs) in S.items():
print(f"=== {sname}", flush=True)
for label, kw in grid:
kw = {"every": 10, **kw}
st = run(p, score, subs, **kw)
rows.append({"score": sname, "config": label, **st})
print(fmt(label, st), flush=True)
return rows
def overlays(p, S, base_kw) -> list[dict]:
lvl = p.benchmark.ffill()
ew = ov.ew_index(p)
X = {
"none": None,
"spy derisk_vol": ov.derisk_vol(lvl),
"spy faber10m": ov.faber_sma(lvl),
"spy jump_model": ov.jump_model(lvl, p.cash_ret),
"spy dm_state24m": ov.momentum_state(lvl),
"ew breadth-free derisk_vol": ov.derisk_vol(ew),
}
rows = []
for sname, (score, subs) in S.items():
print(f"=== {sname} {base_kw}", flush=True)
base = run(p, score, subs, **base_kw)
# strategy-level de-risk-only vol target from its own trailing returns
own = base["_rets"]
own_lvl = (1 + own).cumprod()
X_s = dict(X)
X_s["own derisk_vol"] = ov.derisk_vol(own_lvl, min_hist=126).reindex(p.dates).fillna(1.0)
for name, e in X_s.items():
st = base if e is None else run(p, score, subs, exposure=e, **base_kw)
rows.append({"score": sname, "config": f"overlay {name}", **st})
print(fmt(f"overlay {name}", st), flush=True)
return rows
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--part", default="construction")
ap.add_argument("--top-n", type=int, default=20)
ap.add_argument("--buffer", type=float, default=1.0)
ap.add_argument("--weighting", default="equal")
ap.add_argument("--fee", type=float, default=0.0, help="fixed fee per order on $10k")
args = ap.parse_args()
p, _ = load("us")
if args.fee:
p = p.slice()
p.costs = Costs(p.costs.buy, p.costs.sell, None, args.fee, 10_000.0)
S = scores(p)
if args.part == "construction":
rows = construction(p, S)
else:
rows = overlays(p, S, dict(top_n=args.top_n, buffer=args.buffer, every=10,
weighting=args.weighting))
log_trials(rows, args.part)
with open(os.path.join(RESULTS, f"us_sweep_{args.part}.pkl"), "wb") as fh:
pickle.dump(rows, fh)
if __name__ == "__main__":
main()