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>
96 lines
3.8 KiB
Python
96 lines
3.8 KiB
Python
"""
|
|
Single-factor scan: rank IC and long-only top-N backtests per period.
|
|
|
|
PYTHONPATH=. uv run python -m research.lab.factor_scan --market us
|
|
PYTHONPATH=. uv run python -m research.lab.factor_scan --market cn_hs300
|
|
|
|
Writes data/research/results/factor_scan_{market}.csv.
|
|
"""
|
|
|
|
import argparse
|
|
import os
|
|
import time
|
|
import warnings
|
|
|
|
import pandas as pd
|
|
|
|
from research.lab import panel as pnl
|
|
from research.lab.engine import simulate
|
|
from research.lab.factors import FACTORS
|
|
from research.lab.report import (forward_returns, ic_series, ic_summary, stats,
|
|
top_minus_universe)
|
|
|
|
warnings.filterwarnings("ignore")
|
|
|
|
PERIODS = {
|
|
"us": {"design": ("2006-01-01", "2016-09-26"), "holdout": ("2016-09-27", "2026-09-24")},
|
|
"cn": {"design": ("2010-01-01", "2016-09-26"), "holdout": ("2016-09-27", "2026-09-24")},
|
|
}
|
|
RESULTS = os.path.join("data", "research", "results")
|
|
|
|
|
|
def load(market: str):
|
|
if market == "us":
|
|
return pnl.load_us(), PERIODS["us"]
|
|
return pnl.load_cn(market.split("_", 1)[1]), PERIODS["cn"]
|
|
|
|
|
|
def phase_avg(p, score, start, end, top_n, every, **kw) -> dict:
|
|
"""Average stats over every rebalance phase (offset 0..every-1)."""
|
|
w0 = p.dates.searchsorted(pd.Timestamp(start))
|
|
sp = p.slice(p.dates[max(w0 - 5, 0)], end)
|
|
rows = []
|
|
for off in range(every):
|
|
r = simulate(sp, score, top_n=top_n, every=every, offset=off, warmup=5, **kw)
|
|
rows.append(stats(r.equity.loc[start:end], r.turnover))
|
|
return pd.DataFrame(rows).mean().to_dict()
|
|
|
|
|
|
def main() -> None:
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--market", default="us")
|
|
ap.add_argument("--top-n", type=int, default=20)
|
|
ap.add_argument("--every", type=int, default=5)
|
|
ap.add_argument("--only", nargs="*")
|
|
args = ap.parse_args()
|
|
mk = "us" if args.market == "us" else "cn"
|
|
p, periods = load(args.market)
|
|
fwd5 = forward_returns(p, 5)
|
|
fwd20 = forward_returns(p, 20)
|
|
rows = []
|
|
names = args.only or [n for n, f in FACTORS.items() if mk in f.markets]
|
|
for name in names:
|
|
t = time.time()
|
|
try:
|
|
s = FACTORS[name](p)
|
|
except Exception as exc: # noqa: BLE001
|
|
print(f"{name}: failed {exc}")
|
|
continue
|
|
row = {"factor": name}
|
|
for per, (a, b) in periods.items():
|
|
sl = slice(a, b)
|
|
ic5 = ic_summary(ic_series(s.loc[sl], fwd5.loc[sl], p.member.loc[sl], 5))
|
|
ic20 = ic_summary(ic_series(s.loc[sl], fwd20.loc[sl], p.member.loc[sl], 20), 12)
|
|
tmu = top_minus_universe(s.loc[sl], fwd5.loc[sl], p.member.loc[sl], args.top_n, 5)
|
|
st = phase_avg(p, s, a, b, args.top_n, args.every)
|
|
row.update({f"{per}_ic5": ic5["ic"], f"{per}_ic5_t": ic5["t"],
|
|
f"{per}_ic20": ic20["ic"], f"{per}_ic20_t": ic20["t"],
|
|
f"{per}_top_excess_ann": tmu.mean() * 52,
|
|
f"{per}_cagr": st["cagr"], f"{per}_sharpe": st["sharpe"],
|
|
f"{per}_mdd": st["mdd"], f"{per}_turn": st["turnover"]})
|
|
rows.append(row)
|
|
print(f"{name:16s} "
|
|
f"D: ic5 {row['design_ic5']:+.3f} (t{row['design_ic5_t']:+.1f}) ic20 {row['design_ic20']:+.3f} "
|
|
f"xs {row['design_top_excess_ann']*100:+5.1f}% cagr {row['design_cagr']*100:5.1f}% sh {row['design_sharpe']:.2f} | "
|
|
f"H: ic5 {row['holdout_ic5']:+.3f} (t{row['holdout_ic5_t']:+.1f}) ic20 {row['holdout_ic20']:+.3f} "
|
|
f"xs {row['holdout_top_excess_ann']*100:+5.1f}% cagr {row['holdout_cagr']*100:5.1f}% sh {row['holdout_sharpe']:.2f} "
|
|
f"turn {row['holdout_turn']:.0f}x [{time.time()-t:.0f}s]", flush=True)
|
|
# equal-weight universe reference
|
|
os.makedirs(RESULTS, exist_ok=True)
|
|
out = pd.DataFrame(rows)
|
|
out.to_csv(os.path.join(RESULTS, f"factor_scan_{args.market}.csv"), index=False)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|