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,
useScenarios, useSaveScenario, useDeleteScenario,
useSavedStrategies, useSaveStrategyRecord, useDeleteSavedStrategy,
useWatchlistTickers, useSaxoCatalog, useIvForTrade,
type StrategyLeg, type StrategyScenario, type PriceCombo, type StrategyCandidate,
type OptimizeConstraints, type SavedScenario,
} 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 }: { label: string; now: number; scenario: number }) {
return (
{label}
{now.toFixed(4)}
→
= now ? 'text-emerald-400' : 'text-red-400')}>{scenario.toFixed(4)}
)
}
// ── 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_shift',
label: string, min: number, max: number, step: number, fmt: (v: number) => string,
) => (
)
return (
{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_shift', 'Choc terme (/30j)', -0.1, 0.1, 0.005, (v) => `${v >= 0 ? '+' : ''}${(v * 100).toFixed(1)}pts`)}
)
}
// ── 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_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 && (
setScenario({ ...scenario, manual_grid: [] })} className="text-xs text-slate-500 hover:text-slate-300">
Réinitialiser overrides
)}
)
}
// ── 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)}%
Expiry
{STRIKE_PCTS.map(p => {p}% )}
{chain.expiries.map((exp: any) => (
{exp.expiry_date} ({exp.days_to_expiry}j)
{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 (
{(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 (
{
const exp = chain.expiries.find((x: any) => x.expiry_date === e.target.value)
onChange({ ...leg, expiry_date: e.target.value, days_to_expiry: exp?.days_to_expiry ?? leg.days_to_expiry })
}}
>
{chain?.expiries.map((e: any) => (
{e.expiry_date} ({e.days_to_expiry}j)
))}
onChange({ ...leg, option_type: e.target.value as 'call' | 'put' })}
>
Call
Put
onChange({ ...leg, strike: parseFloat(e.target.value) })}
>
{rows.map((r: any) => (
{fmtPrice(r.strike)} (bid {fmtPrice(r.bid)} / ask {fmtPrice(r.ask)})
))}
onChange({ ...leg, position: e.target.value as 'long' | 'short' })}
>
Achat
Vente
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
{isRunning ? 'Recherche parmi des milliers de combinaisons…' : 'Trouver la stratégie optimale'}
)
}
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
Structure
Jambes
Score
P&L net
Max gain
Max perte
Δ net
{results.map((r, i) => (
onSelect(r)}>
{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)}
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"
/>
{ saveScenario.mutate({ ...scenario, label }); setLabel('') }}
disabled={!label || saveScenario.isPending}
className="flex items-center gap-1 text-xs bg-dark-700 hover:bg-dark-600 border border-slate-700/50 text-slate-300 px-2 py-1 rounded disabled:opacity-40"
>
Sauvegarder
{saved.length > 0 && (
{saved.map(s => (
onLoad(s)} className="flex items-center gap-1">
{s.label || s.id}
deleteScenario.mutate(s.id)}>
))}
)}
)
}
// ── 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 => (
onLoad(s.legs, s.template_name)} className="flex items-center gap-2 text-slate-300 hover:text-white">
{s.template_name}
{fmtMoney(s.net_pnl_scenario)}
{s.legs.length} jambes
deleteStrategy.mutate(s.id)} className="text-slate-500 hover:text-red-400">
))}
)
}
// ── 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_shift: 0, 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 [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)
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 })
}
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_shift: s.term_shift,
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
refetchChain()}
disabled={isFetching}
className="flex items-center gap-1.5 text-xs border border-slate-600 text-slate-400 hover:text-slate-200 hover:border-slate-500 px-3 py-1.5 rounded transition-all disabled:opacity-50"
>
{isFetching ? 'Chargement...' : 'Rafraîchir la chaîne'}
{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 && (
)}
{optimizeMutation.isError && (
{(optimizeMutation.error as any)?.response?.data?.detail ?? "Erreur lors de l'optimisation."}
)}
{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'}
{saveStrategy.isSuccess ? 'Sauvegardé ✓' : 'Sauvegarder cette stratégie'}
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}.
>
)}
)
}