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 fastapi import APIRouter
from pydantic import BaseModel from pydantic import BaseModel, Field
from typing import Optional, List from typing import List
import yfinance as yf import yfinance as yf
import numpy as np import numpy as np
from services.options_pricer import black_scholes 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"]) router = APIRouter(prefix="/api/backtest", tags=["backtest"])
@@ -24,36 +24,55 @@ def backtest_symbols():
@router.get("/strategies") @router.get("/strategies")
def backtest_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): class BacktestRequest(BaseModel):
symbol: str symbol: str
start_date: str start_date: str
end_date: str end_date: str
strategy: str legs: List[BacktestLeg] = Field(min_length=1, max_length=4)
strike_offset_pct: float = 0.05 # e.g. 5% OTM — used by the 6 direct (non-template) strategies
expiry_days: int = 90 expiry_days: int = 90
far_expiry_days: int = 180 # only used by legs with expiry="far"
capital: float = 1000.0 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 """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 case), else a fresh Black-Scholes price for its remaining time (a 'far' leg — closed
far leg — closed alongside the near leg rather than held to its own later expiry, alongside the near leg rather than held to its own later expiry, the standard way
the standard way these are actually managed).""" calendar/diagonal-style structures are actually managed)."""
remaining_days = leg["days_to_expiry"] - near_days remaining_days = days_to_expiry - near_days
if remaining_days <= 0: if remaining_days <= 0:
if leg["option_type"] == "call": if leg.option_type == "call":
return max(0.0, S_settle - leg["strike"]) return max(0.0, S_settle - strike)
return max(0.0, leg["strike"] - S_settle) return max(0.0, strike - S_settle)
T = remaining_days / 365 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") @router.post("/run")
def run_backtest(req: BacktestRequest): def run_backtest(req: BacktestRequest):
try: 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) ticker = yf.Ticker(req.symbol)
hist = ticker.history(start=req.start_date, end=req.end_date, interval="1d") hist = ticker.history(start=req.start_date, end=req.end_date, interval="1d")
if hist.empty or len(hist) < 20: if hist.empty or len(hist) < 20:
@@ -62,8 +81,6 @@ def run_backtest(req: BacktestRequest):
hist = hist.reset_index() hist = hist.reset_index()
returns = np.log(hist["Close"] / hist["Close"].shift(1)).dropna() 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 = [] trades = []
equity = [req.capital] equity = [req.capital]
capital = req.capital capital = req.capital
@@ -82,19 +99,15 @@ def run_backtest(req: BacktestRequest):
if sigma < 0.01: if sigma < 0.01:
sigma = 0.20 sigma = 0.20
near_expiry = synthetic_expiry(date_str, req.expiry_days, S) leg_strikes = [round(S * leg.strike_pct, 4) for leg in req.legs]
far_expiry = synthetic_expiry(date_str, far_days, S) if req.strategy in ("calendar_spread", "diagonal_spread") else None leg_days = [req.expiry_days if leg.expiry != "far" else req.far_expiry_days for leg in req.legs]
legs = build_legs(req.strategy, S, req.strike_offset_pct, near_expiry, far_expiry)
if not legs:
continue
entry_premiums = [] entry_premiums = [
for leg in legs: float(black_scholes(S, k, d / 365, r, sigma, leg.option_type)["price"])
T = leg["days_to_expiry"] / 365 for leg, k, d in zip(req.legs, leg_strikes, leg_days)
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] 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 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) 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"]) S_expiry = float(hist.iloc[expiry_idx]["Close"])
date_expiry = str(hist.iloc[expiry_idx]["Date"])[:10] 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)) exit_signed_value = sum(sq * v for sq, v in zip(signed_qty, exit_values))
pnl = (exit_signed_value - net_premium) * contracts * 100 pnl = (exit_signed_value - net_premium) * contracts * 100
@@ -115,14 +131,12 @@ def run_backtest(req: BacktestRequest):
trades.append({ trades.append({
"entry_date": date_str, "entry_date": date_str,
"exit_date": date_expiry, "exit_date": date_expiry,
"strategy": req.strategy,
"S_entry": round(S, 2), "S_entry": round(S, 2),
"S_expiry": round(S_expiry, 2), "S_expiry": round(S_expiry, 2),
"legs": [ "legs": [
{"strike": round(leg["strike"], 2), "option_type": leg["option_type"], {"strike": round(k, 2), "option_type": leg.option_type,
"position": leg["position"], "quantity": leg["quantity"], "position": leg.position, "quantity": leg.quantity, "days_to_expiry": d}
"days_to_expiry": leg["days_to_expiry"]} for leg, k, d in zip(req.legs, leg_strikes, leg_days)
for leg in legs
], ],
"net_premium": round(net_premium, 4), "net_premium": round(net_premium, 4),
"contracts": contracts, "contracts": contracts,
@@ -149,7 +163,6 @@ def run_backtest(req: BacktestRequest):
return { return {
"symbol": req.symbol, "symbol": req.symbol,
"strategy": req.strategy,
"period": f"{req.start_date}{req.end_date}", "period": f"{req.start_date}{req.end_date}",
"total_trades": len(trades), "total_trades": len(trades),
"wins": len(wins), "wins": len(wins),

View File

@@ -145,3 +145,27 @@ def build_legs(
return [] return []
return _first_by_name(list(tmpl.diagonal_spread(near_expiry, far_expiry, spot)), "Diagonal Spread") or [] return _first_by_name(list(tmpl.diagonal_spread(near_expiry, far_expiry, spot)), "Diagonal Spread") or []
return [] 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
]

View File

@@ -265,7 +265,8 @@ export const useBacktestSymbols = () =>
staleTime: 60_000, staleTime: 60_000,
}) })
export type BacktestStrategyInfo = { key: string; label: string; n_legs: number } export type BacktestLegPreset = { option_type: 'call' | 'put'; position: 'long' | 'short'; quantity: number; strike_pct: number; expiry: 'near' | 'far' }
export type BacktestStrategyInfo = { key: string; label: string; n_legs: number; default_legs: BacktestLegPreset[] }
export const useBacktestStrategies = () => export const useBacktestStrategies = () =>
useQuery<BacktestStrategyInfo[]>({ useQuery<BacktestStrategyInfo[]>({
queryKey: ['backtest-strategies'], queryKey: ['backtest-strategies'],

View File

@@ -1,14 +1,15 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useBacktest, useBacktestSymbols, useBacktestStrategies } from '../hooks/useApi' import { useBacktest, useBacktestSymbols, useBacktestStrategies, type BacktestLegPreset } from '../hooks/useApi'
import clsx from 'clsx' import clsx from 'clsx'
import { import {
AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer, AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer,
CartesianGrid, ReferenceLine, CartesianGrid, ReferenceLine,
} from 'recharts' } from 'recharts'
import { History, Play, TrendingUp, TrendingDown, AlertTriangle } from 'lucide-react' import { History, Play, Plus, Trash2, AlertTriangle } from 'lucide-react'
import type { BacktestResult } from '../types' import type { BacktestResult } from '../types'
type LegRow = { strike: number; option_type: string; position: string; quantity: number; days_to_expiry: number } type LegRow = { strike: number; option_type: string; position: string; quantity: number; days_to_expiry: number }
type EditableLeg = BacktestLegPreset
function legsSummary(legs: LegRow[] | undefined): string { function legsSummary(legs: LegRow[] | undefined): string {
if (!legs || !legs.length) return '—' if (!legs || !legs.length) return '—'
@@ -17,6 +18,9 @@ function legsSummary(legs: LegRow[] | undefined): string {
.join(' / ') .join(' / ')
} }
const MAX_LEGS = 4
const emptyLeg = (): EditableLeg => ({ option_type: 'call', position: 'long', quantity: 1, strike_pct: 1.05, expiry: 'near' })
function StatCard({ label, value, sub, positive }: { label: string; value: string; sub?: string; positive?: boolean }) { function StatCard({ label, value, sub, positive }: { label: string; value: string; sub?: string; positive?: boolean }) {
return ( return (
<div className="card-sm text-center"> <div className="card-sm text-center">
@@ -44,10 +48,13 @@ export default function Backtest() {
start_date: '2022-01-01', start_date: '2022-01-01',
end_date: '2024-12-31', end_date: '2024-12-31',
strategy: 'long_call', strategy: 'long_call',
strike_offset_pct: 0.05,
expiry_days: 90, expiry_days: 90,
far_expiry_days: 180,
capital: 1000, capital: 1000,
}) })
const [legs, setLegs] = useState<EditableLeg[]>([emptyLeg()])
const [legCountFilter, setLegCountFilter] = useState<number | null>(null)
const usesFarExpiry = legs.some(l => l.expiry === 'far')
// Symbols only exist once Config → Instruments Watchlist has a Saxo-linked entry — // 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. // default to the first one once it loads rather than a ticker that may not be there.
@@ -55,9 +62,34 @@ export default function Backtest() {
if (!form.symbol && symbols && symbols.length) set('symbol', symbols[0].ticker) if (!form.symbol && symbols && symbols.length) set('symbol', symbols[0].ticker)
}, [symbols]) // eslint-disable-line react-hooks/exhaustive-deps }, [symbols]) // eslint-disable-line react-hooks/exhaustive-deps
// Seed the leg editor with the very first strategy once presets load, so the page
// never opens with an empty leg list.
useEffect(() => {
if (strategies && strategies.length && legs.length === 1 && legs[0].strike_pct === 1.05) {
pickStrategy(strategies[0])
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [strategies])
const set = (k: string, v: unknown) => setForm(f => ({ ...f, [k]: v })) const set = (k: string, v: unknown) => setForm(f => ({ ...f, [k]: v }))
const run = () => runBacktest(form as Record<string, unknown>) const pickStrategy = (s: { key: string; label: string; default_legs: EditableLeg[] }) => {
set('strategy', s.key)
setLegs(s.default_legs.length ? s.default_legs.map(l => ({ ...l })) : [emptyLeg()])
}
const updateLeg = (idx: number, patch: Partial<EditableLeg>) =>
setLegs(ls => ls.map((l, i) => (i === idx ? { ...l, ...patch } : l)))
const addLeg = () => legs.length < MAX_LEGS && setLegs(ls => [...ls, emptyLeg()])
const removeLeg = (idx: number) => legs.length > 1 && setLegs(ls => ls.filter((_, i) => i !== idx))
const filteredStrategies = (strategies ?? []).filter(s => legCountFilter === null || s.n_legs === legCountFilter)
const run = () => runBacktest({
symbol: form.symbol, start_date: form.start_date, end_date: form.end_date,
expiry_days: form.expiry_days, far_expiry_days: form.far_expiry_days, capital: form.capital,
legs: legs.map(l => ({ option_type: l.option_type, position: l.position, quantity: l.quantity, strike_pct: l.strike_pct, expiry: l.expiry })),
})
const typed = result as BacktestResult | undefined const typed = result as BacktestResult | undefined
const hasResult = typed && !typed.error const hasResult = typed && !typed.error
@@ -99,12 +131,30 @@ export default function Backtest() {
</div> </div>
<div> <div>
<label className="text-xs text-slate-500 mb-1 block">Strategy</label> <label className="text-xs text-slate-500 mb-1 block">Nombre de jambes</label>
<div className="max-h-64 overflow-y-auto pr-1 space-y-1"> <div className="flex gap-1">
{(strategies ?? []).map(s => ( {[null, 1, 2, 3, 4].map(n => (
<button
key={n ?? 'all'}
onClick={() => setLegCountFilter(n)}
className={clsx('flex-1 py-0.5 rounded text-xs border', {
'bg-blue-600 border-blue-500 text-white': legCountFilter === n,
'border-slate-700 text-slate-500': legCountFilter !== n,
})}
>
{n ?? 'Tous'}
</button>
))}
</div>
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Strategy (préréglages)</label>
<div className="max-h-48 overflow-y-auto pr-1 space-y-1">
{filteredStrategies.map(s => (
<button <button
key={s.key} key={s.key}
onClick={() => set('strategy', s.key)} onClick={() => pickStrategy(s)}
className={clsx('w-full flex items-center justify-between gap-2 text-left px-2 py-1.5 rounded 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, '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, 'border-slate-700/40 text-slate-400': form.strategy !== s.key,
@@ -122,6 +172,75 @@ export default function Backtest() {
</div> </div>
</div> </div>
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-xs text-slate-500 block">Jambes ({legs.length}/{MAX_LEGS}) modifiables</label>
<button
onClick={addLeg}
disabled={legs.length >= MAX_LEGS}
className="text-blue-400 hover:text-blue-300 disabled:opacity-30 disabled:hover:text-blue-400"
title="Ajouter une jambe"
>
<Plus className="w-3.5 h-3.5" />
</button>
</div>
<div className="space-y-2">
{legs.map((leg, idx) => (
<div key={idx} className="border border-slate-700/40 rounded p-1.5 space-y-1">
<div className="flex items-center gap-1">
<select
value={leg.option_type}
onChange={e => updateLeg(idx, { option_type: e.target.value as 'call' | 'put' })}
className="flex-1 bg-dark-700 border border-slate-700 rounded px-1 py-1 text-[11px] text-white"
>
<option value="call">Call</option>
<option value="put">Put</option>
</select>
<select
value={leg.position}
onChange={e => updateLeg(idx, { position: e.target.value as 'long' | 'short' })}
className="flex-1 bg-dark-700 border border-slate-700 rounded px-1 py-1 text-[11px] text-white"
>
<option value="long">Achat</option>
<option value="short">Vente</option>
</select>
<button
onClick={() => removeLeg(idx)}
disabled={legs.length <= 1}
className="text-slate-500 hover:text-red-400 disabled:opacity-20 disabled:hover:text-slate-500 shrink-0"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
<div className="flex items-center gap-1">
<input
type="number" min={1} value={leg.quantity}
onChange={e => updateLeg(idx, { quantity: Math.max(1, parseInt(e.target.value) || 1) })}
title="Quantité"
className="w-12 bg-dark-700 border border-slate-700 rounded px-1 py-1 text-[11px] text-white"
/>
<input
type="number" step={0.5} value={Math.round(leg.strike_pct * 1000) / 10}
onChange={e => updateLeg(idx, { strike_pct: (parseFloat(e.target.value) || 100) / 100 })}
title="Strike en % du spot"
className="flex-1 bg-dark-700 border border-slate-700 rounded px-1 py-1 text-[11px] text-white"
/>
<span className="text-[10px] text-slate-600 shrink-0">% spot</span>
<select
value={leg.expiry}
onChange={e => updateLeg(idx, { expiry: e.target.value as 'near' | 'far' })}
title="Échéance"
className="bg-dark-700 border border-slate-700 rounded px-1 py-1 text-[11px] text-white shrink-0"
>
<option value="near">Proche</option>
<option value="far">Lointaine</option>
</select>
</div>
</div>
))}
</div>
</div>
<div> <div>
<label className="text-xs text-slate-500 mb-1 block">Period</label> <label className="text-xs text-slate-500 mb-1 block">Period</label>
<input <input
@@ -139,19 +258,7 @@ export default function Backtest() {
</div> </div>
<div> <div>
<label className="text-xs text-slate-500 mb-1 block"> <label className="text-xs text-slate-500 mb-1 block">Échéance proche: {form.expiry_days}d</label>
Strike OTM: {(form.strike_offset_pct * 100).toFixed(0)}%
</label>
<input
type="range" min={0} max={0.20} step={0.01}
value={form.strike_offset_pct}
onChange={e => set('strike_offset_pct', Number(e.target.value))}
className="w-full accent-blue-500"
/>
</div>
<div>
<label className="text-xs text-slate-500 mb-1 block">Expiration: {form.expiry_days}d</label>
<div className="flex gap-1 mb-1"> <div className="flex gap-1 mb-1">
{[30, 60, 90, 180].map(d => ( {[30, 60, 90, 180].map(d => (
<button <button
@@ -168,6 +275,19 @@ export default function Backtest() {
</div> </div>
</div> </div>
{usesFarExpiry && (
<div>
<label className="text-xs text-slate-500 mb-1 block">
Échéance lointaine ({'>'}{form.expiry_days}j): {form.far_expiry_days}d
</label>
<input
type="number" min={form.expiry_days + 1} step={30} value={form.far_expiry_days}
onChange={e => set('far_expiry_days', Math.max(form.expiry_days + 1, parseInt(e.target.value) || form.expiry_days * 2))}
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"
/>
</div>
)}
<div> <div>
<label className="text-xs text-slate-500 mb-1 block">Initial capital ()</label> <label className="text-xs text-slate-500 mb-1 block">Initial capital ()</label>
<input <input
@@ -180,7 +300,7 @@ export default function Backtest() {
<button <button
onClick={run} onClick={run}
disabled={isPending} disabled={isPending || !form.symbol || legs.length === 0}
className="w-full bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white rounded py-2 text-sm font-semibold flex items-center justify-center gap-2 transition-colors" className="w-full bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white rounded py-2 text-sm font-semibold flex items-center justify-center gap-2 transition-colors"
> >
<Play className="w-4 h-4" /> <Play className="w-4 h-4" />