"""Standalone cash parking backtest — QQQ/SGOV switching as its own strategy. No event trades. 100% of capital goes to parking. Uses the same gate logic as the main backtester. Usage: python -m apps.tools.parking_only_backtest --preset vm_24_m20 --capital 10000 --start 2022 --end 2026 python -m apps.tools.parking_only_backtest --preset vt_24_t13 --symbol qqqm --capital 10000 --start 2022 --end 2026 python -m apps.tools.parking_only_backtest --compare --capital 10000 --start 2022 --end 2026 """ import argparse import asyncio import datetime as dt import math import sys from dataclasses import dataclass, field @dataclass class ParkingResult: preset: str symbol: str start: dt.date end: dt.date capital: float final_value: float total_return_pct: float max_drawdown_pct: float cagr_pct: float sharpe: float switches: int # number of QQQ<->SGOV transitions days_in_equity: int days_in_sgov: int annual_returns: dict = field(default_factory=dict) def run_parking_backtest( macro_data: dict[dt.date, dict], preset_name: str, capital: float, start: dt.date, end: dt.date, sgov_rate: float = 0.05, ) -> ParkingResult: """Simulate parking-only strategy using precomputed macro data.""" from libs.backtest.domain import RiskConfig # Apply preset to get gate parameters risk = RiskConfig() risk.cash_parking_preset = preset_name risk.apply_parking_preset() park_symbol = risk.cash_parking_symbol if park_symbol == "sgov": # Pure SGOV — just accrue interest dates = sorted(d for d in macro_data if start <= d <= end) days = len(dates) final = capital * (1 + sgov_rate) ** (days / 252) years = days / 252 return ParkingResult( preset=preset_name, symbol="sgov", start=start, end=end, capital=capital, final_value=final, total_return_pct=(final / capital - 1) * 100, max_drawdown_pct=0.0, cagr_pct=((final / capital) ** (1 / max(years, 0.01)) - 1) * 100, sharpe=0.0, switches=0, days_in_equity=0, days_in_sgov=days, ) # State cash = capital shares = 0 avg_price = 0.0 current_sym = "cash" # "cash", parking symbol, or "sgov" in_sgov = False # hysteresis state sgov_value = 0.0 peak_equity = capital max_dd = 0.0 switches = 0 days_equity = 0 days_sgov = 0 daily_returns: list[float] = [] prev_equity = capital annual_eq: dict[int, list[float]] = {} dates = sorted(d for d in macro_data if start <= d <= end) for date in dates: macro = macro_data.get(date, {}) if not macro: continue # --- Evaluate gate (simplified version of _evaluate_parking_target) --- target = _evaluate_gate(macro, risk, park_symbol, in_sgov) if target == "sgov": if not in_sgov: in_sgov = True elif target and target != "sgov": if in_sgov: in_sgov = False # Resolve actual target if in_sgov: target = "sgov" else: target = park_symbol # --- Execute transitions --- equity_close = macro.get(f"{park_symbol}_close") if equity_close is None and park_symbol == "qqqm": equity_close = macro.get("qqq_close") # fallback to QQQ if QQQM unavailable if target == "sgov" and current_sym != "sgov": # Sell equity → SGOV if shares > 0 and equity_close: cash += shares * equity_close shares = 0 sgov_value = cash cash = 0 current_sym = "sgov" switches += 1 elif target != "sgov" and current_sym == "sgov": # Sell SGOV → buy equity cash = sgov_value sgov_value = 0 if equity_close and equity_close > 0: shares = int(cash / equity_close) cash -= shares * equity_close avg_price = equity_close current_sym = park_symbol switches += 1 elif target != "sgov" and current_sym == "cash": # Initial buy if equity_close and equity_close > 0: shares = int(cash / equity_close) cash -= shares * equity_close avg_price = equity_close current_sym = park_symbol # --- Accrue SGOV interest --- if current_sym == "sgov": daily_rate = (1 + sgov_rate) ** (1 / 252) - 1 sgov_value *= (1 + daily_rate) days_sgov += 1 else: days_equity += 1 # --- Compute equity --- if current_sym == "sgov": total_equity = sgov_value + cash elif equity_close: total_equity = shares * equity_close + cash else: total_equity = prev_equity # no price data, hold # Track drawdown if total_equity > peak_equity: peak_equity = total_equity dd = (total_equity - peak_equity) / peak_equity if dd < max_dd: max_dd = dd # Track daily return if prev_equity > 0: daily_ret = total_equity / prev_equity - 1 daily_returns.append(daily_ret) # Track annual year = date.year annual_eq.setdefault(year, []).append(total_equity) prev_equity = total_equity # Compute metrics final_value = prev_equity total_return_pct = (final_value / capital - 1) * 100 years = len(dates) / 252 cagr = ((final_value / capital) ** (1 / max(years, 0.01)) - 1) * 100 # Sharpe if daily_returns: mean_r = sum(daily_returns) / len(daily_returns) var_r = sum((r - mean_r) ** 2 for r in daily_returns) / len(daily_returns) std_r = math.sqrt(var_r) if var_r > 0 else 1e-10 sharpe = (mean_r / std_r) * math.sqrt(252) else: sharpe = 0.0 # Annual returns annual_rets = {} for year, eqs in sorted(annual_eq.items()): if len(eqs) >= 2: annual_rets[year] = (eqs[-1] / eqs[0] - 1) * 100 return ParkingResult( preset=preset_name, symbol=park_symbol, start=start, end=end, capital=capital, final_value=final_value, total_return_pct=total_return_pct, max_drawdown_pct=max_dd * 100, cagr_pct=cagr, sharpe=sharpe, switches=switches, days_in_equity=days_equity, days_in_sgov=days_sgov, annual_returns=annual_rets, ) def _evaluate_gate( macro: dict, risk, park_symbol: str, in_sgov: bool ) -> str | None: """Simplified gate evaluation (mirrors _evaluate_parking_target logic).""" gate_mode = risk.cash_parking_gate_mode prefix = park_symbol if park_symbol in ("spy", "qqq") else "qqq" if gate_mode == "volatility": vol_lb = risk.cash_parking_gate_vol_lookback vol = macro.get(f"qqq_vol_{vol_lb}") threshold = risk.cash_parking_gate_vol_threshold if vol is not None and vol >= threshold: return "sgov" # Entropy ent_thr = risk.cash_parking_entropy_threshold if ent_thr > 0: ent_lb = risk.cash_parking_entropy_lookback entropy = macro.get(f"{prefix}_entropy_{ent_lb}") if entropy is not None and entropy > ent_thr and not in_sgov: return "sgov" if in_sgov and entropy is not None and entropy <= ent_thr * 0.8: return park_symbol # recover # VRP vrp_thr = risk.cash_parking_vrp_threshold if vrp_thr > 0: vix = macro.get("VIXCLS") vol_vrp = macro.get(f"qqq_vol_{vol_lb}") if vix is not None and vol_vrp is not None: vrp = vix - (vol_vrp * 100) if vrp > vrp_thr and not in_sgov: return "sgov" if in_sgov and vrp <= vrp_thr * 0.6: return park_symbol # Temperature temp_thr = risk.cash_parking_temperature_threshold if temp_thr > 0: vol_short = macro.get("qqq_vol_15") vol_long = macro.get("qqq_vol_50") if vol_short and vol_long and vol_long > 0: temp = vol_short / vol_long if temp > temp_thr and not in_sgov: return "sgov" if in_sgov and temp <= temp_thr * 0.7: return park_symbol # Hurst hurst_thr = risk.cash_parking_hurst_threshold if hurst_thr > 0: hurst = macro.get(f"{prefix}_hurst_60") if hurst is not None and hurst < hurst_thr and not in_sgov: return "sgov" if in_sgov and hurst is not None and hurst >= hurst_thr + 0.05: return park_symbol # Momentum trend check (asymmetric re-entry) if risk.cash_parking_require_trend and risk.cash_parking_trend_mode == "momentum": period = risk.cash_parking_trend_sma_period reentry_pct = risk.cash_parking_trend_reentry_pct mom = macro.get(f"{prefix}_mom_{period}") if in_sgov: mom_ok = mom is not None and mom > reentry_pct vix_max = risk.cash_parking_vix_reentry_max vix_ok = True if vix_max > 0: vix = macro.get("VIXCLS") vix_ok = vix is not None and vix < vix_max if mom_ok and vix_ok: return park_symbol return "sgov" else: if mom is not None and mom <= 0: return "sgov" # Entropy check within momentum ent_thr = risk.cash_parking_entropy_threshold if ent_thr > 0: ent_lb = risk.cash_parking_entropy_lookback entropy = macro.get(f"{prefix}_entropy_{ent_lb}") if entropy is not None and entropy > ent_thr: return "sgov" return park_symbol # Composite mode if gate_mode == "composite": score = _compute_risk_score(macro) exit_thr = risk.cash_parking_composite_exit_score enter_thr = risk.cash_parking_composite_enter_score if in_sgov: if score <= enter_thr: return park_symbol return "sgov" else: if score >= exit_thr: return "sgov" return park_symbol return park_symbol def _compute_risk_score(macro: dict) -> int: """Compute composite risk score (same as run.py).""" score = 0 vix = macro.get("VIXCLS") if vix is not None: if vix > 30: score += 45 elif vix > 25: score += 35 elif vix > 20: score += 15 elif vix > 17: score += 5 vix_chg = macro.get("vix_change_5d") if vix_chg is not None: if vix_chg > 8: score += 25 elif vix_chg > 5: score += 15 elif vix_chg > 3: score += 8 hy = macro.get("BAMLH0A0HYM2") if hy is not None: if hy > 6.0: score += 30 elif hy > 5.0: score += 20 elif hy > 4.0: score += 8 vol = macro.get("qqq_vol_20") if vol is not None: if vol > 0.30: score += 20 elif vol > 0.24: score += 10 elif vol > 0.20: score += 3 mom = macro.get("qqq_mom_20") if mom is not None: if mom < -0.05: score += 15 elif mom < -0.02: score += 10 elif mom < 0: score += 5 spy_mom = macro.get("spy_mom_20") if spy_mom is not None and mom is not None: if spy_mom < 0 and mom < 0: score += 8 if vix is not None and vol is not None: vrp = vix - (vol * 100) if vrp > 12: score += 20 elif vrp > 8: score += 10 vol_s = macro.get("qqq_vol_15") vol_l = macro.get("qqq_vol_50") if vol_s and vol_l and vol_l > 0: temp = vol_s / vol_l if temp > 1.5: score += 25 elif temp > 1.3: score += 15 hurst = macro.get("qqq_hurst_60") if hurst is not None: if hurst < 0.40: score += 15 elif hurst < 0.45: score += 8 kurt = macro.get("qqq_kurtosis_20") if kurt is not None: if kurt > 4.0: score += 15 elif kurt > 3.0: score += 8 ac = macro.get("qqq_autocorr_20") if ac is not None: if ac < -0.2: score += 12 elif ac < -0.1: score += 6 corr = macro.get("spy_qqq_corr_20") if corr is not None: if corr < 0.75: score += 15 elif corr < 0.80: score += 8 return min(score, 100) def load_macro_data(config_path: str, start: dt.date, end: dt.date) -> dict: """Load macro data directly from Oracle API + FRED DB.""" return asyncio.run(_load_macro_async(start, end)) async def _load_macro_async(start: dt.date, end: dt.date) -> dict: """Fetch SPY/QQQ/QQQM bars from Oracle API and compute indicators.""" import os from libs.backtest.snapshot_store import SnapshotStore oracle_url = os.environ.get("ORACLE_URL") or os.environ.get("STOCK_ORACLE_URL", "http://localhost:8000") macro = await SnapshotStore._fetch_spy_macro( date_range=(start, end), oracle_url=oracle_url, ) # Also load FRED macro data (VIX, HY spread, etc.) db_dsn = os.environ.get("DB_DSN") or os.environ.get("POSTGRES_DSN", "") if db_dsn: fred_macro = await SnapshotStore._fetch_macro( date_range=(start, end), db_dsn=db_dsn, ) for d, vals in fred_macro.items(): if d in macro: macro[d].update(vals) else: macro[d] = vals return macro def print_result(r: ParkingResult, buy_hold: ParkingResult | None = None) -> None: """Pretty print a single result.""" print(f" {r.preset:<25s} {r.symbol:<6s} " f"+{r.total_return_pct:>7.1f}% DD {r.max_drawdown_pct:>6.2f}% " f"CAGR {r.cagr_pct:>5.1f}% Sharpe {r.sharpe:>5.2f} " f"Switches {r.switches:>3d} " f"Eq/SGOV {r.days_in_equity}/{r.days_in_sgov}d") if r.annual_returns: years_str = " Annual: " + ", ".join( f"{y}: {ret:+.1f}%" for y, ret in sorted(r.annual_returns.items()) ) print(years_str) def main(): parser = argparse.ArgumentParser(description="Standalone parking-only backtest") parser.add_argument("--preset", type=str, help="Parking preset name") parser.add_argument("--config", type=str, default="configs/experiments/return_max_long_v10.99.json", help="Config file (used only to load macro data from its snapshot)") parser.add_argument("--symbol", type=str, help="Override parking symbol (qqq, qqqm, spy)") parser.add_argument("--capital", type=float, default=10000) parser.add_argument("--start", type=int, default=2022, help="Start year") parser.add_argument("--end", type=int, default=2026, help="End year") parser.add_argument("--compare", action="store_true", help="Compare all key presets") args = parser.parse_args() start = dt.date(args.start, 1, 1) end = dt.date(args.end, 12, 31) today = dt.date.today() if end > today: end = today print(f"Loading macro data {start} → {end} (Oracle API direct)...") macro = load_macro_data(args.config, start, end) print(f" {len(macro)} trading days loaded\n") if args.compare: presets = [ "qqq_no_gate", "sgov", "vol_20_24", "vt_24_t13", "vm_24_m20", "vmh_24_m20_h50", "ve_10_10", "vme_24_e14", "vmeh_24_e14_h50", "composite_v2", ] print(f"Parking-Only Strategy Comparison (${ args.capital:,.0f}, {args.start}-{args.end})") print("=" * 110) # Buy-and-hold QQQ baseline bh = run_parking_backtest(macro, "qqq_no_gate", args.capital, start, end) print(f"\n {'Preset':<25s} {'Sym':<6s} {'Return':>9s} {'MaxDD':>8s} " f"{'CAGR':>7s} {'Sharpe':>7s} {'Sw':>5s} {'Eq/SGOV':>12s}") print(" " + "-" * 100) results = [] for p in presets: r = run_parking_backtest(macro, p, args.capital, start, end) results.append(r) print_result(r) # Summary print("\n" + "=" * 110) best_ret = max(results, key=lambda r: r.total_return_pct) best_dd = min(results, key=lambda r: abs(r.max_drawdown_pct) if r.max_drawdown_pct != 0 else 999) best_sharpe = max(results, key=lambda r: r.sharpe) print(f" Best Return: {best_ret.preset} (+{best_ret.total_return_pct:.1f}%)") print(f" Best DD: {best_dd.preset} (DD {best_dd.max_drawdown_pct:.2f}%)") print(f" Best Sharpe: {best_sharpe.preset} (Sharpe {best_sharpe.sharpe:.2f})") elif args.preset: r = run_parking_backtest(macro, args.preset, args.capital, start, end) print(f"Parking-Only: {args.preset}") print("=" * 80) print_result(r) else: parser.print_help() if __name__ == "__main__": main()