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

@@ -398,16 +398,59 @@ def time_decay_slices(
return slices
def _find_breakevens(
legs: List[Dict[str, Any]], surface: Any, eval_days_expiry: float, r: float,
entry_ref: float, spot: float, contract_size: float, n: int = 300,
) -> List[float]:
"""Exact expiry P&L zero-crossings — the same value_at boundary check_bounded_risk
already prices, scanned for sign changes and bisected instead of searched for its
extrema. Lets payoff_heatmap pin a real breakeven column instead of only ever landing
near one by luck of the price sampling."""
def f(s: float) -> float:
return value_at(legs, s, eval_days_expiry, surface, r, contract_size) - entry_ref
grid = np.linspace(max(spot * 0.2, 1e-6), spot * 3.0, n)
vals = [f(float(p)) for p in grid]
roots: List[float] = []
for i in range(len(grid) - 1):
a, b = vals[i], vals[i + 1]
if a == 0:
roots.append(float(grid[i]))
continue
if (a < 0) != (b < 0):
lo_b, hi_b, f_lo = float(grid[i]), float(grid[i + 1]), a
for _ in range(30):
mid = (lo_b + hi_b) / 2
f_mid = f(mid)
if (f_mid < 0) == (f_lo < 0):
lo_b, f_lo = mid, f_mid
else:
hi_b = mid
roots.append(round((lo_b + hi_b) / 2, 4))
return roots
def payoff_heatmap(
legs: List[Dict[str, Any]], surface: Any, eval_days_expiry: float, r: float, spot: float,
entry_ref: float, contract_size: float = DEFAULT_CONTRACT_SIZE, n_prices: int = 9, n_days: int = 7,
entry_ref: float, contract_size: float = DEFAULT_CONTRACT_SIZE, n_prices: int = 17, n_days: int = 7,
) -> Dict[str, Any]:
"""Price x days-to-expiry grid of P&L — rows are elapsed-day checkpoints from today down
to expiry (top-to-bottom reading matches watching the position age), columns are
underlying prices zoomed closer to spot than the line chart (a heatmap only reads well
over the range where the color actually varies)."""
lo, hi = spot * 0.85, spot * 1.15
price_points = np.linspace(lo, hi, n_prices)
to expiry (top-to-bottom reading matches watching the position age). Columns are
centered and symmetric around spot, scaled to how far the legs' own strikes sit from it
(tight for a near-the-money single leg, wide for a far-strike spread) — a fixed +-15%
window left more than half the grid flat at max loss/gain for a near-the-money position,
wasting resolution nowhere near where the P&L actually transitions. The exact expiry
breakeven(s) are pinned in as extra columns (breakeven_prices in the response) instead
of only ever landing near one by luck of the price sampling."""
strikes = [l["strike"] for l in legs if l["option_type"] != "stock"]
half_width = max(max(abs(spot - k) for k in strikes) * 1.4, spot * 0.03) if strikes else spot * 0.15
lo, hi = max(spot - half_width, spot * 0.01), spot + half_width
breakevens = _find_breakevens(legs, surface, eval_days_expiry, r, entry_ref, spot, contract_size)
near_breakevens = sorted((p for p in breakevens if lo <= p <= hi), key=lambda p: abs(p - spot))[:2]
price_points = np.unique(np.concatenate([np.linspace(lo, hi, n_prices), np.array(near_breakevens)]))
price_points.sort()
day_points = np.linspace(0, eval_days_expiry, n_days)
rows = [
{
@@ -416,7 +459,11 @@ def payoff_heatmap(
}
for d in day_points
]
return {"prices": [round(float(p), 4) for p in price_points], "rows": rows}
return {
"prices": [round(float(p), 4) for p in price_points],
"rows": rows,
"breakeven_prices": [round(p, 4) for p in near_breakevens],
}
def payoff_curves(

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>
)
}