feat: strategy builder
This commit is contained in:
@@ -398,16 +398,59 @@ def time_decay_slices(
|
||||
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,
|
||||
) -> List[float]:
|
||||
"""Exact expiry P&L zero-crossings — the same value_at boundary check_bounded_risk
|
||||
already prices, scanned for sign changes and bisected instead of searched for its
|
||||
extrema. Lets payoff_heatmap pin a real breakeven column instead of only ever landing
|
||||
near one by luck of the price sampling."""
|
||||
def f(s: float) -> float:
|
||||
return value_at(legs, s, eval_days_expiry, surface, r, contract_size) - entry_ref
|
||||
|
||||
grid = np.linspace(max(spot * 0.2, 1e-6), spot * 3.0, n)
|
||||
vals = [f(float(p)) for p in grid]
|
||||
roots: List[float] = []
|
||||
for i in range(len(grid) - 1):
|
||||
a, b = vals[i], vals[i + 1]
|
||||
if a == 0:
|
||||
roots.append(float(grid[i]))
|
||||
continue
|
||||
if (a < 0) != (b < 0):
|
||||
lo_b, hi_b, f_lo = float(grid[i]), float(grid[i + 1]), a
|
||||
for _ in range(30):
|
||||
mid = (lo_b + hi_b) / 2
|
||||
f_mid = f(mid)
|
||||
if (f_mid < 0) == (f_lo < 0):
|
||||
lo_b, f_lo = mid, f_mid
|
||||
else:
|
||||
hi_b = mid
|
||||
roots.append(round((lo_b + hi_b) / 2, 4))
|
||||
return roots
|
||||
|
||||
|
||||
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,
|
||||
entry_ref: float, contract_size: float = DEFAULT_CONTRACT_SIZE, n_prices: int = 17, 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)
|
||||
to expiry (top-to-bottom reading matches watching the position age). Columns are
|
||||
centered and symmetric around spot, scaled to how far the legs' own strikes sit from it
|
||||
(tight for a near-the-money single leg, wide for a far-strike spread) — a fixed +-15%
|
||||
window left more than half the grid flat at max loss/gain for a near-the-money position,
|
||||
wasting resolution nowhere near where the P&L actually transitions. The exact expiry
|
||||
breakeven(s) are pinned in as extra columns (breakeven_prices in the response) instead
|
||||
of only ever landing near one by luck of the price sampling."""
|
||||
strikes = [l["strike"] for l in legs if l["option_type"] != "stock"]
|
||||
half_width = max(max(abs(spot - k) for k in strikes) * 1.4, spot * 0.03) if strikes else spot * 0.15
|
||||
lo, hi = max(spot - half_width, spot * 0.01), spot + half_width
|
||||
|
||||
breakevens = _find_breakevens(legs, surface, eval_days_expiry, r, entry_ref, spot, contract_size)
|
||||
near_breakevens = sorted((p for p in breakevens if lo <= p <= hi), key=lambda p: abs(p - spot))[:2]
|
||||
|
||||
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 = [
|
||||
{
|
||||
@@ -416,7 +459,11 @@ def payoff_heatmap(
|
||||
}
|
||||
for d in day_points
|
||||
]
|
||||
return {"prices": [round(float(p), 4) for p in price_points], "rows": rows}
|
||||
return {
|
||||
"prices": [round(float(p), 4) for p in price_points],
|
||||
"rows": rows,
|
||||
"breakeven_prices": [round(p, 4) for p in near_breakevens],
|
||||
}
|
||||
|
||||
|
||||
def payoff_curves(
|
||||
|
||||
@@ -1784,7 +1784,7 @@ export type VannaSimulation = {
|
||||
}
|
||||
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 PayoffHeatmap = { prices: number[]; rows: { days_from_now: number; pnl: number[] }[]; breakeven_prices: number[] }
|
||||
|
||||
export type PriceCombo = {
|
||||
entry_cost: number
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
LineChart, Line, AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ReferenceLine, ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
import { Layers, Plus, Trash2, RefreshCw, AlertTriangle, Search, Save, FolderOpen, X, History } from 'lucide-react'
|
||||
import { Layers, Plus, Trash2, RefreshCw, AlertTriangle, Search, Save, FolderOpen, X, History, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
useOptionChainSlice, usePriceStrategy, useOptimizeStrategy, useSuggestedProfile, useReplayStrategy, usePresets, useRealizedScenario,
|
||||
@@ -129,7 +129,12 @@ function TimeDecayChart({ timeSlices, spot, scenarioSpot }: { timeSlices: TimeSl
|
||||
// "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.
|
||||
// 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
|
||||
// to pan) and highlights the breakeven column(s) instead of leaving them to blend in.
|
||||
const HEATMAP_WINDOW = 9
|
||||
|
||||
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)))
|
||||
@@ -139,28 +144,64 @@ function PayoffHeatmapView({ heatmap, spot }: { heatmap: PayoffHeatmap; spot: nu
|
||||
}
|
||||
const rowLabel = (daysFromNow: number, i: number) =>
|
||||
daysFromNow < 0.5 ? "Aujourd'hui" : i === heatmap.rows.length - 1 ? 'Échéance' : `J+${Math.round(daysFromNow)}`
|
||||
const isBreakeven = (p: number) => heatmap.breakeven_prices.some(be => Math.abs(be - p) < 1e-6)
|
||||
|
||||
const total = heatmap.prices.length
|
||||
// Centers the window on spot once, on mount — deliberately not re-centered on every
|
||||
// reprice (scrubbing/tweaking a leg would otherwise yank the scroll position back under
|
||||
// the user while they're mid-interaction with the arrows below).
|
||||
const [windowStart, setWindowStart] = useState(() => {
|
||||
const closest = heatmap.prices.reduce((best, p, i) => Math.abs(p - spot) < Math.abs(heatmap.prices[best] - spot) ? i : best, 0)
|
||||
return Math.max(0, Math.min(total - HEATMAP_WINDOW, closest - Math.floor(HEATMAP_WINDOW / 2)))
|
||||
})
|
||||
const start = Math.max(0, Math.min(windowStart, Math.max(total - HEATMAP_WINDOW, 0)))
|
||||
const visible = Array.from({ length: Math.min(HEATMAP_WINDOW, total) }, (_, i) => start + i)
|
||||
const scroll = (dir: -1 | 1) => setWindowStart(s => Math.max(0, Math.min(Math.max(total - HEATMAP_WINDOW, 0), s + dir * 3)))
|
||||
|
||||
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>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => scroll(-1)} disabled={start <= 0}
|
||||
className="p-1 rounded border border-slate-700/50 text-slate-400 hover:text-white disabled:opacity-30 shrink-0"
|
||||
>
|
||||
<ChevronLeft className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<div className="overflow-x-auto flex-1">
|
||||
<table className="w-full text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="text-slate-500 text-left">
|
||||
<th className="py-1 pr-3">Jours</th>
|
||||
{visible.map(i => (
|
||||
<th key={i} className={clsx('py-1 px-2 text-right', isBreakeven(heatmap.prices[i]) && 'text-amber-400')}>
|
||||
{heatmap.prices[i].toFixed(decimals)}
|
||||
{isBreakeven(heatmap.prices[i]) && <div className="text-[9px] font-normal normal-case">breakeven</div>}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
{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]) }}
|
||||
>
|
||||
{fmtMoney(row.pnl[i])}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => scroll(1)} disabled={start + HEATMAP_WINDOW >= total}
|
||||
className="p-1 rounded border border-slate-700/50 text-slate-400 hover:text-white disabled:opacity-30 shrink-0"
|
||||
>
|
||||
<ChevronRight className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user