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 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 spot_shock_pct: float = 0.0 iv_level_shift: float = 0.0 skew_tilt: float = 0.0 term_shift: float = 0.0 manual_grid: Optional[List[Dict[str, Any]]] = None rate: float = 0.05 n_expiries: int = 3 class PriceRequest(BaseModel): scenario: ScenarioIn legs: List[LegIn] class ConstraintsIn(BaseModel): max_legs: int = 4 delta_threshold: 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 OptimizeRequest(BaseModel): scenario: ScenarioIn constraints: ConstraintsIn class ScenarioSaveRequest(BaseModel): symbol: str label: Optional[str] = "" horizon_days: int spot_shock_pct: float iv_level_shift: float skew_tilt: float term_shift: float 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) 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_shift=scenario.term_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), ): try: return get_chain_slice(symbol, horizon_days, n_expiries) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) @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.rate, ) result["spot"] = chain_slice["spot"] result["scenario_spot"] = surface_scenario.spot result["proxy"] = chain_slice["proxy"] return result @router.post("/optimize") def optimize(req: OptimizeRequest): if req.constraints.max_legs > 4: raise HTTPException(status_code=400, detail="4 jambes maximum") 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_shift=req.scenario.term_shift, manual_grid=req.scenario.manual_grid, n_expiries=req.scenario.n_expiries, rate=req.scenario.rate, constraints=req.constraints.model_dump(), objective=req.constraints.objective, top_n=req.constraints.top_n, ) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) return results @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}