Research scripts exploring paths beyond V7+VT36: - regime_stock_picker_eval: V3 regime + S&P 500 stock picking - v7_parameter_sweep: VT range (20-48%) + adaptive PT variants - v7_synthetic_leverage_eval: synthetic 2x/3x leveraged individual stocks - v7_breakthrough_eval/fixed: ensemble, cross-market, alt regime engines - v7_three_ideas_eval: TMF risk-off, PT entry reset, fast exit - v7_trade_audit: full 10y trade log and alpha attribution - sota_ranking: comprehensive cross-strategy ranking Key findings: - VT36 is optimal risk-return tradeoff (+7% vs VT28, Sharpe ~flat) - PT30 is structural optimum for 3x ETFs (all adaptive variants worse) - V8 (TMF risk-off) debunked: +5% was 1-day lookahead bias artifact - V3 regime engine irreplaceable (all simplified alternatives fail) - PT mechanism is dominant alpha source (+15.6pp ann, +0.58 Sharpe) V8 strategy file kept for reference (not registered). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
367 lines
12 KiB
Python
367 lines
12 KiB
Python
"""Does V3 regime timing + S&P 500 stock picking improve over either alone?
|
|
|
|
Variants tested:
|
|
1. RegimeStockPicker top-10 — V3 regime, risk-on = top-10 momentum stocks
|
|
2. RegimeStockPicker top-20 — V3 regime, risk-on = top-20 momentum stocks
|
|
3. RegimeRecovery top-10 — V3 regime, risk-on = recovery+momentum top-10
|
|
4. RecoveryMomentum top-10 — pure stock picker, no regime filter (baseline)
|
|
5. TrendRider V7 — leveraged ETFs (current SOTA)
|
|
6. SPY buy-and-hold — benchmark
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
|
|
sys.path.insert(0, ".")
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
|
|
import data_manager
|
|
import metrics
|
|
import universe_history as uh
|
|
from main import backtest
|
|
from strategies.base import Strategy
|
|
from strategies.permanent import TrendRiderV3
|
|
from strategies.recovery_momentum import RecoveryMomentumStrategy
|
|
from strategies.trend_rider_v7 import TrendRiderV7
|
|
from universe import UNIVERSES
|
|
|
|
YEARS = 10
|
|
CAPITAL = 100_000
|
|
TX_COST = 0.001
|
|
FIXED_FEE = 2.0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Strategy: V3 regime gate + cross-sectional momentum on S&P 500
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class RegimeStockPicker(Strategy):
|
|
"""V3 macro regime + S&P 500 momentum stock selection.
|
|
|
|
Risk-on: equal-weight top-N stocks by ``mom_lookback``-day momentum.
|
|
Risk-off: momentum leader of (GLD, DBC).
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
stock_tickers: list[str],
|
|
top_n: int = 10,
|
|
signal: str = "SPY",
|
|
defensive: tuple[str, ...] = ("GLD", "DBC"),
|
|
ma_long: int = 150,
|
|
mom_lookback: int = 63,
|
|
rebal_every: int = 21,
|
|
):
|
|
self.stock_tickers = stock_tickers
|
|
self.top_n = top_n
|
|
self.signal = signal
|
|
self.defensive = defensive
|
|
self.ma_long = ma_long
|
|
self.mom_lookback = mom_lookback
|
|
self.rebal_every = rebal_every
|
|
self._v3 = TrendRiderV3(
|
|
signal=signal, risk_on=("TQQQ", "UPRO"), risk_off=defensive,
|
|
ma_long=ma_long,
|
|
)
|
|
|
|
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
|
|
w = pd.DataFrame(np.nan, index=data.index, columns=data.columns)
|
|
|
|
if self.signal not in data.columns:
|
|
return w.fillna(0.0)
|
|
|
|
sig_arr = data[self.signal].to_numpy()
|
|
mom = data.pct_change(self.mom_lookback, fill_method=None)
|
|
|
|
avail_stocks = [t for t in self.stock_tickers if t in data.columns]
|
|
avail_def = [t for t in self.defensive if t in data.columns]
|
|
|
|
need = max(self.ma_long, self.mom_lookback + 1,
|
|
self._v3.vol_window + 1, self._v3.dd_window,
|
|
self._v3.peak_window) + 1
|
|
|
|
regime: str | None = None
|
|
bars = 0
|
|
|
|
for i in range(len(data)):
|
|
if i < need:
|
|
continue
|
|
|
|
closes = sig_arr[:i]
|
|
if np.isnan(closes[-1]):
|
|
continue
|
|
|
|
desired = self._v3._desired_regime(closes, regime)
|
|
changed = False
|
|
if regime is None:
|
|
regime, bars, changed = desired, 0, True
|
|
else:
|
|
bars += 1
|
|
if desired != regime and bars >= 15:
|
|
regime, bars, changed = desired, 0, True
|
|
|
|
if not changed and (i - need) % self.rebal_every != 0:
|
|
continue
|
|
|
|
row = {c: 0.0 for c in data.columns}
|
|
dt = data.index[i]
|
|
|
|
if regime == "risk_on":
|
|
m = mom.iloc[i][avail_stocks].dropna()
|
|
valid = m.index[data.loc[dt, m.index].notna()]
|
|
m = m[valid]
|
|
m = m[m > 0]
|
|
top = m.nlargest(min(self.top_n, len(m)))
|
|
if len(top) > 0:
|
|
wt = 1.0 / len(top)
|
|
for t in top.index:
|
|
row[t] = wt
|
|
elif avail_def:
|
|
row[avail_def[0]] = 1.0
|
|
else:
|
|
if avail_def:
|
|
dm = mom.iloc[i][avail_def].dropna()
|
|
best = dm.idxmax() if len(dm) > 0 else avail_def[0]
|
|
row[best] = 1.0
|
|
|
|
for c, v in row.items():
|
|
w.at[dt, c] = v
|
|
|
|
w = w.ffill().fillna(0.0)
|
|
return w.shift(1).fillna(0.0)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Strategy: V3 regime gate + recovery-momentum composite on S&P 500
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class RegimeRecoveryPicker(Strategy):
|
|
"""V3 regime + recovery-momentum composite for stock selection.
|
|
|
|
Uses the same factor as RecoveryMomentumStrategy but only during risk-on.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
stock_tickers: list[str],
|
|
top_n: int = 10,
|
|
signal: str = "SPY",
|
|
defensive: tuple[str, ...] = ("GLD", "DBC"),
|
|
ma_long: int = 150,
|
|
recovery_window: int = 63,
|
|
mom_lookback: int = 252,
|
|
mom_skip: int = 21,
|
|
rebal_every: int = 21,
|
|
):
|
|
self.stock_tickers = stock_tickers
|
|
self.top_n = top_n
|
|
self.signal = signal
|
|
self.defensive = defensive
|
|
self.ma_long = ma_long
|
|
self.recovery_window = recovery_window
|
|
self.mom_lookback = mom_lookback
|
|
self.mom_skip = mom_skip
|
|
self.rebal_every = rebal_every
|
|
self._v3 = TrendRiderV3(
|
|
signal=signal, risk_on=("TQQQ", "UPRO"), risk_off=defensive,
|
|
ma_long=ma_long,
|
|
)
|
|
|
|
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
|
|
w = pd.DataFrame(np.nan, index=data.index, columns=data.columns)
|
|
|
|
if self.signal not in data.columns:
|
|
return w.fillna(0.0)
|
|
|
|
sig_arr = data[self.signal].to_numpy()
|
|
|
|
stock_data = data[[t for t in self.stock_tickers if t in data.columns]]
|
|
recovery = stock_data / stock_data.rolling(self.recovery_window).min() - 1
|
|
momentum = stock_data.shift(self.mom_skip).pct_change(
|
|
self.mom_lookback - self.mom_skip, fill_method=None,
|
|
)
|
|
rec_rank = recovery.rank(axis=1, pct=True, na_option="keep")
|
|
mom_rank = momentum.rank(axis=1, pct=True, na_option="keep")
|
|
composite = 0.5 * rec_rank + 0.5 * mom_rank
|
|
stock_rank = composite.rank(axis=1, ascending=False, na_option="bottom")
|
|
|
|
def_mom = data[[t for t in self.defensive if t in data.columns]].pct_change(63, fill_method=None)
|
|
avail_def = [t for t in self.defensive if t in data.columns]
|
|
|
|
need = max(self.ma_long, self.mom_lookback + 1,
|
|
self._v3.vol_window + 1, self._v3.dd_window,
|
|
self._v3.peak_window, self.recovery_window) + 1
|
|
|
|
regime: str | None = None
|
|
bars = 0
|
|
|
|
for i in range(len(data)):
|
|
if i < need:
|
|
continue
|
|
closes = sig_arr[:i]
|
|
if np.isnan(closes[-1]):
|
|
continue
|
|
|
|
desired = self._v3._desired_regime(closes, regime)
|
|
changed = False
|
|
if regime is None:
|
|
regime, bars, changed = desired, 0, True
|
|
else:
|
|
bars += 1
|
|
if desired != regime and bars >= 15:
|
|
regime, bars, changed = desired, 0, True
|
|
|
|
if not changed and (i - need) % self.rebal_every != 0:
|
|
continue
|
|
|
|
row = {c: 0.0 for c in data.columns}
|
|
dt = data.index[i]
|
|
|
|
if regime == "risk_on":
|
|
ranks_i = stock_rank.iloc[i]
|
|
n_valid = composite.iloc[i].notna().sum()
|
|
if n_valid >= self.top_n:
|
|
top = ranks_i[ranks_i <= self.top_n].index
|
|
if len(top) > 0:
|
|
wt = 1.0 / len(top)
|
|
for t in top:
|
|
row[t] = wt
|
|
if sum(row.values()) < 0.01 and avail_def:
|
|
row[avail_def[0]] = 1.0
|
|
else:
|
|
if avail_def:
|
|
dm = def_mom.iloc[i][avail_def].dropna()
|
|
best = dm.idxmax() if len(dm) > 0 else avail_def[0]
|
|
row[best] = 1.0
|
|
|
|
for c, v in row.items():
|
|
w.at[dt, c] = v
|
|
|
|
w = w.ffill().fillna(0.0)
|
|
return w.shift(1).fillna(0.0)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Main
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def main():
|
|
print("=" * 80)
|
|
print(" REGIME + STOCK PICKER EVALUATION")
|
|
print("=" * 80)
|
|
|
|
# --- Load S&P 500 data (PIT-safe) ---
|
|
print("\n[1/3] Loading S&P 500 universe (PIT)...")
|
|
universe = UNIVERSES["us"]
|
|
tickers = universe["fetch"]()
|
|
pit_intervals = uh.load_sp500_history()
|
|
hist_tickers = uh.all_tickers_ever(pit_intervals)
|
|
|
|
etf_extra = ["SPY", "GLD", "DBC", "SHY", "TQQQ", "UPRO", "TLT"]
|
|
all_tickers = sorted(set(tickers + hist_tickers + etf_extra))
|
|
print(f" Downloading {len(all_tickers)} tickers...")
|
|
|
|
data = data_manager.update("us", all_tickers, with_open=False)
|
|
if isinstance(data, tuple):
|
|
data = data[0]
|
|
|
|
cutoff = data.index[-1] - pd.DateOffset(years=YEARS)
|
|
data = data[data.index >= cutoff]
|
|
data = uh.mask_prices(data, pit_intervals)
|
|
|
|
stock_tickers = [
|
|
t for t in data.columns
|
|
if t not in etf_extra and data[t].notna().any()
|
|
]
|
|
print(f" Period: {data.index[0].date()} → {data.index[-1].date()}")
|
|
print(f" Tradable stocks: {len(stock_tickers)}")
|
|
|
|
# --- Run all strategies ---
|
|
print("\n[2/3] Running strategies...")
|
|
results: dict[str, pd.Series] = {}
|
|
|
|
def run(name, strategy, price_data):
|
|
print(f" {name}...")
|
|
eq = backtest(strategy, price_data, initial_capital=CAPITAL,
|
|
transaction_cost=TX_COST, fixed_fee=FIXED_FEE)
|
|
results[name] = eq
|
|
|
|
# 1-2. Regime + momentum top-N
|
|
for n in (10, 20):
|
|
run(f"Regime+Mom Top{n}",
|
|
RegimeStockPicker(stock_tickers=stock_tickers, top_n=n),
|
|
data)
|
|
|
|
# 3. Regime + recovery-momentum top-10
|
|
run("Regime+RecMom Top10",
|
|
RegimeRecoveryPicker(stock_tickers=stock_tickers, top_n=10),
|
|
data)
|
|
|
|
# 4. Regime + recovery-momentum top-20
|
|
run("Regime+RecMom Top20",
|
|
RegimeRecoveryPicker(stock_tickers=stock_tickers, top_n=20),
|
|
data)
|
|
|
|
# 5. Pure recovery momentum (no regime) — baseline
|
|
run("RecoveryMom Top10 (pure)",
|
|
RecoveryMomentumStrategy(top_n=10),
|
|
data[stock_tickers])
|
|
|
|
run("RecoveryMom Top20 (pure)",
|
|
RecoveryMomentumStrategy(top_n=20),
|
|
data[stock_tickers])
|
|
|
|
# 6. TrendRider V7 (leveraged ETFs)
|
|
etf_cols = [t for t in ["SPY", "TQQQ", "UPRO", "GLD", "DBC", "SHY"]
|
|
if t in data.columns]
|
|
run("TrendRider V7 (3x ETFs)", TrendRiderV7(), data[etf_cols])
|
|
|
|
# 7. SPY benchmark
|
|
spy = data["SPY"].dropna()
|
|
results["SPY (benchmark)"] = (spy / spy.iloc[0]) * CAPITAL
|
|
|
|
# --- Report ---
|
|
print(f"\n[3/3] Results ({YEARS}y, ${CAPITAL:,.0f}, tx={TX_COST*100:.1f}bps + ${FIXED_FEE:.0f}/trade)")
|
|
print("=" * 100)
|
|
hdr = (f"{'Strategy':<30} {'Ann%':>8} {'Vol%':>8} {'Sharpe':>8} "
|
|
f"{'Sortino':>8} {'MaxDD%':>8} {'Calmar':>8} {'WinRate':>8}")
|
|
print(hdr)
|
|
print("-" * 100)
|
|
|
|
for name, eq in results.items():
|
|
m = metrics.raw_summary(eq)
|
|
print(f"{name:<30} {m['annualizedReturn']*100:>7.1f}% "
|
|
f"{m['annualizedVolatility']*100:>7.1f}% "
|
|
f"{m['sharpeRatio']:>8.2f} {m['sortinoRatio']:>8.2f} "
|
|
f"{m['maxDrawdown']*100:>7.1f}% {m['calmarRatio']:>8.2f} "
|
|
f"{m['winRate']*100:>7.1f}%")
|
|
|
|
print("=" * 100)
|
|
|
|
# Yearly breakdown for top strategies
|
|
print("\n--- Yearly Returns ---")
|
|
yearly: dict[str, dict[str, float]] = {}
|
|
for name, eq in results.items():
|
|
yr = {}
|
|
for year, grp in eq.groupby(eq.index.year):
|
|
if len(grp) >= 2:
|
|
yr[str(year)] = grp.iloc[-1] / grp.iloc[0] - 1
|
|
yearly[name] = yr
|
|
|
|
all_years = sorted(set(y for d in yearly.values() for y in d))
|
|
header = f"{'Year':<6}" + "".join(f"{name[:20]:>22}" for name in results)
|
|
print(header)
|
|
print("-" * len(header))
|
|
for year in all_years:
|
|
cols = []
|
|
for name in results:
|
|
r = yearly[name].get(year)
|
|
cols.append(f"{r*100:>20.1f}%" if r is not None else f"{'—':>21}")
|
|
print(f"{year:<6}" + "".join(cols))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|