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

@@ -375,29 +375,6 @@ def expected_pnl_scenario(
return float(numerator / denominator) if denominator > 1e-12 else float(pnl.mean()) return float(numerator / denominator) if denominator > 1e-12 else float(pnl.mean())
def time_decay_slices(
legs: List[Dict[str, Any]], prices: np.ndarray, surface: Any, eval_days_expiry: float, r: float,
entry_ref: float, contract_size: float = DEFAULT_CONTRACT_SIZE, n_slices: int = 4,
) -> List[Dict[str, Any]]:
"""Payoff curve at n_slices evenly-spaced elapsed-day checkpoints between today (0) and
the nearest leg's expiry — the "T+0/T+10/T+20..." view that shows how the curve morphs
from today's time-value-laden shape into the kinked expiry payoff, instead of only the
two endpoints at_expiry/at_scenario give. Uses the same scenario vol view as those two
curves (see payoff_curves' own comment) so the only thing that varies between slices is
time decay, not the vol assumption."""
day_points = np.linspace(0, eval_days_expiry, n_slices)
slices = []
for d in day_points:
d = float(d)
label = "Aujourd'hui" if d < 0.5 else ("Échéance" if d >= eval_days_expiry - 0.5 else f"J+{round(d)}")
points = [
{"underlying": round(float(p), 4), "pnl": round(float(value_at(legs, float(p), d, surface, r, contract_size) - entry_ref), 2)}
for p in prices
]
slices.append({"days_from_now": round(d, 1), "label": label, "points": points})
return slices
def _find_breakevens( def _find_breakevens(
legs: List[Dict[str, Any]], surface: Any, eval_days_expiry: float, r: float, legs: List[Dict[str, Any]], surface: Any, eval_days_expiry: float, r: float,
entry_ref: float, spot: float, contract_size: float, n: int = 300, entry_ref: float, spot: float, contract_size: float, n: int = 300,
@@ -452,13 +429,33 @@ def payoff_heatmap(
price_points = np.unique(np.concatenate([np.linspace(lo, hi, n_prices), np.array(near_breakevens)])) price_points = np.unique(np.concatenate([np.linspace(lo, hi, n_prices), np.array(near_breakevens)]))
price_points.sort() price_points.sort()
day_points = np.linspace(0, eval_days_expiry, n_days) day_points = np.linspace(0, eval_days_expiry, n_days)
rows = [ # Per-cell Greeks (not just P&L) — same net Greeks the tiles above the chart already
{ # show (per-contract-unit, not scaled by contract_size), just swept across the whole
"days_from_now": round(float(d), 1), # price x time grid instead of only "now" vs "scenario" — lets the metric selector show
"pnl": [round(float(value_at(legs, float(p), float(d), surface, r, contract_size) - entry_ref), 2) for p in price_points], # how Delta/Gamma/Theta/Vega/Rho actually evolve across the scenario, not just their
} # two endpoint values.
for d in day_points rows = []
] for d in day_points:
d = float(d)
pnl_row, delta_row, gamma_row, theta_row, vega_row, rho_row = [], [], [], [], [], []
for p in price_points:
p = float(p)
pnl_row.append(round(float(value_at(legs, p, d, surface, r, contract_size) - entry_ref), 2))
g = greeks_at(legs, p, d, surface, r)
# greeks_at's own round() leaves numpy float64 as numpy float64 (round() doesn't
# coerce to native Python) — black_scholes is scipy-backed, and FastAPI's default
# JSON encoder can't serialize a bare numpy scalar (unlike pnl_row above, which
# already goes through float() explicitly).
delta_row.append(float(g["delta"]))
gamma_row.append(float(g["gamma"]))
theta_row.append(float(g["theta"]))
vega_row.append(float(g["vega"]))
rho_row.append(float(g["rho"]))
rows.append({
"days_from_now": round(d, 1),
"pnl": pnl_row, "delta": delta_row, "gamma": gamma_row,
"theta": theta_row, "vega": vega_row, "rho": rho_row,
})
return { return {
"prices": [round(float(p), 4) for p in price_points], "prices": [round(float(p), 4) for p in price_points],
"rows": rows, "rows": rows,
@@ -473,52 +470,13 @@ def payoff_curves(
surface_scenario: ScenarioSurface, surface_scenario: ScenarioSurface,
horizon_days: int, horizon_days: int,
r: float = 0.05, r: float = 0.05,
n: int = 100,
contract_size: float = DEFAULT_CONTRACT_SIZE, contract_size: float = DEFAULT_CONTRACT_SIZE,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
spot = chain_slice["spot"] spot = chain_slice["spot"]
priced = price_combo(legs, chain_slice, surface_now, surface_scenario, horizon_days, r, contract_size) priced = price_combo(legs, chain_slice, surface_now, surface_scenario, horizon_days, r, contract_size)
entry_ref = priced["entry_cost"] entry_ref = priced["entry_cost"]
lo, hi = spot * 0.6, spot * 1.4
# A calendar/ratio spread's payoff can spike sharply right at a strike — a plain
# uniform sweep over the full 0.6x-1.4x range (n points) can straddle right over that
# peak without ever sampling it (same issue fixed in check_bounded_risk). Blend a
# coarse baseline (overall shape) with a dense window around the legs' own strikes.
# The exact spot/scenario spot are forced in as sample points too — otherwise hovering
# right on the "Spot"/"Scénario" reference line reads the nearest grid point, which can
# sit meaningfully off the true value on a peak this steep, and disagree with the
# entry_cost/net_pnl tiles (computed at the exact spot, not a grid sample).
baseline = np.linspace(lo, hi, n)
strikes = [l["strike"] for l in legs]
lo_k, hi_k = max(min(strikes) * 0.9, lo), min(max(strikes) * 1.1, hi)
near_strikes = np.linspace(lo_k, hi_k, n * 3)
exact_points = np.array([spot, surface_scenario.spot] + strikes)
prices = np.unique(np.concatenate([baseline, near_strikes, exact_points]))
prices.sort()
eval_days_expiry = min(l["days_to_expiry"] for l in legs) eval_days_expiry = min(l["days_to_expiry"] for l in legs)
# Both curves share the scenario's volatility view (surface_scenario) — only the
# evaluation DATE differs: "à échéance" prices the day the near leg expires (matching
# the Max gain/Max perte tile, itself computed with surface_scenario for the same
# reason — see check_bounded_risk), "à J+8" prices your chosen scenario horizon. Using
# surface_now here instead would silently mix in today's un-shocked vol, producing a
# curve whose peak doesn't match the Max gain tile right next to it.
at_expiry = [
{"underlying": round(float(p), 4), "pnl": round(float(value_at(legs, float(p), eval_days_expiry, surface_scenario, r, contract_size) - entry_ref), 2)}
for p in prices
]
at_scenario = [
{"underlying": round(float(p), 4), "pnl": round(float(value_at(legs, float(p), horizon_days, surface_scenario, r, contract_size) - entry_ref), 2)}
for p in prices
]
# Coarser price grid than the two headline curves above — this trades some precision
# for keeping a single /price request's added cost bounded (n_slices/heatmap cells x
# their own price points, on top of the ~1400 value_at calls at_expiry/at_scenario
# above already need).
slice_prices = np.linspace(lo, hi, 80)
time_slices = time_decay_slices(legs, slice_prices, surface_scenario, eval_days_expiry, r, entry_ref, contract_size)
heatmap = payoff_heatmap(legs, surface_scenario, eval_days_expiry, r, spot, entry_ref, contract_size) heatmap = payoff_heatmap(legs, surface_scenario, eval_days_expiry, r, spot, entry_ref, contract_size)
return {"at_expiry": at_expiry, "at_scenario": at_scenario, "time_slices": time_slices, "heatmap": heatmap, **priced} return {"heatmap": heatmap, **priced}

View File

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

View File

@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { import {
LineChart, Line, AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ReferenceLine, ResponsiveContainer, AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ReferenceLine, ResponsiveContainer,
} from 'recharts' } from 'recharts'
import { Layers, Plus, Trash2, RefreshCw, AlertTriangle, Search, Save, FolderOpen, X, History, ChevronLeft, ChevronRight } from 'lucide-react' import { Layers, Plus, Trash2, RefreshCw, AlertTriangle, Search, Save, FolderOpen, X, History, ChevronLeft, ChevronRight } from 'lucide-react'
import clsx from 'clsx' import clsx from 'clsx'
@@ -9,7 +9,7 @@ import {
useScenarios, useSaveScenario, useDeleteScenario, useScenarios, useSaveScenario, useDeleteScenario,
useSavedStrategies, useSaveStrategyRecord, useDeleteSavedStrategy, useSavedStrategies, useSaveStrategyRecord, useDeleteSavedStrategy,
useSaxoSymbols, useIvForTrade, 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 OptimizeConstraints, type SavedScenario,
type GreekProfile, type GreekTarget, type GreekState, type GreekTolerance, DEFAULT_GREEK_PROFILE, type GreekProfile, type GreekTarget, type GreekState, type GreekTolerance, DEFAULT_GREEK_PROFILE,
} from '../hooks/useApi' } from '../hooks/useApi'
@@ -49,98 +49,37 @@ function estimateBaseIv(chain: any, daysToExpiry: number, strikePct: number, spo
return nearest.iv return nearest.iv
} }
// ── Payoff chart ────────────────────────────────────────────────────────────── // ── Payoff heatmap ──────────────────────────────────────────────────────────────
// Sole payoff view — price x days-to-expiry grid (services.strategy_engine.payoff_heatmap).
function PayoffChart({ priced, spot, scenarioSpot, horizonDays }: { priced: PriceCombo; spot: number; scenarioSpot: number; horizonDays: number }) { // A metric selector switches which grid is shown (P&L or any first-order Greek), so the
const data = priced.at_expiry.map((p, i) => ({ // same heatmap explains not just where the position wins/loses but how Delta/Gamma/
underlying: p.underlying, // Theta/Vega/Rho actually evolve across price and time, not just their two endpoint
expiry: p.pnl, // values (now vs scenario) the tiles below already show. Cell shade encodes sign +
scenario: priced.at_scenario[i]?.pnl, // 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
// Decimals scale with the underlying's own magnitude — an equity at ~740 reads fine // backend centers/scales the price columns on spot and the legs' own strikes and pins in
// 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
// the exact breakeven(s) — this view shows a 9-wide window over that wider grid (arrows // 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. // 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 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 decimals = spot < 5 ? 4 : spot < 50 ? 2 : 0
const maxAbs = Math.max(1, ...heatmap.rows.flatMap(r => r.pnl.map(Math.abs))) const maxAbs = Math.max(1e-9, ...heatmap.rows.flatMap(r => r[metric].map(Math.abs)))
const cellBg = (pnl: number) => { const cellBg = (v: number) => {
const alpha = 0.12 + Math.min(1, Math.abs(pnl) / maxAbs) * 0.55 const alpha = 0.12 + Math.min(1, Math.abs(v) / maxAbs) * 0.55
return pnl >= 0 ? `rgba(16,185,129,${alpha})` : `rgba(239,68,68,${alpha})` return v >= 0 ? `rgba(16,185,129,${alpha})` : `rgba(239,68,68,${alpha})`
} }
const rowLabel = (daysFromNow: number, i: number) => const rowLabel = (daysFromNow: number, i: number) =>
daysFromNow < 0.5 ? "Aujourd'hui" : i === heatmap.rows.length - 1 ? 'Échéance' : `J+${Math.round(daysFromNow)}` 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 => ( {visible.map(i => (
<td key={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')} 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> </td>
))} ))}
</tr> </tr>
@@ -1116,7 +1055,7 @@ export default function StrategyBuilder() {
const [symbol, setSymbol] = useState('') const [symbol, setSymbol] = useState('')
const [debouncedSymbol, setDebouncedSymbol] = useState('') const [debouncedSymbol, setDebouncedSymbol] = useState('')
const [horizonDays, setHorizonDays] = useState(8) const [horizonDays, setHorizonDays] = useState(8)
const [payoffView, setPayoffView] = useState<'curve' | 'slices' | 'heatmap'>('curve') const [heatmapMetric, setHeatmapMetric] = useState<PayoffHeatmapMetric>('pnl')
const [scenario, setScenario] = useState<StrategyScenario>({ const [scenario, setScenario] = useState<StrategyScenario>({
symbol: '', horizon_days: 8, spot_shock_pct: 0, iv_level_shift: 0, skew_tilt: 0, term_slope_shift: 0, 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: [], 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="card">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<div className="stat-label mb-0">Diagramme payoff</div> <div className="stat-label mb-0">Diagramme payoff</div>
<div className="flex items-center gap-1"> <select
{([['curve', 'Courbe'], ['slices', 'Paliers T+N'], ['heatmap', 'Heatmap']] as const).map(([v, label]) => ( value={heatmapMetric}
<button key={v} onClick={() => setPayoffView(v)} onChange={(e) => setHeatmapMetric(e.target.value as PayoffHeatmapMetric)}
className={clsx('px-2.5 py-1 rounded text-[11px] font-semibold transition-colors', className="bg-dark-700 border border-slate-700/50 rounded px-2 py-1 text-[11px] font-semibold text-slate-200"
payoffView === v ? 'bg-blue-600 text-white' : 'bg-dark-700 text-slate-400 hover:text-slate-200 border border-slate-700/50')} >
> {HEATMAP_METRICS.map(m => <option key={m.value} value={m.value}>{m.label}</option>)}
{label} </select>
</button>
))}
</div>
</div> </div>
{payoffView === 'curve' && ( <PayoffHeatmapView heatmap={priced.heatmap} spot={priced.spot} metric={heatmapMetric} />
<> <p className="text-[11px] text-slate-500 mt-1">
<PayoffChart priced={priced} spot={priced.spot} scenarioSpot={priced.scenario_spot} horizonDays={scenario.horizon_days} /> {mode === 'historical' ? 'Vraie smile capturée au jour scruté' : 'Même vue de volatilité (celle du scénario)'} sur toute la grille
<p className="text-[11px] text-slate-500 mt-1"> {heatmapMetric === 'pnl'
{mode === 'historical' ? <> — vert/rouge = gain/perte, l'intensité est relative au P&amp;L max de cette grille.</>
? <>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}).</> : <> 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.</>}
: <>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>
</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>
</>
)}
</div> </div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-3"> <div className="grid grid-cols-2 md:grid-cols-5 gap-3">