|
|
|
@@ -651,92 +651,136 @@ function SuggestedProfileCard({
|
|
|
|
)
|
|
|
|
)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function ReplayCard({
|
|
|
|
// "Analyse période historique" — merges the old "Dériver d'un historique" (realized
|
|
|
|
symbol, legs, contractSize, chainAsOf, onUseAsChainAsOf,
|
|
|
|
// spot/IV move summary, feeds the optimizer) and "Tester (Replay)" (real day-by-day
|
|
|
|
|
|
|
|
// mark-to-market) into one period picker with a real day-scrubber. The scrubbed date
|
|
|
|
|
|
|
|
// drives scenario.checkpoint_as_of (parent effect), which is what makes the shared
|
|
|
|
|
|
|
|
// results pane below price off a REAL smile-of-the-day instead of a hypothesis.
|
|
|
|
|
|
|
|
function HistoricalPeriodPanel({
|
|
|
|
|
|
|
|
symbol, legs, contractSize, onUseAsChainAsOf,
|
|
|
|
|
|
|
|
periodStart, setPeriodStart, periodEnd, setPeriodEnd,
|
|
|
|
|
|
|
|
realizedQuery, replayQuery, checkpointDate, setCheckpointDate,
|
|
|
|
}: {
|
|
|
|
}: {
|
|
|
|
symbol: string; legs: StrategyLeg[]; contractSize: number
|
|
|
|
symbol: string; legs: StrategyLeg[]; contractSize: number
|
|
|
|
chainAsOf: string; onUseAsChainAsOf: (date: string) => void
|
|
|
|
onUseAsChainAsOf: (date: string) => void
|
|
|
|
|
|
|
|
periodStart: string; setPeriodStart: (v: string) => void
|
|
|
|
|
|
|
|
periodEnd: string; setPeriodEnd: (v: string) => void
|
|
|
|
|
|
|
|
realizedQuery: ReturnType<typeof useRealizedScenario>
|
|
|
|
|
|
|
|
replayQuery: ReturnType<typeof useReplayStrategy>
|
|
|
|
|
|
|
|
checkpointDate: string; setCheckpointDate: (v: string) => void
|
|
|
|
}) {
|
|
|
|
}) {
|
|
|
|
const { data: saxoSymbols } = useSaxoSymbols()
|
|
|
|
const { data: saxoSymbols } = useSaxoSymbols()
|
|
|
|
const bounds = saxoSymbols?.find(s => s.symbol.toUpperCase() === symbol.toUpperCase())
|
|
|
|
const bounds = saxoSymbols?.find(s => s.symbol.toUpperCase() === symbol.toUpperCase())
|
|
|
|
const { mutate: runReplay, data: result, isPending, error } = useReplayStrategy()
|
|
|
|
const { mutate: computeRealized, data: realized, isPending: realizedPending, error: realizedError, reset: resetRealized } = realizedQuery
|
|
|
|
|
|
|
|
const { mutate: runReplay, data: result, isPending: replayPending, error: replayError } = replayQuery
|
|
|
|
const [startDate, setStartDate] = useState('')
|
|
|
|
|
|
|
|
const [endDate, setEndDate] = useState('')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Default the range to the last available week of real history once bounds load —
|
|
|
|
// Default the range to the last available week of real history once bounds load —
|
|
|
|
// exactly "entre J et J+7" against what's actually been captured, not a guess.
|
|
|
|
// exactly "entre J et J+7" against what's actually been captured, not a guess.
|
|
|
|
useEffect(() => {
|
|
|
|
useEffect(() => {
|
|
|
|
if (bounds && !startDate && !endDate) {
|
|
|
|
if (bounds && !periodStart && !periodEnd) {
|
|
|
|
setEndDate(bounds.last_date.slice(0, 10))
|
|
|
|
setPeriodEnd(bounds.last_date.slice(0, 10))
|
|
|
|
const end = new Date(bounds.last_date.slice(0, 10))
|
|
|
|
const end = new Date(bounds.last_date.slice(0, 10))
|
|
|
|
const start = new Date(Math.max(end.getTime() - 7 * 86400000, new Date(bounds.first_date.slice(0, 10)).getTime()))
|
|
|
|
const start = new Date(Math.max(end.getTime() - 7 * 86400000, new Date(bounds.first_date.slice(0, 10)).getTime()))
|
|
|
|
setStartDate(start.toISOString().slice(0, 10))
|
|
|
|
setPeriodStart(start.toISOString().slice(0, 10))
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}, [bounds]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
|
|
|
}, [bounds])
|
|
|
|
|
|
|
|
|
|
|
|
if (!bounds) return null
|
|
|
|
// Default the scrubber to the most recent real day once a fresh day-by-day walk
|
|
|
|
|
|
|
|
// completes — "où en est-on" is the natural first read of a newly loaded period.
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
|
|
if (result?.points.length) setCheckpointDate(result.points[result.points.length - 1].date)
|
|
|
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
|
|
|
}, [result])
|
|
|
|
|
|
|
|
|
|
|
|
const run = () => {
|
|
|
|
if (!bounds) return <div className="card-sm text-xs text-slate-600">Aucun historique Saxo pour {symbol || 'ce symbole'}.</div>
|
|
|
|
if (legs.length === 0 || !startDate || !endDate) return
|
|
|
|
|
|
|
|
runReplay({ symbol, legs, start_date: startDate, end_date: endDate, contract_size: contractSize })
|
|
|
|
const runAnalysis = () => {
|
|
|
|
|
|
|
|
if (!periodStart || !periodEnd) return
|
|
|
|
|
|
|
|
onUseAsChainAsOf(periodStart)
|
|
|
|
|
|
|
|
if (legs.length > 0) runReplay({ symbol, legs, start_date: periodStart, end_date: periodEnd, contract_size: contractSize })
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const idx = result?.points.findIndex(p => p.date === checkpointDate) ?? -1
|
|
|
|
|
|
|
|
const checkpointLegs = idx >= 0 ? result?.points[idx].legs : undefined
|
|
|
|
|
|
|
|
const entryLegs = result?.points[0]?.legs
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
return (
|
|
|
|
<div className="card space-y-3">
|
|
|
|
<div className="card space-y-3">
|
|
|
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
|
|
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
|
|
|
<div className="stat-label flex items-center gap-2">
|
|
|
|
<div className="stat-label flex items-center gap-2">
|
|
|
|
<History className="w-3.5 h-3.5" /> Rejouer sur l'historique Saxo réel (pas un scénario)
|
|
|
|
<History className="w-3.5 h-3.5" /> Période historique réelle (pas un scénario)
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<span className="text-[10px] text-slate-600">
|
|
|
|
<span className="text-[10px] text-slate-600">
|
|
|
|
Données disponibles : {bounds.first_date.slice(0, 10)} → {bounds.last_date.slice(0, 10)}
|
|
|
|
Données disponibles : {bounds.first_date.slice(0, 10)} → {bounds.last_date.slice(0, 10)}
|
|
|
|
</span>
|
|
|
|
</span>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<p className="text-[11px] text-slate-500">
|
|
|
|
<p className="text-[11px] text-slate-500">
|
|
|
|
Marque au marché les jambes ci-dessus jour par jour avec de vraies cotations Saxo captées — pas de prix théorique.
|
|
|
|
Calcule le mouvement de spot/IV ATM réellement survenu sur la période et marque au marché les jambes ci-dessous jour par jour avec de vraies cotations Saxo captées.
|
|
|
|
Un jour sans cotation réelle pour une jambe est simplement absent de la courbe.
|
|
|
|
Le curseur pilote le diagramme payoff/heatmap/Greeks partagé plus bas avec une vraie smile de volatilité reconstruite depuis les cotations du jour scruté — pas une IV plate hypothétique.
|
|
|
|
|
|
|
|
Un jour sans cotation réelle pour une jambe est simplement absent du curseur.
|
|
|
|
</p>
|
|
|
|
</p>
|
|
|
|
<div className="flex items-end gap-3 flex-wrap">
|
|
|
|
<div className="flex items-end gap-3 flex-wrap">
|
|
|
|
<div>
|
|
|
|
<div>
|
|
|
|
<label className="text-xs text-slate-400 block mb-1">Du</label>
|
|
|
|
<label className="text-xs text-slate-400 block mb-1">Du</label>
|
|
|
|
<input
|
|
|
|
<input
|
|
|
|
type="date" value={startDate} min={bounds.first_date.slice(0, 10)} max={bounds.last_date.slice(0, 10)}
|
|
|
|
type="date" value={periodStart} min={bounds.first_date.slice(0, 10)} max={bounds.last_date.slice(0, 10)}
|
|
|
|
onChange={(e) => setStartDate(e.target.value)}
|
|
|
|
onChange={(e) => { setPeriodStart(e.target.value); resetRealized() }}
|
|
|
|
className="bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-sm text-white"
|
|
|
|
className="bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-sm text-white"
|
|
|
|
/>
|
|
|
|
/>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div>
|
|
|
|
<div>
|
|
|
|
<label className="text-xs text-slate-400 block mb-1">Au</label>
|
|
|
|
<label className="text-xs text-slate-400 block mb-1">Au</label>
|
|
|
|
<input
|
|
|
|
<input
|
|
|
|
type="date" value={endDate} min={bounds.first_date.slice(0, 10)} max={bounds.last_date.slice(0, 10)}
|
|
|
|
type="date" value={periodEnd} min={bounds.first_date.slice(0, 10)} max={bounds.last_date.slice(0, 10)}
|
|
|
|
onChange={(e) => setEndDate(e.target.value)}
|
|
|
|
onChange={(e) => { setPeriodEnd(e.target.value); resetRealized() }}
|
|
|
|
className="bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-sm text-white"
|
|
|
|
className="bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-sm text-white"
|
|
|
|
/>
|
|
|
|
/>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<button
|
|
|
|
<button
|
|
|
|
onClick={run}
|
|
|
|
onClick={() => periodStart && periodEnd && computeRealized({ symbol, start_date: periodStart, end_date: periodEnd })}
|
|
|
|
disabled={isPending || legs.length === 0}
|
|
|
|
disabled={realizedPending || !periodStart || !periodEnd}
|
|
|
|
className="flex items-center gap-1.5 text-xs bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white px-3 py-1.5 rounded font-semibold"
|
|
|
|
className="flex items-center gap-1.5 text-xs bg-dark-700 hover:bg-dark-600 border border-slate-700/50 text-slate-300 px-3 py-1.5 rounded font-semibold disabled:opacity-50"
|
|
|
|
>
|
|
|
|
>
|
|
|
|
<RefreshCw className={clsx('w-3.5 h-3.5', isPending && 'animate-spin')} />
|
|
|
|
<RefreshCw className={clsx('w-3.5 h-3.5', realizedPending && 'animate-spin')} />
|
|
|
|
{isPending ? 'Replay en cours…' : 'Lancer le replay'}
|
|
|
|
{realizedPending ? 'Calcul…' : 'Calculer le mouvement réalisé'}
|
|
|
|
</button>
|
|
|
|
</button>
|
|
|
|
<button
|
|
|
|
<button
|
|
|
|
onClick={() => startDate && onUseAsChainAsOf(startDate)}
|
|
|
|
onClick={runAnalysis}
|
|
|
|
disabled={!startDate || chainAsOf === startDate}
|
|
|
|
disabled={replayPending || legs.length === 0 || !periodStart || !periodEnd}
|
|
|
|
title="Reconstruit la chain (et le catalogue de préréglages) telle qu'elle était à la date 'Du' — une échéance choisie aujourd'hui n'existait peut-être pas encore, ou avait des strikes très différents, à cette date passée."
|
|
|
|
className="flex items-center gap-1.5 text-xs bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white px-3 py-1.5 rounded font-semibold"
|
|
|
|
className="flex items-center gap-1.5 text-xs border border-slate-700/50 hover:border-blue-500/60 disabled:opacity-40 text-slate-300 px-3 py-1.5 rounded"
|
|
|
|
|
|
|
|
>
|
|
|
|
>
|
|
|
|
Construire les jambes depuis la chain du {startDate || '…'}
|
|
|
|
<RefreshCw className={clsx('w-3.5 h-3.5', replayPending && 'animate-spin')} />
|
|
|
|
|
|
|
|
{replayPending ? 'Analyse en cours…' : 'Analyser cette période'}
|
|
|
|
</button>
|
|
|
|
</button>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{error && (
|
|
|
|
{realizedError && (
|
|
|
|
<div className="text-xs text-red-300">
|
|
|
|
<div className="text-xs text-red-300">{(realizedError as any)?.response?.data?.detail ?? 'Erreur de calcul.'}</div>
|
|
|
|
{(error as any)?.response?.data?.detail ?? 'Erreur pendant le replay.'}
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
{realized && (
|
|
|
|
|
|
|
|
<div className="flex flex-wrap gap-4 text-xs bg-dark-700/40 border border-slate-700/40 rounded px-3 py-2">
|
|
|
|
|
|
|
|
<span className="text-slate-400">
|
|
|
|
|
|
|
|
Spot : <span className="text-white font-semibold">{fmtPrice(realized.spot_a)} → {fmtPrice(realized.spot_b)}</span>
|
|
|
|
|
|
|
|
{' '}(<span className={realized.spot_shock_pct >= 0 ? 'text-emerald-400' : 'text-red-400'}>{realized.spot_shock_pct >= 0 ? '+' : ''}{realized.spot_shock_pct.toFixed(2)}%</span>)
|
|
|
|
|
|
|
|
</span>
|
|
|
|
|
|
|
|
{realized.iv_a != null && realized.iv_b != null ? (
|
|
|
|
|
|
|
|
<span className="text-slate-400">
|
|
|
|
|
|
|
|
IV ATM : <span className="text-white font-semibold">{(realized.iv_a * 100).toFixed(1)}% → {(realized.iv_b * 100).toFixed(1)}%</span>
|
|
|
|
|
|
|
|
{' '}(<span className={((realized.iv_level_shift ?? 0) >= 0) ? 'text-orange-400' : 'text-blue-400'}>{(realized.iv_level_shift ?? 0) >= 0 ? '+' : ''}{((realized.iv_level_shift ?? 0) * 100).toFixed(1)}pts</span>)
|
|
|
|
|
|
|
|
</span>
|
|
|
|
|
|
|
|
) : (
|
|
|
|
|
|
|
|
<span className="text-slate-600">IV ATM indisponible à l'une des deux dates</span>
|
|
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
<span className="text-slate-400">Sur <span className="text-white font-semibold">{realized.horizon_days}j</span></span>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
)}
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
{replayError && (
|
|
|
|
|
|
|
|
<div className="text-xs text-red-300">{(replayError as any)?.response?.data?.detail ?? "Erreur pendant l'analyse."}</div>
|
|
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{result && (
|
|
|
|
{result && (
|
|
|
|
<>
|
|
|
|
<>
|
|
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
|
|
@@ -758,45 +802,6 @@ function ReplayCard({
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="overflow-x-auto">
|
|
|
|
|
|
|
|
<table className="w-full text-xs">
|
|
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
|
|
<tr className="text-slate-500">
|
|
|
|
|
|
|
|
<th className="text-left pb-1 pr-3">Jambe</th>
|
|
|
|
|
|
|
|
<th className="text-right pb-1 pr-3">Strike</th>
|
|
|
|
|
|
|
|
<th className="text-left pb-1 pr-3">Échéance</th>
|
|
|
|
|
|
|
|
<th className="text-right pb-1 pr-3">Mid entrée</th>
|
|
|
|
|
|
|
|
<th className="text-right pb-1 pr-3">Bid/Ask entrée</th>
|
|
|
|
|
|
|
|
<th className="text-right pb-1 pr-3">IV entrée</th>
|
|
|
|
|
|
|
|
<th className="text-right pb-1 pr-3">Δ entrée</th>
|
|
|
|
|
|
|
|
<th className="text-right pb-1">Mid sortie</th>
|
|
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
|
|
{result.entry_legs.map((leg, i) => {
|
|
|
|
|
|
|
|
const exitLeg = result.exit_legs[i]
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
|
|
<tr key={i} className="border-t border-slate-700/20">
|
|
|
|
|
|
|
|
<td className="py-1 pr-3 whitespace-nowrap">
|
|
|
|
|
|
|
|
<span className={leg.position === 'long' ? 'text-emerald-400' : 'text-red-400'}>{leg.position === 'long' ? 'Achat' : 'Vente'}</span>
|
|
|
|
|
|
|
|
{' '}{leg.quantity > 1 ? `${leg.quantity}x ` : ''}{leg.option_type === 'stock' ? 'Sous-jacent' : (leg.option_type === 'call' ? 'Call' : 'Put')}
|
|
|
|
|
|
|
|
</td>
|
|
|
|
|
|
|
|
<td className="py-1 pr-3 text-right font-mono">{leg.option_type === 'stock' ? '—' : fmtPrice(leg.strike)}</td>
|
|
|
|
|
|
|
|
<td className="py-1 pr-3 text-slate-400 whitespace-nowrap">{leg.option_type === 'stock' ? '—' : leg.expiry_date}</td>
|
|
|
|
|
|
|
|
<td className="py-1 pr-3 text-right font-mono">{fmtPrice(leg.mid)}</td>
|
|
|
|
|
|
|
|
<td className="py-1 pr-3 text-right font-mono text-slate-400">
|
|
|
|
|
|
|
|
{leg.bid != null && leg.ask != null ? `${fmtPrice(leg.bid)} / ${fmtPrice(leg.ask)}` : '—'}
|
|
|
|
|
|
|
|
</td>
|
|
|
|
|
|
|
|
<td className="py-1 pr-3 text-right font-mono text-slate-400">{leg.iv != null ? `${(leg.iv * 100).toFixed(1)}%` : '—'}</td>
|
|
|
|
|
|
|
|
<td className="py-1 pr-3 text-right font-mono text-slate-400">{leg.greeks ? leg.greeks.delta.toFixed(3) : (leg.option_type === 'stock' ? '1.000' : '—')}</td>
|
|
|
|
|
|
|
|
<td className="py-1 text-right font-mono">{exitLeg ? fmtPrice(exitLeg.mid) : '—'}</td>
|
|
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
})}
|
|
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
|
|
</table>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<ResponsiveContainer width="100%" height={200}>
|
|
|
|
<ResponsiveContainer width="100%" height={200}>
|
|
|
|
<AreaChart data={result.points}>
|
|
|
|
<AreaChart data={result.points}>
|
|
|
|
<defs>
|
|
|
|
<defs>
|
|
|
|
@@ -813,9 +818,67 @@ function ReplayCard({
|
|
|
|
formatter={(v: number, name: string) => [name === 'pnl' ? fmtMoney(v) : v, name === 'pnl' ? 'P&L' : 'Spot']}
|
|
|
|
formatter={(v: number, name: string) => [name === 'pnl' ? fmtMoney(v) : v, name === 'pnl' ? 'P&L' : 'Spot']}
|
|
|
|
/>
|
|
|
|
/>
|
|
|
|
<ReferenceLine y={0} stroke="#475569" strokeDasharray="4 4" />
|
|
|
|
<ReferenceLine y={0} stroke="#475569" strokeDasharray="4 4" />
|
|
|
|
|
|
|
|
{idx >= 0 && (
|
|
|
|
|
|
|
|
<ReferenceLine x={checkpointDate} stroke="#f59e0b" strokeDasharray="2 2" label={{ value: 'Scruté', fill: '#f59e0b', fontSize: 9, position: 'top' }} />
|
|
|
|
|
|
|
|
)}
|
|
|
|
<Area type="monotone" dataKey="pnl" stroke={result.final_pnl >= 0 ? '#10b981' : '#ef4444'} fill="url(#replay-grad)" strokeWidth={2} dot={{ r: 2 }} />
|
|
|
|
<Area type="monotone" dataKey="pnl" stroke={result.final_pnl >= 0 ? '#10b981' : '#ef4444'} fill="url(#replay-grad)" strokeWidth={2} dot={{ r: 2 }} />
|
|
|
|
</AreaChart>
|
|
|
|
</AreaChart>
|
|
|
|
</ResponsiveContainer>
|
|
|
|
</ResponsiveContainer>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
|
|
|
<label className="text-xs text-slate-400 flex items-center justify-between mb-1">
|
|
|
|
|
|
|
|
<span>Jour scruté</span>
|
|
|
|
|
|
|
|
<span className="text-white font-semibold">{checkpointDate}{idx >= 0 ? ` · spot ${fmtPrice(result.points[idx].spot)}` : ''}</span>
|
|
|
|
|
|
|
|
</label>
|
|
|
|
|
|
|
|
<input
|
|
|
|
|
|
|
|
type="range" min={0} max={Math.max(result.points.length - 1, 0)}
|
|
|
|
|
|
|
|
value={Math.max(idx, 0)}
|
|
|
|
|
|
|
|
onChange={(e) => setCheckpointDate(result.points[parseInt(e.target.value)].date)}
|
|
|
|
|
|
|
|
className="w-full accent-blue-500"
|
|
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
{checkpointLegs && (
|
|
|
|
|
|
|
|
<div className="overflow-x-auto">
|
|
|
|
|
|
|
|
<table className="w-full text-xs">
|
|
|
|
|
|
|
|
<thead>
|
|
|
|
|
|
|
|
<tr className="text-slate-500">
|
|
|
|
|
|
|
|
<th className="text-left pb-1 pr-3">Jambe</th>
|
|
|
|
|
|
|
|
<th className="text-right pb-1 pr-3">Strike</th>
|
|
|
|
|
|
|
|
<th className="text-left pb-1 pr-3">Échéance</th>
|
|
|
|
|
|
|
|
<th className="text-right pb-1 pr-3">Mid entrée</th>
|
|
|
|
|
|
|
|
<th className="text-right pb-1 pr-3">Mid ce jour</th>
|
|
|
|
|
|
|
|
<th className="text-right pb-1 pr-3">Bid/Ask ce jour</th>
|
|
|
|
|
|
|
|
<th className="text-right pb-1 pr-3">IV ce jour</th>
|
|
|
|
|
|
|
|
<th className="text-right pb-1">Δ ce jour</th>
|
|
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
|
|
</thead>
|
|
|
|
|
|
|
|
<tbody>
|
|
|
|
|
|
|
|
{checkpointLegs.map((leg, i) => {
|
|
|
|
|
|
|
|
if (!leg) return null
|
|
|
|
|
|
|
|
const entryLeg = entryLegs?.[i]
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
|
|
<tr key={i} className="border-t border-slate-700/20">
|
|
|
|
|
|
|
|
<td className="py-1 pr-3 whitespace-nowrap">
|
|
|
|
|
|
|
|
<span className={leg.position === 'long' ? 'text-emerald-400' : 'text-red-400'}>{leg.position === 'long' ? 'Achat' : 'Vente'}</span>
|
|
|
|
|
|
|
|
{' '}{leg.quantity > 1 ? `${leg.quantity}x ` : ''}{leg.option_type === 'stock' ? 'Sous-jacent' : (leg.option_type === 'call' ? 'Call' : 'Put')}
|
|
|
|
|
|
|
|
</td>
|
|
|
|
|
|
|
|
<td className="py-1 pr-3 text-right font-mono">{leg.option_type === 'stock' ? '—' : fmtPrice(leg.strike)}</td>
|
|
|
|
|
|
|
|
<td className="py-1 pr-3 text-slate-400 whitespace-nowrap">{leg.option_type === 'stock' ? '—' : leg.expiry_date}</td>
|
|
|
|
|
|
|
|
<td className="py-1 pr-3 text-right font-mono text-slate-400">{entryLeg ? fmtPrice(entryLeg.mid) : '—'}</td>
|
|
|
|
|
|
|
|
<td className="py-1 pr-3 text-right font-mono">{fmtPrice(leg.mid)}</td>
|
|
|
|
|
|
|
|
<td className="py-1 pr-3 text-right font-mono text-slate-400">
|
|
|
|
|
|
|
|
{leg.bid != null && leg.ask != null ? `${fmtPrice(leg.bid)} / ${fmtPrice(leg.ask)}` : '—'}
|
|
|
|
|
|
|
|
</td>
|
|
|
|
|
|
|
|
<td className="py-1 pr-3 text-right font-mono text-slate-400">{leg.iv != null ? `${(leg.iv * 100).toFixed(1)}%` : '—'}</td>
|
|
|
|
|
|
|
|
<td className="py-1 text-right font-mono text-slate-400">{leg.greeks ? leg.greeks.delta.toFixed(3) : (leg.option_type === 'stock' ? '1.000' : '—')}</td>
|
|
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
})}
|
|
|
|
|
|
|
|
</tbody>
|
|
|
|
|
|
|
|
</table>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
)}
|
|
|
|
</>
|
|
|
|
</>
|
|
|
|
)}
|
|
|
|
)}
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
@@ -987,6 +1050,12 @@ function SavedStrategiesLibrary({ symbol, onLoad }: { symbol: string; onLoad: (l
|
|
|
|
<button onClick={() => onLoad(s.legs, s.template_name)} className="flex items-center gap-2 text-slate-300 hover:text-white">
|
|
|
|
<button onClick={() => onLoad(s.legs, s.template_name)} className="flex items-center gap-2 text-slate-300 hover:text-white">
|
|
|
|
<FolderOpen className="w-3 h-3 text-blue-400" />
|
|
|
|
<FolderOpen className="w-3 h-3 text-blue-400" />
|
|
|
|
<span>{s.template_name}</span>
|
|
|
|
<span>{s.template_name}</span>
|
|
|
|
|
|
|
|
<span className={clsx('text-[9px] px-1.5 py-0.5 rounded border',
|
|
|
|
|
|
|
|
s.source === 'historical' ? 'border-amber-700/40 text-amber-400' : 'border-blue-700/40 text-blue-400')}
|
|
|
|
|
|
|
|
title={s.source === 'historical' ? 'Priced à partir de données réelles (Analyse période historique)' : 'Priced sous un scénario synthétique (Construire)'}
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
{s.source === 'historical' ? 'Réel' : 'Synthétique'}
|
|
|
|
|
|
|
|
</span>
|
|
|
|
<span className={pnlColor(s.net_pnl_scenario)}>{fmtMoney(s.net_pnl_scenario)}</span>
|
|
|
|
<span className={pnlColor(s.net_pnl_scenario)}>{fmtMoney(s.net_pnl_scenario)}</span>
|
|
|
|
<span className="text-slate-600">{s.legs.length} jambes</span>
|
|
|
|
<span className="text-slate-600">{s.legs.length} jambes</span>
|
|
|
|
</button>
|
|
|
|
</button>
|
|
|
|
@@ -1020,18 +1089,22 @@ export default function StrategyBuilder() {
|
|
|
|
const commitSymbol = (v?: string) => setDebouncedSymbol((v ?? symbol).trim())
|
|
|
|
const commitSymbol = (v?: string) => setDebouncedSymbol((v ?? symbol).trim())
|
|
|
|
|
|
|
|
|
|
|
|
const [legs, setLegs] = useState<StrategyLeg[]>([])
|
|
|
|
const [legs, setLegs] = useState<StrategyLeg[]>([])
|
|
|
|
// Empty = build against the live chain (now). Set (typically synced from the Replay
|
|
|
|
// Empty = build against the live chain (now). Set (from "Analyse période historique"'s
|
|
|
|
// card's "Du") to reconstruct the chain as it stood back then — a leg picked against
|
|
|
|
// "Du") to reconstruct the chain as it stood back then — a leg picked against TODAY's
|
|
|
|
// TODAY's chain may not have existed yet, or may have had a very different strike
|
|
|
|
// chain may not have existed yet, or may have had a very different strike ladder, on a
|
|
|
|
// ladder, on a date a past Replay window actually starts from.
|
|
|
|
// date a past period actually starts from. Also doubles as that period's entry date.
|
|
|
|
const [chainAsOf, setChainAsOf] = useState<string>('')
|
|
|
|
const [chainAsOf, setChainAsOf] = useState<string>('')
|
|
|
|
// Three distinct jobs this page does, kept visually separate per user feedback (a single
|
|
|
|
// Two jobs this page does, kept visually separate: Construire = manual scenario (surface
|
|
|
|
// long vertical page mixed "build a hypothetical position," "derive one from what
|
|
|
|
// deformed by hand) + optimizer against a hypothetical. Analyse période historique = pick
|
|
|
|
// actually happened," and "test a fixed position against real history" together):
|
|
|
|
// a real period, scrub through it day by day, and price/optimize off a REAL smile
|
|
|
|
// Construire = manual scenario + optimizer against a hypothetical. Dériver = auto-scenario
|
|
|
|
// reconstructed from that day's captured Saxo quotes — not a hypothesis. Both share
|
|
|
|
// from a REAL historical window, then optimize under it. Tester = replay fixed legs
|
|
|
|
// symbol/legs/chainAsOf AND the results pane below (payoff/heatmap/Greeks), fed by the
|
|
|
|
// against real quotes day by day. All three share symbol/legs/chainAsOf.
|
|
|
|
// same `priced`, only the surface behind it differs (see the checkpoint_as_of effect).
|
|
|
|
const [mode, setMode] = useState<'build' | 'derive' | 'replay'>('build')
|
|
|
|
const [mode, setMode] = useState<'build' | 'historical'>('build')
|
|
|
|
|
|
|
|
// Groups the parameter panels so switching mode doesn't reset which one is open — makes
|
|
|
|
|
|
|
|
// both modes read as "the same tool" instead of unrelated pages, and cuts the scroll a
|
|
|
|
|
|
|
|
// single long stack of sliders/optimizer/library used to force.
|
|
|
|
|
|
|
|
const [subTab, setSubTab] = useState<'params' | 'optimizer' | 'library'>('params')
|
|
|
|
const [constraints, setConstraints] = useState<OptimizeConstraints>({
|
|
|
|
const [constraints, setConstraints] = useState<OptimizeConstraints>({
|
|
|
|
max_legs: 4, delta_threshold: 0.15, max_loss_cap: null, objective: 'net_pnl', top_n: 20,
|
|
|
|
max_legs: 4, delta_threshold: 0.15, max_loss_cap: null, objective: 'net_pnl', top_n: 20,
|
|
|
|
})
|
|
|
|
})
|
|
|
|
@@ -1112,36 +1185,48 @@ export default function StrategyBuilder() {
|
|
|
|
setLegs(c.legs)
|
|
|
|
setLegs(c.legs)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Dériver d'un historique ────────────────────────────────────────────────
|
|
|
|
// ── Analyse période historique ──────────────────────────────────────────────
|
|
|
|
const deriveBounds = saxoSymbols?.find(s => s.symbol.toUpperCase() === debouncedSymbol.toUpperCase())
|
|
|
|
const [periodStart, setPeriodStart] = useState('')
|
|
|
|
const [deriveStart, setDeriveStart] = useState('')
|
|
|
|
const [periodEnd, setPeriodEnd] = useState('')
|
|
|
|
const [deriveEnd, setDeriveEnd] = useState('')
|
|
|
|
const [checkpointDate, setCheckpointDate] = useState('')
|
|
|
|
const { mutate: computeRealized, data: realized, isPending: realizedPending, error: realizedError, reset: resetRealized } = useRealizedScenario()
|
|
|
|
const realizedQuery = useRealizedScenario()
|
|
|
|
|
|
|
|
const replayQuery = useReplayStrategy()
|
|
|
|
|
|
|
|
const { data: realized } = realizedQuery
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// As soon as the realized move is computed, pin the chain/horizon to that period right
|
|
|
|
|
|
|
|
// away — otherwise the catalogue/leg editor below silently keep pricing off today's live
|
|
|
|
|
|
|
|
// chain until a checkpoint is scrubbed to. spot_shock_pct/iv_level_shift here are purely
|
|
|
|
|
|
|
|
// informational from this point on (SuggestedProfileCard's Greek-direction reading) —
|
|
|
|
|
|
|
|
// once checkpoint_as_of is set below, pricing itself comes from the real smile, not
|
|
|
|
|
|
|
|
// these parametric shock numbers.
|
|
|
|
useEffect(() => {
|
|
|
|
useEffect(() => {
|
|
|
|
if (deriveBounds && !deriveStart && !deriveEnd) {
|
|
|
|
|
|
|
|
setDeriveEnd(deriveBounds.last_date.slice(0, 10))
|
|
|
|
|
|
|
|
const end = new Date(deriveBounds.last_date.slice(0, 10))
|
|
|
|
|
|
|
|
const start = new Date(Math.max(end.getTime() - 7 * 86400000, new Date(deriveBounds.first_date.slice(0, 10)).getTime()))
|
|
|
|
|
|
|
|
setDeriveStart(start.toISOString().slice(0, 10))
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}, [deriveBounds]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const runDeriveOptimize = () => {
|
|
|
|
|
|
|
|
if (!realized) return
|
|
|
|
if (!realized) return
|
|
|
|
const derived: StrategyScenario = {
|
|
|
|
setScenario(s => ({
|
|
|
|
...scenario,
|
|
|
|
...s,
|
|
|
|
spot_shock_pct: realized.spot_shock_pct,
|
|
|
|
spot_shock_pct: realized.spot_shock_pct,
|
|
|
|
iv_level_shift: realized.iv_level_shift ?? scenario.iv_level_shift,
|
|
|
|
iv_level_shift: realized.iv_level_shift ?? s.iv_level_shift,
|
|
|
|
horizon_days: realized.horizon_days,
|
|
|
|
horizon_days: realized.horizon_days,
|
|
|
|
as_of: deriveStart,
|
|
|
|
as_of: periodStart,
|
|
|
|
}
|
|
|
|
}))
|
|
|
|
setScenario(derived)
|
|
|
|
|
|
|
|
setHorizonDays(realized.horizon_days)
|
|
|
|
setHorizonDays(realized.horizon_days)
|
|
|
|
setChainAsOf(deriveStart)
|
|
|
|
setChainAsOf(periodStart)
|
|
|
|
setActiveTemplate(null)
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
optimizeMutation.mutate({ scenario: derived, constraints, greek_profile: greekProfile })
|
|
|
|
}, [realized])
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// The day-scrubber is what actually drives pricing in historical mode: whenever it (or
|
|
|
|
|
|
|
|
// the pinned entry date) changes, keep scenario.checkpoint_as_of/horizon_days in sync so
|
|
|
|
|
|
|
|
// the shared results pane below reprices off that real day's smile — see
|
|
|
|
|
|
|
|
// routers.strategy_builder.ScenarioIn.checkpoint_as_of. Leaving 'build' mode clears it,
|
|
|
|
|
|
|
|
// so a stale historical checkpoint never silently leaks into a synthetic scenario price.
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
|
|
if (mode === 'historical' && checkpointDate && chainAsOf) {
|
|
|
|
|
|
|
|
const elapsed = Math.round((new Date(checkpointDate).getTime() - new Date(chainAsOf).getTime()) / 86400000)
|
|
|
|
|
|
|
|
setScenario(s => ({ ...s, checkpoint_as_of: checkpointDate, horizon_days: elapsed }))
|
|
|
|
|
|
|
|
} else if (mode === 'build') {
|
|
|
|
|
|
|
|
setScenario(s => (s.checkpoint_as_of ? { ...s, checkpoint_as_of: null } : s))
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
|
|
|
}, [mode, checkpointDate, chainAsOf])
|
|
|
|
|
|
|
|
|
|
|
|
const handleLoadScenario = (s: SavedScenario) => {
|
|
|
|
const handleLoadScenario = (s: SavedScenario) => {
|
|
|
|
setSymbol(s.symbol)
|
|
|
|
setSymbol(s.symbol)
|
|
|
|
@@ -1160,6 +1245,7 @@ export default function StrategyBuilder() {
|
|
|
|
symbol, template_name: activeTemplate || 'Manuel', objective: constraints.objective, legs,
|
|
|
|
symbol, template_name: activeTemplate || 'Manuel', objective: constraints.objective, legs,
|
|
|
|
entry_cost: priced.entry_cost, max_gain: priced.max_gain, max_loss: priced.max_loss,
|
|
|
|
entry_cost: priced.entry_cost, max_gain: priced.max_gain, max_loss: priced.max_loss,
|
|
|
|
net_pnl_scenario: priced.net_pnl, net_delta: priced.net_delta_now,
|
|
|
|
net_pnl_scenario: priced.net_pnl, net_delta: priced.net_delta_now,
|
|
|
|
|
|
|
|
source: mode === 'build' ? 'synthetic' : 'historical',
|
|
|
|
})
|
|
|
|
})
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1197,8 +1283,7 @@ export default function StrategyBuilder() {
|
|
|
|
<div className="flex gap-1 border-b border-slate-700/40">
|
|
|
|
<div className="flex gap-1 border-b border-slate-700/40">
|
|
|
|
{([
|
|
|
|
{([
|
|
|
|
['build', 'Construire', 'Scénario manuel + jambes + optimiseur — fabriquer une stratégie dans l\'absolu'],
|
|
|
|
['build', 'Construire', 'Scénario manuel + jambes + optimiseur — fabriquer une stratégie dans l\'absolu'],
|
|
|
|
['derive', 'Dériver d\'un historique', 'Scénario calculé depuis un vrai mouvement passé, puis optimiseur dessus'],
|
|
|
|
['historical', 'Analyse période historique', 'Choisis une vraie période, scrute-la jour par jour, price/optimise contre les cotations Saxo réelles de ce jour-là'],
|
|
|
|
['replay', 'Tester (Replay)', 'Marque au marché des jambes fixes contre l\'historique Saxo réel'],
|
|
|
|
|
|
|
|
] as const).map(([key, label, title]) => (
|
|
|
|
] as const).map(([key, label, title]) => (
|
|
|
|
<button
|
|
|
|
<button
|
|
|
|
key={key}
|
|
|
|
key={key}
|
|
|
|
@@ -1214,110 +1299,43 @@ export default function StrategyBuilder() {
|
|
|
|
))}
|
|
|
|
))}
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{mode === 'build' && (
|
|
|
|
{/* Sub-tabs group the parameter panels the same way in both modes — reduces the
|
|
|
|
<>
|
|
|
|
scroll a single long stack used to force, and shows both modes are the same tool. */}
|
|
|
|
<ScenarioSlidersPanel scenario={scenario} setScenario={setScenario} />
|
|
|
|
<div className="flex gap-1">
|
|
|
|
|
|
|
|
{([
|
|
|
|
|
|
|
|
['params', 'Paramètres'],
|
|
|
|
|
|
|
|
['optimizer', 'Optimiseur'],
|
|
|
|
|
|
|
|
['library', 'Bibliothèque'],
|
|
|
|
|
|
|
|
] as const).map(([key, label]) => (
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
|
|
|
key={key}
|
|
|
|
|
|
|
|
onClick={() => setSubTab(key)}
|
|
|
|
|
|
|
|
className={clsx('px-3 py-1.5 rounded-t text-xs font-semibold transition-colors', {
|
|
|
|
|
|
|
|
'bg-dark-700 text-white': subTab === key,
|
|
|
|
|
|
|
|
'text-slate-500 hover:text-slate-300': subTab !== key,
|
|
|
|
|
|
|
|
})}
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
{label}
|
|
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
))}
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<ScenarioLibrary symbol={debouncedSymbol} scenario={scenario} onLoad={handleLoadScenario} />
|
|
|
|
{subTab === 'params' && mode === 'build' && <ScenarioSlidersPanel scenario={scenario} setScenario={setScenario} />}
|
|
|
|
<SavedStrategiesLibrary symbol={debouncedSymbol} onLoad={(legs, templateName) => { setActiveTemplate(templateName); setLegs(legs) }} />
|
|
|
|
{subTab === 'params' && mode === 'historical' && (
|
|
|
|
</>
|
|
|
|
<HistoricalPeriodPanel
|
|
|
|
|
|
|
|
symbol={debouncedSymbol} legs={legs} contractSize={scenario.contract_size ?? 100_000}
|
|
|
|
|
|
|
|
onUseAsChainAsOf={setChainAsOf}
|
|
|
|
|
|
|
|
periodStart={periodStart} setPeriodStart={setPeriodStart}
|
|
|
|
|
|
|
|
periodEnd={periodEnd} setPeriodEnd={setPeriodEnd}
|
|
|
|
|
|
|
|
realizedQuery={realizedQuery} replayQuery={replayQuery}
|
|
|
|
|
|
|
|
checkpointDate={checkpointDate} setCheckpointDate={setCheckpointDate}
|
|
|
|
|
|
|
|
/>
|
|
|
|
)}
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{chainLoading && <div className="card-sm text-xs text-slate-500">Chargement de la chaîne réelle ({debouncedSymbol})…</div>}
|
|
|
|
{chainLoading && <div className="card-sm text-xs text-slate-500">Chargement de la chaîne réelle ({debouncedSymbol})…</div>}
|
|
|
|
|
|
|
|
|
|
|
|
{mode === 'build' && chain && <ScenarioGrid chain={chain} spot={chain.spot} scenario={scenario} setScenario={setScenario} />}
|
|
|
|
{subTab === 'params' && mode === 'build' && chain && <ScenarioGrid chain={chain} spot={chain.spot} scenario={scenario} setScenario={setScenario} />}
|
|
|
|
{mode === 'build' && chain && <VolSurfaceHeatmap chain={chain} spot={chain.spot} />}
|
|
|
|
{subTab === 'params' && mode === 'build' && chain && <VolSurfaceHeatmap chain={chain} spot={chain.spot} />}
|
|
|
|
|
|
|
|
|
|
|
|
{mode === 'derive' && (
|
|
|
|
|
|
|
|
<div className="card space-y-3">
|
|
|
|
|
|
|
|
<div className="stat-label">Scénario dérivé d'un historique réel</div>
|
|
|
|
|
|
|
|
<p className="text-[11px] text-slate-500">
|
|
|
|
|
|
|
|
Calcule le mouvement de spot et d'IV ATM réellement survenu entre deux dates (vraies cotations Saxo captées, pas une hypothèse), puis l'utilise comme scénario pour l'optimiseur — "qu'aurait-il fallu faire pour ce mouvement-là ?"
|
|
|
|
|
|
|
|
Tilt skew et pente du terme ne sont pas dérivés (comparer deux smiles réels de façon fiable est un exercice à part) — ils restent à 0, ajustables ensuite dans l'onglet Construire.
|
|
|
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
{!deriveBounds && <div className="text-xs text-slate-600">Aucun historique Saxo pour ce symbole.</div>}
|
|
|
|
|
|
|
|
{deriveBounds && (
|
|
|
|
|
|
|
|
<>
|
|
|
|
|
|
|
|
<div className="flex items-end gap-3 flex-wrap">
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
|
|
|
<label className="text-xs text-slate-400 block mb-1">Du</label>
|
|
|
|
|
|
|
|
<input
|
|
|
|
|
|
|
|
type="date" value={deriveStart} min={deriveBounds.first_date.slice(0, 10)} max={deriveBounds.last_date.slice(0, 10)}
|
|
|
|
|
|
|
|
onChange={(e) => { setDeriveStart(e.target.value); resetRealized() }}
|
|
|
|
|
|
|
|
className="bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-sm text-white"
|
|
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
|
|
|
<label className="text-xs text-slate-400 block mb-1">Au</label>
|
|
|
|
|
|
|
|
<input
|
|
|
|
|
|
|
|
type="date" value={deriveEnd} min={deriveBounds.first_date.slice(0, 10)} max={deriveBounds.last_date.slice(0, 10)}
|
|
|
|
|
|
|
|
onChange={(e) => { setDeriveEnd(e.target.value); resetRealized() }}
|
|
|
|
|
|
|
|
className="bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-sm text-white"
|
|
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
|
|
|
onClick={() => deriveStart && deriveEnd && computeRealized({ symbol: debouncedSymbol, start_date: deriveStart, end_date: deriveEnd })}
|
|
|
|
|
|
|
|
disabled={realizedPending || !deriveStart || !deriveEnd}
|
|
|
|
|
|
|
|
className="flex items-center gap-1.5 text-xs bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white px-3 py-1.5 rounded font-semibold"
|
|
|
|
|
|
|
|
>
|
|
|
|
|
|
|
|
<RefreshCw className={clsx('w-3.5 h-3.5', realizedPending && 'animate-spin')} />
|
|
|
|
|
|
|
|
{realizedPending ? 'Calcul…' : 'Calculer le mouvement réalisé'}
|
|
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
{realizedError && (
|
|
|
|
|
|
|
|
<div className="text-xs text-red-300">{(realizedError as any)?.response?.data?.detail ?? 'Erreur de calcul.'}</div>
|
|
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
{realized && (
|
|
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
|
|
<div className="flex flex-wrap gap-4 text-xs bg-dark-700/40 border border-slate-700/40 rounded px-3 py-2">
|
|
|
|
|
|
|
|
<span className="text-slate-400">
|
|
|
|
|
|
|
|
Spot : <span className="text-white font-semibold">{fmtPrice(realized.spot_a)} → {fmtPrice(realized.spot_b)}</span>
|
|
|
|
|
|
|
|
{' '}(<span className={realized.spot_shock_pct >= 0 ? 'text-emerald-400' : 'text-red-400'}>{realized.spot_shock_pct >= 0 ? '+' : ''}{realized.spot_shock_pct.toFixed(2)}%</span>)
|
|
|
|
|
|
|
|
</span>
|
|
|
|
|
|
|
|
{realized.iv_a != null && realized.iv_b != null ? (
|
|
|
|
|
|
|
|
<span className="text-slate-400">
|
|
|
|
|
|
|
|
IV ATM : <span className="text-white font-semibold">{(realized.iv_a * 100).toFixed(1)}% → {(realized.iv_b * 100).toFixed(1)}%</span>
|
|
|
|
|
|
|
|
{' '}(<span className={((realized.iv_level_shift ?? 0) >= 0) ? 'text-orange-400' : 'text-blue-400'}>{(realized.iv_level_shift ?? 0) >= 0 ? '+' : ''}{((realized.iv_level_shift ?? 0) * 100).toFixed(1)}pts</span>)
|
|
|
|
|
|
|
|
</span>
|
|
|
|
|
|
|
|
) : (
|
|
|
|
|
|
|
|
<span className="text-slate-600">IV ATM indisponible à l'une des deux dates</span>
|
|
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
<span className="text-slate-400">Sur <span className="text-white font-semibold">{realized.horizon_days}j</span></span>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<OptimizerPanel constraints={constraints} setConstraints={setConstraints} onRun={runDeriveOptimize} isRunning={optimizeMutation.isPending} />
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
</>
|
|
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
{optimizeMutation.isError && (
|
|
|
|
|
|
|
|
<div className="px-4 py-3 rounded border border-red-700/40 bg-red-900/10 text-xs text-red-300">
|
|
|
|
|
|
|
|
{(optimizeMutation.error as any)?.response?.data?.detail ?? "Erreur lors de l'optimisation."}
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
{optimizeMutation.data && optimizeMutation.data.warnings.length > 0 && (
|
|
|
|
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
|
|
|
|
{optimizeMutation.data.warnings.map((w, i) => (
|
|
|
|
|
|
|
|
<div key={i} className="flex items-start gap-2 px-3 py-2 rounded border border-amber-700/40 bg-amber-900/10 text-xs text-amber-300">
|
|
|
|
|
|
|
|
<AlertTriangle className="w-3.5 h-3.5 mt-0.5 shrink-0" />
|
|
|
|
|
|
|
|
<span>{w}</span>
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
))}
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
{optimizeMutation.data && (
|
|
|
|
|
|
|
|
<>
|
|
|
|
|
|
|
|
<ResultsTable results={optimizeMutation.data.candidates} onSelect={handleSelectCandidate} />
|
|
|
|
|
|
|
|
<p className="text-[11px] text-slate-500">
|
|
|
|
|
|
|
|
Une jambe sélectionnée ci-dessus alimente l'éditeur de jambes plus bas — passe ensuite à l'onglet <strong>Tester (Replay)</strong> pour voir comment cette structure se serait réellement comportée sur cette même fenêtre.
|
|
|
|
|
|
|
|
</p>
|
|
|
|
|
|
|
|
</>
|
|
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
{chain && (
|
|
|
|
{chain && (
|
|
|
|
<div className="card space-y-3">
|
|
|
|
<div className="card space-y-3">
|
|
|
|
@@ -1427,14 +1445,7 @@ export default function StrategyBuilder() {
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
)}
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{mode === 'replay' && chain && legs.length > 0 && (
|
|
|
|
{subTab === 'optimizer' && chain && (
|
|
|
|
<ReplayCard
|
|
|
|
|
|
|
|
symbol={debouncedSymbol} legs={legs} contractSize={scenario.contract_size ?? 100_000}
|
|
|
|
|
|
|
|
chainAsOf={chainAsOf} onUseAsChainAsOf={setChainAsOf}
|
|
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
{mode === 'build' && chain && (
|
|
|
|
|
|
|
|
<>
|
|
|
|
<>
|
|
|
|
<SuggestedProfileCard
|
|
|
|
<SuggestedProfileCard
|
|
|
|
scenario={scenario} enabled={!!chain}
|
|
|
|
scenario={scenario} enabled={!!chain}
|
|
|
|
@@ -1451,12 +1462,12 @@ export default function StrategyBuilder() {
|
|
|
|
</>
|
|
|
|
</>
|
|
|
|
)}
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{mode === 'build' && optimizeMutation.isError && (
|
|
|
|
{subTab === 'optimizer' && optimizeMutation.isError && (
|
|
|
|
<div className="px-4 py-3 rounded border border-red-700/40 bg-red-900/10 text-xs text-red-300">
|
|
|
|
<div className="px-4 py-3 rounded border border-red-700/40 bg-red-900/10 text-xs text-red-300">
|
|
|
|
{(optimizeMutation.error as any)?.response?.data?.detail ?? "Erreur lors de l'optimisation."}
|
|
|
|
{(optimizeMutation.error as any)?.response?.data?.detail ?? "Erreur lors de l'optimisation."}
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
)}
|
|
|
|
)}
|
|
|
|
{mode === 'build' && optimizeMutation.data && optimizeMutation.data.warnings.length > 0 && (
|
|
|
|
{subTab === 'optimizer' && optimizeMutation.data && optimizeMutation.data.warnings.length > 0 && (
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
<div className="space-y-1.5">
|
|
|
|
{optimizeMutation.data.warnings.map((w, i) => (
|
|
|
|
{optimizeMutation.data.warnings.map((w, i) => (
|
|
|
|
<div key={i} className="flex items-start gap-2 px-3 py-2 rounded border border-amber-700/40 bg-amber-900/10 text-xs text-amber-300">
|
|
|
|
<div key={i} className="flex items-start gap-2 px-3 py-2 rounded border border-amber-700/40 bg-amber-900/10 text-xs text-amber-300">
|
|
|
|
@@ -1466,18 +1477,25 @@ export default function StrategyBuilder() {
|
|
|
|
))}
|
|
|
|
))}
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
)}
|
|
|
|
)}
|
|
|
|
{mode === 'build' && optimizeMutation.data && (
|
|
|
|
{subTab === 'optimizer' && optimizeMutation.data && (
|
|
|
|
<ResultsTable results={optimizeMutation.data.candidates} onSelect={handleSelectCandidate} />
|
|
|
|
<ResultsTable results={optimizeMutation.data.candidates} onSelect={handleSelectCandidate} />
|
|
|
|
)}
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{(mode === 'build' || mode === 'derive') && priceMutation.isPending && <div className="card-sm text-xs text-slate-500">Calcul en cours…</div>}
|
|
|
|
{subTab === 'library' && (
|
|
|
|
{(mode === 'build' || mode === 'derive') && priceMutation.isError && (
|
|
|
|
<>
|
|
|
|
|
|
|
|
{mode === 'build' && <ScenarioLibrary symbol={debouncedSymbol} scenario={scenario} onLoad={handleLoadScenario} />}
|
|
|
|
|
|
|
|
<SavedStrategiesLibrary symbol={debouncedSymbol} onLoad={(legs, templateName) => { setActiveTemplate(templateName); setLegs(legs) }} />
|
|
|
|
|
|
|
|
</>
|
|
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
{priceMutation.isPending && <div className="card-sm text-xs text-slate-500">Calcul en cours…</div>}
|
|
|
|
|
|
|
|
{priceMutation.isError && (
|
|
|
|
<div className="px-4 py-3 rounded border border-red-700/40 bg-red-900/10 text-xs text-red-300">
|
|
|
|
<div className="px-4 py-3 rounded border border-red-700/40 bg-red-900/10 text-xs text-red-300">
|
|
|
|
Erreur de pricing — vérifiez les jambes sélectionnées.
|
|
|
|
Erreur de pricing — vérifiez les jambes sélectionnées.
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
)}
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{(mode === 'build' || mode === 'derive') && priced && (
|
|
|
|
{priced && (
|
|
|
|
<>
|
|
|
|
<>
|
|
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
|
|
<div className="card-sm">
|
|
|
|
<div className="card-sm">
|
|
|
|
@@ -1485,7 +1503,7 @@ export default function StrategyBuilder() {
|
|
|
|
<div className={clsx('text-lg font-bold', priced.entry_cost >= 0 ? 'text-white' : 'text-emerald-400')}>{fmtMoney(priced.entry_cost)}</div>
|
|
|
|
<div className={clsx('text-lg font-bold', priced.entry_cost >= 0 ? 'text-white' : 'text-emerald-400')}>{fmtMoney(priced.entry_cost)}</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div className="card-sm">
|
|
|
|
<div className="card-sm">
|
|
|
|
<div className="stat-label">P&L net scénario J+{horizonDays}</div>
|
|
|
|
<div className="stat-label">P&L net scénario J+{scenario.horizon_days}</div>
|
|
|
|
<div className={clsx('text-lg font-bold', pnlColor(priced.net_pnl))}>{fmtMoney(priced.net_pnl)}</div>
|
|
|
|
<div className={clsx('text-lg font-bold', pnlColor(priced.net_pnl))}>{fmtMoney(priced.net_pnl)}</div>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div className="card-sm">
|
|
|
|
<div className="card-sm">
|
|
|
|
@@ -1534,9 +1552,11 @@ export default function StrategyBuilder() {
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
{payoffView === 'curve' && (
|
|
|
|
{payoffView === 'curve' && (
|
|
|
|
<>
|
|
|
|
<>
|
|
|
|
<PayoffChart priced={priced} spot={priced.spot} scenarioSpot={priced.scenario_spot} horizonDays={horizonDays} />
|
|
|
|
<PayoffChart priced={priced} spot={priced.spot} scenarioSpot={priced.scenario_spot} horizonDays={scenario.horizon_days} />
|
|
|
|
<p className="text-[11px] text-slate-500 mt-1">
|
|
|
|
<p className="text-[11px] text-slate-500 mt-1">
|
|
|
|
Les deux courbes utilisent la même vue de volatilité (celle du scénario) — seule la date diffère : bleu = à l'échéance de la jambe la plus proche, orange = à J+{horizonDays}.
|
|
|
|
{mode === 'historical'
|
|
|
|
|
|
|
|
? <>Les deux courbes utilisent la vraie smile de volatilité capturée au jour scruté — seule la date diffère : bleu = à l'échéance de la jambe la plus proche, orange = au jour scruté (J+{scenario.horizon_days}).</>
|
|
|
|
|
|
|
|
: <>Les deux courbes utilisent la même vue de volatilité (celle du scénario) — seule la date diffère : bleu = à l'échéance de la jambe la plus proche, orange = à J+{scenario.horizon_days}.</>}
|
|
|
|
</p>
|
|
|
|
</p>
|
|
|
|
</>
|
|
|
|
</>
|
|
|
|
)}
|
|
|
|
)}
|
|
|
|
@@ -1544,7 +1564,7 @@ export default function StrategyBuilder() {
|
|
|
|
<>
|
|
|
|
<>
|
|
|
|
<TimeDecayChart timeSlices={priced.time_slices} spot={priced.spot} scenarioSpot={priced.scenario_spot} />
|
|
|
|
<TimeDecayChart timeSlices={priced.time_slices} spot={priced.spot} scenarioSpot={priced.scenario_spot} />
|
|
|
|
<p className="text-[11px] text-slate-500 mt-1">
|
|
|
|
<p className="text-[11px] text-slate-500 mt-1">
|
|
|
|
Même vue de volatilité (celle du scénario) pour chaque palier — seule la date change, du jour même à l'échéance de la jambe la plus proche : ne montre que l'effet de la valeur temps (theta), pas un changement de vue de vol.
|
|
|
|
{mode === 'historical' ? 'Vraie smile capturée au jour scruté' : 'Même vue de volatilité (celle du scénario)'} pour chaque palier — seule la date change, du jour même à l'échéance de la jambe la plus proche : ne montre que l'effet de la valeur temps (theta), pas un changement de vue de vol.
|
|
|
|
</p>
|
|
|
|
</p>
|
|
|
|
</>
|
|
|
|
</>
|
|
|
|
)}
|
|
|
|
)}
|
|
|
|
@@ -1552,7 +1572,7 @@ export default function StrategyBuilder() {
|
|
|
|
<>
|
|
|
|
<>
|
|
|
|
<PayoffHeatmapView heatmap={priced.heatmap} spot={priced.spot} />
|
|
|
|
<PayoffHeatmapView heatmap={priced.heatmap} spot={priced.spot} />
|
|
|
|
<p className="text-[11px] text-slate-500 mt-1">
|
|
|
|
<p className="text-[11px] text-slate-500 mt-1">
|
|
|
|
Même vue de volatilité (celle du scénario) sur toute la grille — vert/rouge = gain/perte, l'intensité est relative au P&L max de cette grille.
|
|
|
|
{mode === 'historical' ? 'Vraie smile capturée au jour scruté' : 'Même vue de volatilité (celle du scénario)'} sur toute la grille — vert/rouge = gain/perte, l'intensité est relative au P&L max de cette grille.
|
|
|
|
</p>
|
|
|
|
</p>
|
|
|
|
</>
|
|
|
|
</>
|
|
|
|
)}
|
|
|
|
)}
|
|
|
|
|