feat: backtest

This commit is contained in:
OpenSquared
2026-07-30 10:53:47 +02:00
parent 9a2ffb1c6a
commit 15528e0c98
4 changed files with 216 additions and 58 deletions

View File

@@ -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'],

View File

@@ -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" />