feat: strategy builder
This commit is contained in:
190
backend/routers/strategy_builder.py
Normal file
190
backend/routers/strategy_builder.py
Normal file
@@ -0,0 +1,190 @@
|
||||
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}
|
||||
Reference in New Issue
Block a user