feat: strategy builder

This commit is contained in:
OpenSquared
2026-07-31 15:00:41 +02:00
parent a94f783d33
commit 3fb61f1690
3 changed files with 79 additions and 204 deletions

View File

@@ -1782,9 +1782,12 @@ export type VannaSimulation = {
spot_shock_pct: number; iv_shock_pts: number
delta_before: number; delta_after: number; delta_change: number
}
export type PayoffPoint = { underlying: number; pnl: number }
export type TimeSlice = { days_from_now: number; label: string; points: PayoffPoint[] }
export type PayoffHeatmap = { prices: number[]; rows: { days_from_now: number; pnl: number[] }[]; breakeven_prices: number[] }
export type PayoffHeatmapMetric = 'pnl' | 'delta' | 'gamma' | 'theta' | 'vega' | 'rho'
export type PayoffHeatmap = {
prices: number[]
rows: ({ days_from_now: number } & Record<PayoffHeatmapMetric, number[]>)[]
breakeven_prices: number[]
}
export type PriceCombo = {
entry_cost: number
@@ -1801,9 +1804,6 @@ export type PriceCombo = {
net_delta_now: number
net_delta_scenario: number
vanna_simulation: VannaSimulation | null
at_expiry: PayoffPoint[]
at_scenario: PayoffPoint[]
time_slices: TimeSlice[]
heatmap: PayoffHeatmap
spot: number
scenario_spot: number

View File

@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import {
LineChart, Line, AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ReferenceLine, ResponsiveContainer,
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ReferenceLine, ResponsiveContainer,
} from 'recharts'
import { Layers, Plus, Trash2, RefreshCw, AlertTriangle, Search, Save, FolderOpen, X, History, ChevronLeft, ChevronRight } from 'lucide-react'
import clsx from 'clsx'
@@ -9,7 +9,7 @@ import {
useScenarios, useSaveScenario, useDeleteScenario,
useSavedStrategies, useSaveStrategyRecord, useDeleteSavedStrategy,
useSaxoSymbols, useIvForTrade,
type StrategyLeg, type StrategyScenario, type PriceCombo, type TimeSlice, type PayoffHeatmap, type StrategyCandidate,
type StrategyLeg, type StrategyScenario, type PayoffHeatmap, type PayoffHeatmapMetric, type StrategyCandidate,
type OptimizeConstraints, type SavedScenario,
type GreekProfile, type GreekTarget, type GreekState, type GreekTolerance, DEFAULT_GREEK_PROFILE,
} from '../hooks/useApi'
@@ -49,98 +49,37 @@ function estimateBaseIv(chain: any, daysToExpiry: number, strikePct: number, spo
return nearest.iv
}
// ── Payoff chart ──────────────────────────────────────────────────────────────
function PayoffChart({ priced, spot, scenarioSpot, horizonDays }: { priced: PriceCombo; spot: number; scenarioSpot: number; horizonDays: 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+${horizonDays}`, 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+${horizonDays} (scénario)`} stroke="#f59e0b" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
)
}
// "Paliers T+N" view — same payoff curve at several elapsed-day checkpoints between today
// and the nearest leg's expiry (services.strategy_engine.time_decay_slices), so the shape
// morphing from today's time-value-laden curve into the kinked expiry payoff is visible
// directly, instead of only the two PayoffChart endpoints.
const TIME_SLICE_COLORS = ['#a78bfa', '#60a5fa', '#38bdf8', '#f59e0b', '#fb923c', '#f87171']
function TimeDecayChart({ timeSlices, spot, scenarioSpot }: { timeSlices: TimeSlice[]; spot: number; scenarioSpot: number }) {
const decimals = spot < 5 ? 4 : spot < 50 ? 2 : 0
const data = (timeSlices[0]?.points ?? []).map((pt, i) => {
const row: Record<string, number> = { underlying: pt.underlying }
timeSlices.forEach((s, si) => { row[`slice_${si}`] = s.points[i]?.pnl })
return row
})
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="#94a3b8" strokeDasharray="2 2" label={{ value: 'Scénario', fill: '#94a3b8', fontSize: 9, position: 'insideTopRight' }} />
{timeSlices.map((s, si) => (
<Line key={si} type="monotone" dataKey={`slice_${si}`} name={s.label}
stroke={TIME_SLICE_COLORS[si % TIME_SLICE_COLORS.length]} strokeWidth={2} dot={false} />
))}
</LineChart>
</ResponsiveContainer>
)
}
// "Heatmap" view — same payoff grid (services.strategy_engine.payoff_heatmap) as a
// price x days-to-expiry table instead of curves. Cell shade encodes sign + magnitude
// relative to the grid's own max |P&L|, scaled independently each time (not a fixed
// P&L->color scale) since strategies span wildly different notional sizes. The backend
// already centers/scales the price columns on spot and the legs' own strikes and pins in
// ── Payoff heatmap ──────────────────────────────────────────────────────────────
// Sole payoff view — price x days-to-expiry grid (services.strategy_engine.payoff_heatmap).
// A metric selector switches which grid is shown (P&L or any first-order Greek), so the
// same heatmap explains not just where the position wins/loses but how Delta/Gamma/
// Theta/Vega/Rho actually evolve across price and time, not just their two endpoint
// values (now vs scenario) the tiles below already show. Cell shade encodes sign +
// magnitude relative to the grid's own max |value| for whichever metric is selected
// (not a fixed scale) since both P&L and each Greek span wildly different ranges. The
// backend centers/scales the price columns on spot and the legs' own strikes and pins in
// the exact breakeven(s) — this view shows a 9-wide window over that wider grid (arrows
// to pan) and highlights the breakeven column(s) instead of leaving them to blend in.
const HEATMAP_METRICS: { value: PayoffHeatmapMetric; label: string }[] = [
{ value: 'pnl', label: 'P&L' },
{ value: 'delta', label: 'Delta' },
{ value: 'gamma', label: 'Gamma' },
{ value: 'theta', label: 'Theta' },
{ value: 'vega', label: 'Vega' },
{ value: 'rho', label: 'Rho' },
]
const HEATMAP_WINDOW = 9
function PayoffHeatmapView({ heatmap, spot }: { heatmap: PayoffHeatmap; spot: number }) {
function fmtHeatmapCell(v: number, metric: PayoffHeatmapMetric) {
return metric === 'pnl' ? fmtMoney(v) : `${v >= 0 ? '+' : ''}${v.toFixed(4)}`
}
function PayoffHeatmapView({ heatmap, spot, metric }: { heatmap: PayoffHeatmap; spot: number; metric: PayoffHeatmapMetric }) {
const decimals = spot < 5 ? 4 : spot < 50 ? 2 : 0
const maxAbs = Math.max(1, ...heatmap.rows.flatMap(r => r.pnl.map(Math.abs)))
const cellBg = (pnl: number) => {
const alpha = 0.12 + Math.min(1, Math.abs(pnl) / maxAbs) * 0.55
return pnl >= 0 ? `rgba(16,185,129,${alpha})` : `rgba(239,68,68,${alpha})`
const maxAbs = Math.max(1e-9, ...heatmap.rows.flatMap(r => r[metric].map(Math.abs)))
const cellBg = (v: number) => {
const alpha = 0.12 + Math.min(1, Math.abs(v) / maxAbs) * 0.55
return v >= 0 ? `rgba(16,185,129,${alpha})` : `rgba(239,68,68,${alpha})`
}
const rowLabel = (daysFromNow: number, i: number) =>
daysFromNow < 0.5 ? "Aujourd'hui" : i === heatmap.rows.length - 1 ? 'Échéance' : `J+${Math.round(daysFromNow)}`
@@ -186,9 +125,9 @@ function PayoffHeatmapView({ heatmap, spot }: { heatmap: PayoffHeatmap; spot: nu
{visible.map(i => (
<td key={i}
className={clsx('py-1 px-2 text-right font-mono text-white', isBreakeven(heatmap.prices[i]) && 'border-x border-amber-500/60')}
style={{ backgroundColor: cellBg(row.pnl[i]) }}
style={{ backgroundColor: cellBg(row[metric][i]) }}
>
{fmtMoney(row.pnl[i])}
{fmtHeatmapCell(row[metric][i], metric)}
</td>
))}
</tr>
@@ -1116,7 +1055,7 @@ export default function StrategyBuilder() {
const [symbol, setSymbol] = useState('')
const [debouncedSymbol, setDebouncedSymbol] = useState('')
const [horizonDays, setHorizonDays] = useState(8)
const [payoffView, setPayoffView] = useState<'curve' | 'slices' | 'heatmap'>('curve')
const [heatmapMetric, setHeatmapMetric] = useState<PayoffHeatmapMetric>('pnl')
const [scenario, setScenario] = useState<StrategyScenario>({
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: [],
@@ -1580,43 +1519,21 @@ export default function StrategyBuilder() {
<div className="card">
<div className="flex items-center justify-between mb-2">
<div className="stat-label mb-0">Diagramme payoff</div>
<div className="flex items-center gap-1">
{([['curve', 'Courbe'], ['slices', 'Paliers T+N'], ['heatmap', 'Heatmap']] as const).map(([v, label]) => (
<button key={v} onClick={() => setPayoffView(v)}
className={clsx('px-2.5 py-1 rounded text-[11px] font-semibold transition-colors',
payoffView === v ? 'bg-blue-600 text-white' : 'bg-dark-700 text-slate-400 hover:text-slate-200 border border-slate-700/50')}
>
{label}
</button>
))}
</div>
<select
value={heatmapMetric}
onChange={(e) => setHeatmapMetric(e.target.value as PayoffHeatmapMetric)}
className="bg-dark-700 border border-slate-700/50 rounded px-2 py-1 text-[11px] font-semibold text-slate-200"
>
{HEATMAP_METRICS.map(m => <option key={m.value} value={m.value}>{m.label}</option>)}
</select>
</div>
{payoffView === 'curve' && (
<>
<PayoffChart priced={priced} spot={priced.spot} scenarioSpot={priced.scenario_spot} horizonDays={scenario.horizon_days} />
<p className="text-[11px] text-slate-500 mt-1">
{mode === 'historical'
? <>Les deux courbes utilisent la vraie smile de volatilité capturée au jour scruté — seule la date diffère : bleu = à l'échéance de la jambe la plus proche, orange = au jour scruté (J+{scenario.horizon_days}).</>
: <>Les deux courbes utilisent la même vue de volatilité (celle du scénario) seule la date diffère : bleu = à l'échéance de la jambe la plus proche, orange = à J+{scenario.horizon_days}.</>}
</p>
</>
)}
{payoffView === 'slices' && (
<>
<TimeDecayChart timeSlices={priced.time_slices} spot={priced.spot} scenarioSpot={priced.scenario_spot} />
<p className="text-[11px] text-slate-500 mt-1">
{mode === 'historical' ? 'Vraie smile capturée au jour scruté' : 'Même vue de volatilité (celle du scénario)'} pour chaque palier — seule la date change, du jour même à l'échéance de la jambe la plus proche : ne montre que l'effet de la valeur temps (theta), pas un changement de vue de vol.
</p>
</>
)}
{payoffView === 'heatmap' && (
<>
<PayoffHeatmapView heatmap={priced.heatmap} spot={priced.spot} />
<p className="text-[11px] text-slate-500 mt-1">
{mode === 'historical' ? 'Vraie smile capturée au jour scruté' : 'Même vue de volatilité (celle du scénario)'} sur toute la grille — vert/rouge = gain/perte, l'intensité est relative au P&amp;L max de cette grille.
</p>
</>
)}
<PayoffHeatmapView heatmap={priced.heatmap} spot={priced.spot} metric={heatmapMetric} />
<p className="text-[11px] text-slate-500 mt-1">
{mode === 'historical' ? 'Vraie smile capturée au jour scruté' : 'Même vue de volatilité (celle du scénario)'} sur toute la grille
{heatmapMetric === 'pnl'
? <> — vert/rouge = gain/perte, l'intensité est relative au P&amp;L max de cette grille.</>
: <> vert/rouge = valeur positive/négative de {HEATMAP_METRICS.find(m => m.value === heatmapMetric)?.label}, l'intensité est relative au max |valeur| de cette grille.</>}
</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">