feat: strategy builder

This commit is contained in:
OpenSquared
2026-07-31 15:00:41 +02:00
parent a94f783d33
commit 3fb61f1690
3 changed files with 79 additions and 204 deletions

View File

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

View File

@@ -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<PayoffHeatmapMetric, number[]>)[]
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

View File

@@ -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 (
<ResponsiveContainer width="100%" height={280}>
<LineChart data={data} margin={{ top: 8, right: 16, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#1e2d4d" />
<XAxis dataKey="underlying" type="number" domain={['dataMin', 'dataMax']}
tick={{ fill: '#475569', fontSize: 10 }} tickLine={false}
tickFormatter={(v) => v.toFixed(decimals)} />
<YAxis tick={{ fill: '#475569', fontSize: 10 }} tickLine={false} axisLine={false}
tickFormatter={(v) => `${v}`} />
<Tooltip
contentStyle={{ background: '#0f1623', border: '1px solid #1e2d4d', fontSize: 11 }}
labelFormatter={(v) => `Sous-jacent: ${Number(v).toFixed(decimals)}`}
formatter={(v: number, name: string) => [fmtMoney(v), name]}
/>
<Legend wrapperStyle={{ fontSize: 11 }} />
<ReferenceLine y={0} stroke="#475569" strokeDasharray="4 4" />
<ReferenceLine x={spot} stroke="#3b82f6" strokeDasharray="2 2" label={{ value: 'Spot', fill: '#3b82f6', fontSize: 9, position: 'top' }} />
<ReferenceLine x={scenarioSpot} stroke="#f59e0b" strokeDasharray="2 2" label={{ value: `Scénario J+${horizonDays}`, fill: '#f59e0b', fontSize: 9, position: 'insideTopRight' }} />
<Line type="monotone" dataKey="expiry" name="À échéance jambe proche" stroke="#3b82f6" strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="scenario" name={`À J+${horizonDays} (scénario)`} stroke="#f59e0b" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
)
}
// "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<string, number> = { underlying: pt.underlying }
timeSlices.forEach((s, si) => { row[`slice_${si}`] = s.points[i]?.pnl })
return row
})
return (
<ResponsiveContainer width="100%" height={280}>
<LineChart data={data} margin={{ top: 8, right: 16, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#1e2d4d" />
<XAxis dataKey="underlying" type="number" domain={['dataMin', 'dataMax']}
tick={{ fill: '#475569', fontSize: 10 }} tickLine={false}
tickFormatter={(v) => v.toFixed(decimals)} />
<YAxis tick={{ fill: '#475569', fontSize: 10 }} tickLine={false} axisLine={false}
tickFormatter={(v) => `${v}`} />
<Tooltip
contentStyle={{ background: '#0f1623', border: '1px solid #1e2d4d', fontSize: 11 }}
labelFormatter={(v) => `Sous-jacent: ${Number(v).toFixed(decimals)}`}
formatter={(v: number, name: string) => [fmtMoney(v), name]}
/>
<Legend wrapperStyle={{ fontSize: 11 }} />
<ReferenceLine y={0} stroke="#475569" strokeDasharray="4 4" />
<ReferenceLine x={spot} stroke="#3b82f6" strokeDasharray="2 2" label={{ value: 'Spot', fill: '#3b82f6', fontSize: 9, position: 'top' }} />
<ReferenceLine x={scenarioSpot} stroke="#94a3b8" strokeDasharray="2 2" label={{ value: 'Scénario', fill: '#94a3b8', fontSize: 9, position: 'insideTopRight' }} />
{timeSlices.map((s, si) => (
<Line key={si} type="monotone" dataKey={`slice_${si}`} name={s.label}
stroke={TIME_SLICE_COLORS[si % TIME_SLICE_COLORS.length]} strokeWidth={2} dot={false} />
))}
</LineChart>
</ResponsiveContainer>
)
}
// "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 => (
<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]) }}
style={{ backgroundColor: cellBg(row[metric][i]) }}
>
{fmtMoney(row.pnl[i])}
{fmtHeatmapCell(row[metric][i], metric)}
</td>
))}
</tr>
@@ -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<PayoffHeatmapMetric>('pnl')
const [scenario, setScenario] = useState<StrategyScenario>({
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() {
<div className="card">
<div className="flex items-center justify-between mb-2">
<div className="stat-label mb-0">Diagramme payoff</div>
<div className="flex items-center gap-1">
{([['curve', 'Courbe'], ['slices', 'Paliers T+N'], ['heatmap', 'Heatmap']] as const).map(([v, label]) => (
<button key={v} onClick={() => setPayoffView(v)}
className={clsx('px-2.5 py-1 rounded text-[11px] font-semibold transition-colors',
payoffView === v ? 'bg-blue-600 text-white' : 'bg-dark-700 text-slate-400 hover:text-slate-200 border border-slate-700/50')}
>
{label}
</button>
))}
</div>
<select
value={heatmapMetric}
onChange={(e) => setHeatmapMetric(e.target.value as PayoffHeatmapMetric)}
className="bg-dark-700 border border-slate-700/50 rounded px-2 py-1 text-[11px] font-semibold text-slate-200"
>
{HEATMAP_METRICS.map(m => <option key={m.value} value={m.value}>{m.label}</option>)}
</select>
</div>
{payoffView === 'curve' && (
<>
<PayoffChart priced={priced} spot={priced.spot} scenarioSpot={priced.scenario_spot} horizonDays={scenario.horizon_days} />
<p className="text-[11px] text-slate-500 mt-1">
{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}.</>}
</p>
</>
)}
{payoffView === 'slices' && (
<>
<TimeDecayChart timeSlices={priced.time_slices} spot={priced.spot} scenarioSpot={priced.scenario_spot} />
<p className="text-[11px] text-slate-500 mt-1">
{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.
</p>
</>
)}
{payoffView === 'heatmap' && (
<>
<PayoffHeatmapView heatmap={priced.heatmap} spot={priced.spot} />
<p className="text-[11px] text-slate-500 mt-1">
{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&amp;L max de cette grille.
</p>
</>
)}
<PayoffHeatmapView heatmap={priced.heatmap} spot={priced.spot} metric={heatmapMetric} />
<p className="text-[11px] text-slate-500 mt-1">
{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&amp;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.</>}
</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">