import { useEffect, useMemo, useState } from 'react' import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ReferenceLine, ResponsiveContainer, } from 'recharts' import { Layers, Plus, Trash2, RefreshCw, AlertTriangle, Search, Save, FolderOpen, X } from 'lucide-react' import clsx from 'clsx' import { useOptionChainSlice, usePriceStrategy, useOptimizeStrategy, useSuggestedProfile, useScenarios, useSaveScenario, useDeleteScenario, useSavedStrategies, useSaveStrategyRecord, useDeleteSavedStrategy, useWatchlistTickers, useSaxoCatalog, useIvForTrade, type StrategyLeg, type StrategyScenario, type PriceCombo, type StrategyCandidate, type OptimizeConstraints, type SavedScenario, type GreekProfile, type GreekTarget, type GreekState, type GreekTolerance, DEFAULT_GREEK_PROFILE, } from '../hooks/useApi' import { fmtPrice, fmtAsOf } from '../lib/format' type WatchlistEntry = { ticker: string; is_active: number | boolean; added_by: string } const STRIKE_PCTS = [80, 85, 90, 95, 100, 105, 110, 115, 120] const DELTA_NEUTRAL_THRESHOLD = 0.15 // ── Helpers ─────────────────────────────────────────────────────────────────── function emptyLeg(expiryDate: string, daysToExpiry: number, strike: number): StrategyLeg { return { expiry_date: expiryDate, days_to_expiry: daysToExpiry, strike, option_type: 'call', position: 'long', quantity: 1 } } function fmtMoney(v: number | null | undefined) { if (v == null) return '—' return `${v >= 0 ? '+' : ''}${v.toFixed(2)}` } function pnlColor(v: number | null | undefined) { if (v == null) return 'text-slate-400' return v >= 0 ? 'text-emerald-400' : 'text-red-400' } /** Rough client-side smile preview (avg call/put IV at nearest strike) — mirrors the * cubic-spline surface computed server-side closely enough to preview shocks live. */ function estimateBaseIv(chain: any, daysToExpiry: number, strikePct: number, spot: number): number | null { if (!chain) return null const exp = chain.expiries.reduce((best: any, e: any) => Math.abs(e.days_to_expiry - daysToExpiry) < Math.abs((best?.days_to_expiry ?? Infinity) - daysToExpiry) ? e : best, null) if (!exp) return null const targetStrike = spot * (strikePct / 100) const rows = [...exp.calls, ...exp.puts].filter((r: any) => r.iv > 0.01) if (!rows.length) return null const nearest = rows.reduce((best: any, r: any) => Math.abs(r.strike - targetStrike) < Math.abs(best.strike - targetStrike) ? r : best) return nearest.iv } // ── Payoff chart ────────────────────────────────────────────────────────────── function PayoffChart({ priced, spot, scenarioSpot }: { priced: PriceCombo; spot: number; scenarioSpot: number }) { const data = priced.at_expiry.map((p, i) => ({ underlying: p.underlying, expiry: p.pnl, scenario: priced.at_scenario[i]?.pnl, })) // Decimals scale with the underlying's own magnitude — an equity at ~740 reads fine // rounded to the unit, but an FX rate at ~1.15 needs several decimals or every tick // collapses to the same rounded label. const decimals = spot < 5 ? 4 : spot < 50 ? 2 : 0 return ( v.toFixed(decimals)} /> `${v}`} /> `Sous-jacent: ${Number(v).toFixed(decimals)}`} formatter={(v: number, name: string) => [fmtMoney(v), name]} /> ) } function GreeksTile({ label, now, scenario, precision = 4, hint }: { label: string; now: number; scenario: number; precision?: number; hint?: string }) { return (
{label}
{now.toFixed(precision)} = now ? 'text-emerald-400' : 'text-red-400')}>{scenario.toFixed(precision)}
) } // ── Scenario panel ──────────────────────────────────────────────────────────── function ScenarioPanel({ symbol, setSymbol, onCommitSymbol, horizonDays, setHorizonDays, scenario, setScenario, watchlistTickers, }: { symbol: string; setSymbol: (v: string) => void; onCommitSymbol: (v?: string) => void horizonDays: number; setHorizonDays: (v: number) => void scenario: StrategyScenario; setScenario: (v: StrategyScenario) => void watchlistTickers: string[] }) { const slider = ( key: 'spot_shock_pct' | 'iv_level_shift' | 'skew_tilt' | 'term_slope_shift' | 'rate_shock_bps', label: string, min: number, max: number, step: number, fmt: (v: number) => string, ) => (
{label} {fmt(scenario[key] ?? 0)}
setScenario({ ...scenario, [key]: parseFloat(e.target.value) })} className="w-full accent-blue-500" />
) return (
{ const v = e.target.value.toUpperCase() setSymbol(v) // Native datalist pick lands here as a single onChange with the full value — // commit immediately rather than waiting for a blur that may not follow. if (watchlistTickers.includes(v)) onCommitSymbol(v) }} onBlur={() => onCommitSymbol()} onKeyDown={(e) => e.key === 'Enter' && onCommitSymbol()} className="w-full bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-sm text-white" placeholder="SPY, QQQ, GLD…" list="strategy-builder-watchlist" /> {watchlistTickers.map(t =>
setHorizonDays(parseInt(e.target.value) || 8)} className="w-full bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-sm text-white" />
setScenario({ ...scenario, dte_min: e.target.value === '' ? null : parseInt(e.target.value) })} className="w-full bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-sm text-white" />
setScenario({ ...scenario, dte_max: e.target.value === '' ? null : parseInt(e.target.value) })} className="w-full bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-sm text-white" />
{slider('spot_shock_pct', 'Choc spot', -20, 20, 0.5, (v) => `${v >= 0 ? '+' : ''}${v.toFixed(1)}%`)} {slider('iv_level_shift', 'Choc niveau IV', -0.15, 0.15, 0.005, (v) => `${v >= 0 ? '+' : ''}${(v * 100).toFixed(1)}pts`)} {slider('skew_tilt', 'Tilt skew', -0.1, 0.1, 0.005, (v) => v.toFixed(3))} {slider('term_slope_shift', 'Pente du terme (/30j)', -0.1, 0.1, 0.005, (v) => `${v >= 0 ? '+' : ''}${(v * 100).toFixed(1)}pts`)} {slider('rate_shock_bps', 'Choc de taux', -200, 200, 5, (v) => `${v >= 0 ? '+' : ''}${v.toFixed(0)}bps`)}
) } // ── Manual grid override ────────────────────────────────────────────────────── function ScenarioGrid({ chain, spot, scenario, setScenario, }: { chain: any; spot: number; scenario: StrategyScenario; setScenario: (v: StrategyScenario) => void }) { if (!chain) return null const overrides = scenario.manual_grid || [] const cellValue = (daysToExpiry: number, strikePct: number): number | null => { const hit = overrides.find(o => o.days_to_expiry === daysToExpiry && o.strike_pct === strikePct) if (hit) return hit.iv const base = estimateBaseIv(chain, daysToExpiry, strikePct, spot) if (base == null) return null const moneyness = Math.log(strikePct / 100) return Math.max(0.01, base + scenario.iv_level_shift + scenario.skew_tilt * moneyness + scenario.term_slope_shift * (daysToExpiry / 30)) } const setOverride = (daysToExpiry: number, strikePct: number, iv: number | null) => { const next = overrides.filter(o => !(o.days_to_expiry === daysToExpiry && o.strike_pct === strikePct)) if (iv != null) next.push({ days_to_expiry: daysToExpiry, strike_pct: strikePct, iv }) setScenario({ ...scenario, manual_grid: next }) } const isOverridden = (daysToExpiry: number, strikePct: number) => overrides.some(o => o.days_to_expiry === daysToExpiry && o.strike_pct === strikePct) return (
Grille IV scénario (éditable — clic sur une cellule)
{overrides.length > 0 && ( )}
{STRIKE_PCTS.map(p => )} {chain.expiries.map((exp: any) => ( {STRIKE_PCTS.map(pct => { const v = cellValue(exp.days_to_expiry, pct) const overridden = isOverridden(exp.days_to_expiry, pct) return ( ) })} ))}
Expiry / Strike%{p}%
{exp.expiry_date} ({exp.days_to_expiry}j) { const val = e.target.value === '' ? null : parseFloat(e.target.value) setOverride(exp.days_to_expiry, pct, val) }} className={clsx( 'w-16 text-right bg-dark-700 border rounded px-1 py-0.5', overridden ? 'border-amber-500 text-amber-300' : 'border-slate-700/40 text-slate-300', )} />
) } // ── Volatility surface heatmap ──────────────────────────────────────────────── // Sequential single-hue ramp (light → dark), light steps get dark text for contrast — // magnitude encoding per dataviz convention, no rainbow, no dual-hue diverging misuse. const IV_RAMP = ['#1e2d4d', '#1e3a6e', '#1d4f9e', '#1a63c4', '#3b82f6', '#60a5fa', '#93c5fd', '#bfdbfe'] function ivCellColor(value: number, min: number, max: number): { bg: string; fg: string } { const range = max - min || 1 const t = Math.min(1, Math.max(0, (value - min) / range)) const idx = Math.min(IV_RAMP.length - 1, Math.floor(t * IV_RAMP.length)) const bg = IV_RAMP[IV_RAMP.length - 1 - idx] // low IV -> light, high IV -> dark const fg = idx >= IV_RAMP.length - 3 ? '#0f1623' : '#ffffff' return { bg, fg } } function VolSurfaceHeatmap({ chain, spot }: { chain: any; spot: number }) { const cells = useMemo(() => { if (!chain) return [] const out: { expiry: string; days: number; pct: number; iv: number | null }[] = [] for (const exp of chain.expiries) { for (const pct of STRIKE_PCTS) { const target = spot * (pct / 100) const rows = [...exp.calls, ...exp.puts].filter((r: any) => r.iv > 0.01) if (!rows.length) { out.push({ expiry: exp.expiry_date, days: exp.days_to_expiry, pct, iv: null }) continue } const nearest = rows.reduce((best: any, r: any) => Math.abs(r.strike - target) < Math.abs(best.strike - target) ? r : best) out.push({ expiry: exp.expiry_date, days: exp.days_to_expiry, pct, iv: nearest.iv }) } } return out }, [chain, spot]) if (!chain) return null const ivValues = cells.map(c => c.iv).filter((v): v is number => v != null) if (!ivValues.length) return
Pas assez de données d'IV pour construire la surface.
const min = Math.min(...ivValues) const max = Math.max(...ivValues) return (
Surface de volatilité (strike × expiry)
{(min * 100).toFixed(1)}%
{[...IV_RAMP].reverse().map(c =>
)}
{(max * 100).toFixed(1)}%
{STRIKE_PCTS.map(p => )} {chain.expiries.map((exp: any) => ( {STRIKE_PCTS.map(pct => { const cell = cells.find(c => c.expiry === exp.expiry_date && c.pct === pct) if (!cell || cell.iv == null) { return } const { bg, fg } = ivCellColor(cell.iv, min, max) return ( ) })} ))}
Expiry{p}%
{exp.expiry_date} ({exp.days_to_expiry}j) {(cell.iv * 100).toFixed(1)}
) } // ── Manual leg builder ──────────────────────────────────────────────────────── function LegRow({ leg, chain, onChange, onRemove, }: { leg: StrategyLeg; chain: any onChange: (leg: StrategyLeg) => void onRemove: () => void }) { const expiry = chain?.expiries.find((e: any) => e.expiry_date === leg.expiry_date) const rows = expiry ? (leg.option_type === 'call' ? expiry.calls : expiry.puts) : [] return (
onChange({ ...leg, quantity: parseInt(e.target.value) || 1 })} className="col-span-1 bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200" />
) } // ── Optimizer panel ─────────────────────────────────────────────────────────── const OBJECTIVES: { value: OptimizeConstraints['objective']; label: string }[] = [ { value: 'net_pnl', label: 'P&L net max' }, { value: 'return_on_risk', label: 'Retour sur risque (P&L / perte max)' }, { value: 'prob_weighted', label: 'Espérance pondérée par probabilité' }, ] function OptimizerPanel({ constraints, setConstraints, onRun, isRunning, }: { constraints: OptimizeConstraints; setConstraints: (v: OptimizeConstraints) => void onRun: () => void; isRunning: boolean }) { return (
Optimiseur — contraintes & objectif
setConstraints({ ...constraints, delta_threshold: e.target.value === '' ? null : parseFloat(e.target.value) })} className="w-full bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200" />
setConstraints({ ...constraints, max_loss_cap: e.target.value === '' ? null : parseFloat(e.target.value) })} className="w-full bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200" />
) } const GREEK_STATES: { value: GreekState; label: string }[] = [ { value: 'strong_negative', label: 'Fortement négatif' }, { value: 'negative', label: 'Négatif' }, { value: 'neutral', label: 'Neutre' }, { value: 'positive', label: 'Positif' }, { value: 'strong_positive', label: 'Fortement positif' }, { value: 'free', label: 'Libre / non contraint' }, ] const GREEK_TOLERANCES: { value: GreekTolerance; label: string }[] = [ { value: 'etroite', label: 'Étroite' }, { value: 'normale', label: 'Normale' }, { value: 'large', label: 'Large' }, ] const GREEK_ROWS: { key: keyof GreekProfile; label: string; hint: string }[] = [ { key: 'delta', label: 'Delta', hint: 'Exposition directionnelle immédiate' }, { key: 'gamma', label: 'Gamma', hint: 'Le Delta s’améliore-t-il avec le mouvement ?' }, { key: 'theta', label: 'Theta', hint: 'Collecte de prime (+) ou achat de temps (-)' }, { key: 'vega', label: 'Vega', hint: 'Acheteur (+) ou vendeur (-) de volatilité' }, { key: 'rho', label: 'Rho', hint: 'Sensibilité au taux — surtout utile en LEAPS/futures' }, ] const GREEK_STATE_LABEL: Record = { strong_negative: 'Fortement négatif', negative: 'Négatif', neutral: 'Neutre', positive: 'Positif', strong_positive: 'Fortement positif', free: 'Libre', } function SuggestedProfileCard({ scenario, enabled, onAdopt, }: { scenario: StrategyScenario; enabled: boolean; onAdopt: (states: Record<'delta' | 'gamma' | 'theta' | 'vega' | 'rho', GreekState>) => void }) { const { data } = useSuggestedProfile(scenario, enabled) if (!data) return null const rows: { key: 'delta' | 'gamma' | 'theta' | 'vega' | 'rho'; label: string }[] = [ { key: 'delta', label: 'Delta' }, { key: 'gamma', label: 'Gamma' }, { key: 'theta', label: 'Theta' }, { key: 'vega', label: 'Vega' }, ] const active = rows.filter(r => data[r.key] !== 'free') if (active.length === 0) return null return (
Profil suggéré par le scénario seul (avant vos propres cibles)
{active.map(r => ( {r.label} : {GREEK_STATE_LABEL[data[r.key]]} ))}
    {data.rationale.map((r, i) =>
  • {r}
  • )}
) } function GreekProfilePanel({ profile, setProfile, }: { profile: GreekProfile; setProfile: (v: GreekProfile) => void }) { const setTarget = (key: keyof GreekProfile, patch: Partial) => setProfile({ ...profile, [key]: { ...profile[key], ...patch } }) const anyActive = Object.values(profile).some(t => t.state !== 'free') return (
Profil recherché — comportement Greeks, pas un nouveau scénario
{anyActive && ( )}

Le scénario ci-dessus produit déjà naturellement certains Greeks — ces contrôles servent à favoriser, tolérer ou interdire certaines expositions parmi les candidats trouvés, pas à décrire un second scénario.

{GREEK_ROWS.map(({ key, label, hint }) => { const t = profile[key] const isFree = t.state === 'free' return (
{label}
{hint}
setTarget(key, { weight: parseFloat(e.target.value) })} className="col-span-3 accent-blue-500 disabled:opacity-30" /> {isFree ? '—' : `${t.weight.toFixed(0)}%`}
) })}
) } function ResultsTable({ results, onSelect }: { results: StrategyCandidate[]; onSelect: (c: StrategyCandidate) => void }) { if (!results.length) return
Aucun candidat ne satisfait les contraintes — élargissez le seuil de delta ou le plafond de perte.
return (
{results.length} candidats classés
{results.map((r, i) => ( onSelect(r)}> ))}
Structure Jambes Score P&L net Max gain Max perte Δ net Profil
{r.template_name} {r.legs.length} {r.score.toFixed(2)} {fmtMoney(r.net_pnl)} {r.max_gain != null ? fmtMoney(r.max_gain) : '∞'} {r.max_loss != null ? fmtMoney(r.max_loss) : '−∞'} {r.net_delta_now.toFixed(3)} {r.greek_match_score != null ? `${(r.greek_match_score * 100).toFixed(0)}%` : '—'} Charger →
) } // ── Scenario save/load ──────────────────────────────────────────────────────── function ScenarioLibrary({ symbol, scenario, onLoad, }: { symbol: string; scenario: StrategyScenario; onLoad: (s: SavedScenario) => void }) { const { data: saved = [] } = useScenarios(symbol) const saveScenario = useSaveScenario() const deleteScenario = useDeleteScenario() const [label, setLabel] = useState('') return (
setLabel(e.target.value)} placeholder="Nom du scénario…" className="flex-1 bg-dark-700 border border-slate-700/50 rounded px-2 py-1 text-xs text-white" />
{saved.length > 0 && (
{saved.map(s => ( ))}
)}
) } // ── Saved strategies library ────────────────────────────────────────────────── function SavedStrategiesLibrary({ symbol, onLoad }: { symbol: string; onLoad: (legs: StrategyLeg[], templateName: string) => void }) { const { data: saved = [] } = useSavedStrategies(symbol) const deleteStrategy = useDeleteSavedStrategy() if (!saved.length) return null return (
Stratégies sauvegardées ({symbol})
{saved.map(s => (
))}
) } // ── Page ────────────────────────────────────────────────────────────────────── export default function StrategyBuilder() { const [symbol, setSymbol] = useState('') const [debouncedSymbol, setDebouncedSymbol] = useState('') const [horizonDays, setHorizonDays] = useState(8) const [scenario, setScenario] = useState({ symbol: '', horizon_days: 8, spot_shock_pct: 0, iv_level_shift: 0, skew_tilt: 0, term_slope_shift: 0, rate_shock_bps: 0, dte_min: null, dte_max: null, manual_grid: [], contract_size: 100_000, }) // Chain lookup only commits on blur/Enter/datalist-pick, never mid-keystroke — typing // "EUU" while aiming for "EUU:XCME" must never flash a "ticker not found" error. Takes // an optional explicit value so the datalist-pick path (which calls this synchronously // right after setSymbol in the same onChange) doesn't read a stale pre-update closure. const commitSymbol = (v?: string) => setDebouncedSymbol((v ?? symbol).trim()) const [legs, setLegs] = useState([]) const [constraints, setConstraints] = useState({ max_legs: 4, delta_threshold: 0.15, max_loss_cap: null, objective: 'net_pnl', top_n: 20, }) const [greekProfile, setGreekProfile] = useState(DEFAULT_GREEK_PROFILE) const [showAdvancedGreeks, setShowAdvancedGreeks] = useState(false) const [activeTemplate, setActiveTemplate] = useState(null) const { data: watchlistData } = useWatchlistTickers() const { data: saxoCatalog } = useSaxoCatalog(undefined, undefined, { enabled: true, limit: 500 }) const watchlistTickers = Array.from(new Set([ ...((watchlistData?.tickers ?? []) as WatchlistEntry[]).filter(t => t.is_active).map(t => t.ticker), ...(saxoCatalog ?? []).map(c => c.symbol), ])).sort() const { data: chain, isLoading: chainLoading, isError: chainError, error: chainErrorObj, refetch: refetchChain, isFetching } = useOptionChainSlice(debouncedSymbol, horizonDays, 3, true, scenario.dte_min, scenario.dte_max) const { data: ivForTrade } = useIvForTrade(debouncedSymbol) useEffect(() => { setScenario(s => ({ ...s, symbol: debouncedSymbol, horizon_days: horizonDays })) }, [debouncedSymbol, horizonDays]) useEffect(() => { if (chain && chain.expiries.length && legs.length === 0) { const exp = chain.expiries[0] const atm = exp.calls.reduce((best: any, r: any) => Math.abs(r.strike - chain.spot) < Math.abs(best.strike - chain.spot) ? r : best, exp.calls[0]) if (atm) setLegs([emptyLeg(exp.expiry_date, exp.days_to_expiry, atm.strike)]) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [chain]) const priceMutation = usePriceStrategy() useEffect(() => { if (!chain || legs.length === 0) return const t = setTimeout(() => { priceMutation.mutate({ scenario, legs }) }, 400) return () => clearTimeout(t) // eslint-disable-next-line react-hooks/exhaustive-deps }, [JSON.stringify(scenario), JSON.stringify(legs), chain]) const priced = priceMutation.data const isNonDirectional = useMemo(() => { if (!priced) return null return Math.abs(priced.net_delta_now) <= DELTA_NEUTRAL_THRESHOLD }, [priced]) const addLeg = () => { if (!chain || legs.length >= 4) return const exp = chain.expiries[0] setLegs([...legs, emptyLeg(exp.expiry_date, exp.days_to_expiry, chain.spot)]) } const optimizeMutation = useOptimizeStrategy() const saveStrategy = useSaveStrategyRecord() const handleOptimize = () => { setActiveTemplate(null) optimizeMutation.mutate({ scenario, constraints, greek_profile: greekProfile }) } const handleSelectCandidate = (c: StrategyCandidate) => { setActiveTemplate(c.template_name) setLegs(c.legs) } const handleLoadScenario = (s: SavedScenario) => { setSymbol(s.symbol) setHorizonDays(s.horizon_days) setScenario(prev => ({ symbol: s.symbol, horizon_days: s.horizon_days, spot_shock_pct: s.spot_shock_pct, iv_level_shift: s.iv_level_shift, skew_tilt: s.skew_tilt, term_slope_shift: s.term_slope_shift, rate_shock_bps: s.rate_shock_bps, dte_min: s.dte_min, dte_max: s.dte_max, manual_grid: s.manual_grid, contract_size: prev.contract_size, })) } const handleSaveStrategy = () => { if (!priced) return saveStrategy.mutate({ symbol, template_name: activeTemplate || 'Manuel', objective: constraints.objective, legs, 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, }) } return (

