From 15528e0c98d117f6f72355d72a0a85170878aca2 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Thu, 30 Jul 2026 10:53:47 +0200 Subject: [PATCH] feat: backtest --- backend/routers/backtest.py | 83 +++++++----- backend/services/backtest_strategies.py | 24 ++++ frontend/src/hooks/useApi.ts | 3 +- frontend/src/pages/Backtest.tsx | 164 ++++++++++++++++++++---- 4 files changed, 216 insertions(+), 58 deletions(-) diff --git a/backend/routers/backtest.py b/backend/routers/backtest.py index 7c1096c..6d269f7 100644 --- a/backend/routers/backtest.py +++ b/backend/routers/backtest.py @@ -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), diff --git a/backend/services/backtest_strategies.py b/backend/services/backtest_strategies.py index e2389fb..6d878b2 100644 --- a/backend/services/backtest_strategies.py +++ b/backend/services/backtest_strategies.py @@ -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 + ] diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index c5389e1..7758bab 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -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({ queryKey: ['backtest-strategies'], diff --git a/frontend/src/pages/Backtest.tsx b/frontend/src/pages/Backtest.tsx index 6ab05a9..ad0a58c 100644 --- a/frontend/src/pages/Backtest.tsx +++ b/frontend/src/pages/Backtest.tsx @@ -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 (
@@ -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([emptyLeg()]) + const [legCountFilter, setLegCountFilter] = useState(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) + 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) => + 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() {
- -
- {(strategies ?? []).map(s => ( + +
+ {[null, 1, 2, 3, 4].map(n => ( + + ))} +
+
+ +
+ +
+ {filteredStrategies.map(s => (
+
+
+ + +
+
+ {legs.map((leg, idx) => ( +
+
+ + + +
+
+ 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" + /> + 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" + /> + % spot + +
+
+ ))} +
+
+
- - set('strike_offset_pct', Number(e.target.value))} - className="w-full accent-blue-500" - /> -
- -
- +
{[30, 60, 90, 180].map(d => (
+ {usesFarExpiry && ( +
+ + 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" + /> +
+ )} +