Four research directions beyond V7+VT36: 1. single_stock_swing: 20 famous stocks (Mag 7 + others), per-stock optimized swing trading. High-vol growth stocks (AMD Sharpe 1.55, TSLA 1.54) work best, but overfitting risk is extreme — universal params only TSLA is viable. Not competitive with V7. 2. v7_literature_alpha: 9 academic directions (VIX overlay, Kelly sizing, multi-MA, cross-asset, momentum acceleration, VIX mean- reversion, vol-adaptive PT, combined). V3's regime engine already implicitly captures most literature signals. MA130 marginally better than MA150 (+0.02 Sharpe, within noise). 3. new_frameworks_eval: volatility trading (SVXY risk-off) and calendar effects (turn-of-month). SVXY and V7 regime structurally conflict — SVXY crashes exactly when V7 goes risk-off. Turn-of-month has decent Sharpe (1.30) but only 28% annual. Nothing beats V7. 4. smart_dca_eval: fixed/VIX-scaled/MA-deviation/value-averaging/RSI DCA into SPY/QQQ/TQQQ/UPRO + V7 hybrids. Smart DCA barely beats fixed DCA. Any DCA hybrid dilutes V7's alpha. DCA only useful for new monthly contributions that can't lump-sum into V7. Conclusion: V7+VT36 remains SOTA across all tested frameworks. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
402 lines
16 KiB
Python
402 lines
16 KiB
Python
"""Literature-informed alpha research: can we beat V7+VT36?
|
||
|
||
Grounded in specific academic/industry research:
|
||
|
||
1. VIX regime overlay — Simon & Campasano (2014): VIX level as exogenous fear signal
|
||
2. Kelly-optimal sizing — Kelly (1956), Thorp (2006): return-aware position sizing
|
||
3. Multi-timeframe voting — Faber (2007): multiple MAs reduce false signals
|
||
4. Cross-asset confirmation — Asness et al. (2013): correlated asset agreement
|
||
5. Momentum acceleration — Moskowitz et al. (2012): 2nd derivative of trend
|
||
6. VIX mean-reversion entry — Whaley (2009): buy panic, sell complacency
|
||
7. Carry-enhanced risk-off — Koijen et al. (2018): hold yield during defensive periods
|
||
8. Regime-dependent PT — Optimal stopping theory: vol-drag-aware thresholds
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import sys
|
||
sys.path.insert(0, ".")
|
||
|
||
import numpy as np
|
||
import pandas as pd
|
||
|
||
import data_manager
|
||
import metrics
|
||
from strategies.base import Strategy
|
||
from strategies.permanent import TrendRiderV3
|
||
from strategies.trend_rider_v7 import TrendRiderV7
|
||
from main import backtest
|
||
|
||
YEARS = 10
|
||
CAPITAL = 100_000
|
||
TX_COST = 0.001
|
||
FIXED_FEE = 2.0
|
||
|
||
|
||
class V7Enhanced(Strategy):
|
||
"""V7 with pluggable regime enhancer and sizing model."""
|
||
|
||
def __init__(
|
||
self,
|
||
regime_enhancer=None,
|
||
sizing_model="vol_target",
|
||
pt_model="fixed",
|
||
target_vol=0.36, min_lev=0.75, max_lev=1.0,
|
||
pt_threshold=0.30, pt_band=0.10, pt_park="SHY",
|
||
ma_long=150, **v3_kw,
|
||
):
|
||
self.regime_enhancer = regime_enhancer
|
||
self.sizing_model = sizing_model
|
||
self.pt_model = pt_model
|
||
self.target_vol = target_vol
|
||
self.min_lev = min_lev
|
||
self.max_lev = max_lev
|
||
self.pt_threshold = pt_threshold
|
||
self.pt_band = pt_band
|
||
self.pt_park = pt_park
|
||
self.v3 = TrendRiderV3(
|
||
signal="SPY", risk_on=("TQQQ", "UPRO"), risk_off=("GLD", "DBC"),
|
||
ma_long=ma_long, **v3_kw,
|
||
)
|
||
|
||
def generate_signals(self, data):
|
||
w = self.v3.generate_signals(data)
|
||
if self.pt_park and self.pt_park in data.columns and self.pt_park not in w.columns:
|
||
w[self.pt_park] = 0.0
|
||
|
||
# Regime enhancement: override V3's decision in specific conditions
|
||
if self.regime_enhancer:
|
||
w = self.regime_enhancer(w, data)
|
||
|
||
# Sizing
|
||
daily_ret = data.pct_change(fill_method=None).fillna(0.0)
|
||
common = w.columns.intersection(daily_ret.columns)
|
||
port_rets = (w[common] * daily_ret[common]).sum(axis=1)
|
||
|
||
if self.sizing_model == "kelly":
|
||
# Kelly: scale = E[r] / Var[r], clipped
|
||
roll_mean = port_rets.rolling(60, min_periods=21).mean() * 252
|
||
roll_var = port_rets.rolling(60, min_periods=21).var() * 252
|
||
kelly_f = (roll_mean / roll_var.clip(lower=0.01)).clip(-1, 2)
|
||
scale = kelly_f.clip(lower=self.min_lev, upper=self.max_lev)
|
||
scale = scale.shift(1).fillna(1.0)
|
||
else:
|
||
realized_vol = port_rets.rolling(60, min_periods=21).std() * np.sqrt(252)
|
||
scale = (self.target_vol / realized_vol).clip(
|
||
lower=self.min_lev, upper=self.max_lev)
|
||
scale = scale.shift(1).fillna(1.0)
|
||
|
||
w = w.mul(scale, axis=0)
|
||
|
||
# Profit-take
|
||
if self.pt_threshold <= 0:
|
||
return w
|
||
|
||
risk_on_set = set(self.v3.risk_on)
|
||
held = w.idxmax(axis=1)
|
||
max_w = w.max(axis=1)
|
||
held[max_w < 1e-8] = ""
|
||
park_col = self.pt_park if self.pt_park in w.columns else ""
|
||
ep, cs, stopped = None, None, False
|
||
rl = self.pt_threshold - self.pt_band
|
||
|
||
if self.pt_model == "vol_adaptive":
|
||
# PT threshold inversely proportional to vol drag
|
||
# Vol drag ≈ leverage² × σ² / 2; for 3x: 9σ²/2
|
||
# Optimal PT ≈ base / (1 + k * σ²)
|
||
realized_vol_arr = port_rets.rolling(60, min_periods=21).std().to_numpy() * np.sqrt(252)
|
||
|
||
for i in range(len(w)):
|
||
sym = held.iloc[i]
|
||
if not sym or max_w.iloc[i] < 1e-8:
|
||
cs, ep, stopped = None, None, False
|
||
continue
|
||
if sym != cs:
|
||
cs = sym
|
||
ep = float(data[sym].iloc[i-1]) if i > 0 and sym in data.columns else None
|
||
stopped = False
|
||
continue
|
||
if sym not in risk_on_set:
|
||
continue
|
||
if ep is None or ep <= 0 or sym not in data.columns:
|
||
continue
|
||
y = float(data[sym].iloc[i-1]) if i > 0 else float(data[sym].iloc[i])
|
||
g = y / ep - 1.0
|
||
|
||
if self.pt_model == "vol_adaptive":
|
||
rv = realized_vol_arr[i] if i < len(realized_vol_arr) and not np.isnan(realized_vol_arr[i]) else 0.25
|
||
# Higher vol → lower threshold (take profits faster)
|
||
t = self.pt_threshold * (0.25 / max(rv, 0.10))
|
||
t = np.clip(t, 0.15, 0.50)
|
||
r = t * (1 - self.pt_band / self.pt_threshold)
|
||
else:
|
||
t = self.pt_threshold
|
||
r = rl
|
||
|
||
if stopped:
|
||
if g < r: stopped = False
|
||
else:
|
||
w.iloc[i] = 0.0
|
||
if park_col: w.at[w.index[i], park_col] = scale.iloc[i]
|
||
elif g >= t:
|
||
stopped = True
|
||
w.iloc[i] = 0.0
|
||
if park_col: w.at[w.index[i], park_col] = scale.iloc[i]
|
||
return w
|
||
|
||
|
||
# =========================================================================
|
||
# Regime enhancers
|
||
# =========================================================================
|
||
|
||
def vix_overlay(vix_high=25, vix_low=15):
|
||
"""Force risk-off when VIX > threshold. Simon & Campasano (2014)."""
|
||
def enhancer(w, data):
|
||
if "^VIX" not in data.columns:
|
||
return w
|
||
vix = data["^VIX"].shift(1).fillna(20)
|
||
risk_on_cols = [c for c in ["TQQQ", "UPRO"] if c in w.columns]
|
||
risk_off_cols = [c for c in ["GLD", "DBC"] if c in w.columns]
|
||
park = "SHY" if "SHY" in w.columns else ""
|
||
for i in range(len(w)):
|
||
v = vix.iloc[i]
|
||
if np.isnan(v): continue
|
||
ron_w = sum(float(w.iat[i, w.columns.get_loc(c)]) for c in risk_on_cols)
|
||
if ron_w > 0.01 and v > vix_high:
|
||
for c in risk_on_cols:
|
||
w.iat[i, w.columns.get_loc(c)] = 0.0
|
||
if risk_off_cols:
|
||
w.iat[i, w.columns.get_loc(risk_off_cols[0])] = ron_w
|
||
return w
|
||
return enhancer
|
||
|
||
|
||
def multi_timeframe(windows=(50, 150, 200), min_agree=2):
|
||
"""Multi-MA voting. Faber (2007). Need majority of MAs bullish."""
|
||
def enhancer(w, data):
|
||
if "SPY" not in data.columns:
|
||
return w
|
||
spy = data["SPY"]
|
||
votes = pd.DataFrame(index=data.index)
|
||
for win in windows:
|
||
ma = spy.rolling(win).mean()
|
||
votes[f"ma{win}"] = (spy > ma).astype(int)
|
||
total_votes = votes.sum(axis=1).shift(2) # PIT: shift 2 to match V3
|
||
|
||
risk_on_cols = [c for c in ["TQQQ", "UPRO"] if c in w.columns]
|
||
risk_off_cols = [c for c in ["GLD", "DBC"] if c in w.columns]
|
||
for i in range(len(w)):
|
||
ron_w = sum(float(w.iat[i, w.columns.get_loc(c)]) for c in risk_on_cols)
|
||
if ron_w > 0.01 and total_votes.iloc[i] < min_agree:
|
||
for c in risk_on_cols:
|
||
w.iat[i, w.columns.get_loc(c)] = 0.0
|
||
if risk_off_cols:
|
||
w.iat[i, w.columns.get_loc(risk_off_cols[0])] = ron_w
|
||
return w
|
||
return enhancer
|
||
|
||
|
||
def cross_asset_confirm():
|
||
"""Require both SPY and QQQ trends to agree. Asness et al. (2013)."""
|
||
def enhancer(w, data):
|
||
if "SPY" not in data.columns or "QQQ" not in data.columns:
|
||
return w
|
||
spy_bull = (data["SPY"] > data["SPY"].rolling(150).mean()).shift(2).fillna(False)
|
||
qqq_bull = (data["QQQ"] > data["QQQ"].rolling(150).mean()).shift(2).fillna(False)
|
||
both_bull = spy_bull & qqq_bull
|
||
|
||
risk_on_cols = [c for c in ["TQQQ", "UPRO"] if c in w.columns]
|
||
risk_off_cols = [c for c in ["GLD", "DBC"] if c in w.columns]
|
||
for i in range(len(w)):
|
||
ron_w = sum(float(w.iat[i, w.columns.get_loc(c)]) for c in risk_on_cols)
|
||
if ron_w > 0.01 and not both_bull.iloc[i]:
|
||
for c in risk_on_cols:
|
||
w.iat[i, w.columns.get_loc(c)] = 0.0
|
||
if risk_off_cols:
|
||
w.iat[i, w.columns.get_loc(risk_off_cols[0])] = ron_w
|
||
return w
|
||
return enhancer
|
||
|
||
|
||
def momentum_accel(accel_window=20):
|
||
"""Only risk-on when trend is accelerating. Moskowitz et al. (2012)."""
|
||
def enhancer(w, data):
|
||
if "SPY" not in data.columns:
|
||
return w
|
||
spy = data["SPY"]
|
||
ma150 = spy.rolling(150).mean()
|
||
ma_slope = ma150.diff(accel_window)
|
||
accel_positive = (ma_slope > 0).shift(2).fillna(False)
|
||
|
||
risk_on_cols = [c for c in ["TQQQ", "UPRO"] if c in w.columns]
|
||
risk_off_cols = [c for c in ["GLD", "DBC"] if c in w.columns]
|
||
for i in range(len(w)):
|
||
ron_w = sum(float(w.iat[i, w.columns.get_loc(c)]) for c in risk_on_cols)
|
||
if ron_w > 0.01 and not accel_positive.iloc[i]:
|
||
for c in risk_on_cols:
|
||
w.iat[i, w.columns.get_loc(c)] = 0.0
|
||
if risk_off_cols:
|
||
w.iat[i, w.columns.get_loc(risk_off_cols[0])] = ron_w
|
||
return w
|
||
return enhancer
|
||
|
||
|
||
def vix_mean_revert_entry(vix_spike=30, lookback=5):
|
||
"""After VIX spike + revert, force risk-on. Whaley (2009) mean-reversion."""
|
||
def enhancer(w, data):
|
||
if "^VIX" not in data.columns:
|
||
return w
|
||
vix = data["^VIX"].shift(1).fillna(20)
|
||
vix_was_high = vix.rolling(lookback).max() > vix_spike
|
||
vix_now_falling = vix < vix.rolling(lookback).mean()
|
||
buy_signal = vix_was_high & vix_now_falling
|
||
|
||
risk_on_cols = [c for c in ["TQQQ", "UPRO"] if c in w.columns]
|
||
risk_off_cols = [c for c in ["GLD", "DBC"] if c in w.columns]
|
||
for i in range(len(w)):
|
||
roff_w = sum(float(w.iat[i, w.columns.get_loc(c)]) for c in risk_off_cols)
|
||
if roff_w > 0.01 and buy_signal.iloc[i]:
|
||
for c in risk_off_cols:
|
||
w.iat[i, w.columns.get_loc(c)] = 0.0
|
||
if risk_on_cols:
|
||
w.iat[i, w.columns.get_loc(risk_on_cols[0])] = roff_w
|
||
return w
|
||
return enhancer
|
||
|
||
|
||
def combined_enhancer(*enhancers):
|
||
"""Chain multiple enhancers."""
|
||
def enhancer(w, data):
|
||
for e in enhancers:
|
||
w = e(w, data)
|
||
return w
|
||
return enhancer
|
||
|
||
|
||
# =========================================================================
|
||
# Main
|
||
# =========================================================================
|
||
|
||
def main():
|
||
print("=" * 100)
|
||
print(" LITERATURE-INFORMED ALPHA RESEARCH")
|
||
print("=" * 100)
|
||
|
||
all_etfs = sorted(set([
|
||
"SPY", "QQQ", "TQQQ", "UPRO", "GLD", "DBC", "SHY", "TLT",
|
||
"^VIX",
|
||
]))
|
||
data = data_manager.update("etfs", all_etfs, with_open=False)
|
||
if isinstance(data, tuple):
|
||
data = data[0]
|
||
cutoff = data.index[-1] - pd.DateOffset(years=YEARS)
|
||
data = data[data.index >= cutoff]
|
||
|
||
has_vix = "^VIX" in data.columns
|
||
has_qqq = "QQQ" in data.columns
|
||
print(f"Period: {data.index[0].date()} → {data.index[-1].date()}")
|
||
print(f"VIX available: {has_vix}, QQQ available: {has_qqq}")
|
||
|
||
results = []
|
||
def run(label, strategy):
|
||
eq = backtest(strategy, data, initial_capital=CAPITAL,
|
||
transaction_cost=TX_COST, fixed_fee=FIXED_FEE)
|
||
m = metrics.raw_summary(eq)
|
||
results.append((label, m))
|
||
print(f" {label:<55} Ann={m['annualizedReturn']*100:>5.1f}% "
|
||
f"Sharpe={m['sharpeRatio']:.2f} MaxDD={m['maxDrawdown']*100:.1f}% "
|
||
f"Calmar={m['calmarRatio']:.2f}")
|
||
|
||
# Baseline
|
||
print("\n--- Baseline ---")
|
||
run("V7+VT36 baseline", V7Enhanced())
|
||
|
||
# === Idea 1: VIX overlay ===
|
||
print("\n--- Idea 1: VIX regime overlay (Simon & Campasano 2014) ---")
|
||
if has_vix:
|
||
for hi in (20, 25, 30):
|
||
run(f"VIX overlay (force off >VIX{hi})", V7Enhanced(regime_enhancer=vix_overlay(hi)))
|
||
else:
|
||
print(" VIX not available")
|
||
|
||
# === Idea 2: Kelly sizing ===
|
||
print("\n--- Idea 2: Kelly-optimal sizing (Kelly 1956, Thorp 2006) ---")
|
||
run("Kelly sizing", V7Enhanced(sizing_model="kelly"))
|
||
run("Kelly + VIX>25", V7Enhanced(sizing_model="kelly",
|
||
regime_enhancer=vix_overlay(25) if has_vix else None))
|
||
|
||
# === Idea 3: Multi-timeframe voting ===
|
||
print("\n--- Idea 3: Multi-MA voting (Faber 2007) ---")
|
||
run("Multi-MA 2/3 (50,150,200)", V7Enhanced(regime_enhancer=multi_timeframe()))
|
||
run("Multi-MA 3/3 (all agree)", V7Enhanced(regime_enhancer=multi_timeframe(min_agree=3)))
|
||
|
||
# === Idea 4: Cross-asset confirmation ===
|
||
print("\n--- Idea 4: Cross-asset (Asness et al. 2013) ---")
|
||
if has_qqq:
|
||
run("SPY+QQQ both bullish", V7Enhanced(regime_enhancer=cross_asset_confirm()))
|
||
|
||
# === Idea 5: Momentum acceleration ===
|
||
print("\n--- Idea 5: Momentum acceleration (Moskowitz et al. 2012) ---")
|
||
for w in (10, 20, 40):
|
||
run(f"MA150 slope rising ({w}d)", V7Enhanced(regime_enhancer=momentum_accel(w)))
|
||
|
||
# === Idea 6: VIX mean-reversion entry ===
|
||
print("\n--- Idea 6: VIX mean-reversion entry (Whaley 2009) ---")
|
||
if has_vix:
|
||
for spike in (25, 30, 35):
|
||
run(f"VIX spike>{spike} + revert → buy",
|
||
V7Enhanced(regime_enhancer=vix_mean_revert_entry(spike)))
|
||
|
||
# === Idea 7: Vol-adaptive PT ===
|
||
print("\n--- Idea 7: Vol-drag-aware PT (optimal stopping theory) ---")
|
||
run("Vol-adaptive PT (base=30%)", V7Enhanced(pt_model="vol_adaptive"))
|
||
run("Vol-adaptive PT (base=35%)", V7Enhanced(pt_model="vol_adaptive", pt_threshold=0.35))
|
||
|
||
# === Idea 8: Combined best ideas ===
|
||
print("\n--- Idea 8: Combinations ---")
|
||
if has_vix:
|
||
run("VIX>25 + multi-MA 2/3",
|
||
V7Enhanced(regime_enhancer=combined_enhancer(
|
||
vix_overlay(25), multi_timeframe())))
|
||
run("VIX>25 + cross-asset",
|
||
V7Enhanced(regime_enhancer=combined_enhancer(
|
||
vix_overlay(25), cross_asset_confirm())) if has_qqq else None)
|
||
run("VIX>30 + accel(20d)",
|
||
V7Enhanced(regime_enhancer=combined_enhancer(
|
||
vix_overlay(30), momentum_accel(20))))
|
||
# VIX mean-revert + normal V3
|
||
run("V7 + VIX mean-revert entry (>30)",
|
||
V7Enhanced(regime_enhancer=vix_mean_revert_entry(30)))
|
||
|
||
# === Idea 9: Different MA for V3 regime ===
|
||
print("\n--- Idea 9: Alternative MA windows ---")
|
||
for ma in (100, 120, 130, 150, 170, 200):
|
||
run(f"V3 MA{ma} + VT36", V7Enhanced(ma_long=ma))
|
||
|
||
# Final ranking
|
||
results.sort(key=lambda x: x[1]["sharpeRatio"], reverse=True)
|
||
print(f"\n{'=' * 110}")
|
||
print(" FINAL RANKING (by Sharpe)")
|
||
print(f"{'=' * 110}")
|
||
print(f"{'#':<4} {'Strategy':<55} {'Ann%':>6} {'Vol%':>6} {'Sharpe':>7} "
|
||
f"{'Sortino':>8} {'MaxDD%':>7} {'Calmar':>7}")
|
||
print("-" * 110)
|
||
for i, (label, m) in enumerate(results, 1):
|
||
marker = " ★" if i <= 3 else ""
|
||
print(f"{i:<4} {label:<55} "
|
||
f"{m['annualizedReturn']*100:>5.1f}% "
|
||
f"{m['annualizedVolatility']*100:>5.1f}% "
|
||
f"{m['sharpeRatio']:>7.2f} {m['sortinoRatio']:>8.2f} "
|
||
f"{m['maxDrawdown']*100:>6.1f}% {m['calmarRatio']:>7.2f}{marker}")
|
||
print(f"{'=' * 110}")
|
||
|
||
# Top by Ann Return
|
||
results.sort(key=lambda x: x[1]["annualizedReturn"], reverse=True)
|
||
print(f"\n Top 5 by Ann Return:")
|
||
for i, (label, m) in enumerate(results[:5], 1):
|
||
print(f" {i}. {label:<50} Ann={m['annualizedReturn']*100:.1f}% "
|
||
f"Sharpe={m['sharpeRatio']:.2f}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|