Initial commit — GeoOptions Intelligence Cockpit v2.0
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>
This commit is contained in:
310
frontend/src/pages/Backtest.tsx
Normal file
310
frontend/src/pages/Backtest.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user