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

@@ -15,6 +15,15 @@ from services.database import (
router = APIRouter(prefix="/api/strategy-builder", tags=["strategy-builder"]) router = APIRouter(prefix="/api/strategy-builder", tags=["strategy-builder"])
class PathPointIn(BaseModel):
"""One anchor point of a scenario time-path: `value` is in the same unit as the
scalar field it overrides (spot_shock_pct: %, iv_level_shift: vol pts, skew_tilt/
term_slope_shift: same units as their scalar counterparts). `day` is elapsed days
from entry (0 = today)."""
day: float
value: float
class LegIn(BaseModel): class LegIn(BaseModel):
expiry_date: str expiry_date: str
days_to_expiry: int days_to_expiry: int
@@ -33,6 +42,19 @@ class ScenarioIn(BaseModel):
term_slope_shift: float = 0.0 # term-structure slope, per 30 days (0 at days=0) term_slope_shift: float = 0.0 # term-structure slope, per 30 days (0 at days=0)
rate_shock_bps: float = 0.0 rate_shock_bps: float = 0.0
manual_grid: Optional[List[Dict[str, Any]]] = None manual_grid: Optional[List[Dict[str, Any]]] = None
# Optional time-paths: when given, the /price payoff table prices each day-row against
# the path's own interpolated value at that day (see services.scenario_path and
# payoff_heatmap's surface_at_day) instead of the single terminal shock applied
# uniformly across every day. spot_shock_pct/iv_level_shift/skew_tilt/term_slope_shift
# remain the fallback for days outside the path (and the only inputs when no path is
# given at all) and are also what /optimize and /suggested-profile still read — those
# endpoints price a single scenario point, not a full trajectory, and are unaffected
# by these fields. See services/lib/scenarioPath.ts for how shapes (bell/oscillation/
# exponential/step/custom) turn into these plain anchor-point lists.
spot_path: Optional[List[PathPointIn]] = None
iv_path: Optional[List[PathPointIn]] = None
skew_path: Optional[List[PathPointIn]] = None
term_path: Optional[List[PathPointIn]] = None
rate: float = 0.05 rate: float = 0.05
n_expiries: int = 3 n_expiries: int = 3
contract_size: float = DEFAULT_CONTRACT_SIZE contract_size: float = DEFAULT_CONTRACT_SIZE
@@ -131,6 +153,24 @@ class StrategySaveRequest(BaseModel):
source: str = "synthetic" # "synthetic" (Construire) | "historical" (Analyse période historique) source: str = "synthetic" # "synthetic" (Construire) | "historical" (Analyse période historique)
def _resolve_terminal_shocks(scenario: "ScenarioIn"):
"""The single point-in-time shock at horizon_days — from the path's own interpolated
value there when a path is given, otherwise the plain scalar (unchanged behavior).
This is what /optimize, /suggested-profile, and the entry/scenario cost figures use;
the day-by-day payoff table (payoff_heatmap) reads the full path directly instead."""
from services.scenario_path import interpolate_path
spot_pts = [p.model_dump() for p in scenario.spot_path] if scenario.spot_path else None
iv_pts = [p.model_dump() for p in scenario.iv_path] if scenario.iv_path else None
skew_pts = [p.model_dump() for p in scenario.skew_path] if scenario.skew_path else None
term_pts = [p.model_dump() for p in scenario.term_path] if scenario.term_path else None
return (
interpolate_path(spot_pts, scenario.horizon_days, scenario.spot_shock_pct),
interpolate_path(iv_pts, scenario.horizon_days, scenario.iv_level_shift),
interpolate_path(skew_pts, scenario.horizon_days, scenario.skew_tilt),
interpolate_path(term_pts, scenario.horizon_days, scenario.term_slope_shift),
)
def _build_surfaces(scenario: ScenarioIn): def _build_surfaces(scenario: ScenarioIn):
chain_slice = get_chain_slice( chain_slice = get_chain_slice(
scenario.symbol, scenario.horizon_days, scenario.n_expiries, scenario.symbol, scenario.horizon_days, scenario.n_expiries,
@@ -146,12 +186,13 @@ def _build_surfaces(scenario: ScenarioIn):
) )
surface_scenario = build_surface(checkpoint_chain) surface_scenario = build_surface(checkpoint_chain)
else: else:
spot_shock, iv_shift, skew_tilt, term_slope = _resolve_terminal_shocks(scenario)
surface_scenario = apply_scenario( surface_scenario = apply_scenario(
surface_now, surface_now,
spot_shock_pct=scenario.spot_shock_pct, spot_shock_pct=spot_shock,
iv_level_shift=scenario.iv_level_shift, iv_level_shift=iv_shift,
skew_tilt=scenario.skew_tilt, skew_tilt=skew_tilt,
term_slope_shift=scenario.term_slope_shift, term_slope_shift=term_slope,
manual_grid=scenario.manual_grid, manual_grid=scenario.manual_grid,
) )
return chain_slice, surface_now, surface_scenario return chain_slice, surface_now, surface_scenario
@@ -220,10 +261,23 @@ def price(req: PriceRequest):
raise HTTPException(status_code=404, detail=str(e)) raise HTTPException(status_code=404, detail=str(e))
legs = [leg.model_dump() for leg in req.legs] legs = [leg.model_dump() for leg in req.legs]
# Paths only drive the day-by-day payoff table, and only make sense for the synthetic
# parametric scenario — "Analyse période historique" (checkpoint_as_of) prices against
# a real remembered chain instead, which has no notion of a hypothesized path.
use_paths = not req.scenario.checkpoint_as_of
result = payoff_curves( result = payoff_curves(
legs, chain_slice, surface_now, surface_scenario, legs, chain_slice, surface_now, surface_scenario,
req.scenario.horizon_days, req.scenario.shocked_rate, req.scenario.horizon_days, req.scenario.shocked_rate,
contract_size=req.scenario.contract_size, contract_size=req.scenario.contract_size,
spot_path=([p.model_dump() for p in req.scenario.spot_path] if use_paths and req.scenario.spot_path else None),
iv_path=([p.model_dump() for p in req.scenario.iv_path] if use_paths and req.scenario.iv_path else None),
skew_path=([p.model_dump() for p in req.scenario.skew_path] if use_paths and req.scenario.skew_path else None),
term_path=([p.model_dump() for p in req.scenario.term_path] if use_paths and req.scenario.term_path else None),
base_spot_shock_pct=req.scenario.spot_shock_pct,
base_iv_level_shift=req.scenario.iv_level_shift,
base_skew_tilt=req.scenario.skew_tilt,
base_term_slope_shift=req.scenario.term_slope_shift,
manual_grid=req.scenario.manual_grid,
) )
result["spot"] = chain_slice["spot"] result["spot"] = chain_slice["spot"]
result["scenario_spot"] = surface_scenario.spot result["scenario_spot"] = surface_scenario.spot

View File

@@ -0,0 +1,31 @@
"""
Time-path scenario support: lets a scenario describe an evolving trajectory (bell, range/
oscillation, exponential, step, custom points...) for spot shock / IV level / skew tilt /
term slope across the days between entry and the scenario horizon, instead of only a single
point-in-time shock. The frontend is responsible for turning a shape+params choice into a
plain list of {day, value} anchor points (services/lib/scenarioPath.ts) — this module only
interpolates whatever anchor points it's given, so it has no notion of "bell" or
"oscillation" itself and stays reusable across spot/IV/skew/term alike.
A path is optional everywhere it's accepted: when None/empty, every call site here falls
back to the scalar shock value it already had (unchanged behavior from before paths existed).
"""
from typing import Any, Dict, List, Optional
def interpolate_path(path: Optional[List[Dict[str, Any]]], day: float, default: float) -> float:
"""Linear interpolation between anchor points {day, value}, clamped flat beyond the
first/last anchor. Falls back to `default` when no path is given at all."""
if not path:
return default
pts = sorted(path, key=lambda p: p["day"])
if day <= pts[0]["day"]:
return pts[0]["value"]
if day >= pts[-1]["day"]:
return pts[-1]["value"]
for p0, p1 in zip(pts, pts[1:]):
if p0["day"] <= day <= p1["day"]:
span = p1["day"] - p0["day"]
w = (day - p0["day"]) / span if span > 1e-9 else 0.0
return p0["value"] + (p1["value"] - p0["value"]) * w
return pts[-1]["value"]

View File

@@ -7,14 +7,15 @@ A "leg" dict: {expiry_date, days_to_expiry, strike, option_type ("call"/"put"),
position ("long"/"short"), quantity} position ("long"/"short"), quantity}
""" """
import math import math
from typing import Any, Dict, List, Optional from typing import Any, Callable, Dict, List, Optional
import numpy as np import numpy as np
from scipy.optimize import minimize_scalar from scipy.optimize import minimize_scalar
from services.options_pricer import black_scholes from services.options_pricer import black_scholes
from services.option_chain import find_quote from services.option_chain import find_quote
from services.vol_surface import Surface, ScenarioSurface from services.vol_surface import Surface, ScenarioSurface, apply_scenario
from services.scenario_path import interpolate_path
DEFAULT_SPREAD_PCT = 0.05 # fallback relative bid/ask spread when no live quote is found DEFAULT_SPREAD_PCT = 0.05 # fallback relative bid/ask spread when no live quote is found
DEFAULT_CONTRACT_SIZE = 100_000 # notional per 1 contract/lot (e.g. a standard FX lot); "quantity" on a leg is the number of these DEFAULT_CONTRACT_SIZE = 100_000 # notional per 1 contract/lot (e.g. a standard FX lot); "quantity" on a leg is the number of these
@@ -408,8 +409,8 @@ def _find_breakevens(
def payoff_heatmap( def payoff_heatmap(
legs: List[Dict[str, Any]], surface: Any, eval_days_expiry: float, r: float, spot: float, legs: List[Dict[str, Any]], surface_at_day: Callable[[float], Any], eval_days_expiry: float, r: float,
entry_ref: float, contract_size: float = DEFAULT_CONTRACT_SIZE, n_prices: int = 17, n_days: int = 7, spot: float, entry_ref: float, contract_size: float = DEFAULT_CONTRACT_SIZE, n_prices: int = 17, n_days: int = 7,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Price x days-to-expiry grid of P&L — rows are elapsed-day checkpoints from today down """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 to expiry (top-to-bottom reading matches watching the position age). Columns are
@@ -418,12 +419,20 @@ def payoff_heatmap(
window left more than half the grid flat at max loss/gain for a near-the-money position, 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 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 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.""" of only ever landing near one by luck of the price sampling.
`surface_at_day` is a function of elapsed days -> a Surface-like object (.iv_at), called
ONCE per row rather than per cell. When the scenario has no time-path, the caller just
passes a constant `lambda d: surface_scenario` (today's single-shock behavior, unchanged);
with a path, each row gets its own interpolated spot/IV/skew/term shock — e.g. a vol pop
described for day 4 onward actually raises the extrinsic value in that row (and every
later one), not just at whatever single instant the old single-point scenario evaluated."""
strikes = [l["strike"] for l in legs if l["option_type"] != "stock"] 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 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 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) surface_at_expiry = surface_at_day(eval_days_expiry)
breakevens = _find_breakevens(legs, surface_at_expiry, 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] 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 = np.unique(np.concatenate([np.linspace(lo, hi, n_prices), np.array(near_breakevens)]))
@@ -437,11 +446,12 @@ def payoff_heatmap(
rows = [] rows = []
for d in day_points: for d in day_points:
d = float(d) d = float(d)
surf = surface_at_day(d)
pnl_row, delta_row, gamma_row, theta_row, vega_row, rho_row = [], [], [], [], [], [] pnl_row, delta_row, gamma_row, theta_row, vega_row, rho_row = [], [], [], [], [], []
for p in price_points: for p in price_points:
p = float(p) p = float(p)
pnl_row.append(round(float(value_at(legs, p, d, surface, r, contract_size) - entry_ref), 2)) pnl_row.append(round(float(value_at(legs, p, d, surf, r, contract_size) - entry_ref), 2))
g = greeks_at(legs, p, d, surface, r) g = greeks_at(legs, p, d, surf, r)
# greeks_at's own round() leaves numpy float64 as numpy float64 (round() doesn't # 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 # 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 # JSON encoder can't serialize a bare numpy scalar (unlike pnl_row above, which
@@ -471,12 +481,35 @@ def payoff_curves(
horizon_days: int, horizon_days: int,
r: float = 0.05, r: float = 0.05,
contract_size: float = DEFAULT_CONTRACT_SIZE, contract_size: float = DEFAULT_CONTRACT_SIZE,
spot_path: Optional[List[Dict[str, Any]]] = None,
iv_path: Optional[List[Dict[str, Any]]] = None,
skew_path: Optional[List[Dict[str, Any]]] = None,
term_path: Optional[List[Dict[str, Any]]] = None,
base_spot_shock_pct: float = 0.0,
base_iv_level_shift: float = 0.0,
base_skew_tilt: float = 0.0,
base_term_slope_shift: float = 0.0,
manual_grid: Optional[List[Dict[str, Any]]] = None,
) -> 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"]
eval_days_expiry = min(l["days_to_expiry"] for l in legs) eval_days_expiry = min(l["days_to_expiry"] for l in legs)
heatmap = payoff_heatmap(legs, surface_scenario, eval_days_expiry, r, spot, entry_ref, contract_size) if spot_path or iv_path or skew_path or term_path:
def surface_at_day(d: float):
return apply_scenario(
surface_now,
spot_shock_pct=interpolate_path(spot_path, d, base_spot_shock_pct),
iv_level_shift=interpolate_path(iv_path, d, base_iv_level_shift),
skew_tilt=interpolate_path(skew_path, d, base_skew_tilt),
term_slope_shift=interpolate_path(term_path, d, base_term_slope_shift),
manual_grid=manual_grid,
)
else:
def surface_at_day(d: float):
return surface_scenario
heatmap = payoff_heatmap(legs, surface_at_day, eval_days_expiry, r, spot, entry_ref, contract_size)
return {"heatmap": heatmap, **priced} return {"heatmap": heatmap, **priced}

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 } 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 = { export type StrategyScenario = {
symbol: string symbol: string
horizon_days: number horizon_days: number
@@ -1752,6 +1760,15 @@ export type StrategyScenario = {
term_slope_shift: number term_slope_shift: number
rate_shock_bps?: number rate_shock_bps?: number
manual_grid?: ManualGridCell[] 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 rate?: number
n_expiries?: number n_expiries?: number
contract_size?: 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, useSavedStrategies, useSaveStrategyRecord, useDeleteSavedStrategy,
useSaxoSymbols, useIvForTrade, useSaxoSymbols, useIvForTrade,
type StrategyLeg, type StrategyScenario, type PayoffHeatmap, type PayoffHeatmapMetric, type StrategyCandidate, 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, type GreekProfile, type GreekTarget, type GreekState, type GreekTolerance, DEFAULT_GREEK_PROFILE,
} from '../hooks/useApi' } from '../hooks/useApi'
import { fmtPrice, fmtAsOf } from '../lib/format' 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 STRIKE_PCTS = [80, 85, 90, 95, 100, 105, 110, 115, 120]
const DELTA_NEUTRAL_THRESHOLD = 0.15 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 ────────────────────────────────────────────────────── // ── Manual grid override ──────────────────────────────────────────────────────
function ScenarioGrid({ function ScenarioGrid({
@@ -1301,6 +1502,7 @@ export default function StrategyBuilder() {
</div> </div>
{subTab === 'params' && mode === 'build' && <ScenarioSlidersPanel scenario={scenario} setScenario={setScenario} />} {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' && ( {subTab === 'params' && mode === 'historical' && (
<HistoricalPeriodPanel <HistoricalPeriodPanel
symbol={debouncedSymbol} legs={legs} contractSize={scenario.contract_size ?? 100_000} symbol={debouncedSymbol} legs={legs} contractSize={scenario.contract_size ?? 100_000}