feat: backtest

This commit is contained in:
OpenSquared
2026-07-30 10:53:47 +02:00
parent 9a2ffb1c6a
commit 15528e0c98
4 changed files with 216 additions and 58 deletions

View File

@@ -1,10 +1,10 @@
from fastapi import APIRouter
from pydantic import BaseModel
from typing import Optional, List
from pydantic import BaseModel, Field
from typing import List
import yfinance as yf
import numpy as np
from services.options_pricer import black_scholes
from services.backtest_strategies import STRATEGIES, build_legs, synthetic_expiry
from services.backtest_strategies import STRATEGIES, default_legs_pct
router = APIRouter(prefix="/api/backtest", tags=["backtest"])
@@ -24,36 +24,55 @@ def backtest_symbols():
@router.get("/strategies")
def backtest_strategies():
return [{"key": k, "label": label, "n_legs": n} for k, label, n in STRATEGIES]
"""Each preset's legs are also returned relative to spot (strike_pct) so the frontend
can seed an EDITABLE leg list when a preset is picked, rather than only offering fixed
canned shapes — e.g. turning a 2-leg Call Ratio Spread preset into a custom 3-leg
structure just means adding a leg client-side and re-running."""
return [
{"key": k, "label": label, "n_legs": n, "default_legs": default_legs_pct(k)}
for k, label, n in STRATEGIES
]
class BacktestLeg(BaseModel):
option_type: str # "call" | "put"
position: str # "long" | "short"
quantity: int = 1
strike_pct: float # relative to spot AT EACH ENTRY DATE, e.g. 1.05 = 5% OTM call
expiry: str = "near" # "near" | "far" — far only meaningful when far_expiry_days is set
class BacktestRequest(BaseModel):
symbol: str
start_date: str
end_date: str
strategy: str
strike_offset_pct: float = 0.05 # e.g. 5% OTM — used by the 6 direct (non-template) strategies
legs: List[BacktestLeg] = Field(min_length=1, max_length=4)
expiry_days: int = 90
far_expiry_days: int = 180 # only used by legs with expiry="far"
capital: float = 1000.0
def _settle_leg(leg: dict, near_days: int, S_settle: float, sigma: float, r: float) -> float:
def _settle_leg(leg: BacktestLeg, strike: float, days_to_expiry: int, 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
case), else a fresh Black-Scholes price for its remaining time (a 'far' leg — closed
alongside the near leg rather than held to its own later expiry, the standard way
calendar/diagonal-style structures are actually managed)."""
remaining_days = 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)
if leg.option_type == "call":
return max(0.0, S_settle - strike)
return max(0.0, strike - S_settle)
T = remaining_days / 365
return float(black_scholes(S_settle, leg["strike"], T, r, sigma, leg["option_type"])["price"])
return float(black_scholes(S_settle, strike, T, r, sigma, leg.option_type)["price"])
@router.post("/run")
def run_backtest(req: BacktestRequest):
try:
for leg in req.legs:
if leg.option_type not in ("call", "put") or leg.position not in ("long", "short"):
return {"error": f"Jambe invalide: {leg}"}
ticker = yf.Ticker(req.symbol)
hist = ticker.history(start=req.start_date, end=req.end_date, interval="1d")
if hist.empty or len(hist) < 20:
@@ -62,8 +81,6 @@ 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
@@ -82,19 +99,15 @@ def run_backtest(req: BacktestRequest):
if sigma < 0.01:
sigma = 0.20
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
leg_strikes = [round(S * leg.strike_pct, 4) for leg in req.legs]
leg_days = [req.expiry_days if leg.expiry != "far" else req.far_expiry_days for leg in req.legs]
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)
entry_premiums = [
float(black_scholes(S, k, d / 365, r, sigma, leg.option_type)["price"])
for leg, k, d in zip(req.legs, leg_strikes, leg_days)
]
signed_qty = [(1 if leg["position"] == "long" else -1) * leg["quantity"] for leg in legs]
signed_qty = [(1 if leg.position == "long" else -1) * leg.quantity for leg in req.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)
@@ -105,7 +118,10 @@ def run_backtest(req: BacktestRequest):
S_expiry = float(hist.iloc[expiry_idx]["Close"])
date_expiry = str(hist.iloc[expiry_idx]["Date"])[:10]
exit_values = [_settle_leg(leg, req.expiry_days, S_expiry, sigma, r) for leg in legs]
exit_values = [
_settle_leg(leg, k, d, req.expiry_days, S_expiry, sigma, r)
for leg, k, d in zip(req.legs, leg_strikes, leg_days)
]
exit_signed_value = sum(sq * v for sq, v in zip(signed_qty, exit_values))
pnl = (exit_signed_value - net_premium) * contracts * 100
@@ -115,14 +131,12 @@ def run_backtest(req: BacktestRequest):
trades.append({
"entry_date": date_str,
"exit_date": date_expiry,
"strategy": req.strategy,
"S_entry": round(S, 2),
"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
{"strike": round(k, 2), "option_type": leg.option_type,
"position": leg.position, "quantity": leg.quantity, "days_to_expiry": d}
for leg, k, d in zip(req.legs, leg_strikes, leg_days)
],
"net_premium": round(net_premium, 4),
"contracts": contracts,
@@ -149,7 +163,6 @@ def run_backtest(req: BacktestRequest):
return {
"symbol": req.symbol,
"strategy": req.strategy,
"period": f"{req.start_date}{req.end_date}",
"total_trades": len(trades),
"wins": len(wins),

View File

@@ -145,3 +145,27 @@ def build_legs(
return []
return _first_by_name(list(tmpl.diagonal_spread(near_expiry, far_expiry, spot)), "Diagonal Spread") or []
return []
_NOMINAL_SPOT = 100.0
_NOMINAL_NEAR_DAYS = 90
_NOMINAL_FAR_DAYS = 180
def default_legs_pct(strategy_key: str, strike_offset_pct: float = 0.05) -> List[Dict[str, Any]]:
"""A preset's legs expressed relative to spot (strike_pct = strike/spot, e.g. 1.05 =
5% OTM call) instead of the absolute strikes build_legs() returns — this is what
seeds the frontend's editable leg editor when a preset is picked. Computed once at a
nominal spot=100, not per simulated date (routers/backtest.py's /run instead takes
the user-edited legs directly and reapplies strike_pct * spot at each entry date)."""
near = synthetic_expiry("near", _NOMINAL_NEAR_DAYS, _NOMINAL_SPOT)
far = synthetic_expiry("far", _NOMINAL_FAR_DAYS, _NOMINAL_SPOT)
legs = build_legs(strategy_key, _NOMINAL_SPOT, strike_offset_pct, near, far)
return [
{
"option_type": leg["option_type"], "position": leg["position"], "quantity": leg["quantity"],
"strike_pct": round(leg["strike"] / _NOMINAL_SPOT, 4),
"expiry": "near" if leg["days_to_expiry"] == _NOMINAL_NEAR_DAYS else "far",
}
for leg in legs
]