feat: strategy builder

This commit is contained in:
OpenSquared
2026-07-31 09:32:33 +02:00
parent bef0092ca8
commit c7dc8b5cdc
3 changed files with 179 additions and 10 deletions

View File

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

View File

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

View File

@@ -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:
<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+8', fill: '#f59e0b', fontSize: 9, position: 'insideTopRight' }} />
<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+8 (scénario)" stroke="#f59e0b" 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.
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 (
<div className="overflow-x-auto">
<table className="w-full text-xs border-collapse">
<thead>
<tr className="text-slate-500 text-left">
<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>)}
</tr>
</thead>
<tbody>
{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>
{row.pnl.map((v, ci) => (
<td key={ci} className="py-1 px-2 text-right font-mono text-white" style={{ backgroundColor: cellBg(v) }}>
{fmtMoney(v)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}
function GreeksTile({ label, now, scenario, precision = 4, hint }: { label: string; now: number; scenario: number; precision?: number; hint?: string }) {
return (
<div className="card-sm" title={hint}>
@@ -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<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: [],
@@ -1439,11 +1519,43 @@ export default function StrategyBuilder() {
</div>
<div className="card">
<div className="stat-label mb-2">Diagramme payoff</div>
<PayoffChart priced={priced} spot={priced.spot} scenarioSpot={priced.scenario_spot} />
<p className="text-[11px] text-slate-500 mt-1">
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}.
</p>
<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>
</div>
{payoffView === 'curve' && (
<>
<PayoffChart priced={priced} spot={priced.spot} scenarioSpot={priced.scenario_spot} horizonDays={horizonDays} />
<p className="text-[11px] text-slate-500 mt-1">
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}.
</p>
</>
)}
{payoffView === 'slices' && (
<>
<TimeDecayChart timeSlices={priced.time_slices} spot={priced.spot} scenarioSpot={priced.scenario_spot} />
<p className="text-[11px] text-slate-500 mt-1">
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">
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>
</>
)}
</div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">