feat: backtest

This commit is contained in:
OpenSquared
2026-07-29 21:35:30 +02:00
parent ad3f599082
commit 9a2ffb1c6a
4 changed files with 283 additions and 66 deletions

View File

@@ -3,22 +3,52 @@ from pydantic import BaseModel
from typing import Optional, List
import yfinance as yf
import numpy as np
import pandas as pd
from datetime import datetime
from services.options_pricer import black_scholes
from services.backtest_strategies import STRATEGIES, build_legs, synthetic_expiry
router = APIRouter(prefix="/api/backtest", tags=["backtest"])
@router.get("/symbols")
def backtest_symbols():
"""Underlyings actually tracked via Saxo (Config → Instruments Watchlist, linked to a
real Saxo options chain) — same yfinance-compatible `ticker` field used everywhere
else in the app, so pricing here still runs off yfinance's long history, but the
choices on screen match what's genuinely tradeable rather than an arbitrary ETF list."""
from services.database import get_instruments_watchlist
return [
{"ticker": w["ticker"], "name": w["name"]}
for w in get_instruments_watchlist() if w.get("saxo_option_symbol")
]
@router.get("/strategies")
def backtest_strategies():
return [{"key": k, "label": label, "n_legs": n} for k, label, n in STRATEGIES]
class BacktestRequest(BaseModel):
symbol: str
start_date: str
end_date: str
strategy: str # "long_call" | "long_put" | "bull_call_spread" | "bear_put_spread" | "straddle"
strike_offset_pct: float = 0.05 # e.g. 5% OTM
strategy: str
strike_offset_pct: float = 0.05 # e.g. 5% OTM — used by the 6 direct (non-template) strategies
expiry_days: int = 90
capital: float = 1000.0
geo_filter: Optional[str] = None # optional pattern id to filter
def _settle_leg(leg: dict, near_days: int, S_settle: float, sigma: float, r: float) -> float:
"""Value one leg at the near expiry: intrinsic if it expires there too (the common
case), else a fresh Black-Scholes price for its remaining time (calendar/diagonal's
far leg — closed alongside the near leg rather than held to its own later expiry,
the standard way these are actually managed)."""
remaining_days = leg["days_to_expiry"] - near_days
if remaining_days <= 0:
if leg["option_type"] == "call":
return max(0.0, S_settle - leg["strike"])
return max(0.0, leg["strike"] - S_settle)
T = remaining_days / 365
return float(black_scholes(S_settle, leg["strike"], T, r, sigma, leg["option_type"])["price"])
@router.post("/run")
@@ -32,11 +62,12 @@ def run_backtest(req: BacktestRequest):
hist = hist.reset_index()
returns = np.log(hist["Close"] / hist["Close"].shift(1)).dropna()
far_days = req.expiry_days * 2 # calendar/diagonal's far leg, closed alongside the near leg
trades = []
equity = [req.capital]
capital = req.capital
r = 0.05
T_open = req.expiry_days / 365
step = max(1, req.expiry_days // 3)
for i in range(0, len(hist) - req.expiry_days, step):
@@ -51,26 +82,33 @@ def run_backtest(req: BacktestRequest):
if sigma < 0.01:
sigma = 0.20
if req.strategy in ["long_call", "bull_call_spread"]:
K = S * (1 + req.strike_offset_pct)
else:
K = S * (1 - req.strike_offset_pct)
near_expiry = synthetic_expiry(date_str, req.expiry_days, S)
far_expiry = synthetic_expiry(date_str, far_days, S) if req.strategy in ("calendar_spread", "diagonal_spread") else None
legs = build_legs(req.strategy, S, req.strike_offset_pct, near_expiry, far_expiry)
if not legs:
continue
result = black_scholes(S, K, T_open, r, sigma, "call" if "call" in req.strategy else "put")
premium = result["price"]
contracts = max(1, int((capital * 0.1) / (premium * 100)))
cost = contracts * premium * 100
entry_premiums = []
for leg in legs:
T = leg["days_to_expiry"] / 365
premium = float(black_scholes(S, leg["strike"], T, r, sigma, leg["option_type"])["price"])
entry_premiums.append(premium)
signed_qty = [(1 if leg["position"] == "long" else -1) * leg["quantity"] for leg in legs]
net_premium = sum(sq * p for sq, p in zip(signed_qty, entry_premiums)) # >0 debit, <0 credit
risk_basis = max(abs(net_premium), 0.05 * S)
contracts = max(1, int((capital * 0.1) / (risk_basis * 100)))
cost = net_premium * contracts * 100
expiry_idx = min(i + req.expiry_days, len(hist) - 1)
S_expiry = float(hist.iloc[expiry_idx]["Close"])
date_expiry = str(hist.iloc[expiry_idx]["Date"])[:10]
if req.strategy in ["long_call", "bull_call_spread"]:
intrinsic = max(0, S_expiry - K)
else:
intrinsic = max(0, K - S_expiry)
exit_values = [_settle_leg(leg, req.expiry_days, S_expiry, sigma, r) for leg in legs]
exit_signed_value = sum(sq * v for sq, v in zip(signed_qty, exit_values))
pnl = (intrinsic - premium) * contracts * 100
pnl = (exit_signed_value - net_premium) * contracts * 100
capital += pnl
equity.append(round(capital, 2))
@@ -79,12 +117,16 @@ def run_backtest(req: BacktestRequest):
"exit_date": date_expiry,
"strategy": req.strategy,
"S_entry": round(S, 2),
"K": round(K, 2),
"premium": round(premium, 4),
"S_expiry": round(S_expiry, 2),
"legs": [
{"strike": round(leg["strike"], 2), "option_type": leg["option_type"],
"position": leg["position"], "quantity": leg["quantity"],
"days_to_expiry": leg["days_to_expiry"]}
for leg in legs
],
"net_premium": round(net_premium, 4),
"contracts": contracts,
"cost": round(cost, 2),
"S_expiry": round(S_expiry, 2),
"intrinsic": round(intrinsic, 4),
"pnl": round(pnl, 2),
"capital": round(capital, 2),
})