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),
})

View File

@@ -0,0 +1,147 @@
"""
Multi-leg strategy catalog for the Backtest page.
Backtest simulates years of history (2022-2024 etc.) via a single trailing-realized-vol
Black-Scholes price per leg (see routers/backtest.py) — there's no real option chain to
draw strikes from that far back (accumulated Saxo history only covers the last few weeks,
see services/option_chain.py's own docstring). The 14 template-based strategies below
therefore reuse services.strategy_templates's offset-based generators (built for Strategy
Builder against a REAL chain) against a SYNTHETIC strike grid centered on spot instead —
same leg-selection logic, just fed a fabricated but structurally identical "expiry" dict.
The 6 single/vertical strategies use `strike_offset_pct` directly (S * (1 +/- pct)),
matching the original single-leg backtest's behavior exactly rather than going through
the grid, since they don't need a strike LIST to pick from.
Vertical spreads (bull/bear call/put) aren't in strategy_templates.py — Strategy Builder's
own residual search finds them without needing a template — so they're defined locally
here rather than added to that shared module, to avoid changing Strategy Builder's and
Portfolio's already-shipped optimizer behavior as a side effect of this feature.
"""
from typing import Any, Dict, List, Optional, Tuple
from services import strategy_templates as tmpl
Leg = Dict[str, Any]
# STRATEGIES entries are (key, label, n_legs) — n_legs is purely informational (frontend
# leg-count badge), the actual leg count comes from what build_legs() returns.
STRATEGIES: List[Tuple[str, str, int]] = [
("long_call", "Long Call", 1),
("long_put", "Long Put", 1),
("bull_call_spread", "Bull Call Spread", 2),
("bear_put_spread", "Bear Put Spread", 2),
("bear_call_spread", "Bear Call Spread", 2),
("bull_put_spread", "Bull Put Spread", 2),
("long_straddle", "Long Straddle", 2),
("short_straddle", "Short Straddle", 2),
("long_strangle", "Long Strangle", 2),
("short_strangle", "Short Strangle", 2),
("call_ratio_spread", "Call Ratio Spread", 2),
("put_ratio_spread", "Put Ratio Spread", 2),
("calendar_spread", "Calendar Spread", 2),
("diagonal_spread", "Diagonal Spread", 2),
("call_butterfly", "Call Butterfly", 3),
("put_butterfly", "Put Butterfly", 3),
("call_condor", "Call Condor", 4),
("put_condor", "Put Condor", 4),
("iron_condor", "Iron Condor", 4),
("iron_butterfly", "Iron Butterfly", 4),
]
_TEMPLATE_STRATEGY_KEYS = {s[0] for s in STRATEGIES[6:]} # everything past the 6 direct ones
_GRID_STEP_PCT = 0.02
_GRID_HALF_WIDTH = 25 # strikes from -50% to +50% of spot in 2% steps — enough room for
# strategy_templates' OFFSETS/WIDTHS (max reach ~10 steps either side)
def synthetic_expiry(expiry_date: str, days_to_expiry: int, spot: float) -> Dict[str, Any]:
"""A fabricated 'expiry' shaped exactly like services.option_chain.get_chain_slice's
real output (expiry_date/days_to_expiry/calls/puts with {strike} rows) — strategy_templates'
generators only ever read strike lists off it, so they work unmodified against this."""
strikes = [round(spot * (1 + i * _GRID_STEP_PCT), 4) for i in range(-_GRID_HALF_WIDTH, _GRID_HALF_WIDTH + 1)]
return {
"expiry_date": expiry_date, "days_to_expiry": days_to_expiry,
"calls": [{"strike": k} for k in strikes], "puts": [{"strike": k} for k in strikes],
}
def _atm_index(strikes: List[float], spot: float) -> int:
return min(range(len(strikes)), key=lambda i: abs(strikes[i] - spot))
def _at(strikes: List[float], idx: int) -> Optional[float]:
return strikes[idx] if 0 <= idx < len(strikes) else None
def _leg(expiry: Dict[str, Any], strike: float, option_type: str, position: str, quantity: int = 1) -> Leg:
return {
"expiry_date": expiry["expiry_date"], "days_to_expiry": expiry["days_to_expiry"],
"strike": strike, "option_type": option_type, "position": position, "quantity": quantity,
}
def _first_by_name(candidates: List[Tuple[str, List[Leg]]], name: str) -> Optional[List[Leg]]:
return next((legs for n, legs in candidates if n == name), None)
def _vertical(expiry: Dict[str, Any], spot: float, offset_pct: float, option_type: str, buy_near: bool) -> List[Leg]:
"""2-leg vertical, same expiry/type: one leg at spot*(1+/-offset_pct), the other at
3x that offset. buy_near=True -> debit spread (long the closer strike, short the
farther); False -> credit spread (short the closer, long the farther)."""
sign = 1 if option_type == "call" else -1
near_k = round(spot * (1 + sign * offset_pct), 4)
far_k = round(spot * (1 + sign * offset_pct * 3), 4)
near_pos, far_pos = ("long", "short") if buy_near else ("short", "long")
return [_leg(expiry, near_k, option_type, near_pos), _leg(expiry, far_k, option_type, far_pos)]
def build_legs(
strategy_key: str, spot: float, strike_offset_pct: float,
near_expiry: Dict[str, Any], far_expiry: Optional[Dict[str, Any]],
) -> List[Leg]:
"""Returns the leg list for one of STRATEGIES' keys, or [] if it can't be built
(calendar/diagonal with no far_expiry, or the synthetic grid came up short)."""
if strategy_key == "long_call":
return [_leg(near_expiry, round(spot * (1 + strike_offset_pct), 4), "call", "long")]
if strategy_key == "long_put":
return [_leg(near_expiry, round(spot * (1 - strike_offset_pct), 4), "put", "long")]
if strategy_key == "bull_call_spread":
return _vertical(near_expiry, spot, strike_offset_pct, "call", buy_near=True)
if strategy_key == "bear_put_spread":
return _vertical(near_expiry, spot, strike_offset_pct, "put", buy_near=True)
if strategy_key == "bear_call_spread":
return _vertical(near_expiry, spot, strike_offset_pct, "call", buy_near=False)
if strategy_key == "bull_put_spread":
return _vertical(near_expiry, spot, strike_offset_pct, "put", buy_near=False)
if strategy_key not in _TEMPLATE_STRATEGY_KEYS:
return []
if strategy_key in ("long_straddle", "short_straddle", "long_strangle", "short_strangle"):
name = {"long_straddle": "Long Straddle", "short_straddle": "Short Straddle",
"long_strangle": "Long Strangle", "short_strangle": "Short Strangle"}[strategy_key]
return _first_by_name(list(tmpl.straddle_strangle(near_expiry, spot)), name) or []
if strategy_key in ("call_butterfly", "put_butterfly"):
name = "Call Butterfly" if strategy_key == "call_butterfly" else "Put Butterfly"
return _first_by_name(list(tmpl.butterfly(near_expiry, spot)), name) or []
if strategy_key == "iron_butterfly":
return _first_by_name(list(tmpl.iron_butterfly(near_expiry, spot)), "Iron Butterfly") or []
if strategy_key in ("call_condor", "put_condor"):
name = "Call Condor" if strategy_key == "call_condor" else "Put Condor"
return _first_by_name(list(tmpl.condor(near_expiry, spot)), name) or []
if strategy_key == "iron_condor":
return _first_by_name(list(tmpl.iron_condor(near_expiry, spot)), "Iron Condor") or []
if strategy_key in ("call_ratio_spread", "put_ratio_spread"):
name = "Call Ratio Spread" if strategy_key == "call_ratio_spread" else "Put Ratio Spread"
return _first_by_name(list(tmpl.ratio_spread(near_expiry, spot)), name) or []
if strategy_key == "calendar_spread":
if far_expiry is None:
return []
return _first_by_name(list(tmpl.calendar_spread(near_expiry, far_expiry, spot)), "Calendar Spread") or []
if strategy_key == "diagonal_spread":
if far_expiry is None:
return []
return _first_by_name(list(tmpl.diagonal_spread(near_expiry, far_expiry, spot)), "Diagonal Spread") or []
return []

