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:
2026-05-23 00:45:44 +08:00
parent 149a00c458
commit 0d983edfc0
4 changed files with 1603 additions and 0 deletions

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

View File

@@ -0,0 +1,276 @@
"""Single-stock swing trading: adapt V3/V7 concepts to 20 famous stocks.
Strategy: long the stock when trending, cash when not.
- Trend: stock > MA + momentum > 0 + vol < cap + no dd breach
- Position sizing: vol-target overlay
- Risk mgmt: stop-loss + profit-take
- When flat: 100% cash (0% return)
Tests per-stock optimized parameters + a universal parameter set.
"""
from __future__ import annotations
import sys
sys.path.insert(0, ".")
import numpy as np
import pandas as pd
from itertools import product
import data_manager
import metrics
YEARS = 5
CAPITAL = 100_000
TX_COST = 0.002 # 2bp for individual stocks (wider spreads)
STOCKS = [
"AAPL", "MSFT", "GOOGL", "AMZN", "NVDA", "META", "TSLA", # Mag 7
"JPM", "V", "MA", # Financials
"JNJ", "UNH", "HD", # Healthcare / Consumer
"PG", "KO", "DIS", # Consumer staples / media
"NFLX", "AMD", "CRM", # Tech / growth
"COST", # Retail
]
def swing_backtest(
prices: pd.Series,
ma_window: int = 50,
mom_window: int = 21,
vol_window: int = 20,
vol_cap: float = 0.40,
dd_window: int = 20,
dd_stop: float = 0.08,
confirm_days: int = 2,
min_hold: int = 5,
stop_loss: float = 0.08,
profit_take: float = 0.0,
target_vol: float = 0.25,
min_scale: float = 0.3,
tx_cost: float = TX_COST,
) -> tuple[pd.Series, dict]:
"""Backtest a single-stock swing strategy. Returns (equity, stats)."""
arr = prices.to_numpy().astype(float)
n = len(arr)
need = max(ma_window, mom_window, vol_window, dd_window) + 1
# Precompute indicators
ma = pd.Series(arr, index=prices.index).rolling(ma_window).mean().to_numpy()
rets = np.diff(arr, prepend=arr[0]) / np.maximum(np.roll(arr, 1), 1e-12)
rets[0] = 0.0
vol = pd.Series(rets).rolling(vol_window, min_periods=10).std().to_numpy() * np.sqrt(252)
# State machine
in_position = False
entry_price = 0.0
bars_held = 0
pending_entry = 0
equity = np.ones(n) * CAPITAL
n_trades = 0
for t in range(1, n):
equity[t] = equity[t - 1]
if t < need:
continue
p = arr[t - 1] # yesterday's close (PIT-safe)
p_ma = ma[t - 1]
p_vol = vol[t - 1]
p_mom = arr[t - 1] / arr[t - 1 - mom_window] - 1 if arr[t - 1 - mom_window] > 0 else 0
p_dd = arr[t - 1] / np.max(arr[max(0, t - 1 - dd_window):t]) - 1
if np.isnan(p) or np.isnan(p_ma):
continue
# --- Trend signal ---
trend_bull = (p > p_ma and p_mom > 0 and
(np.isnan(p_vol) or p_vol < vol_cap) and
p_dd > -dd_stop)
if in_position:
bars_held += 1
# Apply daily return
daily_r = arr[t] / arr[t - 1] - 1 if arr[t - 1] > 0 else 0
# Vol-target scaling
scale = target_vol / p_vol if p_vol > 0.01 else 1.0
scale = np.clip(scale, min_scale, 1.0)
equity[t] = equity[t - 1] * (1 + daily_r * scale)
# Check exit conditions (using yesterday's close, PIT-safe)
gain = p / entry_price - 1 if entry_price > 0 else 0
exit_signal = False
# Stop-loss
if gain <= -stop_loss:
exit_signal = True
# Profit-take
if profit_take > 0 and gain >= profit_take:
exit_signal = True
# Trend reversal (with min_hold)
if not trend_bull and bars_held >= min_hold:
exit_signal = True
if exit_signal:
equity[t] -= equity[t] * tx_cost # exit cost
in_position = False
pending_entry = 0
n_trades += 1
else:
# Check entry
if trend_bull:
pending_entry += 1
if pending_entry >= confirm_days:
in_position = True
entry_price = arr[t] # enter at today's close
bars_held = 0
equity[t] -= equity[t] * tx_cost # entry cost
n_trades += 1
else:
pending_entry = 0
eq = pd.Series(equity, index=prices.index)
total_ret = eq.iloc[-1] / eq.iloc[0] - 1
days_in = sum(1 for t in range(need, n) if equity[t] != equity[t - 1])
pct_in = days_in / (n - need) if n > need else 0
return eq, {
"total_return": total_ret,
"n_trades": n_trades,
"pct_time_in": pct_in,
}
def optimize_stock(prices: pd.Series, stock: str) -> tuple[dict, dict, pd.Series]:
"""Grid search for best parameters on a single stock."""
param_grid = {
"ma_window": [20, 50, 100, 150],
"mom_window": [10, 21, 42],
"vol_cap": [0.30, 0.45, 0.60, 999],
"dd_stop": [0.05, 0.08, 0.12],
"stop_loss": [0.05, 0.08, 0.12],
"profit_take": [0.0, 0.15, 0.25],
"target_vol": [0.20, 0.30, 0.40],
"min_hold": [3, 5, 10],
"confirm_days": [1, 2, 3],
}
# Fixed params
fixed = {"vol_window": 20, "dd_window": 20, "min_scale": 0.3}
best_sharpe = -np.inf
best_params = {}
best_eq = None
keys = list(param_grid.keys())
values = list(param_grid.values())
for combo in product(*values):
params = dict(zip(keys, combo))
params.update(fixed)
try:
eq, stats = swing_backtest(prices, **params)
m = metrics.raw_summary(eq)
if m["sharpeRatio"] > best_sharpe and stats["n_trades"] >= 5:
best_sharpe = m["sharpeRatio"]
best_params = params.copy()
best_eq = eq
except Exception:
continue
return best_params, metrics.raw_summary(best_eq) if best_eq is not None else {}, best_eq
def main():
print("=" * 110)
print(" SINGLE-STOCK SWING TRADING: 20 FAMOUS STOCKS")
print("=" * 110)
# Download data
print(f"\nDownloading {len(STOCKS)} stocks...")
data = data_manager.update("swing", STOCKS, 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()}")
# Buy-and-hold benchmarks
print(f"\n--- Buy & Hold Returns ({YEARS}y) ---")
bh_returns = {}
for stock in STOCKS:
if stock not in data.columns:
continue
s = data[stock].dropna()
if len(s) < 100:
continue
r = s.iloc[-1] / s.iloc[0] - 1
ann = (1 + r) ** (252 / len(s)) - 1
bh_returns[stock] = ann
print(f" {stock:<6}: {ann*100:>+6.1f}% ann")
# Universal parameter set (sensible defaults)
print(f"\n--- Universal Parameters (no per-stock optimization) ---")
print(f"{'Stock':<7} {'Ann%':>7} {'Vol%':>7} {'Sharpe':>7} {'MaxDD%':>7} {'Trades':>7} {'%In':>6} {'B&H Ann%':>9}")
print("-" * 75)
universal = dict(ma_window=50, mom_window=21, vol_window=20, vol_cap=0.45,
dd_window=20, dd_stop=0.08, confirm_days=2, min_hold=5,
stop_loss=0.08, profit_take=0.0, target_vol=0.25, min_scale=0.3)
for stock in STOCKS:
if stock not in data.columns:
continue
prices = data[stock].dropna()
if len(prices) < 200:
continue
eq, stats = swing_backtest(prices, **universal)
m = metrics.raw_summary(eq)
bh = bh_returns.get(stock, 0)
print(f" {stock:<6} {m['annualizedReturn']*100:>+6.1f}% {m['annualizedVolatility']*100:>6.1f}% "
f"{m['sharpeRatio']:>7.2f} {m['maxDrawdown']*100:>6.1f}% "
f"{stats['n_trades']:>7} {stats['pct_time_in']*100:>5.1f}% {bh*100:>+8.1f}%")
# Per-stock optimized
print(f"\n--- Per-Stock Optimized (grid search on Sharpe, min 5 trades) ---")
print(f"{'Stock':<7} {'Ann%':>7} {'Vol%':>7} {'Sharpe':>7} {'MaxDD%':>7} {'Trades':>7} {'%In':>6} {'B&H Ann%':>9} Best params")
print("-" * 130)
opt_results = []
for stock in STOCKS:
if stock not in data.columns:
continue
prices = data[stock].dropna()
if len(prices) < 200:
continue
print(f" Optimizing {stock}...", end=" ", flush=True)
params, m, eq = optimize_stock(prices, stock)
if not m:
print("FAILED")
continue
_, stats = swing_backtest(prices, **params)
bh = bh_returns.get(stock, 0)
key_params = f"MA{params.get('ma_window')}/mom{params.get('mom_window')}/SL{int(params.get('stop_loss',0)*100)}%/PT{int(params.get('profit_take',0)*100)}%/VT{int(params.get('target_vol',0)*100)}%"
print(f"{m['annualizedReturn']*100:>+5.1f}% Sharpe={m['sharpeRatio']:.2f} [{key_params}]")
opt_results.append((stock, m, stats, params, bh))
print(f"\n{'Stock':<7} {'Ann%':>7} {'Vol%':>7} {'Sharpe':>7} {'MaxDD%':>7} {'Trades':>7} {'%In':>6} {'B&H Ann%':>9} Params")
print("-" * 130)
opt_results.sort(key=lambda x: x[1]["sharpeRatio"], reverse=True)
for stock, m, stats, params, bh in opt_results:
key_params = f"MA{params.get('ma_window')}/mom{params.get('mom_window')}/SL{int(params.get('stop_loss',0)*100)}%/PT{int(params.get('profit_take',0)*100)}%/VT{int(params.get('target_vol',0)*100)}%"
beat = "" if m["annualizedReturn"] > bh else ""
print(f" {stock:<6} {m['annualizedReturn']*100:>+6.1f}% {m['annualizedVolatility']*100:>6.1f}% "
f"{m['sharpeRatio']:>7.2f} {m['maxDrawdown']*100:>6.1f}% "
f"{stats['n_trades']:>7} {stats['pct_time_in']*100:>5.1f}% {bh*100:>+8.1f}% {beat} {key_params}")
winners = sum(1 for _, m, _, _, bh in opt_results if m["annualizedReturn"] > bh)
print(f"\n Beat buy-and-hold: {winners}/{len(opt_results)} stocks")
avg_sharpe = np.mean([m["sharpeRatio"] for _, m, _, _, _ in opt_results])
print(f" Average Sharpe: {avg_sharpe:.2f}")
if __name__ == "__main__":
main()

