diff --git a/backend/routers/strategy_builder.py b/backend/routers/strategy_builder.py index db03681..6765fb6 100644 --- a/backend/routers/strategy_builder.py +++ b/backend/routers/strategy_builder.py @@ -15,6 +15,15 @@ from services.database import ( 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): expiry_date: str 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) rate_shock_bps: float = 0.0 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 n_expiries: int = 3 contract_size: float = DEFAULT_CONTRACT_SIZE @@ -131,6 +153,24 @@ class StrategySaveRequest(BaseModel): 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): chain_slice = get_chain_slice( scenario.symbol, scenario.horizon_days, scenario.n_expiries, @@ -146,12 +186,13 @@ def _build_surfaces(scenario: ScenarioIn): ) surface_scenario = build_surface(checkpoint_chain) else: + spot_shock, iv_shift, skew_tilt, term_slope = _resolve_terminal_shocks(scenario) surface_scenario = apply_scenario( surface_now, - spot_shock_pct=scenario.spot_shock_pct, - iv_level_shift=scenario.iv_level_shift, - skew_tilt=scenario.skew_tilt, - term_slope_shift=scenario.term_slope_shift, + spot_shock_pct=spot_shock, + iv_level_shift=iv_shift, + skew_tilt=skew_tilt, + term_slope_shift=term_slope, manual_grid=scenario.manual_grid, ) return chain_slice, surface_now, surface_scenario @@ -220,10 +261,23 @@ def price(req: PriceRequest): raise HTTPException(status_code=404, detail=str(e)) 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( legs, chain_slice, surface_now, surface_scenario, req.scenario.horizon_days, req.scenario.shocked_rate, 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["scenario_spot"] = surface_scenario.spot diff --git a/backend/services/scenario_path.py b/backend/services/scenario_path.py new file mode 100644 index 0000000..495ba37 --- /dev/null +++ b/backend/services/scenario_path.py @@ -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"] diff --git a/backend/services/strategy_engine.py b/backend/services/strategy_engine.py index a5fce1f..a11eb8a 100644 --- a/backend/services/strategy_engine.py +++ b/backend/services/strategy_engine.py @@ -7,14 +7,15 @@ A "leg" dict: {expiry_date, days_to_expiry, strike, option_type ("call"/"put"), position ("long"/"short"), quantity} """ import math -from typing import Any, Dict, List, Optional +from typing import Any, Callable, Dict, List, Optional import numpy as np from scipy.optimize import minimize_scalar from services.options_pricer import black_scholes 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_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( - 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 = 17, n_days: int = 7, + legs: List[Dict[str, Any]], surface_at_day: Callable[[float], Any], eval_days_expiry: float, r: float, + spot: float, 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 @@ -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, 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.""" + 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"] 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) + 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] price_points = np.unique(np.concatenate([np.linspace(lo, hi, n_prices), np.array(near_breakevens)])) @@ -437,11 +446,12 @@ def payoff_heatmap( rows = [] for d in day_points: d = float(d) + surf = surface_at_day(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) + pnl_row.append(round(float(value_at(legs, p, d, surf, r, contract_size) - entry_ref), 2)) + g = greeks_at(legs, p, d, surf, 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 @@ -471,12 +481,35 @@ def payoff_curves( horizon_days: int, r: float = 0.05, 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]: spot = chain_slice["spot"] priced = price_combo(legs, chain_slice, surface_now, surface_scenario, horizon_days, r, contract_size) entry_ref = priced["entry_cost"] 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} diff --git a/frontend/src/hooks/useApi.ts b/frontend/src/hooks/useApi.ts index d2a0bde..628463b 100644 --- a/frontend/src/hooks/useApi.ts +++ b/frontend/src/hooks/useApi.ts @@ -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 diff --git a/frontend/src/lib/scenarioPath.ts b/frontend/src/lib/scenarioPath.ts new file mode 100644 index 0000000..b2060d3 --- /dev/null +++ b/frontend/src/lib/scenarioPath.ts @@ -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 } diff --git a/frontend/src/pages/StrategyBuilder.tsx b/frontend/src/pages/StrategyBuilder.tsx index 99ece6f..2da5262 100644 --- a/frontend/src/pages/StrategyBuilder.tsx +++ b/frontend/src/pages/StrategyBuilder.tsx @@ -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 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 ( + + + + + ) +} + +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 ( +
+
+
{cfg.label}
+ +
+ +
+ +
+ {params.shape !== 'custom' && ( + + )} + {(params.shape === 'bell' || params.shape === 'oscillation') && ( + + )} + {params.shape === 'bell' && ( + + )} + {params.shape === 'oscillation' && ( + + )} + {params.shape === 'exponential' && ( + + )} + {params.shape === 'step' && ( + + )} +
+
+ + {params.shape === 'custom' && ( +
+ {params.customPoints.map((p, i) => ( +
+ J+ + { + 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" + /> + { + 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" + /> + +
+ ))} + +
+ )} +
+ ) +} + +function ScenarioPathPanel({ + scenario, setScenario, horizonDays, +}: { scenario: StrategyScenario; setScenario: (v: StrategyScenario) => void; horizonDays: number }) { + const [paramsByDim, setParamsByDim] = useState>({ + spot: defaultPathParams(scenario.spot_shock_pct), + iv: defaultPathParams(scenario.iv_level_shift), + skew: defaultPathParams(scenario.skew_tilt), + term: defaultPathParams(scenario.term_slope_shift), + }) + + return ( +
+
+
Trajectoire du scénario (optionnel)
+
Décrit le tableau de payoff jour par jour — le choc ponctuel ci-dessus reste utilisé par l'optimiseur
+
+ {(Object.keys(PATH_DIM_CONFIG) as PathDim[]).map(dim => ( + setParamsByDim(prev => ({ ...prev, [dim]: p }))} + scenario={scenario} setScenario={setScenario} + /> + ))} +
+ ) +} + // ── Manual grid override ────────────────────────────────────────────────────── function ScenarioGrid({ @@ -1301,6 +1502,7 @@ export default function StrategyBuilder() { {subTab === 'params' && mode === 'build' && } + {subTab === 'params' && mode === 'build' && } {subTab === 'params' && mode === 'historical' && (