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"])
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