View File

@@ -257,6 +257,22 @@ export const useBacktest = () =>
mutationFn: (data) => api.post('/backtest/run', data).then(r => r.data),
})
export type BacktestSymbol = { ticker: string; name: string }
export const useBacktestSymbols = () =>
useQuery<BacktestSymbol[]>({
queryKey: ['backtest-symbols'],
queryFn: () => api.get('/backtest/symbols').then(r => r.data),
staleTime: 60_000,
})
export type BacktestStrategyInfo = { key: string; label: string; n_legs: number }
export const useBacktestStrategies = () =>
useQuery<BacktestStrategyInfo[]>({
queryKey: ['backtest-strategies'],
queryFn: () => api.get('/backtest/strategies').then(r => r.data),
staleTime: 60 * 60_000,
})
// ── AI ────────────────────────────────────────────────────────────────────────
export const useAiStatus = () =>
useQuery({

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'
import { useBacktest } from '../hooks/useApi'
import { useEffect, useState } from 'react'
import { useBacktest, useBacktestSymbols, useBacktestStrategies } from '../hooks/useApi'
import clsx from 'clsx'
import {
AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer,
@@ -8,17 +8,14 @@ import {
import { History, Play, TrendingUp, TrendingDown, AlertTriangle } from 'lucide-react'
import type { BacktestResult } from '../types'
const STRATEGIES = [
{ key: 'long_call', label: 'Long Call' },
{ key: 'long_put', label: 'Long Put' },
{ key: 'bull_call_spread', label: 'Bull Call Spread' },
{ key: 'bear_put_spread', label: 'Bear Put Spread' },
]
type LegRow = { strike: number; option_type: string; position: string; quantity: number; days_to_expiry: number }
const SYMBOLS = [
'GLD', 'USO', 'WEAT', 'UNG', 'SPY', 'QQQ', 'GDX', 'COPX',
'XLE', 'FXE', 'XOM', 'LMT', 'BA', 'RTX',
]
function legsSummary(legs: LegRow[] | undefined): string {
if (!legs || !legs.length) return '—'
return legs
.map(l => `${l.position === 'long' ? '+' : ''}${l.quantity > 1 ? l.quantity + 'x' : ''}${l.option_type === 'call' ? 'C' : 'P'}${l.strike}`)
.join(' / ')
}
function StatCard({ label, value, sub, positive }: { label: string; value: string; sub?: string; positive?: boolean }) {
return (
@@ -39,8 +36,11 @@ function StatCard({ label, value, sub, positive }: { label: string; value: strin
export default function Backtest() {
const { mutate: runBacktest, data: result, isPending } = useBacktest()
const { data: symbols } = useBacktestSymbols()
const { data: strategies } = useBacktestStrategies()
const [form, setForm] = useState({
symbol: 'GLD',
symbol: '',
start_date: '2022-01-01',
end_date: '2024-12-31',
strategy: 'long_call',
@@ -49,6 +49,12 @@ export default function Backtest() {
capital: 1000,
})
// Symbols only exist once Config → Instruments Watchlist has a Saxo-linked entry —
// default to the first one once it loads rather than a ticker that may not be there.
useEffect(() => {
if (!form.symbol && symbols && symbols.length) set('symbol', symbols[0].ticker)
}, [symbols]) // eslint-disable-line react-hooks/exhaustive-deps
const set = (k: string, v: unknown) => setForm(f => ({ ...f, [k]: v }))
const run = () => runBacktest(form as Record<string, unknown>)
@@ -74,43 +80,46 @@ export default function Backtest() {
<div className="section-title">Configuration</div>
<div className="space-y-3">
<div>
<label className="text-xs text-slate-500 mb-1 block">Underlying</label>
<div className="flex flex-wrap gap-1 mb-1">
{SYMBOLS.slice(0, 7).map(s => (
<button
key={s}
onClick={() => set('symbol', s)}
className={clsx('px-1.5 py-0.5 rounded text-xs border', {
'bg-blue-600 border-blue-500 text-white': form.symbol === s,
'border-slate-700 text-slate-500': form.symbol !== s,
})}
>
{s}
</button>
))}
</div>
<input
type="text"
value={form.symbol}
onChange={e => set('symbol', e.target.value.toUpperCase())}
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500"
/>
<label className="text-xs text-slate-500 mb-1 block">Underlying (Saxo-linked)</label>
{!symbols?.length ? (
<div className="text-xs text-slate-600 border border-slate-700/40 rounded px-2 py-1.5">
Aucun instrument lié à Saxo Config Instruments Watchlist.
</div>
) : (
<select
value={form.symbol}
onChange={e => set('symbol', e.target.value)}
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white focus:outline-none focus:border-blue-500"
>
{symbols.map(s => (
<option key={s.ticker} value={s.ticker}>{s.ticker} {s.name}</option>
))}
</select>
)}
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Strategy</label>
{STRATEGIES.map(s => (
<div className="max-h-64 overflow-y-auto pr-1 space-y-1">
{(strategies ?? []).map(s => (
<button
key={s.key}
onClick={() => set('strategy', s.key)}
className={clsx('w-full text-left px-2 py-1.5 rounded mb-1 text-xs border transition-all', {
className={clsx('w-full flex items-center justify-between gap-2 text-left px-2 py-1.5 rounded text-xs border transition-all', {
'bg-blue-600/20 border-blue-500/60 text-blue-300': form.strategy === s.key,
'border-slate-700/40 text-slate-400': form.strategy !== s.key,
})}
>
{s.label}
<span>{s.label}</span>
<span className={clsx('text-[10px] px-1 rounded shrink-0', {
'bg-blue-500/20 text-blue-300': form.strategy === s.key,
'bg-slate-700/40 text-slate-500': form.strategy !== s.key,
})}>
{s.n_legs}j
</span>
</button>
))}
</div>
</div>
<div>
@@ -221,7 +230,7 @@ export default function Backtest() {
{/* Equity curve */}
<div className="card">
<div className="section-title">Equity curve {form.symbol} {STRATEGIES.find(s => s.key === form.strategy)?.label}</div>
<div className="section-title">Equity curve {form.symbol} {(strategies ?? []).find(s => s.key === form.strategy)?.label}</div>
<div className="text-xs text-slate-500 mb-3">
Final capital: <span className="text-white font-bold">{typed.final_capital.toFixed(2)}</span>
{' '}(initial: {form.capital} · P&L: {typed.total_pnl >= 0 ? '+' : ''}{typed.total_pnl.toFixed(2)})
@@ -262,8 +271,8 @@ export default function Backtest() {
<th className="text-left pb-2">Entry</th>
<th className="text-left pb-2">Exit</th>
<th className="text-right pb-2">Entry price</th>
<th className="text-right pb-2">Strike</th>
<th className="text-right pb-2">Premium</th>
<th className="text-left pb-2">Legs</th>
<th className="text-right pb-2">Net premium</th>
<th className="text-right pb-2">Exit price</th>
<th className="text-right pb-2">P&L</th>
<th className="text-right pb-2">Capital</th>
@@ -272,13 +281,16 @@ export default function Backtest() {
<tbody>
{typed.trades.map((t, i) => {
const pnl = t.pnl as number
const netPremium = t.net_premium as number
return (
<tr key={i} className="border-b border-slate-700/20 hover:bg-dark-700/50">
<td className="py-1">{t.entry_date as string}</td>
<td className="py-1">{t.exit_date as string}</td>
<td className="py-1 text-right font-mono">${(t.S_entry as number).toFixed(2)}</td>
<td className="py-1 text-right font-mono">${(t.K as number).toFixed(2)}</td>
<td className="py-1 text-right font-mono">${(t.premium as number).toFixed(4)}</td>
<td className="py-1 font-mono text-slate-400">{legsSummary(t.legs as LegRow[])}</td>
<td className="py-1 text-right font-mono">
{netPremium >= 0 ? '' : '+'}{(-netPremium).toFixed(2)}{netPremium >= 0 ? ' débit' : ' crédit'}
</td>
<td className="py-1 text-right font-mono">${(t.S_expiry as number).toFixed(2)}</td>
<td className={clsx('py-1 text-right font-mono font-bold', pnl >= 0 ? 'positive' : 'negative')}>
{pnl >= 0 ? '+' : ''}{pnl.toFixed(2)}
@@ -299,7 +311,7 @@ export default function Backtest() {
<div className="text-center">
<History className="w-10 h-10 mx-auto mb-3 opacity-20" />
<div className="text-sm">Configure and run a backtest</div>
<div className="text-xs mt-1">yfinance data full history available</div>
<div className="text-xs mt-1">Prix sous-jacent yfinance (historique complet) · options simulées Black-Scholes</div>
</div>
</div>
)}