feat: strategy builder
This commit is contained in:
@@ -375,29 +375,6 @@ def expected_pnl_scenario(
|
|||||||
return float(numerator / denominator) if denominator > 1e-12 else float(pnl.mean())
|
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(
|
def _find_breakevens(
|
||||||
legs: List[Dict[str, Any]], surface: Any, eval_days_expiry: float, r: float,
|
legs: List[Dict[str, Any]], surface: Any, eval_days_expiry: float, r: float,
|
||||||
entry_ref: float, spot: float, contract_size: float, n: int = 300,
|
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 = np.unique(np.concatenate([np.linspace(lo, hi, n_prices), np.array(near_breakevens)]))
|
||||||
price_points.sort()
|
price_points.sort()
|
||||||
day_points = np.linspace(0, eval_days_expiry, n_days)
|
day_points = np.linspace(0, eval_days_expiry, n_days)
|
||||||
rows = [
|
# 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
|
||||||
"days_from_now": round(float(d), 1),
|
# price x time grid instead of only "now" vs "scenario" — lets the metric selector show
|
||||||
"pnl": [round(float(value_at(legs, float(p), float(d), surface, r, contract_size) - entry_ref), 2) for p in price_points],
|
# how Delta/Gamma/Theta/Vega/Rho actually evolve across the scenario, not just their
|
||||||
}
|
# two endpoint values.
|
||||||
for d in day_points
|
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 {
|
return {
|
||||||
"prices": [round(float(p), 4) for p in price_points],
|
"prices": [round(float(p), 4) for p in price_points],
|
||||||
"rows": rows,
|
"rows": rows,
|
||||||
@@ -473,52 +470,13 @@ def payoff_curves(
|
|||||||
surface_scenario: ScenarioSurface,
|
surface_scenario: ScenarioSurface,
|
||||||
horizon_days: int,
|
horizon_days: int,
|
||||||
r: float = 0.05,
|
r: float = 0.05,
|
||||||
n: int = 100,
|
|
||||||
contract_size: float = DEFAULT_CONTRACT_SIZE,
|
contract_size: float = DEFAULT_CONTRACT_SIZE,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
spot = chain_slice["spot"]
|
spot = chain_slice["spot"]
|
||||||
priced = price_combo(legs, chain_slice, surface_now, surface_scenario, horizon_days, r, contract_size)
|
priced = price_combo(legs, chain_slice, surface_now, surface_scenario, horizon_days, r, contract_size)
|
||||||
entry_ref = priced["entry_cost"]
|
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)
|
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)
|
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}
|
||||||
|
|||||||
@@ -1782,9 +1782,12 @@ export type VannaSimulation = {
|
|||||||
spot_shock_pct: number; iv_shock_pts: number
|
spot_shock_pct: number; iv_shock_pts: number
|
||||||
delta_before: number; delta_after: number; delta_change: number
|
delta_before: number; delta_after: number; delta_change: number
|
||||||
}
|
}
|
||||||
export type PayoffPoint = { underlying: number; pnl: number }
|
export type PayoffHeatmapMetric = 'pnl' | 'delta' | 'gamma' | 'theta' | 'vega' | 'rho'
|
||||||
export type TimeSlice = { days_from_now: number; label: string; points: PayoffPoint[] }
|
export type PayoffHeatmap = {
|
||||||
export type PayoffHeatmap = { prices: number[]; rows: { days_from_now: number; pnl: number[] }[]; breakeven_prices: number[] }
|
prices: number[]
|
||||||
|
rows: ({ days_from_now: number } & Record<PayoffHeatmapMetric, number[]>)[]
|
||||||
|
breakeven_prices: number[]
|
||||||
|
}
|
||||||
|
|
||||||
export type PriceCombo = {
|
export type PriceCombo = {
|
||||||
entry_cost: number
|
entry_cost: number
|
||||||
@@ -1801,9 +1804,6 @@ export type PriceCombo = {
|
|||||||
net_delta_now: number
|
net_delta_now: number
|
||||||
net_delta_scenario: number
|
net_delta_scenario: number
|
||||||
vanna_simulation: VannaSimulation | null
|
vanna_simulation: VannaSimulation | null
|
||||||
at_expiry: PayoffPoint[]
|
|
||||||
at_scenario: PayoffPoint[]
|
|
||||||
time_slices: TimeSlice[]
|
|
||||||
heatmap: PayoffHeatmap
|
heatmap: PayoffHeatmap
|
||||||
spot: number
|
spot: number
|
||||||
scenario_spot: number
|
scenario_spot: number
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
LineChart, Line, AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ReferenceLine, ResponsiveContainer,
|
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ReferenceLine, ResponsiveContainer,
|
||||||
} from 'recharts'
|
} from 'recharts'
|
||||||
import { Layers, Plus, Trash2, RefreshCw, AlertTriangle, Search, Save, FolderOpen, X, History, ChevronLeft, ChevronRight } from 'lucide-react'
|
import { Layers, Plus, Trash2, RefreshCw, AlertTriangle, Search, Save, FolderOpen, X, History, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
useScenarios, useSaveScenario, useDeleteScenario,
|
useScenarios, useSaveScenario, useDeleteScenario,
|
||||||
useSavedStrategies, useSaveStrategyRecord, useDeleteSavedStrategy,
|
useSavedStrategies, useSaveStrategyRecord, useDeleteSavedStrategy,
|
||||||
useSaxoSymbols, useIvForTrade,
|
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 OptimizeConstraints, type SavedScenario,
|
||||||
type GreekProfile, type GreekTarget, type GreekState, type GreekTolerance, DEFAULT_GREEK_PROFILE,
|
type GreekProfile, type GreekTarget, type GreekState, type GreekTolerance, DEFAULT_GREEK_PROFILE,
|
||||||
} from '../hooks/useApi'
|
} from '../hooks/useApi'
|
||||||
@@ -49,98 +49,37 @@ function estimateBaseIv(chain: any, daysToExpiry: number, strikePct: number, spo
|
|||||||
return nearest.iv
|
return nearest.iv
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Payoff chart ──────────────────────────────────────────────────────────────
|
// ── Payoff heatmap ──────────────────────────────────────────────────────────────
|
||||||
|
// Sole payoff view — price x days-to-expiry grid (services.strategy_engine.payoff_heatmap).
|
||||||
function PayoffChart({ priced, spot, scenarioSpot, horizonDays }: { priced: PriceCombo; spot: number; scenarioSpot: number; horizonDays: number }) {
|
// A metric selector switches which grid is shown (P&L or any first-order Greek), so the
|
||||||
const data = priced.at_expiry.map((p, i) => ({
|
// same heatmap explains not just where the position wins/loses but how Delta/Gamma/
|
||||||
underlying: p.underlying,
|
// Theta/Vega/Rho actually evolve across price and time, not just their two endpoint
|
||||||
expiry: p.pnl,
|
// values (now vs scenario) the tiles below already show. Cell shade encodes sign +
|
||||||
scenario: priced.at_scenario[i]?.pnl,
|
// 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
|
||||||
// Decimals scale with the underlying's own magnitude — an equity at ~740 reads fine
|
// backend centers/scales the price columns on spot and the legs' own strikes and pins in
|
||||||
// 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
|
|
||||||
// the exact breakeven(s) — this view shows a 9-wide window over that wider grid (arrows
|
// 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.
|
// 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
|
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 decimals = spot < 5 ? 4 : spot < 50 ? 2 : 0
|
||||||
const maxAbs = Math.max(1, ...heatmap.rows.flatMap(r => r.pnl.map(Math.abs)))
|
const maxAbs = Math.max(1e-9, ...heatmap.rows.flatMap(r => r[metric].map(Math.abs)))
|
||||||
const cellBg = (pnl: number) => {
|
const cellBg = (v: number) => {
|
||||||
const alpha = 0.12 + Math.min(1, Math.abs(pnl) / maxAbs) * 0.55
|
const alpha = 0.12 + Math.min(1, Math.abs(v) / maxAbs) * 0.55
|
||||||
return pnl >= 0 ? `rgba(16,185,129,${alpha})` : `rgba(239,68,68,${alpha})`
|
return v >= 0 ? `rgba(16,185,129,${alpha})` : `rgba(239,68,68,${alpha})`
|
||||||
}
|
}
|
||||||
const rowLabel = (daysFromNow: number, i: number) =>
|
const rowLabel = (daysFromNow: number, i: number) =>
|
||||||
daysFromNow < 0.5 ? "Aujourd'hui" : i === heatmap.rows.length - 1 ? 'Échéance' : `J+${Math.round(daysFromNow)}`
|
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 => (
|
{visible.map(i => (
|
||||||
<td key={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')}
|
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>
|
</td>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
@@ -1116,7 +1055,7 @@ export default function StrategyBuilder() {
|
|||||||
const [symbol, setSymbol] = useState('')
|
const [symbol, setSymbol] = useState('')
|
||||||
const [debouncedSymbol, setDebouncedSymbol] = useState('')
|
const [debouncedSymbol, setDebouncedSymbol] = useState('')
|
||||||
const [horizonDays, setHorizonDays] = useState(8)
|
const [horizonDays, setHorizonDays] = useState(8)
|
||||||
const [payoffView, setPayoffView] = useState<'curve' | 'slices' | 'heatmap'>('curve')
|
const [heatmapMetric, setHeatmapMetric] = useState<PayoffHeatmapMetric>('pnl')
|
||||||
const [scenario, setScenario] = useState<StrategyScenario>({
|
const [scenario, setScenario] = useState<StrategyScenario>({
|
||||||
symbol: '', horizon_days: 8, spot_shock_pct: 0, iv_level_shift: 0, skew_tilt: 0, term_slope_shift: 0,
|
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: [],
|
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="card">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="stat-label mb-0">Diagramme payoff</div>
|
<div className="stat-label mb-0">Diagramme payoff</div>
|
||||||
<div className="flex items-center gap-1">
|
<select
|
||||||
{([['curve', 'Courbe'], ['slices', 'Paliers T+N'], ['heatmap', 'Heatmap']] as const).map(([v, label]) => (
|
value={heatmapMetric}
|
||||||
<button key={v} onClick={() => setPayoffView(v)}
|
onChange={(e) => setHeatmapMetric(e.target.value as PayoffHeatmapMetric)}
|
||||||
className={clsx('px-2.5 py-1 rounded text-[11px] font-semibold transition-colors',
|
className="bg-dark-700 border border-slate-700/50 rounded px-2 py-1 text-[11px] font-semibold text-slate-200"
|
||||||
payoffView === v ? 'bg-blue-600 text-white' : 'bg-dark-700 text-slate-400 hover:text-slate-200 border border-slate-700/50')}
|
>
|
||||||
>
|
{HEATMAP_METRICS.map(m => <option key={m.value} value={m.value}>{m.label}</option>)}
|
||||||
{label}
|
</select>
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{payoffView === 'curve' && (
|
<PayoffHeatmapView heatmap={priced.heatmap} spot={priced.spot} metric={heatmapMetric} />
|
||||||
<>
|
<p className="text-[11px] text-slate-500 mt-1">
|
||||||
<PayoffChart priced={priced} spot={priced.spot} scenarioSpot={priced.scenario_spot} horizonDays={scenario.horizon_days} />
|
{mode === 'historical' ? 'Vraie smile capturée au jour scruté' : 'Même vue de volatilité (celle du scénario)'} sur toute la grille
|
||||||
<p className="text-[11px] text-slate-500 mt-1">
|
{heatmapMetric === 'pnl'
|
||||||
{mode === 'historical'
|
? <> — vert/rouge = gain/perte, l'intensité est relative au P&L max de cette grille.</>
|
||||||
? <>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}).</>
|
: <> — 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.</>}
|
||||||
: <>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>
|
||||||
</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&L max de cette grille.
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||||
|
|||||||
Reference in New Issue
Block a user