From c7dc8b5cdcd72762369e32dc58ad3aa132310f60 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Fri, 31 Jul 2026 09:32:33 +0200 Subject: [PATCH] feat: strategy builder --- backend/services/strategy_engine.py | 55 ++++++++++- frontend/src/hooks/useApi.ts | 4 + frontend/src/pages/StrategyBuilder.tsx | 130 +++++++++++++++++++++++-- 3 files changed, 179 insertions(+), 10 deletions(-) diff --git a/backend/services/strategy_engine.py b/backend/services/strategy_engine.py index 80a4cbb..9d222e1 100644 --- a/backend/services/strategy_engine.py +++ b/backend/services/strategy_engine.py @@ -375,6 +375,50 @@ 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 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, +) -> 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) + 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 + ] + return {"prices": [round(float(p), 4) for p in price_points], "rows": rows} + + def payoff_curves( legs: List[Dict[str, Any]], chain_slice: Dict[str, Any], @@ -421,4 +465,13 @@ def payoff_curves( {"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 ] - return {"at_expiry": at_expiry, "at_scenario": at_scenario, **priced} + + # 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} diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index 30d837c..8613d89 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -1778,6 +1778,8 @@ export type VannaSimulation = { 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[] }[] } export type PriceCombo = { entry_cost: number @@ -1796,6 +1798,8 @@ export type PriceCombo = { vanna_simulation: VannaSimulation | null at_expiry: PayoffPoint[] at_scenario: PayoffPoint[] + time_slices: TimeSlice[] + heatmap: PayoffHeatmap spot: number scenario_spot: number proxy: string diff --git a/frontend/src/pages/StrategyBuilder.tsx b/frontend/src/pages/StrategyBuilder.tsx index b2c784e..322ad17 100644 --- a/frontend/src/pages/StrategyBuilder.tsx +++ b/frontend/src/pages/StrategyBuilder.tsx @@ -9,7 +9,7 @@ import { useScenarios, useSaveScenario, useDeleteScenario, useSavedStrategies, useSaveStrategyRecord, useDeleteSavedStrategy, useSaxoSymbols, useIvForTrade, - type StrategyLeg, type StrategyScenario, type PriceCombo, type StrategyCandidate, + type StrategyLeg, type StrategyScenario, type PriceCombo, type TimeSlice, type PayoffHeatmap, type StrategyCandidate, type OptimizeConstraints, type SavedScenario, type GreekProfile, type GreekTarget, type GreekState, type GreekTolerance, DEFAULT_GREEK_PROFILE, } from '../hooks/useApi' @@ -51,7 +51,7 @@ function estimateBaseIv(chain: any, daysToExpiry: number, strikePct: number, spo // ── Payoff chart ────────────────────────────────────────────────────────────── -function PayoffChart({ priced, spot, scenarioSpot }: { priced: PriceCombo; spot: number; scenarioSpot: number }) { +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, @@ -78,14 +78,93 @@ function PayoffChart({ priced, spot, scenarioSpot }: { priced: PriceCombo; spot: - + - + ) } +// "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. +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))) + 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 rowLabel = (daysFromNow: number, i: number) => + daysFromNow < 0.5 ? "Aujourd'hui" : i === heatmap.rows.length - 1 ? 'Échéance' : `J+${Math.round(daysFromNow)}` + return ( +
+ + + + + {heatmap.prices.map((p, i) => )} + + + + {heatmap.rows.map((row, ri) => ( + + + {row.pnl.map((v, ci) => ( + + ))} + + ))} + +
Jours{p.toFixed(decimals)}
{rowLabel(row.days_from_now, ri)} + {fmtMoney(v)} +
+
+ ) +} + function GreeksTile({ label, now, scenario, precision = 4, hint }: { label: string; now: number; scenario: number; precision?: number; hint?: string }) { return (
@@ -927,6 +1006,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 [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: [], @@ -1439,11 +1519,43 @@ export default function StrategyBuilder() {
-
Diagramme payoff
- -

- 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+{horizonDays}. -

+
+
Diagramme payoff
+
+ {([['curve', 'Courbe'], ['slices', 'Paliers T+N'], ['heatmap', 'Heatmap']] as const).map(([v, label]) => ( + + ))} +
+
+ {payoffView === 'curve' && ( + <> + +

+ 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+{horizonDays}. +

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

+ 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' && ( + <> + +

+ 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. +

+ + )}