Stack: FastAPI + React/TypeScript + SQLite + GPT-4o Features: Radar géopolitique, Marchés, Régime Macro, Journal de Bord MTM, Rapport IA, Super Contexte (base de raisonnement évolutive), Boucle feedback IA. Deploy: Docker + docker-compose + nginx pour openfin.open-squared.tech Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
311 lines
14 KiB
TypeScript
311 lines
14 KiB
TypeScript
import { useState } from 'react'
|
|
import { useBacktest } 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 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' },
|
|
]
|
|
|
|
const SYMBOLS = [
|
|
'GLD', 'USO', 'WEAT', 'UNG', 'SPY', 'QQQ', 'GDX', 'COPX',
|
|
'XLE', 'FXE', 'XOM', 'LMT', 'BA', 'RTX',
|
|
]
|
|
|
|
function StatCard({ label, value, sub, positive }: { label: string; value: string; sub?: string; positive?: boolean }) {
|
|
return (
|
|
<div className="card-sm text-center">
|
|
<div className="stat-label">{label}</div>
|
|
<div className={clsx('text-xl font-bold mt-1', {
|
|
'text-emerald-400': positive === true,
|
|
'text-red-400': positive === false,
|
|
'text-white': positive === undefined,
|
|
})}>
|
|
{value}
|
|
</div>
|
|
{sub && <div className="text-xs text-slate-600 mt-0.5">{sub}</div>}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function Backtest() {
|
|
const { mutate: runBacktest, data: result, isPending } = useBacktest()
|
|
|
|
const [form, setForm] = useState({
|
|
symbol: 'GLD',
|
|
start_date: '2022-01-01',
|
|
end_date: '2024-12-31',
|
|
strategy: 'long_call',
|
|
strike_offset_pct: 0.05,
|
|
expiry_days: 90,
|
|
capital: 1000,
|
|
})
|
|
|
|
const set = (k: string, v: unknown) => setForm(f => ({ ...f, [k]: v }))
|
|
|
|
const run = () => runBacktest(form as Record<string, unknown>)
|
|
|
|
const typed = result as BacktestResult | undefined
|
|
const hasResult = typed && !typed.error
|
|
|
|
return (
|
|
<div className="p-6 space-y-5">
|
|
<div>
|
|
<h1 className="text-xl font-bold text-white flex items-center gap-2">
|
|
<History className="w-5 h-5 text-blue-400" /> Backtest & Simulation
|
|
</h1>
|
|
<p className="text-xs text-slate-500 mt-0.5">
|
|
Testez vos stratégies options sur des données historiques réelles
|
|
</p>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-4 gap-5">
|
|
{/* Config panel */}
|
|
<div className="col-span-1 space-y-4">
|
|
<div className="card">
|
|
<div className="section-title">Configuration</div>
|
|
<div className="space-y-3">
|
|
<div>
|
|
<label className="text-xs text-slate-500 mb-1 block">Sous-jacent</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"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-xs text-slate-500 mb-1 block">Stratégie</label>
|
|
{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', {
|
|
'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}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-xs text-slate-500 mb-1 block">Période</label>
|
|
<input
|
|
type="date"
|
|
value={form.start_date}
|
|
onChange={e => set('start_date', e.target.value)}
|
|
className="w-full bg-dark-700 border border-slate-700 rounded px-2 py-1.5 text-sm text-white mb-1 focus:outline-none focus:border-blue-500"
|
|
/>
|
|
<input
|
|
type="date"
|
|
value={form.end_date}
|
|
onChange={e => set('end_date', 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"
|
|
/>
|
|
</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}j</label>
|
|
<div className="flex gap-1 mb-1">
|
|
{[30, 60, 90, 180].map(d => (
|
|
<button
|
|
key={d}
|
|
onClick={() => set('expiry_days', d)}
|
|
className={clsx('flex-1 py-0.5 rounded text-xs border', {
|
|
'bg-blue-600 border-blue-500 text-white': form.expiry_days === d,
|
|
'border-slate-700 text-slate-500': form.expiry_days !== d,
|
|
})}
|
|
>
|
|
{d}j
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="text-xs text-slate-500 mb-1 block">Capital initial (€)</label>
|
|
<input
|
|
type="number"
|
|
value={form.capital}
|
|
onChange={e => set('capital', Number(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"
|
|
/>
|
|
</div>
|
|
|
|
<button
|
|
onClick={run}
|
|
disabled={isPending}
|
|
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" />
|
|
{isPending ? 'Calcul...' : 'Lancer le backtest'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Results panel */}
|
|
<div className="col-span-3 space-y-4">
|
|
{typed?.error && (
|
|
<div className="card border-red-700/40 bg-red-900/10">
|
|
<div className="flex items-center gap-2 text-red-400 text-sm">
|
|
<AlertTriangle className="w-4 h-4" /> {typed.error}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{hasResult && (
|
|
<>
|
|
{/* KPIs */}
|
|
<div className="grid grid-cols-4 gap-3">
|
|
<StatCard
|
|
label="Retour total"
|
|
value={`${typed.total_return_pct >= 0 ? '+' : ''}${typed.total_return_pct.toFixed(2)}%`}
|
|
positive={typed.total_return_pct >= 0}
|
|
/>
|
|
<StatCard
|
|
label="Taux de succès"
|
|
value={`${typed.win_rate.toFixed(1)}%`}
|
|
sub={`${typed.wins}W / ${typed.losses}L`}
|
|
positive={typed.win_rate >= 50}
|
|
/>
|
|
<StatCard
|
|
label="Max Drawdown"
|
|
value={`${typed.max_drawdown_pct.toFixed(2)}%`}
|
|
positive={false}
|
|
/>
|
|
<StatCard
|
|
label="Profit Factor"
|
|
value={typed.profit_factor.toFixed(2)}
|
|
sub={`${typed.total_trades} trades`}
|
|
positive={typed.profit_factor >= 1}
|
|
/>
|
|
</div>
|
|
|
|
{/* Equity curve */}
|
|
<div className="card">
|
|
<div className="section-title">Courbe d'équité — {form.symbol} {STRATEGIES.find(s => s.key === form.strategy)?.label}</div>
|
|
<div className="text-xs text-slate-500 mb-3">
|
|
Capital final: <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)}€)
|
|
</div>
|
|
<ResponsiveContainer width="100%" height={220}>
|
|
<AreaChart data={typed.equity_curve}>
|
|
<defs>
|
|
<linearGradient id="equity-grad" x1="0" y1="0" x2="0" y2="1">
|
|
<stop offset="5%" stopColor={typed.total_return_pct >= 0 ? '#10b981' : '#ef4444'} stopOpacity={0.3} />
|
|
<stop offset="95%" stopColor={typed.total_return_pct >= 0 ? '#10b981' : '#ef4444'} stopOpacity={0} />
|
|
</linearGradient>
|
|
</defs>
|
|
<CartesianGrid strokeDasharray="3 3" stroke="#1e2d4d" />
|
|
<XAxis dataKey="index" tick={{ fill: '#475569', fontSize: 9 }} />
|
|
<YAxis tick={{ fill: '#475569', fontSize: 9 }} tickLine={false} axisLine={false}
|
|
tickFormatter={v => `${v.toFixed(0)}€`} />
|
|
<Tooltip
|
|
contentStyle={{ background: '#0f1623', border: '1px solid #1e2d4d', fontSize: 11 }}
|
|
formatter={(v: number) => [`${v.toFixed(2)}€`, 'Capital']}
|
|
/>
|
|
<ReferenceLine y={form.capital} stroke="#475569" strokeDasharray="4 4" label={{ value: 'Initial', fill: '#475569', fontSize: 9 }} />
|
|
<Area
|
|
type="monotone" dataKey="capital"
|
|
stroke={typed.total_return_pct >= 0 ? '#10b981' : '#ef4444'}
|
|
fill="url(#equity-grad)" strokeWidth={2} dot={false}
|
|
/>
|
|
</AreaChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
|
|
{/* Last trades */}
|
|
<div className="card">
|
|
<div className="section-title">Derniers trades exécutés</div>
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-xs">
|
|
<thead>
|
|
<tr className="text-slate-600 border-b border-slate-700/40">
|
|
<th className="text-left pb-2">Entrée</th>
|
|
<th className="text-left pb-2">Sortie</th>
|
|
<th className="text-right pb-2">Prix entrée</th>
|
|
<th className="text-right pb-2">Strike</th>
|
|
<th className="text-right pb-2">Prime</th>
|
|
<th className="text-right pb-2">Prix sortie</th>
|
|
<th className="text-right pb-2">P&L</th>
|
|
<th className="text-right pb-2">Capital</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{typed.trades.map((t, i) => {
|
|
const pnl = t.pnl 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 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)}€
|
|
</td>
|
|
<td className="py-1 text-right font-mono text-slate-300">{(t.capital as number).toFixed(2)}€</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{!hasResult && !isPending && !typed?.error && (
|
|
<div className="card h-80 flex items-center justify-center text-slate-600">
|
|
<div className="text-center">
|
|
<History className="w-10 h-10 mx-auto mb-3 opacity-20" />
|
|
<div className="text-sm">Configurer et lancer un backtest</div>
|
|
<div className="text-xs mt-1">Données yfinance — historique complet disponible</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|