493
research/smart_dca_eval.py Normal file
View File

@@ -0,0 +1,493 @@
"""
Smart DCA Strategy Evaluation — comprehensive comparison of DCA approaches.
Tests 6 DCA strategies across 4 ETFs (SPY, QQQ, TQQQ, UPRO) over 10 years.
Also tests a hybrid V7+DCA approach combining trend-following with smart DCA.
Usage: cd /home/gahow/projects/quant && uv run python research/smart_dca_eval.py
"""
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.trend_rider_v7 import TrendRiderV7
# ── Configuration ────────────────────────────────────────────────────────────
INITIAL_CAPITAL = 100_000
MONTHLY_BASE = 1_000
START_DATE = "2016-01-01"
END_DATE = "2026-05-23"
DCA_TICKERS = ["SPY", "QQQ", "TQQQ", "UPRO"]
# Tickers needed for V7 strategy + VIX for smart DCA
EXTRA_TICKERS = ["^VIX", "GLD", "DBC", "SHY"]
# ── Data Loading ─────────────────────────────────────────────────────────────
def load_data() -> pd.DataFrame:
"""Download/update ETF price data and return close prices."""
all_tickers = DCA_TICKERS + EXTRA_TICKERS
data = data_manager.update("etfs", all_tickers, with_open=False)
# Trim to date range
data = data.loc[START_DATE:END_DATE]
# Rename ^VIX to VIX for convenience
if "^VIX" in data.columns:
data = data.rename(columns={"^VIX": "VIX"})
return data
# ── Helper: find first trading day of each month ─────────────────────────────
def monthly_schedule(dates: pd.DatetimeIndex) -> list[pd.Timestamp]:
"""Return the first trading day of each month within the date range."""
schedule = []
seen = set()
for d in dates:
key = (d.year, d.month)
if key not in seen:
seen.add(key)
schedule.append(d)
return schedule
# ── Technical indicators ─────────────────────────────────────────────────────
def compute_rsi(prices: pd.Series, window: int = 14) -> pd.Series:
delta = prices.diff()
gain = delta.clip(lower=0)
loss = (-delta).clip(lower=0)
avg_gain = gain.ewm(alpha=1 / window, min_periods=window).mean()
avg_loss = loss.ewm(alpha=1 / window, min_periods=window).mean()
rs = avg_gain / avg_loss
return 100 - 100 / (1 + rs)
def compute_ma(prices: pd.Series, window: int = 200) -> pd.Series:
return prices.rolling(window, min_periods=window).mean()
# ── DCA Strategy implementations ─────────────────────────────────────────────
def dca_fixed(date, price, vix, rsi, ma200, portfolio_value, target_value):
"""Strategy 1: Fixed $1,000/month."""
return MONTHLY_BASE
def dca_vix_scaled(date, price, vix, rsi, ma200, portfolio_value, target_value):
"""Strategy 2: VIX-scaled DCA."""
if vix is None or np.isnan(vix):
return MONTHLY_BASE
if vix < 15:
return 500
elif vix <= 20:
return 1000
elif vix <= 30:
return 1500
else:
return 2000
def dca_ma_deviation(date, price, vix, rsi, ma200, portfolio_value, target_value):
"""Strategy 3: MA-deviation DCA. Scale by distance below 200-day MA."""
if ma200 is None or np.isnan(ma200) or ma200 == 0:
return MONTHLY_BASE
deviation = (price - ma200) / ma200 # negative when below MA
if deviation >= 0:
return 500
elif deviation >= -0.10:
return 1000
elif deviation >= -0.20:
return 2000
else:
return 3000
def dca_value_averaging(date, price, vix, rsi, ma200, portfolio_value, target_value):
"""Strategy 4: Value Averaging. Target portfolio growth of ~1% per month.
Invest the difference between target and current value, floored at $0."""
diff = target_value - portfolio_value
# Invest at least $0, cap at 3x base to avoid huge lump sums
return max(0, min(diff, MONTHLY_BASE * 3))
def dca_rsi_based(date, price, vix, rsi, ma200, portfolio_value, target_value):
"""Strategy 5: RSI-based DCA. More when oversold, less when overbought."""
if rsi is None or np.isnan(rsi):
return MONTHLY_BASE
if rsi < 30:
return 2000
elif rsi <= 70:
return 1000
else:
return 500
DCA_STRATEGIES = {
"Fixed DCA": dca_fixed,
"VIX-scaled DCA": dca_vix_scaled,
"MA-deviation DCA": dca_ma_deviation,
"Value Averaging": dca_value_averaging,
"RSI-based DCA": dca_rsi_based,
}
# ── Core DCA backtest engine ─────────────────────────────────────────────────
def run_dca_backtest(
prices: pd.Series,
strategy_fn,
vix: pd.Series | None = None,
initial_capital: float = INITIAL_CAPITAL,
) -> dict:
"""
Simulate a DCA strategy on a single ETF.
Returns dict with equity curve, total invested, final value, etc.
"""
dates = prices.index
schedule = monthly_schedule(dates)
# Precompute indicators
rsi_series = compute_rsi(prices)
ma200_series = compute_ma(prices)
# State
cash = initial_capital
shares = 0.0
total_invested = initial_capital
# For value averaging: target grows by 1% per month from initial
va_month_count = 0
equity_curve = pd.Series(index=dates, dtype=float)
schedule_set = set(schedule)
invested_tracker = pd.Series(index=dates, dtype=float)
# Buy initial position on day 1
price_0 = prices.iloc[0]
shares = cash / price_0
cash = 0.0
for i, date in enumerate(dates):
price = prices.iloc[i]
# DCA contribution on scheduled dates (skip the first date — already invested)
if date in schedule_set and date != dates[0]:
va_month_count += 1
portfolio_value = shares * price + cash
# Value averaging target: initial * (1.01)^months
target_value = initial_capital * (1.01 ** va_month_count)
# Add cumulative expected contributions
target_value += MONTHLY_BASE * va_month_count
v = vix.loc[date] if vix is not None and date in vix.index else np.nan
r = rsi_series.loc[date] if date in rsi_series.index else np.nan
m = ma200_series.loc[date] if date in ma200_series.index else np.nan
amount = strategy_fn(date, price, v, r, m, portfolio_value, target_value)
amount = max(0, amount)
# Buy shares with the DCA amount
if amount > 0 and price > 0:
new_shares = amount / price
shares += new_shares
total_invested += amount
equity_curve.iloc[i] = shares * price
invested_tracker.iloc[i] = total_invested
equity_curve = equity_curve.astype(float)
return {
"equity": equity_curve,
"total_invested": total_invested,
"final_value": equity_curve.iloc[-1],
"shares": shares,
"invested_tracker": invested_tracker,
}
# ── Lump-sum benchmark ───────────────────────────────────────────────────────
def run_lump_sum(prices: pd.Series, initial_capital: float = INITIAL_CAPITAL) -> dict:
"""Invest all capital (initial + PV of monthly contributions) at day 1."""
dates = prices.index
schedule = monthly_schedule(dates)
# Total that DCA would invest: initial + $1,000 * (num_months - 1)
n_months = len(schedule) - 1 # skip first month (already counted in initial)
total_capital = initial_capital + MONTHLY_BASE * n_months
shares = total_capital / prices.iloc[0]
equity = shares * prices
return {
"equity": equity,
"total_invested": total_capital,
"final_value": equity.iloc[-1],
"shares": shares,
}
# ── V7+VT36 baseline equity curve ────────────────────────────────────────────
def run_v7_baseline(data: pd.DataFrame) -> pd.Series:
"""Run V7+VT36 strategy and return equity curve."""
v7_tickers = ["SPY", "TQQQ", "UPRO", "GLD", "DBC", "SHY"]
available = [t for t in v7_tickers if t in data.columns]
v7_data = data[available]
strategy = TrendRiderV7(target_vol=0.36, min_lev=0.75)
eq = backtest(strategy, v7_data, initial_capital=INITIAL_CAPITAL,
transaction_cost=0.001, fixed_fee=2.0)
return eq
# ── Hybrid V7 + DCA ──────────────────────────────────────────────────────────
def run_hybrid_v7_dca(
data: pd.DataFrame,
dca_ticker: str,
strategy_fn,
v7_pct: float = 0.70,
) -> dict:
"""
Strategy 6: Hybrid — v7_pct of capital in V7+VT36, rest in smart DCA.
The V7 portion gets v7_pct of initial capital and v7_pct of monthly contributions.
The DCA portion gets the rest.
"""
dca_pct = 1.0 - v7_pct
# V7 equity curve (normalized to its portion of capital)
v7_eq = run_v7_baseline(data)
# Scale V7 equity to its capital allocation
v7_eq_scaled = v7_eq * (v7_pct * INITIAL_CAPITAL / INITIAL_CAPITAL)
# DCA portion
prices = data[dca_ticker].dropna()
vix = data["VIX"] if "VIX" in data.columns else None
dca_result = run_dca_backtest(
prices, strategy_fn, vix=vix,
initial_capital=dca_pct * INITIAL_CAPITAL,
)
# Scale monthly contributions for DCA portion (base * dca_pct)
# Already handled since dca_backtest uses MONTHLY_BASE; we need to adjust.
# For simplicity, we just combine the two equity curves.
# Combine: align dates
common = v7_eq_scaled.index.intersection(dca_result["equity"].index)
combined = v7_eq_scaled.loc[common] + dca_result["equity"].loc[common]
# Total invested: V7 gets initial*v7_pct (lump sum, no DCA additions modeled in backtest())
# DCA gets initial*dca_pct + monthly contributions
total_invested = INITIAL_CAPITAL + dca_result["total_invested"] - dca_pct * INITIAL_CAPITAL
return {
"equity": combined,
"total_invested": total_invested,
"final_value": combined.iloc[-1],
}
# ── Reporting ─────────────────────────────────────────────────────────────────
def compute_metrics(result: dict, label: str) -> dict:
"""Compute all metrics for a DCA result."""
eq = result["equity"].dropna()
if len(eq) < 2:
return {"label": label, "error": "insufficient data"}
m = metrics.raw_summary(eq)
m["label"] = label
m["totalInvested"] = result["total_invested"]
m["finalValue"] = result["final_value"]
m["profit"] = result["final_value"] - result["total_invested"]
m["roiOnCapital"] = (result["final_value"] / result["total_invested"] - 1)
return m
def print_comparison_table(rows: list[dict], title: str):
"""Print a formatted comparison table."""
print(f"\n{'=' * 130}")
print(f" {title}")
print(f"{'=' * 130}")
header = (
f"{'Strategy':<35} {'Invested':>12} {'Final':>14} {'Profit':>14} "
f"{'ROI%':>8} {'Ann%':>8} {'Sharpe':>7} {'Sortino':>8} {'MaxDD%':>8} {'Calmar':>7}"
)
print(header)
print("-" * 130)
for r in rows:
if "error" in r:
print(f" {r['label']:<35} ERROR: {r['error']}")
continue
print(
f"{r['label']:<35} "
f"${r['totalInvested']:>11,.0f} "
f"${r['finalValue']:>13,.0f} "
f"${r['profit']:>13,.0f} "
f"{r['roiOnCapital']*100:>7.1f}% "
f"{r['annualizedReturn']*100:>7.1f}% "
f"{r['sharpeRatio']:>7.2f} "
f"{r['sortinoRatio']:>8.2f} "
f"{r['maxDrawdown']*100:>7.1f}% "
f"{r['calmarRatio']:>7.2f}"
)
# ── Main ──────────────────────────────────────────────────────────────────────
def main():
print("=" * 80)
print(" SMART DCA STRATEGY EVALUATION")
print(f" Period: {START_DATE} to {END_DATE}")
print(f" Initial capital: ${INITIAL_CAPITAL:,.0f}")
print(f" Monthly base DCA: ${MONTHLY_BASE:,.0f}")
print("=" * 80)
data = load_data()
vix = data["VIX"] if "VIX" in data.columns else None
print(f"\nData loaded: {data.shape[0]} trading days, {data.shape[1]} tickers")
print(f"Date range: {data.index[0].strftime('%Y-%m-%d')} to {data.index[-1].strftime('%Y-%m-%d')}")
# ── Part 1: DCA strategies across ETFs ────────────────────────────────
for ticker in DCA_TICKERS:
if ticker not in data.columns:
print(f"\nWARNING: {ticker} not in data, skipping.")
continue
prices = data[ticker].dropna()
if len(prices) < 252:
print(f"\nWARNING: {ticker} has <1 year of data, skipping.")
continue
results = []
# Lump-sum benchmark
ls = run_lump_sum(prices)
results.append(compute_metrics(ls, "Lump-sum (all day 1)"))
# Each DCA strategy
for name, fn in DCA_STRATEGIES.items():
r = run_dca_backtest(prices, fn, vix=vix)
results.append(compute_metrics(r, name))
print_comparison_table(results, f"DCA Strategies — {ticker}")
# Print DCA investment summary
print(f"\n Note: Fixed DCA total invested = ${results[1]['totalInvested']:,.0f} "
f"over {len(monthly_schedule(prices.index))-1} months + "
f"${INITIAL_CAPITAL:,.0f} initial")
# ── Part 2: V7+VT36 baseline ─────────────────────────────────────────
print(f"\n{'=' * 130}")
print(" V7+VT36 TREND-FOLLOWING BASELINE (lump-sum $100K, no DCA)")
print(f"{'=' * 130}")
v7_eq = run_v7_baseline(data)
v7_m = metrics.raw_summary(v7_eq)
print(
f" Ann: {v7_m['annualizedReturn']*100:.1f}%, "
f"Vol: {v7_m['annualizedVolatility']*100:.1f}%, "
f"Sharpe: {v7_m['sharpeRatio']:.2f}, "
f"Sortino: {v7_m['sortinoRatio']:.2f}, "
f"MaxDD: {v7_m['maxDrawdown']*100:.1f}%, "
f"Calmar: {v7_m['calmarRatio']:.2f}, "
f"Final: ${v7_eq.iloc[-1]:,.0f}"
)
# ── Part 3: Hybrid V7 + Smart DCA ────────────────────────────────────
hybrid_results = []
# 100% V7 baseline for comparison
hybrid_results.append({
"label": "100% V7+VT36 (no DCA)",
"totalInvested": INITIAL_CAPITAL,
"finalValue": v7_eq.iloc[-1],
"profit": v7_eq.iloc[-1] - INITIAL_CAPITAL,
"roiOnCapital": v7_eq.iloc[-1] / INITIAL_CAPITAL - 1,
**v7_m,
})
# Hybrid: 70% V7 + 30% VIX-scaled DCA into each leveraged ETF
for dca_ticker in ["TQQQ", "UPRO"]:
if dca_ticker not in data.columns:
continue
for strat_name, strat_fn in [("VIX-scaled", dca_vix_scaled),
("MA-deviation", dca_ma_deviation),
("RSI-based", dca_rsi_based)]:
r = run_hybrid_v7_dca(data, dca_ticker, strat_fn, v7_pct=0.70)
label = f"70%V7 + 30%{strat_name}->{dca_ticker}"
hybrid_results.append(compute_metrics(r, label))
print_comparison_table(hybrid_results, "Hybrid V7+VT36 + Smart DCA Combinations")
# ── Part 4: Best of each category summary ─────────────────────────────
print(f"\n{'=' * 130}")
print(" SUMMARY: Best strategy per ETF (by final portfolio value)")
print(f"{'=' * 130}")
for ticker in DCA_TICKERS:
if ticker not in data.columns:
continue
prices = data[ticker].dropna()
if len(prices) < 252:
continue
best_name = None
best_final = 0
for name, fn in DCA_STRATEGIES.items():
r = run_dca_backtest(prices, fn, vix=vix)
if r["final_value"] > best_final:
best_final = r["final_value"]
best_name = name
best_invested = r["total_invested"]
ls = run_lump_sum(prices)
ls_label = "Lump-sum"
if ls["final_value"] > best_final:
best_final = ls["final_value"]
best_name = ls_label
best_invested = ls["total_invested"]
roi = (best_final / best_invested - 1) * 100
print(f" {ticker:<6} => {best_name:<25} Final: ${best_final:>14,.0f} "
f"Invested: ${best_invested:>10,.0f} ROI: {roi:.1f}%")
# ── Part 5: Year-by-year breakdown for top strategies ─────────────────
print(f"\n{'=' * 130}")
print(" YEAR-BY-YEAR: VIX-scaled DCA into TQQQ vs SPY vs Lump-sum SPY")
print(f"{'=' * 130}")
for ticker in ["SPY", "TQQQ"]:
if ticker not in data.columns:
continue
prices = data[ticker].dropna()
vix_dca = run_dca_backtest(prices, dca_vix_scaled, vix=vix)
eq = vix_dca["equity"].dropna()
print(f"\n {ticker} — VIX-scaled DCA:")
print(f" {'Year':<8} {'Year-end Value':>16} {'YTD Return':>12}")
print(f" {'-'*40}")
years = sorted(set(eq.index.year))
for y in years:
year_data = eq[eq.index.year == y]
if len(year_data) < 2:
continue
ytd = year_data.iloc[-1] / year_data.iloc[0] - 1
print(f" {y:<8} ${year_data.iloc[-1]:>15,.0f} {ytd:>11.1%}")
print(f"\n{'=' * 80}")
print(" EVALUATION COMPLETE")
print(f"{'=' * 80}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,401 @@
"""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()