diff --git a/backend/services/strategy_engine.py b/backend/services/strategy_engine.py index 9d222e1..3af7de2 100644 --- a/backend/services/strategy_engine.py +++ b/backend/services/strategy_engine.py @@ -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( diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index c78b1d5..a90db71 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -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 diff --git a/frontend/src/pages/StrategyBuilder.tsx b/frontend/src/pages/StrategyBuilder.tsx index 95956a3..51bd95c 100644 --- a/frontend/src/pages/StrategyBuilder.tsx +++ b/frontend/src/pages/StrategyBuilder.tsx @@ -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 ( -
| Jours | - {heatmap.prices.map((p, i) =>{p.toFixed(decimals)} | )} -||||
|---|---|---|---|---|---|
| {rowLabel(row.days_from_now, ri)} | - {row.pnl.map((v, ci) => ( -- {fmtMoney(v)} - | +
| Jours | + {visible.map(i => ( +
+ {heatmap.prices[i].toFixed(decimals)}
+ {isBreakeven(heatmap.prices[i]) && breakeven }
+ |
))}
|---|