You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

490 lines
17 KiB
Python

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