feat: backtest
This commit is contained in:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user