feat: strategy builder

This commit is contained in:
OpenSquared
2026-08-03 09:36:13 +02:00
parent 7e640a09d0
commit 663e7eaa74
6 changed files with 440 additions and 14 deletions

View File

@@ -1743,6 +1743,14 @@ export type ChainSlice = { symbol: string; proxy: string; spot: number; as_of?:
export type ManualGridCell = { days_to_expiry: number; strike_pct: number; iv: number | null }
// One anchor point of a scenario time-path: `value` is in the same unit as the scalar
// field it overrides (spot: %, iv: vol pts, skew/term: same units as their scalar
// counterparts). `day` is elapsed days from entry (0 = today). See lib/scenarioPath.ts
// for turning a shape (bell/oscillation/exponential/step/custom) into this plain list —
// the backend (services/scenario_path.py) only ever interpolates points, it has no
// notion of "shapes" itself.
export type PathPoint = { day: number; value: number }
export type StrategyScenario = {
symbol: string
horizon_days: number
@@ -1752,6 +1760,15 @@ export type StrategyScenario = {
term_slope_shift: number
rate_shock_bps?: number
manual_grid?: ManualGridCell[]
// Optional time-paths driving the day-by-day payoff table (see payoff_heatmap on the
// backend) — when omitted, pricing behaves exactly as the plain scalar shock above
// (unchanged, single point-in-time scenario). The scalar fields above remain what
// /optimize and /suggested-profile read (single scenario point), and also serve as the
// fallback value for days outside whichever path IS given.
spot_path?: PathPoint[] | null
iv_path?: PathPoint[] | null
skew_path?: PathPoint[] | null
term_path?: PathPoint[] | null
rate?: number
n_expiries?: number
contract_size?: number

View File

@@ -0,0 +1,89 @@
// Turns a shape + params choice into a plain list of {day, value} anchor points — the
// backend (services/scenario_path.py) only ever interpolates whatever points it's given,
// it has no notion of "bell" or "oscillation" itself. This is the one place shapes are
// defined, reused for all four scenario dimensions (spot shock %, IV level shift in vol
// points, skew tilt, term slope shift) via the same PathParams shape.
import type { PathPoint } from '../hooks/useApi'
export type PathShape = 'point' | 'linear' | 'bell' | 'oscillation' | 'exponential' | 'step' | 'custom'
export const PATH_SHAPES: { key: PathShape; label: string; hint: string }[] = [
{ key: 'point', label: 'Ponctuel', hint: "Un seul choc à l'horizon, comme avant — pas de trajectoire." },
{ key: 'linear', label: 'Linéaire', hint: 'Évolution progressive et régulière vers la valeur finale.' },
{ key: 'bell', label: 'Cloche', hint: 'Monte vers un pic à un jour donné, puis revient vers la valeur finale.' },
{ key: 'oscillation', label: 'Oscillation (range)', hint: 'Va-et-vient autour de zéro — plusieurs cycles possibles.' },
{ key: 'exponential', label: 'Exponentielle', hint: 'Mouvement qui accélère (ou décélère) vers la valeur finale.' },
{ key: 'step', label: 'Palier', hint: 'Reste plat puis bascule nettement à un jour donné (ex: pic de vol le jeudi).' },
{ key: 'custom', label: 'Personnalisé', hint: 'Points définis à la main.' },
]
export type PathParams = {
shape: PathShape
finalValue: number // value reached at horizon_days (linear/exponential/step's post-jump level)
amplitude: number // bell peak height above the linear baseline / oscillation half-amplitude / step jump size
peakDay: number // bell: day of the peak
stepDay: number // step: day of the jump; oscillation/bell reuse it as a phase anchor where relevant
cycles: number // oscillation: number of full cycles across the horizon
curvature: number // exponential: shape parameter k (0 = linear-ish; >0 back-loaded; <0 front-loaded)
customPoints: PathPoint[] // used when shape === 'custom'
}
export function defaultPathParams(finalValue = 0): PathParams {
return {
shape: 'point', finalValue, amplitude: 0, peakDay: 1, stepDay: 1, cycles: 1, curvature: 2,
customPoints: [{ day: 0, value: 0 }, { day: 1, value: finalValue }],
}
}
/** Dense sample of the shape as {day, value} points across [0, horizonDays], for both the
* live sparkline preview and the payload sent to the backend (which just interpolates). */
export function generatePath(params: PathParams, horizonDays: number, nSamples = 24): PathPoint[] {
const h = Math.max(horizonDays, 0.001)
if (params.shape === 'custom') {
return [...params.customPoints].sort((a, b) => a.day - b.day)
}
const pts: PathPoint[] = []
for (let i = 0; i <= nSamples; i++) {
const day = (h * i) / nSamples
pts.push({ day: round4(day), value: round4(valueAt(params, day, h)) })
}
return pts
}
function valueAt(params: PathParams, day: number, horizonDays: number): number {
const t = horizonDays > 0 ? day / horizonDays : 1
const { shape, finalValue, amplitude, peakDay, stepDay, cycles, curvature } = params
switch (shape) {
case 'point':
// Flat at 0 until the very last instant, then the terminal value — matches today's
// "single shock evaluated only at horizon_days" behavior if ever sampled mid-path.
return day >= horizonDays - 1e-6 ? finalValue : 0
case 'linear':
return finalValue * t
case 'bell': {
const tp = horizonDays > 0 ? Math.min(Math.max(peakDay / horizonDays, 1e-3), 1 - 1e-3) : 0.5
// Smooth hump peaking at tp, riding on top of the linear path to finalValue, built
// from two half-cosine lobes so the peak day is adjustable without distorting the
// endpoints (bump is exactly 0 at t=0 and t=1).
const bump = t <= tp
? amplitude * (1 - Math.cos(Math.PI * (t / tp))) / 2
: amplitude * (1 - Math.cos(Math.PI * (1 - (t - tp) / (1 - tp)))) / 2
return finalValue * t + bump
}
case 'oscillation':
return finalValue * t + amplitude * Math.sin(2 * Math.PI * cycles * t)
case 'exponential': {
const k = curvature
if (Math.abs(k) < 1e-6) return finalValue * t
return finalValue * (Math.exp(k * t) - 1) / (Math.exp(k) - 1)
}
case 'step':
// Flat at 0, then flat at finalValue from stepDay onward — e.g. "vol calme jusqu'à
// jeudi, puis un cran plus haut" is stepDay=3, finalValue=+8 (vol pts).
return day < stepDay - 1e-9 ? 0 : finalValue
default:
return 0
}
}
function round4(v: number) { return Math.round(v * 10000) / 10000 }

View File

@@ -10,10 +10,11 @@ import {
useSavedStrategies, useSaveStrategyRecord, useDeleteSavedStrategy,
useSaxoSymbols, useIvForTrade,
type StrategyLeg, type StrategyScenario, type PayoffHeatmap, type PayoffHeatmapMetric, type StrategyCandidate,
type OptimizeConstraints, type SavedScenario,
type OptimizeConstraints, type SavedScenario, type PathPoint,
type GreekProfile, type GreekTarget, type GreekState, type GreekTolerance, DEFAULT_GREEK_PROFILE,
} from '../hooks/useApi'
import { fmtPrice, fmtAsOf } from '../lib/format'
import { PATH_SHAPES, defaultPathParams, generatePath, type PathShape, type PathParams } from '../lib/scenarioPath'
const STRIKE_PCTS = [80, 85, 90, 95, 100, 105, 110, 115, 120]
const DELTA_NEUTRAL_THRESHOLD = 0.15
@@ -261,6 +262,206 @@ function ScenarioSlidersPanel({ scenario, setScenario }: { scenario: StrategySce
)
}
// ── Scenario time-paths (trajectoire spot/vol/skew/terme) ────────────────────
type PathDim = 'spot' | 'iv' | 'skew' | 'term'
const PATH_DIM_CONFIG: Record<PathDim, {
label: string; scenarioKey: 'spot_shock_pct' | 'iv_level_shift' | 'skew_tilt' | 'term_slope_shift'
pathKey: 'spot_path' | 'iv_path' | 'skew_path' | 'term_path'
min: number; max: number; step: number; fmt: (v: number) => string
}> = {
spot: { label: 'Trajectoire du sous-jacent', scenarioKey: 'spot_shock_pct', pathKey: 'spot_path', min: -20, max: 20, step: 0.1, fmt: (v) => `${v >= 0 ? '+' : ''}${v.toFixed(1)}%` },
iv: { label: 'Trajectoire de la volatilité (niveau IV)', scenarioKey: 'iv_level_shift', pathKey: 'iv_path', min: -0.15, max: 0.15, step: 0.005, fmt: (v) => `${v >= 0 ? '+' : ''}${(v * 100).toFixed(1)}pts` },
skew: { label: 'Trajectoire du skew', scenarioKey: 'skew_tilt', pathKey: 'skew_path', min: -0.1, max: 0.1, step: 0.005, fmt: (v) => v.toFixed(3) },
term: { label: 'Trajectoire de la pente du terme', scenarioKey: 'term_slope_shift', pathKey: 'term_path', min: -0.1, max: 0.1, step: 0.005, fmt: (v) => `${v >= 0 ? '+' : ''}${(v * 100).toFixed(1)}pts` },
}
function Sparkline({ points, horizonDays, width = 220, height = 44 }: { points: PathPoint[]; horizonDays: number; width?: number; height?: number }) {
if (!points.length) return null
const values = points.map(p => p.value)
const vMin = Math.min(0, ...values), vMax = Math.max(0, ...values)
const span = vMax - vMin || 1
const x = (day: number) => (horizonDays > 0 ? (day / horizonDays) * (width - 4) + 2 : 2)
const y = (v: number) => height - 4 - ((v - vMin) / span) * (height - 8)
const path = points.map((p, i) => `${i === 0 ? 'M' : 'L'}${x(p.day).toFixed(1)},${y(p.value).toFixed(1)}`).join(' ')
const zeroY = y(0)
return (
<svg width={width} height={height} className="shrink-0">
<line x1={0} y1={zeroY} x2={width} y2={zeroY} stroke="currentColor" strokeOpacity={0.15} strokeDasharray="3,3" />
<path d={path} fill="none" stroke="#38bdf8" strokeWidth={1.5} />
</svg>
)
}
function PathDimEditor({
dim, params, setParams, horizonDays, scenario, setScenario,
}: {
dim: PathDim; params: PathParams; setParams: (p: PathParams) => void; horizonDays: number
scenario: StrategyScenario; setScenario: (v: StrategyScenario) => void
}) {
const cfg = PATH_DIM_CONFIG[dim]
const apply = (next: PathParams) => {
setParams(next)
if (next.shape === 'point') {
setScenario({ ...scenario, [cfg.pathKey]: null, [cfg.scenarioKey]: next.finalValue })
return
}
const path = generatePath(next, horizonDays)
setScenario({ ...scenario, [cfg.pathKey]: path, [cfg.scenarioKey]: next.finalValue })
}
const preview = params.shape === 'point' ? [{ day: 0, value: 0 }, { day: horizonDays, value: params.finalValue }] : generatePath(params, horizonDays)
return (
<div className="space-y-2 border-t border-slate-800/60 pt-3 first:border-0 first:pt-0">
<div className="flex items-center justify-between gap-3">
<div className="text-xs text-slate-400">{cfg.label}</div>
<select
value={params.shape}
onChange={(e) => apply({ ...params, shape: e.target.value as PathShape })}
className="bg-dark-700 border border-slate-700/50 rounded px-2 py-1 text-xs text-slate-200"
>
{PATH_SHAPES.map(s => <option key={s.key} value={s.key}>{s.label}</option>)}
</select>
</div>
<div className="flex items-center gap-3">
<Sparkline points={preview} horizonDays={horizonDays} />
<div className="flex-1 grid grid-cols-2 gap-2 text-xs">
{params.shape !== 'custom' && (
<label className="flex items-center justify-between gap-2 text-slate-400">
{params.shape === 'step' ? 'Niveau après le palier' : 'Valeur finale (J+' + horizonDays + ')'}
<input
type="number" step={cfg.step} value={params.finalValue}
onChange={(e) => apply({ ...params, finalValue: parseFloat(e.target.value) || 0 })}
className="w-24 bg-dark-700 border border-slate-700/50 rounded px-1.5 py-0.5 text-slate-200 text-right"
/>
</label>
)}
{(params.shape === 'bell' || params.shape === 'oscillation') && (
<label className="flex items-center justify-between gap-2 text-slate-400">
{params.shape === 'bell' ? 'Amplitude du pic' : 'Amplitude'}
<input
type="number" step={cfg.step} value={params.amplitude}
onChange={(e) => apply({ ...params, amplitude: parseFloat(e.target.value) || 0 })}
className="w-24 bg-dark-700 border border-slate-700/50 rounded px-1.5 py-0.5 text-slate-200 text-right"
/>
</label>
)}
{params.shape === 'bell' && (
<label className="flex items-center justify-between gap-2 text-slate-400">
Jour du pic
<input
type="number" min={0} max={horizonDays} step={0.5} value={params.peakDay}
onChange={(e) => apply({ ...params, peakDay: parseFloat(e.target.value) || 0 })}
className="w-24 bg-dark-700 border border-slate-700/50 rounded px-1.5 py-0.5 text-slate-200 text-right"
/>
</label>
)}
{params.shape === 'oscillation' && (
<label className="flex items-center justify-between gap-2 text-slate-400">
Cycles sur la période
<input
type="number" min={0.5} step={0.5} value={params.cycles}
onChange={(e) => apply({ ...params, cycles: parseFloat(e.target.value) || 1 })}
className="w-24 bg-dark-700 border border-slate-700/50 rounded px-1.5 py-0.5 text-slate-200 text-right"
/>
</label>
)}
{params.shape === 'exponential' && (
<label className="flex items-center justify-between gap-2 text-slate-400">
Courbure (k)
<input
type="number" step={0.5} value={params.curvature}
onChange={(e) => apply({ ...params, curvature: parseFloat(e.target.value) || 0 })}
className="w-24 bg-dark-700 border border-slate-700/50 rounded px-1.5 py-0.5 text-slate-200 text-right"
/>
</label>
)}
{params.shape === 'step' && (
<label className="flex items-center justify-between gap-2 text-slate-400">
Jour du palier
<input
type="number" min={0} max={horizonDays} step={0.5} value={params.stepDay}
onChange={(e) => apply({ ...params, stepDay: parseFloat(e.target.value) || 0 })}
className="w-24 bg-dark-700 border border-slate-700/50 rounded px-1.5 py-0.5 text-slate-200 text-right"
/>
</label>
)}
</div>
</div>
{params.shape === 'custom' && (
<div className="space-y-1">
{params.customPoints.map((p, i) => (
<div key={i} className="flex items-center gap-2 text-xs">
<span className="text-slate-500 w-10">J+</span>
<input
type="number" min={0} max={horizonDays} step={0.5} value={p.day}
onChange={(e) => {
const pts = [...params.customPoints]
pts[i] = { ...pts[i], day: parseFloat(e.target.value) || 0 }
apply({ ...params, customPoints: pts })
}}
className="w-16 bg-dark-700 border border-slate-700/50 rounded px-1.5 py-0.5 text-slate-200"
/>
<input
type="number" step={cfg.step} value={p.value}
onChange={(e) => {
const pts = [...params.customPoints]
pts[i] = { ...pts[i], value: parseFloat(e.target.value) || 0 }
apply({ ...params, customPoints: pts })
}}
className="w-24 bg-dark-700 border border-slate-700/50 rounded px-1.5 py-0.5 text-slate-200"
/>
<button
onClick={() => apply({ ...params, customPoints: params.customPoints.filter((_, j) => j !== i) })}
className="text-slate-500 hover:text-red-400"
>
<Trash2 size={12} />
</button>
</div>
))}
<button
onClick={() => apply({ ...params, customPoints: [...params.customPoints, { day: horizonDays, value: 0 }] })}
className="text-xs text-blue-400 hover:text-blue-300"
>
+ point
</button>
</div>
)}
</div>
)
}
function ScenarioPathPanel({
scenario, setScenario, horizonDays,
}: { scenario: StrategyScenario; setScenario: (v: StrategyScenario) => void; horizonDays: number }) {
const [paramsByDim, setParamsByDim] = useState<Record<PathDim, PathParams>>({
spot: defaultPathParams(scenario.spot_shock_pct),
iv: defaultPathParams(scenario.iv_level_shift),
skew: defaultPathParams(scenario.skew_tilt),
term: defaultPathParams(scenario.term_slope_shift),
})
return (
<div className="card space-y-3">
<div className="flex items-center justify-between">
<div className="stat-label">Trajectoire du scénario (optionnel)</div>
<div className="text-[11px] text-slate-500">Décrit le tableau de payoff jour par jour le choc ponctuel ci-dessus reste utilisé par l'optimiseur</div>
</div>
{(Object.keys(PATH_DIM_CONFIG) as PathDim[]).map(dim => (
<PathDimEditor
key={dim} dim={dim} params={paramsByDim[dim]} horizonDays={horizonDays}
setParams={(p) => setParamsByDim(prev => ({ ...prev, [dim]: p }))}
scenario={scenario} setScenario={setScenario}
/>
))}
</div>
)
}
// ── Manual grid override ──────────────────────────────────────────────────────
function ScenarioGrid({
@@ -1301,6 +1502,7 @@ export default function StrategyBuilder() {
</div>
{subTab === 'params' && mode === 'build' && <ScenarioSlidersPanel scenario={scenario} setScenario={setScenario} />}
{subTab === 'params' && mode === 'build' && <ScenarioPathPanel scenario={scenario} setScenario={setScenario} horizonDays={scenario.horizon_days} />}
{subTab === 'params' && mode === 'historical' && (
<HistoricalPeriodPanel
symbol={debouncedSymbol} legs={legs} contractSize={scenario.contract_size ?? 100_000}