feat: backtest
This commit is contained in:
@@ -3,22 +3,52 @@ from pydantic import BaseModel
|
|||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
import yfinance as yf
|
import yfinance as yf
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pandas as pd
|
|
||||||
from datetime import datetime
|
|
||||||
from services.options_pricer import black_scholes
|
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 = 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):
|
class BacktestRequest(BaseModel):
|
||||||
symbol: str
|
symbol: str
|
||||||
start_date: str
|
start_date: str
|
||||||
end_date: str
|
end_date: str
|
||||||
strategy: str # "long_call" | "long_put" | "bull_call_spread" | "bear_put_spread" | "straddle"
|
strategy: str
|
||||||
strike_offset_pct: float = 0.05 # e.g. 5% OTM
|
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
|
||||||
capital: float = 1000.0
|
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")
|
@router.post("/run")
|
||||||
@@ -32,11 +62,12 @@ 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
|
||||||
r = 0.05
|
r = 0.05
|
||||||
T_open = req.expiry_days / 365
|
|
||||||
|
|
||||||
step = max(1, req.expiry_days // 3)
|
step = max(1, req.expiry_days // 3)
|
||||||
for i in range(0, len(hist) - req.expiry_days, step):
|
for i in range(0, len(hist) - req.expiry_days, step):
|
||||||
@@ -51,26 +82,33 @@ def run_backtest(req: BacktestRequest):
|
|||||||
if sigma < 0.01:
|
if sigma < 0.01:
|
||||||
sigma = 0.20
|
sigma = 0.20
|
||||||
|
|
||||||
if req.strategy in ["long_call", "bull_call_spread"]:
|
near_expiry = synthetic_expiry(date_str, req.expiry_days, S)
|
||||||
K = S * (1 + req.strike_offset_pct)
|
far_expiry = synthetic_expiry(date_str, far_days, S) if req.strategy in ("calendar_spread", "diagonal_spread") else None
|
||||||
else:
|
legs = build_legs(req.strategy, S, req.strike_offset_pct, near_expiry, far_expiry)
|
||||||
K = S * (1 - req.strike_offset_pct)
|
if not legs:
|
||||||
|
continue
|
||||||
|
|
||||||
result = black_scholes(S, K, T_open, r, sigma, "call" if "call" in req.strategy else "put")
|
entry_premiums = []
|
||||||
premium = result["price"]
|
for leg in legs:
|
||||||
contracts = max(1, int((capital * 0.1) / (premium * 100)))
|
T = leg["days_to_expiry"] / 365
|
||||||
cost = contracts * premium * 100
|
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)
|
expiry_idx = min(i + req.expiry_days, len(hist) - 1)
|
||||||
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]
|
||||||
|
|
||||||
if req.strategy in ["long_call", "bull_call_spread"]:
|
exit_values = [_settle_leg(leg, req.expiry_days, S_expiry, sigma, r) for leg in legs]
|
||||||
intrinsic = max(0, S_expiry - K)
|
exit_signed_value = sum(sq * v for sq, v in zip(signed_qty, exit_values))
|
||||||
else:
|
|
||||||
intrinsic = max(0, K - S_expiry)
|
|
||||||
|
|
||||||
pnl = (intrinsic - premium) * contracts * 100
|
pnl = (exit_signed_value - net_premium) * contracts * 100
|
||||||
capital += pnl
|
capital += pnl
|
||||||
equity.append(round(capital, 2))
|
equity.append(round(capital, 2))
|
||||||
|
|
||||||
@@ -79,12 +117,16 @@ def run_backtest(req: BacktestRequest):
|
|||||||
"exit_date": date_expiry,
|
"exit_date": date_expiry,
|
||||||
"strategy": req.strategy,
|
"strategy": req.strategy,
|
||||||
"S_entry": round(S, 2),
|
"S_entry": round(S, 2),
|
||||||
"K": round(K, 2),
|
"S_expiry": round(S_expiry, 2),
|
||||||
"premium": round(premium, 4),
|
"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,
|
"contracts": contracts,
|
||||||
"cost": round(cost, 2),
|
"cost": round(cost, 2),
|
||||||
"S_expiry": round(S_expiry, 2),
|
|
||||||
"intrinsic": round(intrinsic, 4),
|
|
||||||
"pnl": round(pnl, 2),
|
"pnl": round(pnl, 2),
|
||||||
"capital": round(capital, 2),
|
"capital": round(capital, 2),
|
||||||
})
|
})
|
||||||
|
|||||||
147
backend/services/backtest_strategies.py
Normal file
147
backend/services/backtest_strategies.py
Normal 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 []
|
||||||
@@ -257,6 +257,22 @@ export const useBacktest = () =>
|
|||||||
mutationFn: (data) => api.post('/backtest/run', data).then(r => r.data),
|
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 ────────────────────────────────────────────────────────────────────────
|
// ── AI ────────────────────────────────────────────────────────────────────────
|
||||||
export const useAiStatus = () =>
|
export const useAiStatus = () =>
|
||||||
useQuery({
|
useQuery({
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useBacktest } from '../hooks/useApi'
|
import { useBacktest, useBacktestSymbols, useBacktestStrategies } 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,
|
||||||
@@ -8,17 +8,14 @@ import {
|
|||||||
import { History, Play, TrendingUp, TrendingDown, AlertTriangle } from 'lucide-react'
|
import { History, Play, TrendingUp, TrendingDown, AlertTriangle } from 'lucide-react'
|
||||||
import type { BacktestResult } from '../types'
|
import type { BacktestResult } from '../types'
|
||||||
|
|
||||||
const STRATEGIES = [
|
type LegRow = { strike: number; option_type: string; position: string; quantity: number; days_to_expiry: number }
|
||||||
{ 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' },
|
|
||||||
]
|
|
||||||
|
|
||||||
const SYMBOLS = [
|
function legsSummary(legs: LegRow[] | undefined): string {
|
||||||
'GLD', 'USO', 'WEAT', 'UNG', 'SPY', 'QQQ', 'GDX', 'COPX',
|
if (!legs || !legs.length) return '—'
|
||||||
'XLE', 'FXE', 'XOM', 'LMT', 'BA', 'RTX',
|
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 }) {
|
function StatCard({ label, value, sub, positive }: { label: string; value: string; sub?: string; positive?: boolean }) {
|
||||||
return (
|
return (
|
||||||
@@ -39,8 +36,11 @@ function StatCard({ label, value, sub, positive }: { label: string; value: strin
|
|||||||
export default function Backtest() {
|
export default function Backtest() {
|
||||||
const { mutate: runBacktest, data: result, isPending } = useBacktest()
|
const { mutate: runBacktest, data: result, isPending } = useBacktest()
|
||||||
|
|
||||||
|
const { data: symbols } = useBacktestSymbols()
|
||||||
|
const { data: strategies } = useBacktestStrategies()
|
||||||
|
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
symbol: 'GLD',
|
symbol: '',
|
||||||
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',
|
||||||
@@ -49,6 +49,12 @@ export default function Backtest() {
|
|||||||
capital: 1000,
|
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 set = (k: string, v: unknown) => setForm(f => ({ ...f, [k]: v }))
|
||||||
|
|
||||||
const run = () => runBacktest(form as Record<string, unknown>)
|
const run = () => runBacktest(form as Record<string, unknown>)
|
||||||
@@ -74,43 +80,46 @@ export default function Backtest() {
|
|||||||
<div className="section-title">Configuration</div>
|
<div className="section-title">Configuration</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-slate-500 mb-1 block">Underlying</label>
|
<label className="text-xs text-slate-500 mb-1 block">Underlying (Saxo-linked)</label>
|
||||||
<div className="flex flex-wrap gap-1 mb-1">
|
{!symbols?.length ? (
|
||||||
{SYMBOLS.slice(0, 7).map(s => (
|
<div className="text-xs text-slate-600 border border-slate-700/40 rounded px-2 py-1.5">
|
||||||
<button
|
Aucun instrument lié à Saxo — Config → Instruments Watchlist.
|
||||||
key={s}
|
</div>
|
||||||
onClick={() => set('symbol', s)}
|
) : (
|
||||||
className={clsx('px-1.5 py-0.5 rounded text-xs border', {
|
<select
|
||||||
'bg-blue-600 border-blue-500 text-white': form.symbol === s,
|
value={form.symbol}
|
||||||
'border-slate-700 text-slate-500': form.symbol !== s,
|
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"
|
||||||
>
|
>
|
||||||
{s}
|
{symbols.map(s => (
|
||||||
</button>
|
<option key={s.ticker} value={s.ticker}>{s.ticker} — {s.name}</option>
|
||||||
))}
|
))}
|
||||||
</div>
|
</select>
|
||||||
<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"
|
|
||||||
/>
|
|
||||||
</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">Strategy</label>
|
||||||
{STRATEGIES.map(s => (
|
<div className="max-h-64 overflow-y-auto pr-1 space-y-1">
|
||||||
|
{(strategies ?? []).map(s => (
|
||||||
<button
|
<button
|
||||||
key={s.key}
|
key={s.key}
|
||||||
onClick={() => set('strategy', 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,
|
'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,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{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>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -221,7 +230,7 @@ export default function Backtest() {
|
|||||||
|
|
||||||
{/* Equity curve */}
|
{/* Equity curve */}
|
||||||
<div className="card">
|
<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">
|
<div className="text-xs text-slate-500 mb-3">
|
||||||
Final capital: <span className="text-white font-bold">{typed.final_capital.toFixed(2)}€</span>
|
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)}€)
|
{' '}(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">Entry</th>
|
||||||
<th className="text-left pb-2">Exit</th>
|
<th className="text-left pb-2">Exit</th>
|
||||||
<th className="text-right pb-2">Entry price</th>
|
<th className="text-right pb-2">Entry price</th>
|
||||||
<th className="text-right pb-2">Strike</th>
|
<th className="text-left pb-2">Legs</th>
|
||||||
<th className="text-right pb-2">Premium</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">Exit price</th>
|
||||||
<th className="text-right pb-2">P&L</th>
|
<th className="text-right pb-2">P&L</th>
|
||||||
<th className="text-right pb-2">Capital</th>
|
<th className="text-right pb-2">Capital</th>
|
||||||
@@ -272,13 +281,16 @@ export default function Backtest() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{typed.trades.map((t, i) => {
|
{typed.trades.map((t, i) => {
|
||||||
const pnl = t.pnl as number
|
const pnl = t.pnl as number
|
||||||
|
const netPremium = t.net_premium as number
|
||||||
return (
|
return (
|
||||||
<tr key={i} className="border-b border-slate-700/20 hover:bg-dark-700/50">
|
<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.entry_date as string}</td>
|
||||||
<td className="py-1">{t.exit_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.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 font-mono text-slate-400">{legsSummary(t.legs as LegRow[])}</td>
|
||||||
<td className="py-1 text-right font-mono">${(t.premium as number).toFixed(4)}</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="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')}>
|
<td className={clsx('py-1 text-right font-mono font-bold', pnl >= 0 ? 'positive' : 'negative')}>
|
||||||
{pnl >= 0 ? '+' : ''}{pnl.toFixed(2)}€
|
{pnl >= 0 ? '+' : ''}{pnl.toFixed(2)}€
|
||||||
@@ -299,7 +311,7 @@ export default function Backtest() {
|
|||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<History className="w-10 h-10 mx-auto mb-3 opacity-20" />
|
<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-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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user