feat: strategy builder
This commit is contained in:
@@ -26,15 +26,25 @@ class LegIn(BaseModel):
|
||||
|
||||
class ScenarioIn(BaseModel):
|
||||
symbol: str
|
||||
horizon_days: int = 8
|
||||
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
|
||||
iv_level_shift: float = 0.0 # parallel IV shift — applies to every strike/expiry uniformly
|
||||
skew_tilt: float = 0.0
|
||||
term_shift: 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):
|
||||
@@ -50,9 +60,31 @@ class ConstraintsIn(BaseModel):
|
||||
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):
|
||||
@@ -62,7 +94,10 @@ class ScenarioSaveRequest(BaseModel):
|
||||
spot_shock_pct: float
|
||||
iv_level_shift: float
|
||||
skew_tilt: float
|
||||
term_shift: 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
|
||||
|
||||
|
||||
@@ -81,14 +116,17 @@ class StrategySaveRequest(BaseModel):
|
||||
|
||||
|
||||
def _build_surfaces(scenario: ScenarioIn):
|
||||
chain_slice = get_chain_slice(scenario.symbol, scenario.horizon_days, scenario.n_expiries)
|
||||
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_shift=scenario.term_shift,
|
||||
term_slope_shift=scenario.term_slope_shift,
|
||||
manual_grid=scenario.manual_grid,
|
||||
)
|
||||
return chain_slice, surface_now, surface_scenario
|
||||
@@ -99,9 +137,11 @@ 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)
|
||||
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))
|
||||
|
||||
@@ -121,7 +161,7 @@ def price(req: PriceRequest):
|
||||
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,
|
||||
req.scenario.horizon_days, req.scenario.shocked_rate,
|
||||
contract_size=req.scenario.contract_size,
|
||||
)
|
||||
result["spot"] = chain_slice["spot"]
|
||||
@@ -130,10 +170,24 @@ def price(req: PriceRequest):
|
||||
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)
|
||||
|
||||
|
||||
@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,
|
||||
@@ -141,14 +195,18 @@ def optimize(req: OptimizeRequest):
|
||||
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,
|
||||
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
|
||||
@@ -161,7 +219,7 @@ def optimize(req: OptimizeRequest):
|
||||
)
|
||||
status = 404 if isinstance(e, ValueError) else 500
|
||||
raise HTTPException(status_code=status, detail=f"{e}")
|
||||
return results
|
||||
return {"candidates": results, "warnings": warnings}
|
||||
|
||||
|
||||
@router.post("/scenarios")
|
||||
|
||||
Reference in New Issue
Block a user