317 lines
12 KiB
Python
317 lines
12 KiB
Python
from typing import Any, Dict, List, Optional
|
|
|
|
from fastapi import APIRouter, HTTPException, Query
|
|
from pydantic import BaseModel
|
|
|
|
from services.option_chain import get_chain_slice
|
|
from services.vol_surface import build_surface, apply_scenario
|
|
from services.strategy_engine import payoff_curves, DEFAULT_CONTRACT_SIZE
|
|
from services.strategy_optimizer import optimize as run_optimizer
|
|
from services.database import (
|
|
save_scenario, get_scenarios, delete_scenario,
|
|
save_strategy, get_saved_strategies, delete_saved_strategy,
|
|
)
|
|
|
|
router = APIRouter(prefix="/api/strategy-builder", tags=["strategy-builder"])
|
|
|
|
|
|
class LegIn(BaseModel):
|
|
expiry_date: str
|
|
days_to_expiry: int
|
|
strike: float
|
|
option_type: str # "call" | "put"
|
|
position: str # "long" | "short"
|
|
quantity: int = 1
|
|
|
|
|
|
class ScenarioIn(BaseModel):
|
|
symbol: str
|
|
horizon_days: int = 8 # scenario P&L evaluation date — NOT the expiry filter, see dte_min/dte_max
|
|
spot_shock_pct: float = 0.0
|
|
iv_level_shift: float = 0.0 # parallel IV shift — applies to every strike/expiry uniformly
|
|
skew_tilt: float = 0.0
|
|
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
|
|
rate: float = 0.05
|
|
n_expiries: int = 3
|
|
contract_size: float = DEFAULT_CONTRACT_SIZE
|
|
# Which expiries the chain/optimizer may pick legs from — independent of horizon_days,
|
|
# so a short-horizon scenario (e.g. 8 days) can still be evaluated with longer-dated
|
|
# options (e.g. dte_min=20, dte_max=60) instead of horizon_days doing double duty.
|
|
dte_min: Optional[int] = None
|
|
dte_max: Optional[int] = None
|
|
|
|
@property
|
|
def shocked_rate(self) -> float:
|
|
return self.rate + self.rate_shock_bps / 10000.0
|
|
|
|
|
|
class PriceRequest(BaseModel):
|
|
scenario: ScenarioIn
|
|
legs: List[LegIn]
|
|
|
|
|
|
class ConstraintsIn(BaseModel):
|
|
max_legs: int = 4
|
|
delta_threshold: Optional[float] = 0.15
|
|
max_loss_cap: Optional[float] = None
|
|
objective: str = "net_pnl" # "net_pnl" | "return_on_risk" | "prob_weighted"
|
|
top_n: int = 20
|
|
|
|
|
|
class GreekTargetIn(BaseModel):
|
|
"""One Greek's desired behavior — deliberately NOT a numeric slider (see project memory,
|
|
Strategy Builder Greeks plan): a qualitative state the optimizer resolves against the
|
|
actual candidate pool, so "strongly positive" means "top of what's achievable for this
|
|
instrument/scenario right now" rather than a guessed absolute number."""
|
|
state: str = "free" # "strong_negative"|"negative"|"neutral"|"positive"|"strong_positive"|"free"
|
|
tolerance: str = "normale" # "etroite"|"normale"|"large" — etroite hard-filters sign mismatches
|
|
weight: float = 50.0 # 0-100, importance relative to the base objective (net_pnl/return_on_risk/...)
|
|
|
|
|
|
class GreekProfileIn(BaseModel):
|
|
"""Layer B of the scenario/profile/constraints split: the behavior the user wants,
|
|
kept separate from the scenario (Layer A, what's anticipated) and from ConstraintsIn
|
|
(Layer C, hard construction limits)."""
|
|
delta: GreekTargetIn = GreekTargetIn()
|
|
gamma: GreekTargetIn = GreekTargetIn()
|
|
theta: GreekTargetIn = GreekTargetIn()
|
|
vega: GreekTargetIn = GreekTargetIn()
|
|
rho: GreekTargetIn = GreekTargetIn()
|
|
|
|
|
|
class OptimizeRequest(BaseModel):
|
|
scenario: ScenarioIn
|
|
constraints: ConstraintsIn
|
|
greek_profile: Optional[GreekProfileIn] = None
|
|
|
|
|
|
class ScenarioSaveRequest(BaseModel):
|
|
symbol: str
|
|
label: Optional[str] = ""
|
|
horizon_days: int
|
|
spot_shock_pct: float
|
|
iv_level_shift: float
|
|
skew_tilt: float
|
|
term_slope_shift: float
|
|
rate_shock_bps: float = 0.0
|
|
dte_min: Optional[int] = None
|
|
dte_max: Optional[int] = None
|
|
manual_grid: Optional[List[Dict[str, Any]]] = None
|
|
|
|
|
|
class StrategySaveRequest(BaseModel):
|
|
scenario_id: Optional[str] = None
|
|
symbol: str
|
|
template_name: Optional[str] = ""
|
|
objective: Optional[str] = ""
|
|
legs: List[LegIn]
|
|
entry_cost: Optional[float] = None
|
|
max_gain: Optional[float] = None
|
|
max_loss: Optional[float] = None
|
|
net_pnl_scenario: Optional[float] = None
|
|
net_delta: Optional[float] = None
|
|
notes: Optional[str] = ""
|
|
|
|
|
|
def _build_surfaces(scenario: ScenarioIn):
|
|
chain_slice = get_chain_slice(
|
|
scenario.symbol, scenario.horizon_days, scenario.n_expiries,
|
|
dte_min=scenario.dte_min, dte_max=scenario.dte_max,
|
|
)
|
|
surface_now = build_surface(chain_slice)
|
|
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,
|
|
manual_grid=scenario.manual_grid,
|
|
)
|
|
return chain_slice, surface_now, surface_scenario
|
|
|
|
|
|
@router.get("/chain")
|
|
def chain(
|
|
symbol: str = Query(...),
|
|
horizon_days: int = Query(8),
|
|
n_expiries: int = Query(3),
|
|
dte_min: Optional[int] = Query(None),
|
|
dte_max: Optional[int] = Query(None),
|
|
):
|
|
try:
|
|
return get_chain_slice(symbol, horizon_days, n_expiries, dte_min=dte_min, dte_max=dte_max)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
|
|
|
|
@router.get("/presets")
|
|
def presets(
|
|
symbol: str = Query(...),
|
|
horizon_days: int = Query(8),
|
|
dte_min: Optional[int] = Query(None),
|
|
dte_max: Optional[int] = Query(None),
|
|
):
|
|
"""The full strategy catalog (services.backtest_strategies.STRATEGIES) built from the
|
|
REAL current chain instead of Backtest's synthetic grid — so a preset click here seeds
|
|
the leg editor with actually-quoted strikes/expiries, ready to price or replay as-is.
|
|
n_expiries=5 (vs. Strategy Builder's own default of 3) so calendar/diagonal presets,
|
|
which need two distinct expiries, reliably have a second one to draw from."""
|
|
from services.backtest_strategies import STRATEGIES, build_legs
|
|
try:
|
|
chain_slice = get_chain_slice(symbol, horizon_days, 5, dte_min=dte_min, dte_max=dte_max)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
|
|
expiries = chain_slice["expiries"]
|
|
if not expiries:
|
|
raise HTTPException(status_code=404, detail=f"Aucune échéance exploitable pour '{symbol}'.")
|
|
near, far = expiries[0], expiries[1] if len(expiries) > 1 else None
|
|
|
|
out = []
|
|
for key, label, n_legs in STRATEGIES:
|
|
legs = build_legs(key, chain_slice["spot"], 0.05, near, far)
|
|
if not legs:
|
|
continue # e.g. calendar/diagonal with only one real expiry available right now
|
|
out.append({"key": key, "label": label, "n_legs": n_legs, "legs": legs})
|
|
return out
|
|
|
|
|
|
@router.post("/price")
|
|
def price(req: PriceRequest):
|
|
if not req.legs:
|
|
raise HTTPException(status_code=400, detail="Au moins une jambe est requise")
|
|
if len(req.legs) > 4:
|
|
raise HTTPException(status_code=400, detail="4 jambes maximum")
|
|
|
|
try:
|
|
chain_slice, surface_now, surface_scenario = _build_surfaces(req.scenario)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
|
|
legs = [leg.model_dump() for leg in req.legs]
|
|
result = payoff_curves(
|
|
legs, chain_slice, surface_now, surface_scenario,
|
|
req.scenario.horizon_days, req.scenario.shocked_rate,
|
|
contract_size=req.scenario.contract_size,
|
|
)
|
|
result["spot"] = chain_slice["spot"]
|
|
result["scenario_spot"] = surface_scenario.spot
|
|
result["proxy"] = chain_slice["proxy"]
|
|
return result
|
|
|
|
|
|
@router.post("/suggested-profile")
|
|
def suggested_profile(scenario: ScenarioIn):
|
|
"""Mode 1 of the scenario/profile/constraints split: what Greek behavior this scenario
|
|
already implies on its own, before the user sets any explicit target — see
|
|
services.scenario_profile.infer_natural_greek_profile."""
|
|
from services.scenario_profile import infer_natural_greek_profile
|
|
return infer_natural_greek_profile(scenario.spot_shock_pct, scenario.iv_level_shift, scenario.horizon_days)
|
|
|
|
|
|
class ReplayRequest(BaseModel):
|
|
symbol: str
|
|
legs: List[LegIn]
|
|
start_date: str
|
|
end_date: str
|
|
contract_size: float = DEFAULT_CONTRACT_SIZE
|
|
|
|
|
|
@router.post("/replay")
|
|
def replay(req: ReplayRequest):
|
|
"""Day-by-day mark-to-market of these exact legs against REAL accumulated Saxo
|
|
history between two dates — not a scenario, a replay of what actually happened.
|
|
See services.strategy_replay for why it's a distinct thing from /price's scenario
|
|
pricing (which prices a hypothetical spot/IV shock, not real historical quotes)."""
|
|
from services.strategy_replay import replay_position
|
|
try:
|
|
return replay_position(
|
|
req.symbol, [leg.dict() for leg in req.legs], req.start_date, req.end_date,
|
|
contract_size=req.contract_size,
|
|
)
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=404, detail=str(e))
|
|
|
|
|
|
@router.post("/optimize")
|
|
def optimize(req: OptimizeRequest):
|
|
if req.constraints.max_legs > 4:
|
|
raise HTTPException(status_code=400, detail="4 jambes maximum")
|
|
from services.scenario_profile import detect_greek_contradictions
|
|
warnings = detect_greek_contradictions(
|
|
req.greek_profile.model_dump() if req.greek_profile else None,
|
|
req.scenario.n_expiries, req.scenario.dte_min, req.scenario.dte_max,
|
|
)
|
|
try:
|
|
results = run_optimizer(
|
|
symbol=req.scenario.symbol,
|
|
horizon_days=req.scenario.horizon_days,
|
|
spot_shock_pct=req.scenario.spot_shock_pct,
|
|
iv_level_shift=req.scenario.iv_level_shift,
|
|
skew_tilt=req.scenario.skew_tilt,
|
|
term_slope_shift=req.scenario.term_slope_shift,
|
|
manual_grid=req.scenario.manual_grid,
|
|
n_expiries=req.scenario.n_expiries,
|
|
rate=req.scenario.rate,
|
|
rate_shock_bps=req.scenario.rate_shock_bps,
|
|
dte_min=req.scenario.dte_min,
|
|
dte_max=req.scenario.dte_max,
|
|
constraints=req.constraints.model_dump(),
|
|
objective=req.constraints.objective,
|
|
top_n=req.constraints.top_n,
|
|
contract_size=req.scenario.contract_size,
|
|
greek_profile=req.greek_profile.model_dump() if req.greek_profile else None,
|
|
)
|
|
except Exception as e:
|
|
import traceback
|
|
from services.database import log_system_event
|
|
tb = traceback.format_exc()
|
|
log_system_event(
|
|
level="ERROR", source="strategy_optimizer",
|
|
message=f"Optimize failed for {req.scenario.symbol}: {e}",
|
|
ticker=req.scenario.symbol, details={"error": str(e), "traceback": tb},
|
|
)
|
|
status = 404 if isinstance(e, ValueError) else 500
|
|
raise HTTPException(status_code=status, detail=f"{e}")
|
|
return {"candidates": results, "warnings": warnings}
|
|
|
|
|
|
@router.post("/scenarios")
|
|
def create_scenario(req: ScenarioSaveRequest):
|
|
scenario_id = save_scenario(req.model_dump())
|
|
return {"id": scenario_id}
|
|
|
|
|
|
@router.get("/scenarios")
|
|
def list_scenarios(symbol: Optional[str] = Query(None)):
|
|
return get_scenarios(symbol)
|
|
|
|
|
|
@router.delete("/scenarios/{scenario_id}")
|
|
def remove_scenario(scenario_id: str):
|
|
if not delete_scenario(scenario_id):
|
|
raise HTTPException(status_code=404, detail="Scénario non trouvé")
|
|
return {"deleted": True}
|
|
|
|
|
|
@router.post("/saved")
|
|
def create_saved_strategy(req: StrategySaveRequest):
|
|
payload = req.model_dump()
|
|
payload["legs"] = [leg for leg in payload["legs"]]
|
|
strategy_id = save_strategy(payload)
|
|
return {"id": strategy_id}
|
|
|
|
|
|
@router.get("/saved")
|
|
def list_saved_strategies(symbol: Optional[str] = Query(None)):
|
|
return get_saved_strategies(symbol)
|
|
|
|
|
|
@router.delete("/saved/{strategy_id}")
|
|
def remove_saved_strategy(strategy_id: str):
|
|
if not delete_saved_strategy(strategy_id):
|
|
raise HTTPException(status_code=404, detail="Stratégie non trouvée")
|
|
return {"deleted": True}
|