research: add US alpha exploration scripts

This commit is contained in:
2026-04-18 16:14:27 +08:00
parent 40ec3b828a
commit d0e8c97695
6 changed files with 1445 additions and 0 deletions

145
research/strategy_sweep.py Normal file
View File

@@ -0,0 +1,145 @@
"""
Unified 3/5/10-year PIT backtest for every production strategy.
Runs the full strategy roster against the point-in-time S&P 500 price matrix
from research/pit_backtest and reports CAGR / Sharpe / Sortino / MaxDD / Calmar
for three trailing windows. Results are written to data/sweep_<years>y.csv and
printed to stdout.
Usage:
uv run python -m research.strategy_sweep
"""
import os
import pandas as pd
import research.pit_backtest as pit
from strategies.adaptive_momentum import AdaptiveMomentumStrategy
from strategies.dual_momentum import DualMomentumStrategy
from strategies.factor_combo import SIGNAL_REGISTRY, FactorComboStrategy
from strategies.inverse_vol import InverseVolatilityStrategy
from strategies.mean_reversion import MeanReversionStrategy
from strategies.momentum import MomentumStrategy
from strategies.momentum_quality import MomentumQualityStrategy
from strategies.multi_factor import MultiFactorStrategy
from strategies.recovery_momentum import RecoveryMomentumStrategy
from strategies.trend_following import TrendFollowingStrategy
DATA_DIR = "data"
BENCHMARK = "SPY"
def build_strategies(tickers: list[str]) -> dict:
"""Instantiate every production strategy; returns {name: strategy}."""
top_n = max(5, len(tickers) // 10)
strategies: dict = {
# --- Baselines ---
"SPY buy-and-hold": None, # handled separately
"Momentum": MomentumStrategy(lookback=252, skip=21, top_n=top_n),
"Inverse Volatility": InverseVolatilityStrategy(vol_window=20),
"Multi-Factor": MultiFactorStrategy(tickers=tickers, benchmark=BENCHMARK,
top_n=top_n),
"Mean Reversion": MeanReversionStrategy(top_n=top_n),
"Trend Following": TrendFollowingStrategy(ma_window=150, momentum_period=126,
top_n=top_n),
"Dual Momentum": DualMomentumStrategy(top_n=top_n),
"Momentum+Quality": MomentumQualityStrategy(momentum_period=252, skip=21,
top_n=top_n),
"Mom+InvVol": AdaptiveMomentumStrategy(top_n=top_n),
"Recovery+Mom Top20": RecoveryMomentumStrategy(top_n=min(20, top_n)),
"Recovery+Mom Top10": RecoveryMomentumStrategy(top_n=10),
}
# Factor-combo (monthly rebalance; biweekly is the other interesting one,
# but monthly aligns with how the RecoveryMomentum defaults are set).
for name in SIGNAL_REGISTRY:
key = f"fc_{name.replace('+', '_').replace('×', 'x')}_monthly"
strategies[key] = FactorComboStrategy(name, rebal_freq=21, top_n=10)
return strategies
def slice_years(prices: pd.DataFrame, years: int) -> pd.DataFrame:
cutoff = prices.index[-1] - pd.DateOffset(years=years)
return prices[prices.index >= cutoff]
def run_one(name: str, strat, prices: pd.DataFrame,
tickers: list[str]) -> dict:
if strat is None:
# SPY buy-and-hold
spy = prices[BENCHMARK].dropna()
eq = (spy / spy.iloc[0]) * 10_000
return {"strategy": name, **{k: v for k, v in pit.summarize(eq, name=name).items()
if k != "name"}}
# MultiFactor needs the benchmark column → pass full `prices`; others only tickers.
if isinstance(strat, MultiFactorStrategy):
strat_prices = prices # keep SPY column
else:
strat_prices = prices[tickers]
eq = pit.backtest(strategy=strat, prices=strat_prices, initial_capital=10_000,
transaction_cost=0.001)
return {"strategy": name, **{k: v for k, v in pit.summarize(eq, name=name).items()
if k != "name"}}
def fmt(row: dict) -> str:
return (f" {row['strategy']:<44s} "
f"CAGR={row['CAGR']*100:>6.1f}% "
f"Sharpe={row['Sharpe']:>5.2f} "
f"Sortino={row['Sortino']:>5.2f} "
f"MaxDD={row['MaxDD']*100:>6.1f}% "
f"Calmar={row['Calmar']:>5.2f}")
def main() -> None:
print("Loading point-in-time price data…")
raw = pit.load_pit_prices()
masked = pit.pit_universe(raw)
# Preserve SPY even though it's not in the membership intervals.
if BENCHMARK in raw.columns:
masked[BENCHMARK] = raw[BENCHMARK]
tickers = [c for c in masked.columns if c != BENCHMARK]
print(f" tickers={len(tickers)} rows={len(masked)} "
f"range={masked.index[0].date()}{masked.index[-1].date()}")
all_results: dict[int, pd.DataFrame] = {}
for years in (10, 5, 3):
sliced = slice_years(masked, years)
strategies = build_strategies(tickers)
print("\n" + "=" * 110)
print(f"Window = last {years} years ({sliced.index[0].date()}{sliced.index[-1].date()})")
print("=" * 110)
rows = []
for name, strat in strategies.items():
try:
rows.append(run_one(name, strat, sliced, tickers))
except Exception as exc: # noqa: BLE001
print(f" [skip] {name}: {type(exc).__name__}: {exc}")
continue
df = pd.DataFrame(rows).sort_values("Sharpe", ascending=False)
for _, r in df.iterrows():
print(fmt(r))
out = os.path.join(DATA_DIR, f"sweep_{years}y.csv")
df.to_csv(out, index=False)
all_results[years] = df
print(f" → saved {out}")
# Cross-window comparison: only strategies present in all windows.
print("\n" + "=" * 110)
print("Cross-window CAGR comparison (sorted by 10y Sharpe)")
print("=" * 110)
pivot = pd.DataFrame({
f"CAGR_{y}y": all_results[y].set_index("strategy")["CAGR"]
for y in (10, 5, 3)
})
sharpe10 = all_results[10].set_index("strategy")["Sharpe"]
pivot["Sharpe_10y"] = sharpe10
pivot = pivot.sort_values("Sharpe_10y", ascending=False)
print(pivot.to_string(formatters={
"CAGR_10y": "{:.1%}".format, "CAGR_5y": "{:.1%}".format,
"CAGR_3y": "{:.1%}".format, "Sharpe_10y": "{:.2f}".format,
}))
if __name__ == "__main__":
main()