feat: strategy builder

This commit is contained in:
OpenSquared
2026-07-31 09:32:33 +02:00
parent bef0092ca8
commit c7dc8b5cdc
3 changed files with 179 additions and 10 deletions

View File

@@ -1778,6 +1778,8 @@ export type VannaSimulation = {
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[] }[] }
export type PriceCombo = {
entry_cost: number
@@ -1796,6 +1798,8 @@ export type PriceCombo = {
vanna_simulation: VannaSimulation | null
at_expiry: PayoffPoint[]
at_scenario: PayoffPoint[]
time_slices: TimeSlice[]
heatmap: PayoffHeatmap
spot: number
scenario_spot: number
proxy: string

View File

@@ -9,7 +9,7 @@ import {
useScenarios, useSaveScenario, useDeleteScenario,
useSavedStrategies, useSaveStrategyRecord, useDeleteSavedStrategy,
useSaxoSymbols, useIvForTrade,
type StrategyLeg, type StrategyScenario, type PriceCombo, type StrategyCandidate,
type StrategyLeg, type StrategyScenario, type PriceCombo, type TimeSlice, type PayoffHeatmap, type StrategyCandidate,
type OptimizeConstraints, type SavedScenario,
type GreekProfile, type GreekTarget, type GreekState, type GreekTolerance, DEFAULT_GREEK_PROFILE,
} from '../hooks/useApi'
@@ -51,7 +51,7 @@ function estimateBaseIv(chain: any, daysToExpiry: number, strikePct: number, spo
// ── Payoff chart ──────────────────────────────────────────────────────────────
function PayoffChart({ priced, spot, scenarioSpot }: { priced: PriceCombo; spot: number; scenarioSpot: number }) {
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,
@@ -78,14 +78,93 @@ function PayoffChart({ priced, spot, scenarioSpot }: { priced: PriceCombo; spot:
<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' }} />
<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+8 (scénario)" stroke="#f59e0b" 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.
function PayoffHeatmapView({ heatmap, spot }: { heatmap: PayoffHeatmap; spot: number }) {
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 rowLabel = (daysFromNow: number, i: number) =>
daysFromNow < 0.5 ? "Aujourd'hui" : i === heatmap.rows.length - 1 ? 'Échéance' : `J+${Math.round(daysFromNow)}`
return (
<div className="overflow-x-auto">
<table className="w-full text-xs border-collapse">
<thead>
<tr className="text-slate-500 text-left">
<th className="py-1 pr-3">Jours</th>
{heatmap.prices.map((p, i) => <th key={i} className="py-1 px-2 text-right">{p.toFixed(decimals)}</th>)}
</tr>
</thead>
<tbody>
{heatmap.rows.map((row, ri) => (
<tr key={ri} className="border-t border-slate-700/30">
<td className="py-1 pr-3 text-slate-400 whitespace-nowrap">{rowLabel(row.days_from_now, ri)}</td>
{row.pnl.map((v, ci) => (
<td key={ci} className="py-1 px-2 text-right font-mono text-white" style={{ backgroundColor: cellBg(v) }}>
{fmtMoney(v)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}
function GreeksTile({ label, now, scenario, precision = 4, hint }: { label: string; now: number; scenario: number; precision?: number; hint?: string }) {
return (
<div className="card-sm" title={hint}>
@@ -927,6 +1006,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 [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: [],
@@ -1439,11 +1519,43 @@ export default function StrategyBuilder() {
</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 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>
</div>
{payoffView === 'curve' && (
<>
<PayoffChart priced={priced} spot={priced.spot} scenarioSpot={priced.scenario_spot} horizonDays={horizonDays} />
<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>
</>
)}
{payoffView === 'slices' && (
<>
<TimeDecayChart timeSlices={priced.time_slices} spot={priced.spot} scenarioSpot={priced.scenario_spot} />
<p className="text-[11px] text-slate-500 mt-1">
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">
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 className="grid grid-cols-2 md:grid-cols-5 gap-3">