feat: strategy builder

This commit is contained in:
OpenSquared
2026-07-30 13:28:03 +02:00
parent 1c4d8013c4
commit 81165581d7
8 changed files with 361 additions and 69 deletions

View File

@@ -56,7 +56,10 @@ def _settle_leg(leg: BacktestLeg, strike: float, days_to_expiry: int, near_days:
"""Value one leg at the near expiry: intrinsic if it expires there too (the common
case), else a fresh Black-Scholes price for its remaining time (a 'far' leg — closed
alongside the near leg rather than held to its own later expiry, the standard way
calendar/diagonal-style structures are actually managed)."""
calendar/diagonal-style structures are actually managed). A 'stock' leg (Covered
Call/Protective Put/Collar's underlying position) is worth exactly the spot, always."""
if leg.option_type == "stock":
return S_settle
remaining_days = days_to_expiry - near_days
if remaining_days <= 0:
if leg.option_type == "call":
@@ -70,7 +73,7 @@ def _settle_leg(leg: BacktestLeg, strike: float, days_to_expiry: int, near_days:
def run_backtest(req: BacktestRequest):
try:
for leg in req.legs:
if leg.option_type not in ("call", "put") or leg.position not in ("long", "short"):
if leg.option_type not in ("call", "put", "stock") or leg.position not in ("long", "short"):
return {"error": f"Jambe invalide: {leg}"}
ticker = yf.Ticker(req.symbol)
@@ -103,7 +106,7 @@ def run_backtest(req: BacktestRequest):
leg_days = [req.expiry_days if leg.expiry != "far" else req.far_expiry_days for leg in req.legs]
entry_premiums = [
float(black_scholes(S, k, d / 365, r, sigma, leg.option_type)["price"])
S if leg.option_type == "stock" else float(black_scholes(S, k, d / 365, r, sigma, leg.option_type)["price"])
for leg, k, d in zip(req.legs, leg_strikes, leg_days)
]

View File

@@ -146,6 +146,38 @@ def chain(
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: