feat: backtest

This commit is contained in:
OpenSquared
2026-07-29 21:35:30 +02:00
parent ad3f599082
commit 9a2ffb1c6a
4 changed files with 283 additions and 66 deletions

View File

@@ -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<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 ────────────────────────────────────────────────────────────────────────
export const useAiStatus = () =>
useQuery({

View File

@@ -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<string, unknown>)
@@ -74,43 +80,46 @@ export default function Backtest() {
<div className="section-title">Configuration</div>
<div className="space-y-3">
<div>
<label className="text-xs text-slate-500 mb-1 block">Underlying</label>
<div className="flex flex-wrap gap-1 mb-1">
{SYMBOLS.slice(0, 7).map(s => (
<button
key={s}
onClick={() => set('symbol', s)}
className={clsx('px-1.5 py-0.5 rounded text-xs border', {
'bg-blue-600 border-blue-500 text-white': form.symbol === s,
'border-slate-700 text-slate-500': form.symbol !== s,
})}
>
{s}
</button>
))}
</div>
<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"
/>
<label className="text-xs text-slate-500 mb-1 block">Underlying (Saxo-linked)</label>
{!symbols?.length ? (
<div className="text-xs text-slate-600 border border-slate-700/40 rounded px-2 py-1.5">
Aucun instrument lié à Saxo Config Instruments Watchlist.
</div>
) : (
<select
value={form.symbol}
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"
>
{symbols.map(s => (
<option key={s.ticker} value={s.ticker}>{s.ticker} {s.name}</option>
))}
</select>
)}
</div>
<div>
<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
key={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,
'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>
))}
</div>
</div>
<div>
@@ -221,7 +230,7 @@ export default function Backtest() {
{/* Equity curve */}
<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">
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)})
@@ -262,8 +271,8 @@ export default function Backtest() {
<th className="text-left pb-2">Entry</th>
<th className="text-left pb-2">Exit</th>
<th className="text-right pb-2">Entry price</th>
<th className="text-right pb-2">Strike</th>
<th className="text-right pb-2">Premium</th>
<th className="text-left pb-2">Legs</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">P&L</th>
<th className="text-right pb-2">Capital</th>
@@ -272,13 +281,16 @@ export default function Backtest() {
<tbody>
{typed.trades.map((t, i) => {
const pnl = t.pnl as number
const netPremium = t.net_premium as number
return (
<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.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.K as number).toFixed(2)}</td>
<td className="py-1 text-right font-mono">${(t.premium as number).toFixed(4)}</td>
<td className="py-1 font-mono text-slate-400">{legsSummary(t.legs as LegRow[])}</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={clsx('py-1 text-right font-mono font-bold', pnl >= 0 ? 'positive' : 'negative')}>
{pnl >= 0 ? '+' : ''}{pnl.toFixed(2)}
@@ -299,7 +311,7 @@ export default function Backtest() {
<div className="text-center">
<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-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>
)}