feat: strategy builder
This commit is contained in:
@@ -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
|
||||||
|
|||||||
31
backend/services/scenario_path.py
Normal file
31
backend/services/scenario_path.py
Normal 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"]
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
89
frontend/src/lib/scenarioPath.ts
Normal file
89
frontend/src/lib/scenarioPath.ts
Normal 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 }
|
||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user