From 9a2ffb1c6a093c24cdbdd919f1fe93fa777ff7d0 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Wed, 29 Jul 2026 21:35:30 +0200 Subject: [PATCH] feat: backtest --- backend/routers/backtest.py | 88 ++++++++++---- backend/services/backtest_strategies.py | 147 ++++++++++++++++++++++++ frontend/src/hooks/useApi.ts | 16 +++ frontend/src/pages/Backtest.tsx | 98 +++++++++------- 4 files changed, 283 insertions(+), 66 deletions(-) create mode 100644 backend/services/backtest_strategies.py diff --git a/backend/routers/backtest.py b/backend/routers/backtest.py index 65bd060..7c1096c 100644 --- a/backend/routers/backtest.py +++ b/backend/routers/backtest.py @@ -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), }) diff --git a/backend/services/backtest_strategies.py b/backend/services/backtest_strategies.py new file mode 100644 index 0000000..e2389fb --- /dev/null +++ b/backend/services/backtest_strategies.py @@ -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 [] diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index 5162897..c5389e1 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -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({ + 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({ + queryKey: ['backtest-strategies'], + queryFn: () => api.get('/backtest/strategies').then(r => r.data), + staleTime: 60 * 60_000, + }) + // ── AI ──────────────────────────────────────────────────────────────────────── export const useAiStatus = () => useQuery({ diff --git a/frontend/src/pages/Backtest.tsx b/frontend/src/pages/Backtest.tsx index 3bbac9c..6ab05a9 100644 --- a/frontend/src/pages/Backtest.tsx +++ b/frontend/src/pages/Backtest.tsx @@ -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) @@ -74,43 +80,46 @@ export default function Backtest() {
Configuration
- -
- {SYMBOLS.slice(0, 7).map(s => ( - - ))} -
- 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" - /> + + {!symbols?.length ? ( +
+ Aucun instrument lié à Saxo — Config → Instruments Watchlist. +
+ ) : ( + + )}
- {STRATEGIES.map(s => ( +
+ {(strategies ?? []).map(s => ( ))} +
@@ -221,7 +230,7 @@ export default function Backtest() { {/* Equity curve */}
-
Equity curve — {form.symbol} {STRATEGIES.find(s => s.key === form.strategy)?.label}
+
Equity curve — {form.symbol} {(strategies ?? []).find(s => s.key === form.strategy)?.label}
Final capital: {typed.final_capital.toFixed(2)}€ {' '}(initial: {form.capital}€ · P&L: {typed.total_pnl >= 0 ? '+' : ''}{typed.total_pnl.toFixed(2)}€) @@ -262,8 +271,8 @@ export default function Backtest() { Entry Exit Entry price - Strike - Premium + Legs + Net premium Exit price P&L Capital @@ -272,13 +281,16 @@ export default function Backtest() { {typed.trades.map((t, i) => { const pnl = t.pnl as number + const netPremium = t.net_premium as number return ( {t.entry_date as string} {t.exit_date as string} ${(t.S_entry as number).toFixed(2)} - ${(t.K as number).toFixed(2)} - ${(t.premium as number).toFixed(4)} + {legsSummary(t.legs as LegRow[])} + + {netPremium >= 0 ? '' : '+'}{(-netPremium).toFixed(2)}{netPremium >= 0 ? ' débit' : ' crédit'} + ${(t.S_expiry as number).toFixed(2)} = 0 ? 'positive' : 'negative')}> {pnl >= 0 ? '+' : ''}{pnl.toFixed(2)}€ @@ -299,7 +311,7 @@ export default function Backtest() {
Configure and run a backtest
-
yfinance data — full history available
+
Prix sous-jacent yfinance (historique complet) · options simulées Black-Scholes
)}