Research scripts exploring paths beyond V7+VT36: - regime_stock_picker_eval: V3 regime + S&P 500 stock picking - v7_parameter_sweep: VT range (20-48%) + adaptive PT variants - v7_synthetic_leverage_eval: synthetic 2x/3x leveraged individual stocks - v7_breakthrough_eval/fixed: ensemble, cross-market, alt regime engines - v7_three_ideas_eval: TMF risk-off, PT entry reset, fast exit - v7_trade_audit: full 10y trade log and alpha attribution - sota_ranking: comprehensive cross-strategy ranking Key findings: - VT36 is optimal risk-return tradeoff (+7% vs VT28, Sharpe ~flat) - PT30 is structural optimum for 3x ETFs (all adaptive variants worse) - V8 (TMF risk-off) debunked: +5% was 1-day lookahead bias artifact - V3 regime engine irreplaceable (all simplified alternatives fail) - PT mechanism is dominant alpha source (+15.6pp ann, +0.58 Sharpe) V8 strategy file kept for reference (not registered). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
386 lines
15 KiB
Python
386 lines
15 KiB
Python
"""Test three structural improvements to V7+VT36 identified by independent review.
|
|
|
|
Idea 1: PT entry price reset on restore (fix stale anchor)
|
|
Idea 2: TMF (3x bonds) in risk-off basket with TLT MA gate
|
|
Idea 3: Open-price fast exit overlay for crash protection
|
|
|
|
All tested against V7+VT36 baseline (61.2% Ann, Sharpe 1.89, MaxDD -29.2%).
|
|
"""
|
|
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
|
|
|
|
YEARS = 10
|
|
CAPITAL = 100_000
|
|
TX_COST = 0.001
|
|
FIXED_FEE = 2.0
|
|
|
|
|
|
# =========================================================================
|
|
# V7 with all three ideas as toggleable flags
|
|
# =========================================================================
|
|
|
|
class TrendRiderV7X(Strategy):
|
|
"""V7 extended with three structural improvements.
|
|
|
|
Flags:
|
|
reset_entry_on_restore: Idea 1 — reset entry_price when PT restores.
|
|
tmf_risk_off: Idea 2 — include TMF in risk-off when TLT > MA.
|
|
fast_exit: Idea 3 — emergency exit when SPY opens below threshold.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
# V3 regime
|
|
ma_long: int = 150,
|
|
signal: str = "SPY",
|
|
risk_on: tuple[str, ...] = ("TQQQ", "UPRO"),
|
|
risk_off: tuple[str, ...] = ("GLD", "DBC"),
|
|
# Vol-target
|
|
target_vol: float = 0.36,
|
|
vol_window: int = 60,
|
|
min_lev: float = 0.75,
|
|
max_lev: float = 1.0,
|
|
# Profit-take
|
|
pt_threshold: float = 0.30,
|
|
pt_band: float = 0.10,
|
|
pt_park: str = "SHY",
|
|
# === Idea 1: reset entry on restore ===
|
|
reset_entry_on_restore: bool = False,
|
|
# === Idea 2: TMF risk-off with bond gate ===
|
|
tmf_risk_off: bool = False,
|
|
tmf_symbol: str = "TMF",
|
|
tlt_symbol: str = "TLT",
|
|
tlt_ma_window: int = 200,
|
|
# === Idea 3: fast exit on open ===
|
|
fast_exit: bool = False,
|
|
fast_exit_gap_pct: float = -0.03,
|
|
fast_exit_low_window: int = 20,
|
|
# V3 passthrough
|
|
**v3_kwargs,
|
|
) -> None:
|
|
self.target_vol = target_vol
|
|
self.vol_window = vol_window
|
|
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.signal = signal
|
|
self.risk_off_base = risk_off
|
|
self.reset_entry_on_restore = reset_entry_on_restore
|
|
self.tmf_risk_off = tmf_risk_off
|
|
self.tmf_symbol = tmf_symbol
|
|
self.tlt_symbol = tlt_symbol
|
|
self.tlt_ma_window = tlt_ma_window
|
|
self.fast_exit = fast_exit
|
|
self.fast_exit_gap_pct = fast_exit_gap_pct
|
|
self.fast_exit_low_window = fast_exit_low_window
|
|
|
|
self.v3 = TrendRiderV3(
|
|
signal=signal, risk_on=risk_on, risk_off=risk_off,
|
|
ma_long=ma_long, **v3_kwargs,
|
|
)
|
|
|
|
def generate_signals(self, data: pd.DataFrame) -> pd.DataFrame:
|
|
# --- Layer 1: V3 regime weights ---
|
|
w = self.v3.generate_signals(data)
|
|
|
|
# --- Idea 2: dynamically swap risk-off pick to TMF when bond regime is bullish ---
|
|
if self.tmf_risk_off and self.tmf_symbol in data.columns and self.tlt_symbol in data.columns:
|
|
tlt = data[self.tlt_symbol]
|
|
tlt_ma = tlt.rolling(self.tlt_ma_window).mean()
|
|
tlt_bull = (tlt > tlt_ma).shift(1).fillna(False)
|
|
|
|
risk_off_cols = [c for c in self.risk_off_base if c in w.columns]
|
|
tmf_col = self.tmf_symbol
|
|
|
|
if tmf_col not in w.columns:
|
|
w[tmf_col] = 0.0
|
|
|
|
for i in range(len(w)):
|
|
roff_weight = sum(w.iloc[i].get(c, 0.0) for c in risk_off_cols)
|
|
if roff_weight < 1e-8:
|
|
continue
|
|
if tlt_bull.iloc[i]:
|
|
# Candidate: TMF vs best of original risk-off by momentum
|
|
mom_lookback = 63
|
|
if i >= mom_lookback + 1:
|
|
best_sym = tmf_col
|
|
best_r = -np.inf
|
|
candidates = risk_off_cols + [tmf_col]
|
|
for sym in candidates:
|
|
if sym not in data.columns:
|
|
continue
|
|
p_now = data[sym].iloc[i - 1]
|
|
p_past = data[sym].iloc[i - 1 - mom_lookback]
|
|
if pd.notna(p_now) and pd.notna(p_past) and p_past > 0:
|
|
r = p_now / p_past - 1.0
|
|
if r > best_r:
|
|
best_r, best_sym = r, sym
|
|
# Reassign risk-off weight to the winner
|
|
for c in risk_off_cols:
|
|
w.iat[i, w.columns.get_loc(c)] = 0.0
|
|
if tmf_col in w.columns:
|
|
w.iat[i, w.columns.get_loc(tmf_col)] = 0.0
|
|
if best_sym in w.columns:
|
|
w.iat[i, w.columns.get_loc(best_sym)] = roff_weight
|
|
|
|
# --- Idea 3: fast exit overlay (check SPY open for gap-downs) ---
|
|
if self.fast_exit and self.signal in data.columns:
|
|
spy = data[self.signal]
|
|
spy_arr = spy.to_numpy()
|
|
risk_on_cols = list(self.v3.risk_on)
|
|
risk_off_cols_fast = [c for c in self.risk_off_base if c in w.columns]
|
|
park = self.pt_park if self.pt_park in w.columns else ""
|
|
|
|
for i in range(max(self.fast_exit_low_window + 1, 2), len(w)):
|
|
# Check if currently in risk-on
|
|
ron_weight = sum(float(w.iloc[i].get(c, 0.0))
|
|
for c in risk_on_cols if c in w.columns)
|
|
if ron_weight < 1e-8:
|
|
continue
|
|
|
|
prev_close = spy_arr[i - 1]
|
|
if np.isnan(prev_close) or prev_close <= 0:
|
|
continue
|
|
|
|
# Gap-down check: today's "effective open" approximated by
|
|
# checking if yesterday's close is below N-day low
|
|
low_window = spy_arr[max(0, i - 1 - self.fast_exit_low_window):i - 1]
|
|
if len(low_window) == 0:
|
|
continue
|
|
low_val = np.nanmin(low_window)
|
|
|
|
# Trigger 1: close below N-day low
|
|
trigger_low = prev_close <= low_val
|
|
|
|
# Trigger 2: large single-day drop (gap-down proxy using close-to-close)
|
|
if i >= 2:
|
|
prev2_close = spy_arr[i - 2]
|
|
daily_ret = (prev_close / prev2_close - 1.0) if prev2_close > 0 else 0.0
|
|
trigger_gap = daily_ret <= self.fast_exit_gap_pct
|
|
else:
|
|
trigger_gap = False
|
|
|
|
if trigger_low or trigger_gap:
|
|
# Emergency: zero out risk-on, move to park
|
|
for c in risk_on_cols:
|
|
if c in w.columns:
|
|
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)] = 1.0
|
|
|
|
# --- Layer 2: Vol-target overlay ---
|
|
daily_ret = data.pct_change(fill_method=None).fillna(0.0)
|
|
# Only use columns present in w
|
|
common_cols = w.columns.intersection(daily_ret.columns)
|
|
port_rets = (w[common_cols] * daily_ret[common_cols]).sum(axis=1)
|
|
realized_vol = (
|
|
port_rets.rolling(self.vol_window, 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)
|
|
|
|
# --- Layer 3: Profit-take with hysteresis ---
|
|
if self.pt_threshold <= 0:
|
|
return w
|
|
|
|
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 ""
|
|
entry_price: float | None = None
|
|
current_sym: str | None = None
|
|
is_stopped = False
|
|
restore_level = self.pt_threshold - self.pt_band
|
|
|
|
for i in range(len(w)):
|
|
sym = held.iloc[i]
|
|
if not sym or max_w.iloc[i] < 1e-8:
|
|
current_sym = None
|
|
entry_price = None
|
|
is_stopped = False
|
|
continue
|
|
|
|
if sym != current_sym:
|
|
current_sym = sym
|
|
entry_price = (
|
|
float(data[sym].iloc[i - 1])
|
|
if i > 0 and sym in data.columns else None
|
|
)
|
|
is_stopped = False
|
|
continue
|
|
|
|
if entry_price is None or entry_price <= 0 or sym not in data.columns:
|
|
continue
|
|
|
|
yesterday = float(data[sym].iloc[i - 1]) if i > 0 else float(data[sym].iloc[i])
|
|
gain = yesterday / entry_price - 1.0
|
|
|
|
if is_stopped:
|
|
if gain < restore_level:
|
|
is_stopped = False
|
|
# === Idea 1: reset entry price on restore ===
|
|
if self.reset_entry_on_restore:
|
|
entry_price = yesterday
|
|
else:
|
|
w.iloc[i] = 0.0
|
|
if park_col:
|
|
w.at[w.index[i], park_col] = scale.iloc[i]
|
|
else:
|
|
if gain >= self.pt_threshold:
|
|
is_stopped = True
|
|
w.iloc[i] = 0.0
|
|
if park_col:
|
|
w.at[w.index[i], park_col] = scale.iloc[i]
|
|
|
|
return w
|
|
|
|
|
|
# =========================================================================
|
|
# Main
|
|
# =========================================================================
|
|
|
|
def main():
|
|
print("=" * 100)
|
|
print(" THREE IDEAS EVALUATION")
|
|
print("=" * 100)
|
|
|
|
# Load data including TMF and TLT
|
|
all_etfs = sorted(set([
|
|
"SPY", "TQQQ", "UPRO", "GLD", "DBC", "SHY", "TLT", "TMF",
|
|
]))
|
|
print(f"\nLoading ETFs: {all_etfs}")
|
|
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]
|
|
|
|
avail = sorted(data.columns.tolist())
|
|
print(f"Available: {avail}")
|
|
print(f"Period: {data.index[0].date()} → {data.index[-1].date()}")
|
|
|
|
results: list[tuple[str, dict]] = []
|
|
|
|
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:<60} Ann={m['annualizedReturn']*100:>5.1f}% "
|
|
f"Sharpe={m['sharpeRatio']:.2f} MaxDD={m['maxDrawdown']*100:.1f}% "
|
|
f"Sortino={m['sortinoRatio']:.2f} Calmar={m['calmarRatio']:.2f}")
|
|
|
|
# Baseline
|
|
print("\n--- Baseline ---")
|
|
run("V7+VT36 (baseline)",
|
|
TrendRiderV7X(target_vol=0.36, min_lev=0.75))
|
|
|
|
# === Idea 1: PT entry reset ===
|
|
print("\n--- Idea 1: PT Entry Price Reset ---")
|
|
run("V7+VT36 + PT reset",
|
|
TrendRiderV7X(target_vol=0.36, min_lev=0.75,
|
|
reset_entry_on_restore=True))
|
|
|
|
# Also test with different PT thresholds to see if reset changes the optimum
|
|
for pt in (0.20, 0.25, 0.30, 0.35, 0.40):
|
|
run(f" PT reset + PT{int(pt*100)}",
|
|
TrendRiderV7X(target_vol=0.36, min_lev=0.75,
|
|
reset_entry_on_restore=True,
|
|
pt_threshold=pt, pt_band=pt * 0.33))
|
|
|
|
# === Idea 2: TMF risk-off ===
|
|
print("\n--- Idea 2: TMF in Risk-Off ---")
|
|
if "TMF" in data.columns and "TLT" in data.columns:
|
|
run("V7+VT36 + TMF risk-off (TLT gate)",
|
|
TrendRiderV7X(target_vol=0.36, min_lev=0.75,
|
|
tmf_risk_off=True))
|
|
|
|
# TMF + PT reset combo
|
|
run("V7+VT36 + TMF + PT reset",
|
|
TrendRiderV7X(target_vol=0.36, min_lev=0.75,
|
|
tmf_risk_off=True, reset_entry_on_restore=True))
|
|
|
|
# Different TLT MA windows
|
|
for tlt_ma in (100, 150, 200, 250):
|
|
run(f" TMF risk-off (TLT MA{tlt_ma})",
|
|
TrendRiderV7X(target_vol=0.36, min_lev=0.75,
|
|
tmf_risk_off=True, tlt_ma_window=tlt_ma))
|
|
else:
|
|
print(" TMF or TLT not available, skipping")
|
|
|
|
# === Idea 3: Fast exit ===
|
|
print("\n--- Idea 3: Fast Exit ---")
|
|
for gap in (-0.02, -0.03, -0.04):
|
|
for low_w in (10, 20):
|
|
run(f"V7+VT36 + fast exit (gap={gap:.0%}, low={low_w}d)",
|
|
TrendRiderV7X(target_vol=0.36, min_lev=0.75,
|
|
fast_exit=True, fast_exit_gap_pct=gap,
|
|
fast_exit_low_window=low_w))
|
|
|
|
# === All three combined ===
|
|
print("\n--- All Three Combined ---")
|
|
if "TMF" in data.columns:
|
|
run("V7+VT36 + ALL (reset+TMF+fast exit)",
|
|
TrendRiderV7X(target_vol=0.36, min_lev=0.75,
|
|
reset_entry_on_restore=True,
|
|
tmf_risk_off=True,
|
|
fast_exit=True, fast_exit_gap_pct=-0.03,
|
|
fast_exit_low_window=20))
|
|
|
|
# Best combo tuning
|
|
for pt in (0.25, 0.30, 0.35):
|
|
run(f" ALL + PT{int(pt*100)}",
|
|
TrendRiderV7X(target_vol=0.36, min_lev=0.75,
|
|
reset_entry_on_restore=True,
|
|
tmf_risk_off=True,
|
|
fast_exit=True, fast_exit_gap_pct=-0.03,
|
|
fast_exit_low_window=20,
|
|
pt_threshold=pt, pt_band=pt * 0.33))
|
|
|
|
# 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':<60} {'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:<60} "
|
|
f"{m['annualizedReturn']*100:>5.1f}% "
|
|
f"{m['annualizedVolatility']*100:>5.1f}% "
|
|
f"{m['sharpeRatio']:>7.2f} "
|
|
f"{m['sortinoRatio']:>8.2f} "
|
|
f"{m['maxDrawdown']*100:>6.1f}% "
|
|
f"{m['calmarRatio']:>7.2f}{marker}")
|
|
print(f"{'=' * 110}")
|
|
|
|
# Also rank by Ann return
|
|
results.sort(key=lambda x: x[1]["annualizedReturn"], reverse=True)
|
|
print(f"\n Top 5 by Annualized Return:")
|
|
for i, (label, m) in enumerate(results[:5], 1):
|
|
print(f" {i}. {label:<55} Ann={m['annualizedReturn']*100:.1f}% "
|
|
f"Sharpe={m['sharpeRatio']:.2f} MaxDD={m['maxDrawdown']*100:.1f}%")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|