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),
|
||||
|
||||
@@ -145,3 +145,27 @@ def build_legs(
|
||||
return []
|
||||
return _first_by_name(list(tmpl.diagonal_spread(near_expiry, far_expiry, spot)), "Diagonal Spread") or []
|
||||
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
|
||||
]
|
||||
|
||||
@@ -265,7 +265,8 @@ export const useBacktestSymbols = () =>
|
||||
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 = () =>
|
||||
useQuery<BacktestStrategyInfo[]>({
|
||||
queryKey: ['backtest-strategies'],
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
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 {
|
||||
AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer,
|
||||
CartesianGrid, ReferenceLine,
|
||||
} 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'
|
||||
|
||||
type LegRow = { strike: number; option_type: string; position: string; quantity: number; days_to_expiry: number }
|
||||
type EditableLeg = BacktestLegPreset
|
||||
|
||||
function legsSummary(legs: LegRow[] | undefined): string {
|
||||
if (!legs || !legs.length) return '—'
|
||||
@@ -17,6 +18,9 @@ function legsSummary(legs: LegRow[] | undefined): string {
|
||||
.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 }) {
|
||||
return (
|
||||
<div className="card-sm text-center">
|
||||
@@ -44,10 +48,13 @@ export default function Backtest() {
|
||||
start_date: '2022-01-01',
|
||||
end_date: '2024-12-31',
|
||||
strategy: 'long_call',
|
||||
strike_offset_pct: 0.05,
|
||||
expiry_days: 90,
|
||||
far_expiry_days: 180,
|
||||
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 —
|
||||
// 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)
|
||||
}, [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 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 hasResult = typed && !typed.error
|
||||
@@ -99,12 +131,30 @@ export default function Backtest() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-slate-500 mb-1 block">Strategy</label>
|
||||
<div className="max-h-64 overflow-y-auto pr-1 space-y-1">
|
||||
{(strategies ?? []).map(s => (
|
||||
<label className="text-xs text-slate-500 mb-1 block">Nombre de jambes</label>
|
||||
<div className="flex gap-1">
|
||||
{[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
|
||||
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', {
|
||||
'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,
|
||||
@@ -122,6 +172,75 @@ export default function Backtest() {
|
||||
</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>
|
||||
<label className="text-xs text-slate-500 mb-1 block">Period</label>
|
||||
<input
|
||||
@@ -139,19 +258,7 @@ export default function Backtest() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-slate-500 mb-1 block">
|
||||
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>
|
||||
<label className="text-xs text-slate-500 mb-1 block">Échéance proche: {form.expiry_days}d</label>
|
||||
<div className="flex gap-1 mb-1">
|
||||
{[30, 60, 90, 180].map(d => (
|
||||
<button
|
||||
@@ -168,6 +275,19 @@ export default function Backtest() {
|
||||
</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>
|
||||
<label className="text-xs text-slate-500 mb-1 block">Initial capital (€)</label>
|
||||
<input
|
||||
@@ -180,7 +300,7 @@ export default function Backtest() {
|
||||
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<Play className="w-4 h-4" />
|
||||
|
||||
Reference in New Issue
Block a user