"""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()