Files
OpenFin/frontend/src/pages/StrategyBuilder.tsx
2026-07-21 10:18:15 +02:00

857 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<ResponsiveContainer width="100%" height={280}>
<LineChart data={data} margin={{ top: 8, right: 16, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#1e2d4d" />
<XAxis dataKey="underlying" type="number" domain={['dataMin', 'dataMax']}
tick={{ fill: '#475569', fontSize: 10 }} tickLine={false}
tickFormatter={(v) => v.toFixed(decimals)} />
<YAxis tick={{ fill: '#475569', fontSize: 10 }} tickLine={false} axisLine={false}
tickFormatter={(v) => `${v}`} />
<Tooltip
contentStyle={{ background: '#0f1623', border: '1px solid #1e2d4d', fontSize: 11 }}
labelFormatter={(v) => `Sous-jacent: ${Number(v).toFixed(decimals)}`}
formatter={(v: number, name: string) => [fmtMoney(v), name]}
/>
<Legend wrapperStyle={{ fontSize: 11 }} />
<ReferenceLine y={0} stroke="#475569" strokeDasharray="4 4" />
<ReferenceLine x={spot} stroke="#3b82f6" strokeDasharray="2 2" label={{ value: 'Spot', fill: '#3b82f6', fontSize: 9, position: 'top' }} />
<ReferenceLine x={scenarioSpot} stroke="#f59e0b" strokeDasharray="2 2" label={{ value: 'Scénario J+8', fill: '#f59e0b', fontSize: 9, position: 'insideTopRight' }} />
<Line type="monotone" dataKey="expiry" name="À échéance jambe proche" stroke="#3b82f6" strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="scenario" name="À J+8 (scénario)" stroke="#f59e0b" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
)
}
function GreeksTile({ label, now, scenario }: { label: string; now: number; scenario: number }) {
return (
<div className="card-sm">
<div className="stat-label">{label}</div>
<div className="flex items-baseline gap-2 mt-1">
<span className="text-lg font-bold text-white">{now.toFixed(4)}</span>
<span className="text-xs text-slate-500"></span>
<span className={clsx('text-sm font-semibold', scenario >= now ? 'text-emerald-400' : 'text-red-400')}>{scenario.toFixed(4)}</span>
</div>
</div>
)
}
// ── 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,
) => (
<div>
<div className="flex items-center justify-between text-xs text-slate-400 mb-1">
<span>{label}</span>
<span className="text-white font-semibold">{fmt(scenario[key])}</span>
</div>
<input
type="range" min={min} max={max} step={step} value={scenario[key]}
onChange={(e) => setScenario({ ...scenario, [key]: parseFloat(e.target.value) })}
className="w-full accent-blue-500"
/>
</div>
)
return (
<div className="card space-y-4">
<div className="flex items-center gap-3">
<div className="flex-1">
<label className="stat-label block mb-1">Symbole</label>
<input
value={symbol}
onChange={(e) => {
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"
/>
<datalist id="strategy-builder-watchlist">
{watchlistTickers.map(t => <option key={t} value={t} />)}
</datalist>
</div>
<div className="w-28">
<label className="stat-label block mb-1">Horizon (j)</label>
<input
type="number" min={1} max={90} value={horizonDays}
onChange={(e) => 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"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
{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`)}
</div>
</div>
)
}
// ── 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 (
<div className="card">
<div className="flex items-center justify-between mb-2">
<div className="stat-label">Grille IV scénario (éditable clic sur une cellule)</div>
{overrides.length > 0 && (
<button onClick={() => setScenario({ ...scenario, manual_grid: [] })} className="text-xs text-slate-500 hover:text-slate-300">
Réinitialiser overrides
</button>
)}
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-slate-500">
<th className="text-left py-1 pr-3">Expiry / Strike%</th>
{STRIKE_PCTS.map(p => <th key={p} className="px-2 py-1 text-right">{p}%</th>)}
</tr>
</thead>
<tbody>
{chain.expiries.map((exp: any) => (
<tr key={exp.expiry_date} className="border-t border-slate-700/30">
<td className="py-1 pr-3 text-slate-400 whitespace-nowrap">{exp.expiry_date} ({exp.days_to_expiry}j)</td>
{STRIKE_PCTS.map(pct => {
const v = cellValue(exp.days_to_expiry, pct)
const overridden = isOverridden(exp.days_to_expiry, pct)
return (
<td key={pct} className="px-1 py-1">
<input
type="number" step={0.005}
value={v != null ? Math.round(v * 1000) / 1000 : ''}
onChange={(e) => {
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',
)}
/>
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
// ── 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 <div className="card-sm text-xs text-slate-500">Pas assez de données d'IV pour construire la surface.</div>
const min = Math.min(...ivValues)
const max = Math.max(...ivValues)
return (
<div className="card">
<div className="flex items-center justify-between mb-2">
<div className="stat-label">Surface de volatilité (strike × expiry)</div>
<div className="flex items-center gap-2 text-[10px] text-slate-500">
<span>{(min * 100).toFixed(1)}%</span>
<div className="flex h-2 w-24 rounded overflow-hidden">
{[...IV_RAMP].reverse().map(c => <div key={c} className="flex-1" style={{ background: c }} />)}
</div>
<span>{(max * 100).toFixed(1)}%</span>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-[10px] border-separate" style={{ borderSpacing: 2 }}>
<thead>
<tr>
<th className="text-left text-slate-500 pr-2">Expiry</th>
{STRIKE_PCTS.map(p => <th key={p} className="text-slate-500 font-normal px-1">{p}%</th>)}
</tr>
</thead>
<tbody>
{chain.expiries.map((exp: any) => (
<tr key={exp.expiry_date}>
<td className="text-slate-400 pr-2 whitespace-nowrap">{exp.expiry_date} ({exp.days_to_expiry}j)</td>
{STRIKE_PCTS.map(pct => {
const cell = cells.find(c => c.expiry === exp.expiry_date && c.pct === pct)
if (!cell || cell.iv == null) {
return <td key={pct} className="text-center text-slate-700 bg-dark-700/40 rounded">—</td>
}
const { bg, fg } = ivCellColor(cell.iv, min, max)
return (
<td key={pct} className="text-center rounded font-semibold" style={{ background: bg, color: fg }}
title={`${exp.expiry_date} · ${pct}% (${fmtPrice(spot * pct / 100)}) · IV ${(cell.iv * 100).toFixed(1)}%`}>
{(cell.iv * 100).toFixed(1)}
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
// ── 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 (
<div className="grid grid-cols-12 gap-2 items-center text-xs">
<select
className="col-span-3 bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200"
value={leg.expiry_date}
onChange={(e) => {
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) => (
<option key={e.expiry_date} value={e.expiry_date}>{e.expiry_date} ({e.days_to_expiry}j)</option>
))}
</select>
<select
className="col-span-2 bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200"
value={leg.option_type}
onChange={(e) => onChange({ ...leg, option_type: e.target.value as 'call' | 'put' })}
>
<option value="call">Call</option>
<option value="put">Put</option>
</select>
<select
className="col-span-3 bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200"
value={leg.strike}
onChange={(e) => onChange({ ...leg, strike: parseFloat(e.target.value) })}
>
{rows.map((r: any) => (
<option key={r.strike} value={r.strike}>{fmtPrice(r.strike)} (bid {fmtPrice(r.bid)} / ask {fmtPrice(r.ask)})</option>
))}
</select>
<select
className="col-span-2 bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200"
value={leg.position}
onChange={(e) => onChange({ ...leg, position: e.target.value as 'long' | 'short' })}
>
<option value="long">Achat</option>
<option value="short">Vente</option>
</select>
<input
type="number" min={1} value={leg.quantity}
onChange={(e) => 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"
/>
<button onClick={onRemove} className="col-span-1 text-slate-500 hover:text-red-400 flex justify-center">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
)
}
// ── 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 (
<div className="card space-y-3">
<div className="stat-label">Optimiseur — contraintes &amp; objectif</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
<div>
<label className="text-slate-400 block mb-1">Max jambes</label>
<select
value={constraints.max_legs}
onChange={(e) => setConstraints({ ...constraints, max_legs: parseInt(e.target.value) })}
className="w-full bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200"
>
{[1, 2, 3, 4].map(n => <option key={n} value={n}>{n}</option>)}
</select>
</div>
<div>
<label className="text-slate-400 block mb-1">Seuil neutralité Δ (optionnel)</label>
<input
type="number" step={0.01} min={0} placeholder="illimité"
value={constraints.delta_threshold ?? ''}
onChange={(e) => 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"
/>
</div>
<div>
<label className="text-slate-400 block mb-1">Plafond perte max (optionnel)</label>
<input
type="number" placeholder="illimité"
value={constraints.max_loss_cap ?? ''}
onChange={(e) => 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"
/>
</div>
<div>
<label className="text-slate-400 block mb-1">Objectif</label>
<select
value={constraints.objective}
onChange={(e) => setConstraints({ ...constraints, objective: e.target.value as OptimizeConstraints['objective'] })}
className="w-full bg-dark-700 border border-slate-700/50 rounded px-2 py-1.5 text-slate-200"
>
{OBJECTIVES.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
</div>
<button
onClick={onRun}
disabled={isRunning}
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"
>
<Search className={clsx('w-3.5 h-3.5', isRunning && 'animate-pulse')} />
{isRunning ? 'Recherche parmi des milliers de combinaisons' : 'Trouver la stratégie optimale'}
</button>
</div>
)
}
function ResultsTable({ results, onSelect }: { results: StrategyCandidate[]; onSelect: (c: StrategyCandidate) => void }) {
if (!results.length) return <div className="card-sm text-xs text-slate-500">Aucun candidat ne satisfait les contraintes — élargissez le seuil de delta ou le plafond de perte.</div>
return (
<div className="card overflow-x-auto">
<div className="stat-label mb-2">{results.length} candidats classés</div>
<table className="w-full text-xs">
<thead>
<tr className="text-slate-500 text-left">
<th className="py-1 pr-3">Structure</th>
<th className="py-1 pr-3">Jambes</th>
<th className="py-1 pr-3 text-right">Score</th>
<th className="py-1 pr-3 text-right">P&amp;L net</th>
<th className="py-1 pr-3 text-right" title="À l'échéance de la jambe la plus proche, sous la même vue de vol que le scénario. Approximatif (balayage rapide pour classer des centaines de candidats) se précise après «Charger».">Max gain</th>
<th className="py-1 pr-3 text-right" title="À l'échéance de la jambe la plus proche, sous la même vue de vol que le scénario. Approximatif (balayage rapide pour classer des centaines de candidats) — se précise après «Charger».">Max perte</th>
<th className="py-1 pr-3 text-right">Δ net</th>
<th className="py-1 pr-1"></th>
</tr>
</thead>
<tbody>
{results.map((r, i) => (
<tr key={i} className="border-t border-slate-700/30 hover:bg-dark-700/40 cursor-pointer" onClick={() => onSelect(r)}>
<td className="py-1.5 pr-3 text-slate-200">{r.template_name}</td>
<td className="py-1.5 pr-3 text-slate-400">{r.legs.length}</td>
<td className="py-1.5 pr-3 text-right font-semibold text-white">{r.score.toFixed(2)}</td>
<td className={clsx('py-1.5 pr-3 text-right font-semibold', pnlColor(r.net_pnl))}>{fmtMoney(r.net_pnl)}</td>
<td className="py-1.5 pr-3 text-right text-emerald-400">{r.max_gain != null ? fmtMoney(r.max_gain) : ''}</td>
<td className="py-1.5 pr-3 text-right text-red-400">{r.max_loss != null ? fmtMoney(r.max_loss) : ''}</td>
<td className="py-1.5 pr-3 text-right text-slate-400">{r.net_delta_now.toFixed(3)}</td>
<td className="py-1.5 pr-1 text-blue-400 text-right">Charger →</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
// ── 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 (
<div className="card-sm space-y-2">
<div className="flex items-center gap-2">
<input
value={label} onChange={(e) => 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"
/>
<button
onClick={() => { 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"
>
<Save className="w-3 h-3" /> Sauvegarder
</button>
</div>
{saved.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{saved.map(s => (
<span key={s.id} className="badge-blue flex items-center gap-1">
<button onClick={() => onLoad(s)} className="flex items-center gap-1">
<FolderOpen className="w-3 h-3" /> {s.label || s.id}
</button>
<button onClick={() => deleteScenario.mutate(s.id)}><X className="w-3 h-3" /></button>
</span>
))}
</div>
)}
</div>
)
}
// ── 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 (
<div className="card-sm space-y-2">
<div className="stat-label">Stratégies sauvegardées ({symbol})</div>
<div className="space-y-1">
{saved.map(s => (
<div key={s.id} className="flex items-center justify-between text-xs bg-dark-700/50 rounded px-2 py-1.5">
<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" />
<span>{s.template_name}</span>
<span className={pnlColor(s.net_pnl_scenario)}>{fmtMoney(s.net_pnl_scenario)}</span>
<span className="text-slate-600">{s.legs.length} jambes</span>
</button>
<button onClick={() => deleteStrategy.mutate(s.id)} className="text-slate-500 hover:text-red-400">
<Trash2 className="w-3 h-3" />
</button>
</div>
))}
</div>
</div>
)
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default function StrategyBuilder() {
const [symbol, setSymbol] = useState('')
const [debouncedSymbol, setDebouncedSymbol] = useState('')
const [horizonDays, setHorizonDays] = useState(8)
const [scenario, setScenario] = useState<StrategyScenario>({
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<StrategyLeg[]>([])
const [constraints, setConstraints] = useState<OptimizeConstraints>({
max_legs: 4, delta_threshold: 0.15, max_loss_cap: null, objective: 'net_pnl', top_n: 20,
})
const [activeTemplate, setActiveTemplate] = useState<string | null>(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 (
<div className="p-6 space-y-5">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-white flex items-center gap-2">
<Layers className="w-5 h-5 text-blue-400" /> Strategy Builder
</h1>
<p className="text-xs text-slate-500 mt-0.5">
Scénario spot/IV/surface à J+N · builder manuel 1-4 jambes · payoff &amp; greeks avec spread broker réel
</p>
</div>
<button
onClick={() => 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"
>
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} />
{isFetching ? 'Chargement...' : 'Rafraîchir la chaîne'}
</button>
</div>
{chainError && (
<div className="px-4 py-3 rounded border border-red-700/40 bg-red-900/10 text-xs text-red-300 flex items-center gap-2">
<AlertTriangle className="w-4 h-4" />
{(chainErrorObj as any)?.response?.data?.detail ?? `Chaîne d'options indisponible pour ${debouncedSymbol}. Essayez un autre symbole.`}
</div>
)}
<ScenarioPanel symbol={symbol} setSymbol={setSymbol} onCommitSymbol={commitSymbol} horizonDays={horizonDays} setHorizonDays={setHorizonDays}
scenario={scenario} setScenario={setScenario} watchlistTickers={watchlistTickers} />
<ScenarioLibrary symbol={debouncedSymbol} scenario={scenario} onLoad={handleLoadScenario} />
<SavedStrategiesLibrary symbol={debouncedSymbol} onLoad={(legs, templateName) => { setActiveTemplate(templateName); setLegs(legs) }} />
{chainLoading && <div className="card-sm text-xs text-slate-500">Chargement de la chaîne réelle ({debouncedSymbol})</div>}
{chain && <ScenarioGrid chain={chain} spot={chain.spot} scenario={scenario} setScenario={setScenario} />}
{chain && <VolSurfaceHeatmap chain={chain} spot={chain.spot} />}
{chain && (
<div className="card space-y-3">
<div className="flex items-center justify-between">
<div className="stat-label flex items-center gap-2 flex-wrap">
<span>Jambes (1-4) Spot {fmtPrice(chain.spot)}</span>
{fmtAsOf(chain.as_of) && (
<span className="text-slate-600 font-normal text-[10px]" title="Date de capture de la chaîne d'options (dernier snapshot Saxo)">
· données du {fmtAsOf(chain.as_of)}
</span>
)}
{ivForTrade?.iv_current_pct != null && (
<span className="text-slate-500 font-normal text-[10px]">
· IV ATM {ivForTrade.iv_current_pct.toFixed(1)}%
{ivForTrade.iv_change_1d_pct != null && Math.abs(ivForTrade.iv_change_1d_pct) >= 0.1 && (
<span className={ivForTrade.iv_change_1d_pct > 0 ? 'text-orange-400' : 'text-blue-400'}>
{' '}({ivForTrade.iv_change_1d_pct >= 0 ? '+' : ''}{ivForTrade.iv_change_1d_pct.toFixed(1)}pt vs veille)
</span>
)}
</span>
)}
</div>
<div className="flex items-center gap-3">
<label className="flex items-center gap-1.5 text-xs text-slate-400" title="Notionnel par contrat (ex. 100 000 = 1 lot standard EURUSD). S'applique à chaque jambe, multiplié par sa quantité.">
Nominal (USD)
<input
type="number" step={1000} min={1}
value={scenario.contract_size ?? 100_000}
onChange={(e) => setScenario(s => ({ ...s, contract_size: parseFloat(e.target.value) || 100_000 }))}
className="w-28 bg-dark-700 border border-slate-700/50 rounded px-2 py-1 text-slate-200"
/>
</label>
<button
onClick={addLeg}
disabled={legs.length >= 4}
className="flex items-center gap-1 text-xs bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white px-2.5 py-1 rounded"
>
<Plus className="w-3.5 h-3.5" /> Ajouter une jambe
</button>
</div>
</div>
<div className="space-y-2">
{legs.map((leg, i) => (
<LegRow
key={i} leg={leg} chain={chain}
onChange={(l) => setLegs(legs.map((x, j) => j === i ? l : x))}
onRemove={() => setLegs(legs.filter((_, j) => j !== i))}
/>
))}
{legs.length === 0 && <div className="text-xs text-slate-500">Aucune jambe ajoutez-en une pour commencer.</div>}
</div>
</div>
)}
{chain && (
<OptimizerPanel constraints={constraints} setConstraints={setConstraints} onRun={handleOptimize} isRunning={optimizeMutation.isPending} />
)}
{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 && (
<ResultsTable results={optimizeMutation.data} onSelect={handleSelectCandidate} />
)}
{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">
Erreur de pricing vérifiez les jambes sélectionnées.
</div>
)}
{priced && (
<>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="card-sm">
<div className="stat-label">Coût d'entrée (spread inclus)</div>
<div className={clsx('text-lg font-bold', priced.entry_cost >= 0 ? 'text-white' : 'text-emerald-400')}>{fmtMoney(priced.entry_cost)}</div>
</div>
<div className="card-sm">
<div className="stat-label">P&amp;L net scénario J+{horizonDays}</div>
<div className={clsx('text-lg font-bold', pnlColor(priced.net_pnl))}>{fmtMoney(priced.net_pnl)}</div>
</div>
<div className="card-sm">
<div className="stat-label">Coût spread broker</div>
<div className="text-lg font-bold text-amber-400">{fmtMoney(priced.broker_spread_cost)}</div>
</div>
<div className="card-sm" title="Borne théorique à l'échéance de la jambe la plus proche, sous la même vue de volatilité que votre scénario. Pour les structures mono-échéance (condor, butterfly...), c'est un calcul purement intrinsèque, indépendant de la vol. Le P&L net scénario est une valorisation avant échéance (mark-to-market) : sur une structure vendeuse de vega, un choc de vol important dans le scénario peut transitoirement le faire sortir de cette fourchette — risque vega réel, pas un bug.">
<div className="stat-label">Max gain / Max perte (à échéance)</div>
<div className="text-sm font-semibold">
<span className="text-emerald-400">{priced.max_gain != null ? fmtMoney(priced.max_gain) : ''}</span>
<span className="text-slate-500"> / </span>
<span className="text-red-400">{priced.max_loss != null ? fmtMoney(priced.max_loss) : ''}</span>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<span className={clsx('badge', priced.bounded_risk ? 'badge-green' : 'badge-red')}>
{priced.bounded_risk ? 'Risque borné' : 'Risque non borné'}
</span>
<span className={clsx('badge', isNonDirectional ? 'badge-blue' : 'badge-orange')}>
Δ net {priced.net_delta_now.toFixed(3)} — {isNonDirectional ? 'non-directionnel' : 'directionnel'}
</span>
<button
onClick={handleSaveStrategy}
disabled={saveStrategy.isPending}
className="ml-auto flex items-center gap-1 text-xs bg-dark-700 hover:bg-dark-600 border border-slate-700/50 text-slate-300 px-2.5 py-1 rounded disabled:opacity-40"
>
<Save className="w-3 h-3" /> {saveStrategy.isSuccess ? 'Sauvegardé ' : 'Sauvegarder cette stratégie'}
</button>
</div>
<div className="card">
<div className="stat-label mb-2">Diagramme payoff</div>
<PayoffChart priced={priced} spot={priced.spot} scenarioSpot={priced.scenario_spot} />
<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}.
</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<GreeksTile label="Delta net" now={priced.greeks_now.delta} scenario={priced.greeks_scenario.delta} />
<GreeksTile label="Gamma net" now={priced.greeks_now.gamma} scenario={priced.greeks_scenario.gamma} />
<GreeksTile label="Theta net" now={priced.greeks_now.theta} scenario={priced.greeks_scenario.theta} />
<GreeksTile label="Vega net" now={priced.greeks_now.vega} scenario={priced.greeks_scenario.vega} />
</div>
</>
)}
</div>
)
}