feat: strategy builder
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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&L max de cette grille.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
||||
|
||||
Reference in New Issue
Block a user