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), as_of: Optional[str] = Query(None, description="Reconstruct the chain as it stood at/before this date instead of now — e.g. to build legs against the same chain a past Replay window will walk, rather than today's."), ): try: return get_chain_slice(symbol, horizon_days, n_expiries, dte_min=dte_min, dte_max=dte_max, as_of=as_of) 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), as_of: Optional[str] = Query(None), ): """The full strategy catalog (services.backtest_strategies.STRATEGIES) built from the REAL 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=20 (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, and so a wide dte_min/dte_max window (e.g. hunting for a ~30d expiry) isn't silently narrowed back down to whatever's nearest horizon_days.""" from services.backtest_strategies import STRATEGIES, build_legs try: chain_slice = get_chain_slice(symbol, horizon_days, 20, dte_min=dte_min, dte_max=dte_max, as_of=as_of) 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}