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