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 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( def payoff_heatmap(
legs: List[Dict[str, Any]], surface: Any, eval_days_expiry: float, r: float, spot: float, 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]: ) -> Dict[str, Any]:
"""Price x days-to-expiry grid of P&L — rows are elapsed-day checkpoints from today down """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 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 centered and symmetric around spot, scaled to how far the legs' own strikes sit from it
over the range where the color actually varies).""" (tight for a near-the-money single leg, wide for a far-strike spread) — a fixed +-15%
lo, hi = spot * 0.85, spot * 1.15 window left more than half the grid flat at max loss/gain for a near-the-money position,
price_points = np.linspace(lo, hi, n_prices) 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) day_points = np.linspace(0, eval_days_expiry, n_days)
rows = [ rows = [
{ {
@@ -416,7 +459,11 @@ def payoff_heatmap(
} }
for d in day_points 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( def payoff_curves(

View File

@@ -1784,7 +1784,7 @@ export type VannaSimulation = {
} }
export type PayoffPoint = { underlying: number; pnl: number } export type PayoffPoint = { underlying: number; pnl: number }
export type TimeSlice = { days_from_now: number; label: string; points: PayoffPoint[] } 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 = { export type PriceCombo = {
entry_cost: number entry_cost: number

View File

@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react'
import { import {
LineChart, Line, AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ReferenceLine, ResponsiveContainer, LineChart, Line, AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ReferenceLine, ResponsiveContainer,
} from 'recharts' } 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 clsx from 'clsx'
import { import {
useOptionChainSlice, usePriceStrategy, useOptimizeStrategy, useSuggestedProfile, useReplayStrategy, usePresets, useRealizedScenario, 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 // "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 // 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 // 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 }) { function PayoffHeatmapView({ heatmap, spot }: { heatmap: PayoffHeatmap; spot: number }) {
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(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) => 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)}`
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 ( return (
<div className="overflow-x-auto"> <div className="flex items-center gap-1">
<table className="w-full text-xs border-collapse"> <button
<thead> onClick={() => scroll(-1)} disabled={start <= 0}
<tr className="text-slate-500 text-left"> className="p-1 rounded border border-slate-700/50 text-slate-400 hover:text-white disabled:opacity-30 shrink-0"
<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>)} <ChevronLeft className="w-3.5 h-3.5" />
</tr> </button>
</thead> <div className="overflow-x-auto flex-1">
<tbody> <table className="w-full text-xs border-collapse">
{heatmap.rows.map((row, ri) => ( <thead>
<tr key={ri} className="border-t border-slate-700/30"> <tr className="text-slate-500 text-left">
<td className="py-1 pr-3 text-slate-400 whitespace-nowrap">{rowLabel(row.days_from_now, ri)}</td> <th className="py-1 pr-3">Jours</th>
{row.pnl.map((v, ci) => ( {visible.map(i => (
<td key={ci} className="py-1 px-2 text-right font-mono text-white" style={{ backgroundColor: cellBg(v) }}> <th key={i} className={clsx('py-1 px-2 text-right', isBreakeven(heatmap.prices[i]) && 'text-amber-400')}>
{fmtMoney(v)} {heatmap.prices[i].toFixed(decimals)}
</td> {isBreakeven(heatmap.prices[i]) && <div className="text-[9px] font-normal normal-case">breakeven</div>}
</th>
))} ))}
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {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> </div>
) )
} }