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