feat: strategy builder

This commit is contained in:
OpenSquared
2026-07-31 13:56:49 +02:00
parent cc22cbd3e0
commit a94f783d33
3 changed files with 117 additions and 29 deletions

View File

@@ -1784,7 +1784,7 @@ export type VannaSimulation = {
}
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 PayoffHeatmap = { prices: number[]; rows: { days_from_now: number; pnl: number[] }[]; breakeven_prices: number[] }
export type PriceCombo = {
entry_cost: number

View File

@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react'
import {
LineChart, Line, AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ReferenceLine, ResponsiveContainer,
} from 'recharts'
import { Layers, Plus, Trash2, RefreshCw, AlertTriangle, Search, Save, FolderOpen, X, History } from 'lucide-react'
import { Layers, Plus, Trash2, RefreshCw, AlertTriangle, Search, Save, FolderOpen, X, History, ChevronLeft, ChevronRight } from 'lucide-react'
import clsx from 'clsx'
import {
useOptionChainSlice, usePriceStrategy, useOptimizeStrategy, useSuggestedProfile, useReplayStrategy, usePresets, useRealizedScenario,
@@ -129,7 +129,12 @@ function TimeDecayChart({ timeSlices, spot, scenarioSpot }: { timeSlices: TimeSl
// "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.
// 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
// to pan) and highlights the breakeven column(s) instead of leaving them to blend in.
const HEATMAP_WINDOW = 9
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)))
@@ -139,28 +144,64 @@ function PayoffHeatmapView({ heatmap, spot }: { heatmap: PayoffHeatmap; spot: nu
}
const rowLabel = (daysFromNow: number, i: number) =>
daysFromNow < 0.5 ? "Aujourd'hui" : i === heatmap.rows.length - 1 ? 'Échéance' : `J+${Math.round(daysFromNow)}`
const isBreakeven = (p: number) => heatmap.breakeven_prices.some(be => Math.abs(be - p) < 1e-6)
const total = heatmap.prices.length
// Centers the window on spot once, on mount — deliberately not re-centered on every
// reprice (scrubbing/tweaking a leg would otherwise yank the scroll position back under
// the user while they're mid-interaction with the arrows below).
const [windowStart, setWindowStart] = useState(() => {
const closest = heatmap.prices.reduce((best, p, i) => Math.abs(p - spot) < Math.abs(heatmap.prices[best] - spot) ? i : best, 0)
return Math.max(0, Math.min(total - HEATMAP_WINDOW, closest - Math.floor(HEATMAP_WINDOW / 2)))
})
const start = Math.max(0, Math.min(windowStart, Math.max(total - HEATMAP_WINDOW, 0)))
const visible = Array.from({ length: Math.min(HEATMAP_WINDOW, total) }, (_, i) => start + i)
const scroll = (dir: -1 | 1) => setWindowStart(s => Math.max(0, Math.min(Math.max(total - HEATMAP_WINDOW, 0), s + dir * 3)))
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>
<div className="flex items-center gap-1">
<button
onClick={() => scroll(-1)} disabled={start <= 0}
className="p-1 rounded border border-slate-700/50 text-slate-400 hover:text-white disabled:opacity-30 shrink-0"
>
<ChevronLeft className="w-3.5 h-3.5" />
</button>
<div className="overflow-x-auto flex-1">
<table className="w-full text-xs border-collapse">
<thead>
<tr className="text-slate-500 text-left">
<th className="py-1 pr-3">Jours</th>
{visible.map(i => (
<th key={i} className={clsx('py-1 px-2 text-right', isBreakeven(heatmap.prices[i]) && 'text-amber-400')}>
{heatmap.prices[i].toFixed(decimals)}
{isBreakeven(heatmap.prices[i]) && <div className="text-[9px] font-normal normal-case">breakeven</div>}
</th>
))}
</tr>
))}
</tbody>
</table>
</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>
{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]) }}
>
{fmtMoney(row.pnl[i])}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<button
onClick={() => scroll(1)} disabled={start + HEATMAP_WINDOW >= total}
className="p-1 rounded border border-slate-700/50 text-slate-400 hover:text-white disabled:opacity-30 shrink-0"
>
<ChevronRight className="w-3.5 h-3.5" />
</button>
</div>
)
}