From 3fb61f16902a2a4b032dcae2fbb89b6a263b7770 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Fri, 31 Jul 2026 15:00:41 +0200 Subject: [PATCH] feat: strategy builder --- backend/services/strategy_engine.py | 98 ++++---------- frontend/src/hooks/useApi.ts | 12 +- frontend/src/pages/StrategyBuilder.tsx | 173 +++++++------------------ 3 files changed, 79 insertions(+), 204 deletions(-) diff --git a/backend/services/strategy_engine.py b/backend/services/strategy_engine.py index 3af7de2..a5fce1f 100644 --- a/backend/services/strategy_engine.py +++ b/backend/services/strategy_engine.py @@ -375,29 +375,6 @@ def expected_pnl_scenario( return float(numerator / denominator) if denominator > 1e-12 else float(pnl.mean()) -def time_decay_slices( - legs: List[Dict[str, Any]], prices: np.ndarray, surface: Any, eval_days_expiry: float, r: float, - entry_ref: float, contract_size: float = DEFAULT_CONTRACT_SIZE, n_slices: int = 4, -) -> List[Dict[str, Any]]: - """Payoff curve at n_slices evenly-spaced elapsed-day checkpoints between today (0) and - the nearest leg's expiry — the "T+0/T+10/T+20..." view that shows how the curve morphs - from today's time-value-laden shape into the kinked expiry payoff, instead of only the - two endpoints at_expiry/at_scenario give. Uses the same scenario vol view as those two - curves (see payoff_curves' own comment) so the only thing that varies between slices is - time decay, not the vol assumption.""" - day_points = np.linspace(0, eval_days_expiry, n_slices) - slices = [] - for d in day_points: - d = float(d) - label = "Aujourd'hui" if d < 0.5 else ("Échéance" if d >= eval_days_expiry - 0.5 else f"J+{round(d)}") - points = [ - {"underlying": round(float(p), 4), "pnl": round(float(value_at(legs, float(p), d, surface, r, contract_size) - entry_ref), 2)} - for p in prices - ] - slices.append({"days_from_now": round(d, 1), "label": label, "points": points}) - 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, @@ -452,13 +429,33 @@ def payoff_heatmap( 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 = [ - { - "days_from_now": round(float(d), 1), - "pnl": [round(float(value_at(legs, float(p), float(d), surface, r, contract_size) - entry_ref), 2) for p in price_points], - } - for d in day_points - ] + # Per-cell Greeks (not just P&L) — same net Greeks the tiles above the chart already + # show (per-contract-unit, not scaled by contract_size), just swept across the whole + # price x time grid instead of only "now" vs "scenario" — lets the metric selector show + # how Delta/Gamma/Theta/Vega/Rho actually evolve across the scenario, not just their + # two endpoint values. + rows = [] + for d in day_points: + d = float(d) + pnl_row, delta_row, gamma_row, theta_row, vega_row, rho_row = [], [], [], [], [], [] + for p in price_points: + p = float(p) + pnl_row.append(round(float(value_at(legs, p, d, surface, r, contract_size) - entry_ref), 2)) + g = greeks_at(legs, p, d, surface, r) + # greeks_at's own round() leaves numpy float64 as numpy float64 (round() doesn't + # coerce to native Python) — black_scholes is scipy-backed, and FastAPI's default + # JSON encoder can't serialize a bare numpy scalar (unlike pnl_row above, which + # already goes through float() explicitly). + delta_row.append(float(g["delta"])) + gamma_row.append(float(g["gamma"])) + theta_row.append(float(g["theta"])) + vega_row.append(float(g["vega"])) + rho_row.append(float(g["rho"])) + rows.append({ + "days_from_now": round(d, 1), + "pnl": pnl_row, "delta": delta_row, "gamma": gamma_row, + "theta": theta_row, "vega": vega_row, "rho": rho_row, + }) return { "prices": [round(float(p), 4) for p in price_points], "rows": rows, @@ -473,52 +470,13 @@ def payoff_curves( surface_scenario: ScenarioSurface, horizon_days: int, r: float = 0.05, - n: int = 100, contract_size: float = DEFAULT_CONTRACT_SIZE, ) -> Dict[str, Any]: spot = chain_slice["spot"] priced = price_combo(legs, chain_slice, surface_now, surface_scenario, horizon_days, r, contract_size) entry_ref = priced["entry_cost"] - - lo, hi = spot * 0.6, spot * 1.4 - # A calendar/ratio spread's payoff can spike sharply right at a strike — a plain - # uniform sweep over the full 0.6x-1.4x range (n points) can straddle right over that - # peak without ever sampling it (same issue fixed in check_bounded_risk). Blend a - # coarse baseline (overall shape) with a dense window around the legs' own strikes. - # The exact spot/scenario spot are forced in as sample points too — otherwise hovering - # right on the "Spot"/"Scénario" reference line reads the nearest grid point, which can - # sit meaningfully off the true value on a peak this steep, and disagree with the - # entry_cost/net_pnl tiles (computed at the exact spot, not a grid sample). - baseline = np.linspace(lo, hi, n) - strikes = [l["strike"] for l in legs] - lo_k, hi_k = max(min(strikes) * 0.9, lo), min(max(strikes) * 1.1, hi) - near_strikes = np.linspace(lo_k, hi_k, n * 3) - exact_points = np.array([spot, surface_scenario.spot] + strikes) - prices = np.unique(np.concatenate([baseline, near_strikes, exact_points])) - prices.sort() eval_days_expiry = min(l["days_to_expiry"] for l in legs) - # Both curves share the scenario's volatility view (surface_scenario) — only the - # evaluation DATE differs: "à échéance" prices the day the near leg expires (matching - # the Max gain/Max perte tile, itself computed with surface_scenario for the same - # reason — see check_bounded_risk), "à J+8" prices your chosen scenario horizon. Using - # surface_now here instead would silently mix in today's un-shocked vol, producing a - # curve whose peak doesn't match the Max gain tile right next to it. - at_expiry = [ - {"underlying": round(float(p), 4), "pnl": round(float(value_at(legs, float(p), eval_days_expiry, surface_scenario, r, contract_size) - entry_ref), 2)} - for p in prices - ] - at_scenario = [ - {"underlying": round(float(p), 4), "pnl": round(float(value_at(legs, float(p), horizon_days, surface_scenario, r, contract_size) - entry_ref), 2)} - for p in prices - ] - - # Coarser price grid than the two headline curves above — this trades some precision - # for keeping a single /price request's added cost bounded (n_slices/heatmap cells x - # their own price points, on top of the ~1400 value_at calls at_expiry/at_scenario - # above already need). - slice_prices = np.linspace(lo, hi, 80) - time_slices = time_decay_slices(legs, slice_prices, surface_scenario, eval_days_expiry, r, entry_ref, contract_size) heatmap = payoff_heatmap(legs, surface_scenario, eval_days_expiry, r, spot, entry_ref, contract_size) - return {"at_expiry": at_expiry, "at_scenario": at_scenario, "time_slices": time_slices, "heatmap": heatmap, **priced} + return {"heatmap": heatmap, **priced} diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index a90db71..d2a0bde 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -1782,9 +1782,12 @@ export type VannaSimulation = { spot_shock_pct: number; iv_shock_pts: number 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[] }[]; breakeven_prices: number[] } +export type PayoffHeatmapMetric = 'pnl' | 'delta' | 'gamma' | 'theta' | 'vega' | 'rho' +export type PayoffHeatmap = { + prices: number[] + rows: ({ days_from_now: number } & Record)[] + breakeven_prices: number[] +} export type PriceCombo = { entry_cost: number @@ -1801,9 +1804,6 @@ export type PriceCombo = { net_delta_now: number net_delta_scenario: number vanna_simulation: VannaSimulation | null - at_expiry: PayoffPoint[] - at_scenario: PayoffPoint[] - time_slices: TimeSlice[] heatmap: PayoffHeatmap spot: number scenario_spot: number diff --git a/frontend/src/pages/StrategyBuilder.tsx b/frontend/src/pages/StrategyBuilder.tsx index 51bd95c..99ece6f 100644 --- a/frontend/src/pages/StrategyBuilder.tsx +++ b/frontend/src/pages/StrategyBuilder.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState } from 'react' import { - LineChart, Line, AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ReferenceLine, ResponsiveContainer, + AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ReferenceLine, ResponsiveContainer, } from 'recharts' import { Layers, Plus, Trash2, RefreshCw, AlertTriangle, Search, Save, FolderOpen, X, History, ChevronLeft, ChevronRight } from 'lucide-react' import clsx from 'clsx' @@ -9,7 +9,7 @@ import { useScenarios, useSaveScenario, useDeleteScenario, useSavedStrategies, useSaveStrategyRecord, useDeleteSavedStrategy, useSaxoSymbols, useIvForTrade, - type StrategyLeg, type StrategyScenario, type PriceCombo, type TimeSlice, type PayoffHeatmap, type StrategyCandidate, + type StrategyLeg, type StrategyScenario, type PayoffHeatmap, type PayoffHeatmapMetric, type StrategyCandidate, type OptimizeConstraints, type SavedScenario, type GreekProfile, type GreekTarget, type GreekState, type GreekTolerance, DEFAULT_GREEK_PROFILE, } from '../hooks/useApi' @@ -49,98 +49,37 @@ function estimateBaseIv(chain: any, daysToExpiry: number, strikePct: number, spo return nearest.iv } -// ── Payoff chart ────────────────────────────────────────────────────────────── - -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, - scenario: priced.at_scenario[i]?.pnl, - })) - // Decimals scale with the underlying's own magnitude — an equity at ~740 reads fine - // rounded to the unit, but an FX rate at ~1.15 needs several decimals or every tick - // collapses to the same rounded label. - const decimals = spot < 5 ? 4 : spot < 50 ? 2 : 0 - return ( - - - - v.toFixed(decimals)} /> - `${v}`} /> - `Sous-jacent: ${Number(v).toFixed(decimals)}`} - formatter={(v: number, name: string) => [fmtMoney(v), name]} - /> - - - - - - - - - ) -} - -// "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 = { underlying: pt.underlying } - timeSlices.forEach((s, si) => { row[`slice_${si}`] = s.points[i]?.pnl }) - return row - }) - return ( - - - - v.toFixed(decimals)} /> - `${v}`} /> - `Sous-jacent: ${Number(v).toFixed(decimals)}`} - formatter={(v: number, name: string) => [fmtMoney(v), name]} - /> - - - - - {timeSlices.map((s, si) => ( - - ))} - - - ) -} - -// "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. The backend -// already centers/scales the price columns on spot and the legs' own strikes and pins in +// ── Payoff heatmap ────────────────────────────────────────────────────────────── +// Sole payoff view — price x days-to-expiry grid (services.strategy_engine.payoff_heatmap). +// A metric selector switches which grid is shown (P&L or any first-order Greek), so the +// same heatmap explains not just where the position wins/loses but how Delta/Gamma/ +// Theta/Vega/Rho actually evolve across price and time, not just their two endpoint +// values (now vs scenario) the tiles below already show. Cell shade encodes sign + +// magnitude relative to the grid's own max |value| for whichever metric is selected +// (not a fixed scale) since both P&L and each Greek span wildly different ranges. The +// backend 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_METRICS: { value: PayoffHeatmapMetric; label: string }[] = [ + { value: 'pnl', label: 'P&L' }, + { value: 'delta', label: 'Delta' }, + { value: 'gamma', label: 'Gamma' }, + { value: 'theta', label: 'Theta' }, + { value: 'vega', label: 'Vega' }, + { value: 'rho', label: 'Rho' }, +] const HEATMAP_WINDOW = 9 -function PayoffHeatmapView({ heatmap, spot }: { heatmap: PayoffHeatmap; spot: number }) { +function fmtHeatmapCell(v: number, metric: PayoffHeatmapMetric) { + return metric === 'pnl' ? fmtMoney(v) : `${v >= 0 ? '+' : ''}${v.toFixed(4)}` +} + +function PayoffHeatmapView({ heatmap, spot, metric }: { heatmap: PayoffHeatmap; spot: number; metric: PayoffHeatmapMetric }) { 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 maxAbs = Math.max(1e-9, ...heatmap.rows.flatMap(r => r[metric].map(Math.abs))) + const cellBg = (v: number) => { + const alpha = 0.12 + Math.min(1, Math.abs(v) / maxAbs) * 0.55 + return v >= 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)}` @@ -186,9 +125,9 @@ function PayoffHeatmapView({ heatmap, spot }: { heatmap: PayoffHeatmap; spot: nu {visible.map(i => ( - {fmtMoney(row.pnl[i])} + {fmtHeatmapCell(row[metric][i], metric)} ))} @@ -1116,7 +1055,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 [heatmapMetric, setHeatmapMetric] = useState('pnl') const [scenario, setScenario] = useState({ 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: [], @@ -1580,43 +1519,21 @@ export default function StrategyBuilder() {
Diagramme payoff
-
- {([['curve', 'Courbe'], ['slices', 'Paliers T+N'], ['heatmap', 'Heatmap']] as const).map(([v, label]) => ( - - ))} -
+
- {payoffView === 'curve' && ( - <> - -

- {mode === 'historical' - ? <>Les deux courbes utilisent la vraie smile de volatilité capturée au jour scruté — seule la date diffère : bleu = à l'échéance de la jambe la plus proche, orange = au jour scruté (J+{scenario.horizon_days}). - : <>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+{scenario.horizon_days}.} -

- - )} - {payoffView === 'slices' && ( - <> - -

- {mode === 'historical' ? 'Vraie smile capturée au jour scruté' : '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. -

- - )} - {payoffView === 'heatmap' && ( - <> - -

- {mode === 'historical' ? 'Vraie smile capturée au jour scruté' : 'Même vue de volatilité (celle du scénario)'} sur toute la grille — vert/rouge = gain/perte, l'intensité est relative au P&L max de cette grille. -

- - )} + +

+ {mode === 'historical' ? 'Vraie smile capturée au jour scruté' : 'Même vue de volatilité (celle du scénario)'} sur toute la grille + {heatmapMetric === 'pnl' + ? <> — vert/rouge = gain/perte, l'intensité est relative au P&L max de cette grille. + : <> — vert/rouge = valeur positive/négative de {HEATMAP_METRICS.find(m => m.value === heatmapMetric)?.label}, l'intensité est relative au max |valeur| de cette grille.} +