feat: strategy builder
This commit is contained in:
697
frontend/src/pages/StrategyBuilder.tsx
Normal file
697
frontend/src/pages/StrategyBuilder.tsx
Normal file
@@ -0,0 +1,697 @@
|
||||
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,
|
||||
type StrategyLeg, type StrategyScenario, type PriceCombo, type StrategyCandidate,
|
||||
type OptimizeConstraints, type SavedScenario,
|
||||
} from '../hooks/useApi'
|
||||
|
||||
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,
|
||||
}))
|
||||
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" tick={{ fill: '#475569', fontSize: 10 }} tickLine={false}
|
||||
tickFormatter={(v) => v.toFixed(0)} />
|
||||
<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(2)}`}
|
||||
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" 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, horizonDays, setHorizonDays, scenario, setScenario,
|
||||
}: {
|
||||
symbol: string; setSymbol: (v: string) => void
|
||||
horizonDays: number; setHorizonDays: (v: number) => void
|
||||
scenario: StrategyScenario; setScenario: (v: StrategyScenario) => void
|
||||
}) {
|
||||
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) => setSymbol(e.target.value.toUpperCase())}
|
||||
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…"
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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}>{r.strike} (bid {r.bid} / ask {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 & 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é Δ</label>
|
||||
<input
|
||||
type="number" step={0.01} min={0} value={constraints.delta_threshold}
|
||||
onChange={(e) => setConstraints({ ...constraints, delta_threshold: parseFloat(e.target.value) || 0 })}
|
||||
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&L net</th>
|
||||
<th className="py-1 pr-3 text-right">Max gain</th>
|
||||
<th className="py-1 pr-3 text-right">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('SPY')
|
||||
const [horizonDays, setHorizonDays] = useState(8)
|
||||
const [scenario, setScenario] = useState<StrategyScenario>({
|
||||
symbol: 'SPY', horizon_days: 8, spot_shock_pct: 0, iv_level_shift: 0, skew_tilt: 0, term_shift: 0, manual_grid: [],
|
||||
})
|
||||
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: chain, isLoading: chainLoading, isError: chainError, refetch: refetchChain, isFetching } =
|
||||
useOptionChainSlice(symbol, horizonDays, 3)
|
||||
|
||||
useEffect(() => {
|
||||
setScenario(s => ({ ...s, symbol, horizon_days: horizonDays }))
|
||||
}, [symbol, 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({
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
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 & 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" /> Chaîne d'options indisponible pour {symbol}. Essayez un autre symbole (ETF/action optionable).
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ScenarioPanel symbol={symbol} setSymbol={setSymbol} horizonDays={horizonDays} setHorizonDays={setHorizonDays}
|
||||
scenario={scenario} setScenario={setScenario} />
|
||||
|
||||
<ScenarioLibrary symbol={symbol} scenario={scenario} onLoad={handleLoadScenario} />
|
||||
<SavedStrategiesLibrary symbol={symbol} onLoad={(legs, templateName) => { setActiveTemplate(templateName); setLegs(legs) }} />
|
||||
|
||||
{chainLoading && <div className="card-sm text-xs text-slate-500">Chargement de la chaîne réelle ({symbol})…</div>}
|
||||
|
||||
{chain && <ScenarioGrid chain={chain} spot={chain.spot} scenario={scenario} setScenario={setScenario} />}
|
||||
|
||||
{chain && (
|
||||
<div className="card space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="stat-label">Jambes (1-4) — Spot {chain.spot}</div>
|
||||
<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 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">
|
||||
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&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">
|
||||
<div className="stat-label">Max gain / Max perte</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} />
|
||||
</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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user