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