Strategy Builder

Scénario spot/IV/surface à J+N · builder manuel 1-4 jambes · payoff & greeks avec spread broker réel

{chainError && (
{(chainErrorObj as any)?.response?.data?.detail ?? `Chaîne d'options indisponible pour ${debouncedSymbol}. Essayez un autre symbole.`}
)} { setActiveTemplate(templateName); setLegs(legs) }} /> {chainLoading &&
Chargement de la chaîne réelle ({debouncedSymbol})…
} {chain && } {chain && } {chain && (
Jambes (1-4) — Spot {fmtPrice(chain.spot)} {fmtAsOf(chain.as_of) && ( · données du {fmtAsOf(chain.as_of)} )} {ivForTrade?.iv_current_pct != null && ( · IV ATM {ivForTrade.iv_current_pct.toFixed(1)}% {ivForTrade.iv_change_1d_pct != null && Math.abs(ivForTrade.iv_change_1d_pct) >= 0.1 && ( 0 ? 'text-orange-400' : 'text-blue-400'}> {' '}({ivForTrade.iv_change_1d_pct >= 0 ? '+' : ''}{ivForTrade.iv_change_1d_pct.toFixed(1)}pt vs veille) )} )}
{legs.map((leg, i) => ( setLegs(legs.map((x, j) => j === i ? l : x))} onRemove={() => setLegs(legs.filter((_, j) => j !== i))} /> ))} {legs.length === 0 &&
Aucune jambe — ajoutez-en une pour commencer.
}
)} {chain && ( <> setGreekProfile({ delta: { state: states.delta, tolerance: 'normale', weight: 60 }, gamma: { state: states.gamma, tolerance: 'normale', weight: 60 }, theta: { state: states.theta, tolerance: 'normale', weight: 60 }, vega: { state: states.vega, tolerance: 'normale', weight: 60 }, rho: { state: states.rho, tolerance: 'normale', weight: 60 }, })} /> )} {optimizeMutation.isError && (
{(optimizeMutation.error as any)?.response?.data?.detail ?? "Erreur lors de l'optimisation."}
)} {optimizeMutation.data && optimizeMutation.data.warnings.length > 0 && (
{optimizeMutation.data.warnings.map((w, i) => (
{w}
))}
)} {optimizeMutation.data && ( )} {priceMutation.isPending &&
Calcul en cours…
} {priceMutation.isError && (
Erreur de pricing — vérifiez les jambes sélectionnées.
)} {priced && ( <>
Coût d'entrée (spread inclus)
= 0 ? 'text-white' : 'text-emerald-400')}>{fmtMoney(priced.entry_cost)}
P&L net scénario J+{horizonDays}
{fmtMoney(priced.net_pnl)}
Coût spread broker
{fmtMoney(priced.broker_spread_cost)}
Max gain / Max perte (à échéance)
{priced.max_gain != null ? fmtMoney(priced.max_gain) : '∞'} / {priced.max_loss != null ? fmtMoney(priced.max_loss) : '−∞'}
{priced.bounded_risk ? 'Risque borné' : 'Risque non borné'} Δ net {priced.net_delta_now.toFixed(3)} — {isNonDirectional ? 'non-directionnel' : 'directionnel'}
Diagramme payoff

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}.

{priced.vanna_simulation && (
Simulation Vanna — pas juste un signe

Spot {priced.vanna_simulation.spot_shock_pct}%{' '} et IV +{priced.vanna_simulation.iv_shock_pts}pts → {' '}Delta net passe de{' '} {priced.vanna_simulation.delta_before.toFixed(4)} {' '}à{' '} {priced.vanna_simulation.delta_after.toFixed(4)} = 0 ? 'text-emerald-400' : 'text-red-400')}> ({priced.vanna_simulation.delta_change >= 0 ? '+' : ''}{priced.vanna_simulation.delta_change.toFixed(4)})

Repricing Black-Scholes réel sous ce choc conjoint — pas une approximation linéaire, valable même pour un choc large.

)}
{showAdvancedGreeks && (
)}
)}
) }