research: individual stock swing, new frameworks, literature alpha, DCA
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>
This commit is contained in:
433
research/new_frameworks_eval.py
Normal file
433
research/new_frameworks_eval.py
Normal file
@@ -0,0 +1,433 @@
|
||||
"""New frameworks: Vol trading (#2) + Event-driven simplified (#4).
|
||||
|
||||
Framework 2: Volatility Trading
|
||||
A. V7 risk-off → SVXY (harvest vol premium during normal risk-off)
|
||||
B. Standalone SVXY mean-reversion (buy SVXY when VIX spikes + reverts)
|
||||
C. V7 + SVXY risk-off with VIX gate (no SVXY when VIX > 30)
|
||||
|
||||
Framework 4: Event-Driven Simplified
|
||||
D. Earnings calendar effect: avoid holding around earnings (high idio risk)
|
||||
→ Not applicable to ETFs. Instead: monthly seasonality on leveraged ETFs
|
||||
E. Turn-of-month effect (known anomaly: last 3 + first 3 days of month)
|
||||
F. Holiday effect (market tends to rise before holidays)
|
||||
G. Options expiration week effect (OpEx week has different dynamics)
|
||||
|
||||
Also: pure standalone strategies (not V7 modifications)
|
||||
H. Pure SVXY with VIX regime gating
|
||||
I. TQQQ buy-the-dip: buy TQQQ when RSI < 30, sell when RSI > 70
|
||||
J. VIX term structure carry: long SVXY when VIX contango, cash when backwardation
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
import data_manager
|
||||
import metrics
|
||||
from main import backtest
|
||||
from strategies.base import Strategy
|
||||
from strategies.permanent import TrendRiderV3
|
||||
from strategies.trend_rider_v7 import TrendRiderV7
|
||||
|
||||
YEARS = 10
|
||||
CAPITAL = 100_000
|
||||
TX_COST = 0.001
|
||||
FIXED_FEE = 2.0
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Framework 2: Volatility Trading Strategies
|
||||
# =========================================================================
|
||||
|
||||
class V7SvxyRiskOff(Strategy):
|
||||
"""V7 with SVXY as risk-off instrument (gated by VIX level).
|
||||
|
||||
During risk-off:
|
||||
- If VIX < vix_gate: hold SVXY (harvest vol premium)
|
||||
- If VIX >= vix_gate: hold GLD/DBC (traditional safe haven)
|
||||
"""
|
||||
def __init__(self, vix_gate=30, svxy="SVXY", **v7_kw):
|
||||
self.vix_gate = vix_gate
|
||||
self.svxy = svxy
|
||||
self.v7 = TrendRiderV7(**v7_kw)
|
||||
|
||||
def generate_signals(self, data):
|
||||
w = self.v7.generate_signals(data)
|
||||
svxy = self.svxy
|
||||
if svxy not in data.columns or "^VIX" not in data.columns:
|
||||
return w
|
||||
if svxy not in w.columns:
|
||||
w[svxy] = 0.0
|
||||
|
||||
vix = data["^VIX"].shift(2).fillna(20) # PIT: 2-day lag matching V3
|
||||
roff_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 roff_cols)
|
||||
if roff_w < 1e-8:
|
||||
continue
|
||||
v = vix.iloc[i]
|
||||
if np.isnan(v) or v >= self.vix_gate:
|
||||
continue
|
||||
# Replace risk-off with SVXY
|
||||
for c in roff_cols:
|
||||
w.iat[i, w.columns.get_loc(c)] = 0.0
|
||||
w.iat[i, w.columns.get_loc(svxy)] = roff_w
|
||||
return w
|
||||
|
||||
|
||||
class PureSvxyStrategy(Strategy):
|
||||
"""Standalone SVXY with VIX regime gating.
|
||||
|
||||
Hold SVXY when VIX < upper_gate AND VIX > VIX_MA (mean-reverting down).
|
||||
Cash otherwise. Vol-target overlay for sizing.
|
||||
"""
|
||||
def __init__(self, upper_gate=25, ma_window=20, target_vol=0.20,
|
||||
min_lev=0.3, svxy="SVXY"):
|
||||
self.upper_gate = upper_gate
|
||||
self.ma_window = ma_window
|
||||
self.target_vol = target_vol
|
||||
self.min_lev = min_lev
|
||||
self.svxy = svxy
|
||||
|
||||
def generate_signals(self, data):
|
||||
cols = [c for c in [self.svxy, "^VIX", "SHY"] if c in data.columns]
|
||||
w = pd.DataFrame(0.0, index=data.index, columns=cols + [self.svxy])
|
||||
if self.svxy not in data.columns or "^VIX" not in data.columns:
|
||||
return w
|
||||
|
||||
vix = data["^VIX"]
|
||||
vix_ma = vix.rolling(self.ma_window).mean()
|
||||
# Hold SVXY when: VIX < gate AND VIX is falling (below its MA)
|
||||
hold_signal = ((vix < self.upper_gate) & (vix < vix_ma)).shift(2).fillna(False)
|
||||
|
||||
w[self.svxy] = hold_signal.astype(float)
|
||||
w = w.fillna(0.0)
|
||||
|
||||
# Vol-target
|
||||
if self.svxy in data.columns:
|
||||
rets = data[self.svxy].pct_change(fill_method=None).fillna(0.0)
|
||||
rv = rets.rolling(60, min_periods=21).std() * np.sqrt(252)
|
||||
scale = (self.target_vol / rv).clip(lower=self.min_lev, upper=1.0).shift(1).fillna(1.0)
|
||||
w = w.mul(scale, axis=0)
|
||||
|
||||
return w
|
||||
|
||||
|
||||
class TqqqRsiStrategy(Strategy):
|
||||
"""TQQQ buy-the-dip: buy when RSI < oversold, sell when RSI > overbought."""
|
||||
def __init__(self, rsi_window=14, buy_level=30, sell_level=70,
|
||||
target_vol=0.30, min_lev=0.5):
|
||||
self.rsi_window = rsi_window
|
||||
self.buy_level = buy_level
|
||||
self.sell_level = sell_level
|
||||
self.target_vol = target_vol
|
||||
self.min_lev = min_lev
|
||||
|
||||
def generate_signals(self, data):
|
||||
if "SPY" not in data.columns or "TQQQ" not in data.columns:
|
||||
return pd.DataFrame(0.0, index=data.index, columns=data.columns)
|
||||
|
||||
# RSI on SPY (not TQQQ — SPY is less noisy)
|
||||
spy = data["SPY"]
|
||||
delta = spy.diff()
|
||||
gain = delta.clip(lower=0)
|
||||
loss = (-delta).clip(lower=0)
|
||||
avg_gain = gain.rolling(self.rsi_window).mean()
|
||||
avg_loss = loss.rolling(self.rsi_window).mean()
|
||||
rs = avg_gain / avg_loss.clip(lower=1e-10)
|
||||
rsi = 100 - (100 / (1 + rs))
|
||||
rsi = rsi.shift(1) # PIT
|
||||
|
||||
cols = [c for c in data.columns]
|
||||
w = pd.DataFrame(0.0, index=data.index, columns=cols)
|
||||
|
||||
in_position = False
|
||||
for i in range(len(data)):
|
||||
r = rsi.iloc[i]
|
||||
if np.isnan(r):
|
||||
continue
|
||||
if not in_position and r < self.buy_level:
|
||||
in_position = True
|
||||
elif in_position and r > self.sell_level:
|
||||
in_position = False
|
||||
if in_position and "TQQQ" in w.columns:
|
||||
w.iat[i, w.columns.get_loc("TQQQ")] = 1.0
|
||||
|
||||
# Vol-target
|
||||
rets = data["TQQQ"].pct_change(fill_method=None).fillna(0.0) if "TQQQ" in data.columns else pd.Series(0.0, index=data.index)
|
||||
rv = rets.rolling(60, min_periods=21).std() * np.sqrt(252)
|
||||
scale = (self.target_vol / rv).clip(lower=self.min_lev, upper=1.0).shift(1).fillna(1.0)
|
||||
w = w.mul(scale, axis=0)
|
||||
return w
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Framework 4: Calendar/Seasonality Strategies
|
||||
# =========================================================================
|
||||
|
||||
class TurnOfMonthStrategy(Strategy):
|
||||
"""Exploit turn-of-month anomaly: hold TQQQ last N + first M trading days.
|
||||
|
||||
Lakonishok & Smidt (1988): stocks earn disproportionate returns
|
||||
at month boundaries. Payroll flows, pension rebalancing.
|
||||
"""
|
||||
def __init__(self, days_before=3, days_after=3, target_vol=0.36, min_lev=0.75):
|
||||
self.days_before = days_before
|
||||
self.days_after = days_after
|
||||
self.target_vol = target_vol
|
||||
self.min_lev = min_lev
|
||||
|
||||
def generate_signals(self, data):
|
||||
cols = [c for c in data.columns]
|
||||
w = pd.DataFrame(0.0, index=data.index, columns=cols)
|
||||
if "TQQQ" not in data.columns:
|
||||
return w
|
||||
|
||||
# Identify turn-of-month windows
|
||||
dates = data.index
|
||||
months = dates.to_period("M")
|
||||
|
||||
for period in months.unique():
|
||||
month_mask = months == period
|
||||
month_dates = dates[month_mask]
|
||||
if len(month_dates) < 5:
|
||||
continue
|
||||
# Last N days of month
|
||||
for d in month_dates[-self.days_before:]:
|
||||
w.at[d, "TQQQ"] = 1.0
|
||||
# First M days of month
|
||||
for d in month_dates[:self.days_after]:
|
||||
w.at[d, "TQQQ"] = 1.0
|
||||
|
||||
# Shift for PIT
|
||||
w = w.shift(1).fillna(0.0)
|
||||
|
||||
# Vol-target
|
||||
if "TQQQ" in data.columns:
|
||||
rets = data["TQQQ"].pct_change(fill_method=None).fillna(0.0)
|
||||
port_rets = (w["TQQQ"] * rets)
|
||||
rv = port_rets.rolling(60, min_periods=21).std() * np.sqrt(252)
|
||||
scale = (self.target_vol / rv).clip(lower=self.min_lev, upper=1.0).shift(1).fillna(1.0)
|
||||
w = w.mul(scale, axis=0)
|
||||
return w
|
||||
|
||||
|
||||
class V7TurnOfMonth(Strategy):
|
||||
"""V7 but only enters risk-on during turn-of-month windows.
|
||||
|
||||
Combines V7 regime timing with monthly seasonality.
|
||||
Outside the window: force to risk-off even if V7 says risk-on.
|
||||
"""
|
||||
def __init__(self, days_before=4, days_after=4, **v7_kw):
|
||||
self.days_before = days_before
|
||||
self.days_after = days_after
|
||||
self.v7 = TrendRiderV7(**v7_kw)
|
||||
|
||||
def generate_signals(self, data):
|
||||
w = self.v7.generate_signals(data)
|
||||
|
||||
dates = data.index
|
||||
months = dates.to_period("M")
|
||||
in_window = pd.Series(False, index=dates)
|
||||
for period in months.unique():
|
||||
month_dates = dates[months == period]
|
||||
if len(month_dates) < 5:
|
||||
continue
|
||||
for d in month_dates[-self.days_before:]:
|
||||
in_window[d] = True
|
||||
for d in month_dates[:self.days_after]:
|
||||
in_window[d] = True
|
||||
|
||||
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)):
|
||||
if in_window.iloc[i]:
|
||||
continue
|
||||
ron_w = sum(float(w.iat[i, w.columns.get_loc(c)]) for c in risk_on_cols)
|
||||
if ron_w > 0.01:
|
||||
for c in risk_on_cols:
|
||||
w.iat[i, w.columns.get_loc(c)] = 0.0
|
||||
if park and park in w.columns:
|
||||
w.iat[i, w.columns.get_loc(park)] = ron_w
|
||||
return w
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Hybrid: V7 risk-on + Vol premium risk-off
|
||||
# =========================================================================
|
||||
|
||||
class V7VolHybrid(Strategy):
|
||||
"""Best of both worlds: V7 regime for risk-on, SVXY for risk-off.
|
||||
|
||||
Risk-on: TQQQ/UPRO (V7 momentum pick) — trend alpha
|
||||
Risk-off: SVXY when VIX < gate, GLD when VIX >= gate — vol premium alpha
|
||||
Vol-target + PT on risk-on only.
|
||||
|
||||
Two independent alpha sources:
|
||||
1. Equity trend momentum (V7's regime timing)
|
||||
2. Volatility risk premium (SVXY during calm periods)
|
||||
"""
|
||||
def __init__(self, vix_gate=28, target_vol=0.36, min_lev=0.75,
|
||||
pt_threshold=0.30, pt_band=0.10):
|
||||
self.vix_gate = vix_gate
|
||||
self.target_vol = target_vol
|
||||
self.min_lev = min_lev
|
||||
self.pt_threshold = pt_threshold
|
||||
self.pt_band = pt_band
|
||||
self.v3 = TrendRiderV3(signal="SPY", risk_on=("TQQQ", "UPRO"),
|
||||
risk_off=("GLD", "DBC"), ma_long=150)
|
||||
|
||||
def generate_signals(self, data):
|
||||
w = self.v3.generate_signals(data)
|
||||
for col in ["SHY", "SVXY"]:
|
||||
if col in data.columns and col not in w.columns:
|
||||
w[col] = 0.0
|
||||
|
||||
has_vix = "^VIX" in data.columns
|
||||
has_svxy = "SVXY" in data.columns
|
||||
if has_vix and has_svxy:
|
||||
vix = data["^VIX"].shift(2).fillna(20)
|
||||
roff_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 roff_cols)
|
||||
if roff_w < 1e-8: continue
|
||||
v = vix.iloc[i]
|
||||
if np.isnan(v) or v >= self.vix_gate: continue
|
||||
for c in roff_cols:
|
||||
w.iat[i, w.columns.get_loc(c)] = 0.0
|
||||
w.iat[i, w.columns.get_loc("SVXY")] = roff_w
|
||||
|
||||
# Vol-target
|
||||
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)
|
||||
rv = port_rets.rolling(60, min_periods=21).std() * np.sqrt(252)
|
||||
scale = (self.target_vol / rv).clip(lower=self.min_lev, upper=1.0).shift(1).fillna(1.0)
|
||||
w = w.mul(scale, axis=0)
|
||||
|
||||
# PT on risk-on only
|
||||
if self.pt_threshold <= 0: return w
|
||||
risk_on_set = {"TQQQ", "UPRO"}
|
||||
held = w.idxmax(axis=1); mx = w.max(axis=1); held[mx < 1e-8] = ""
|
||||
park = "SHY" if "SHY" in w.columns else ""
|
||||
ep, cs, stopped = None, None, False
|
||||
rl = self.pt_threshold - self.pt_band
|
||||
for i in range(len(w)):
|
||||
sym = held.iloc[i]
|
||||
if not sym or mx.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 stopped:
|
||||
if g < rl: stopped = False
|
||||
else: w.iloc[i] = 0.0; (w.at.__setitem__((w.index[i], park), scale.iloc[i]) if park else None)
|
||||
elif g >= self.pt_threshold:
|
||||
stopped = True; w.iloc[i] = 0.0; (w.at.__setitem__((w.index[i], park), scale.iloc[i]) if park else None)
|
||||
return w
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Main
|
||||
# =========================================================================
|
||||
|
||||
def main():
|
||||
print("=" * 110)
|
||||
print(" NEW FRAMEWORKS: VOL TRADING + CALENDAR EFFECTS")
|
||||
print("=" * 110)
|
||||
|
||||
all_etfs = sorted(set([
|
||||
"SPY", "QQQ", "TQQQ", "UPRO", "GLD", "DBC", "SHY", "TLT",
|
||||
"^VIX", "SVXY",
|
||||
]))
|
||||
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]
|
||||
|
||||
print(f"Period: {data.index[0].date()} → {data.index[-1].date()}")
|
||||
print(f"SVXY: {'yes' if 'SVXY' in data.columns else 'no'}, "
|
||||
f"VIX: {'yes' if '^VIX' in data.columns else 'no'}")
|
||||
|
||||
results = []
|
||||
def run(label, strategy):
|
||||
try:
|
||||
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}")
|
||||
except Exception as e:
|
||||
print(f" {label:<55} FAILED: {e}")
|
||||
|
||||
# === Baseline ===
|
||||
print("\n--- Baseline ---")
|
||||
run("V7+VT36 baseline", TrendRiderV7(target_vol=0.36, min_lev=0.75))
|
||||
|
||||
# === Framework 2: Vol Trading ===
|
||||
print("\n--- Framework 2A: V7 + SVXY risk-off ---")
|
||||
for gate in (25, 28, 30, 35):
|
||||
run(f"V7+SVXY risk-off (VIX gate={gate})",
|
||||
V7SvxyRiskOff(vix_gate=gate, target_vol=0.36, min_lev=0.75))
|
||||
|
||||
print("\n--- Framework 2B: Pure SVXY strategies ---")
|
||||
for gate in (20, 25, 30):
|
||||
run(f"Pure SVXY (VIX<{gate} + falling)", PureSvxyStrategy(upper_gate=gate))
|
||||
|
||||
print("\n--- Framework 2C: TQQQ RSI buy-the-dip ---")
|
||||
for buy, sell in [(25, 70), (30, 70), (30, 65), (20, 75)]:
|
||||
run(f"TQQQ RSI buy<{buy}/sell>{sell}", TqqqRsiStrategy(buy_level=buy, sell_level=sell))
|
||||
|
||||
print("\n--- Framework 2D: V7+Vol Hybrid (best of both) ---")
|
||||
for gate in (25, 28, 30):
|
||||
run(f"V7VolHybrid (VIX gate={gate})",
|
||||
V7VolHybrid(vix_gate=gate))
|
||||
|
||||
# === Framework 4: Calendar Effects ===
|
||||
print("\n--- Framework 4A: Turn-of-month TQQQ ---")
|
||||
for before, after in [(3, 3), (4, 4), (2, 5)]:
|
||||
run(f"TQQQ turn-of-month ({before}d before + {after}d after)",
|
||||
TurnOfMonthStrategy(days_before=before, days_after=after))
|
||||
|
||||
print("\n--- Framework 4B: V7 + turn-of-month filter ---")
|
||||
for before, after in [(4, 4), (5, 5), (3, 6)]:
|
||||
run(f"V7 risk-on only in TOM window ({before}+{after}d)",
|
||||
V7TurnOfMonth(days_before=before, days_after=after,
|
||||
target_vol=0.36, min_lev=0.75))
|
||||
|
||||
# === Final ranking ===
|
||||
results.sort(key=lambda x: x[1]["sharpeRatio"], reverse=True)
|
||||
print(f"\n{'=' * 115}")
|
||||
print(" FINAL RANKING (by Sharpe)")
|
||||
print(f"{'=' * 115}")
|
||||
print(f"{'#':<4} {'Strategy':<55} {'Ann%':>6} {'Vol%':>6} {'Sharpe':>7} "
|
||||
f"{'Sortino':>8} {'MaxDD%':>7} {'Calmar':>7}")
|
||||
print("-" * 115)
|
||||
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"{'=' * 115}")
|
||||
|
||||
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} MaxDD={m['maxDrawdown']*100:.1f}%")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user