182 lines
7.3 KiB
Python
182 lines
7.3 KiB
Python
from fastapi import APIRouter
|
|
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, default_legs_pct
|
|
|
|
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():
|
|
"""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
|
|
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: 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 (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 - strike)
|
|
return max(0.0, strike - S_settle)
|
|
T = remaining_days / 365
|
|
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:
|
|
return {"error": "Insufficient data for the period"}
|
|
|
|
hist = hist.reset_index()
|
|
returns = np.log(hist["Close"] / hist["Close"].shift(1)).dropna()
|
|
|
|
trades = []
|
|
equity = [req.capital]
|
|
capital = req.capital
|
|
r = 0.05
|
|
|
|
step = max(1, req.expiry_days // 3)
|
|
for i in range(0, len(hist) - req.expiry_days, step):
|
|
row = hist.iloc[i]
|
|
S = float(row["Close"])
|
|
date_str = str(row["Date"])[:10]
|
|
|
|
sigma_window = returns.iloc[max(0, i - 30):i]
|
|
if len(sigma_window) < 5:
|
|
continue
|
|
sigma = float(sigma_window.std() * np.sqrt(252))
|
|
if sigma < 0.01:
|
|
sigma = 0.20
|
|
|
|
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 = [
|
|
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 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)
|
|
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]
|
|
|
|
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
|
|
capital += pnl
|
|
equity.append(round(capital, 2))
|
|
|
|
trades.append({
|
|
"entry_date": date_str,
|
|
"exit_date": date_expiry,
|
|
"S_entry": round(S, 2),
|
|
"S_expiry": round(S_expiry, 2),
|
|
"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,
|
|
"cost": round(cost, 2),
|
|
"pnl": round(pnl, 2),
|
|
"capital": round(capital, 2),
|
|
})
|
|
|
|
if not trades:
|
|
return {"error": "No trades generated"}
|
|
|
|
wins = [t for t in trades if t["pnl"] > 0]
|
|
losses = [t for t in trades if t["pnl"] <= 0]
|
|
total_pnl = sum(t["pnl"] for t in trades)
|
|
gross_profit = sum(t["pnl"] for t in wins) if wins else 0
|
|
gross_loss = abs(sum(t["pnl"] for t in losses)) if losses else 1
|
|
|
|
eq = np.array(equity)
|
|
peak = np.maximum.accumulate(eq)
|
|
drawdown = (eq - peak) / peak
|
|
max_dd = float(drawdown.min()) * 100
|
|
|
|
equity_curve = [{"index": i, "capital": v} for i, v in enumerate(equity)]
|
|
|
|
return {
|
|
"symbol": req.symbol,
|
|
"period": f"{req.start_date} → {req.end_date}",
|
|
"total_trades": len(trades),
|
|
"wins": len(wins),
|
|
"losses": len(losses),
|
|
"win_rate": round(len(wins) / len(trades) * 100, 1) if trades else 0,
|
|
"total_pnl": round(total_pnl, 2),
|
|
"total_return_pct": round((capital - req.capital) / req.capital * 100, 2),
|
|
"max_drawdown_pct": round(max_dd, 2),
|
|
"profit_factor": round(gross_profit / gross_loss, 2) if gross_loss else 0,
|
|
"final_capital": round(capital, 2),
|
|
"equity_curve": equity_curve,
|
|
"trades": trades[-20:],
|
|
}
|
|
|
|
except Exception as e:
|
|
return {"error": str(e)}
|