feat: backtest
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user