Files
quant/research/v7_three_ideas_eval.py
Gahow Wang 1f50253d13 research: extensive V7 optimization and V8 (TMF) evaluation
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>
2026-05-21 20:57:34 +08:00

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