feat: strategy builder

This commit is contained in:
OpenSquared
2026-07-18 16:37:35 +02:00
parent 16ccc7c2c7
commit 91054979ec
14 changed files with 2106 additions and 2 deletions

View File

@@ -20,6 +20,7 @@ from routers import instrument_models as instrument_models_router
from routers import instruments_watchlist as instruments_watchlist_router
from routers import wavelet as wavelet_router
from routers import ai_chat as ai_chat_router
from routers import strategy_builder as strategy_builder_router
from services.database import init_db, get_config, cleanup_stale_running_cycles
import os
import logging
@@ -247,6 +248,7 @@ app.include_router(instrument_models_router.router)
app.include_router(instruments_watchlist_router.router)
app.include_router(wavelet_router.router)
app.include_router(ai_chat_router.router)
app.include_router(strategy_builder_router.router)
@app.get("/")

View 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}

View File

@@ -44,6 +44,36 @@ def init_db():
created_at TEXT DEFAULT (datetime('now'))
)""")
c.execute("""CREATE TABLE IF NOT EXISTS strategy_scenarios (
id TEXT PRIMARY KEY,
symbol TEXT NOT NULL,
label TEXT,
horizon_days INTEGER NOT NULL,
spot_shock_pct REAL NOT NULL,
iv_level_shift REAL NOT NULL,
skew_tilt REAL NOT NULL,
term_shift REAL NOT NULL,
manual_grid TEXT,
created_at TEXT DEFAULT (datetime('now'))
)""")
c.execute("""CREATE TABLE IF NOT EXISTS saved_strategies (
id TEXT PRIMARY KEY,
scenario_id TEXT,
symbol TEXT NOT NULL,
template_name TEXT,
objective TEXT,
legs TEXT NOT NULL,
entry_cost REAL,
max_gain REAL,
max_loss REAL,
net_pnl_scenario REAL,
net_delta REAL,
notes TEXT,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (scenario_id) REFERENCES strategy_scenarios(id)
)""")
c.execute("""CREATE TABLE IF NOT EXISTS custom_patterns (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
@@ -5885,3 +5915,105 @@ def get_market_events_near_date(date_str: str, days: int = 2,
return [dict(r) for r in rows]
finally:
conn.close()
# ── Strategy Builder: scenarios & saved strategies ───────────────────────────
def save_scenario(scenario: Dict[str, Any]) -> str:
import uuid
scenario_id = scenario.get("id") or f"SCN-{uuid.uuid4().hex[:8].upper()}"
conn = get_conn()
conn.execute("""INSERT INTO strategy_scenarios (
id, symbol, label, horizon_days, spot_shock_pct, iv_level_shift, skew_tilt, term_shift, manual_grid
) VALUES (?,?,?,?,?,?,?,?,?)""", (
scenario_id,
scenario["symbol"],
scenario.get("label", ""),
scenario["horizon_days"],
scenario["spot_shock_pct"],
scenario["iv_level_shift"],
scenario["skew_tilt"],
scenario["term_shift"],
json.dumps(scenario.get("manual_grid") or []),
))
conn.commit()
conn.close()
return scenario_id
def get_scenarios(symbol: Optional[str] = None) -> List[Dict[str, Any]]:
conn = get_conn()
if symbol:
rows = conn.execute(
"SELECT * FROM strategy_scenarios WHERE symbol=? ORDER BY created_at DESC", (symbol,)
).fetchall()
else:
rows = conn.execute("SELECT * FROM strategy_scenarios ORDER BY created_at DESC").fetchall()
conn.close()
out = []
for r in rows:
d = dict(r)
d["manual_grid"] = json.loads(d.get("manual_grid") or "[]")
out.append(d)
return out
def delete_scenario(scenario_id: str) -> bool:
conn = get_conn()
cur = conn.execute("DELETE FROM strategy_scenarios WHERE id=?", (scenario_id,))
conn.commit()
deleted = cur.rowcount > 0
conn.close()
return deleted
def save_strategy(strategy: Dict[str, Any]) -> str:
import uuid
strategy_id = strategy.get("id") or f"STR-{uuid.uuid4().hex[:8].upper()}"
conn = get_conn()
conn.execute("""INSERT INTO saved_strategies (
id, scenario_id, symbol, template_name, objective, legs,
entry_cost, max_gain, max_loss, net_pnl_scenario, net_delta, notes
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)""", (
strategy_id,
strategy.get("scenario_id"),
strategy["symbol"],
strategy.get("template_name", ""),
strategy.get("objective", ""),
json.dumps(strategy.get("legs", [])),
strategy.get("entry_cost"),
strategy.get("max_gain"),
strategy.get("max_loss"),
strategy.get("net_pnl_scenario"),
strategy.get("net_delta"),
strategy.get("notes", ""),
))
conn.commit()
conn.close()
return strategy_id
def get_saved_strategies(symbol: Optional[str] = None) -> List[Dict[str, Any]]:
conn = get_conn()
if symbol:
rows = conn.execute(
"SELECT * FROM saved_strategies WHERE symbol=? ORDER BY created_at DESC", (symbol,)
).fetchall()
else:
rows = conn.execute("SELECT * FROM saved_strategies ORDER BY created_at DESC").fetchall()
conn.close()
out = []
for r in rows:
d = dict(r)
d["legs"] = json.loads(d.get("legs") or "[]")
out.append(d)
return out
def delete_saved_strategy(strategy_id: str) -> bool:
conn = get_conn()
cur = conn.execute("DELETE FROM saved_strategies WHERE id=?", (strategy_id,))
conn.commit()
deleted = cur.rowcount > 0
conn.close()
return deleted

View File

@@ -0,0 +1,99 @@
"""
Real option chain fetcher for the Strategy Builder — reuses the same yfinance
proxy/resolution logic as iv_engine.py (futures/indices → optionable ETFs).
"""
import logging
import math
from datetime import date, datetime
from typing import Any, Dict, List, Optional
import yfinance as yf
from services.iv_engine import _resolve_ticker, _get_current_price
logger = logging.getLogger(__name__)
def _num(v: Any, default: float = 0.0) -> float:
try:
f = float(v)
return default if math.isnan(f) else f
except (TypeError, ValueError):
return default
def _rows_from_df(df) -> List[Dict[str, Any]]:
rows = []
for _, r in df.iterrows():
bid = _num(r.get("bid"))
ask = _num(r.get("ask"))
rows.append({
"strike": _num(r.get("strike")),
"bid": bid,
"ask": ask,
"mid": round((bid + ask) / 2, 4) if (bid > 0 and ask > 0) else _num(r.get("lastPrice")),
"last": _num(r.get("lastPrice")),
"iv": _num(r.get("impliedVolatility")),
"open_interest": int(_num(r.get("openInterest"))),
"volume": int(_num(r.get("volume"))),
})
return sorted(rows, key=lambda x: x["strike"])
def get_chain_slice(symbol: str, target_days: int = 8, n_expiries: int = 3) -> Dict[str, Any]:
"""
Fetch the real option chain for `symbol` around a target horizon (days).
Returns the `n_expiries` expirations closest to target_days, each with
normalized calls/puts rows (strike, bid, ask, mid, last, iv, open_interest, volume).
"""
proxy = _resolve_ticker(symbol)
t = yf.Ticker(proxy)
spot = _get_current_price(t)
if not spot:
raise ValueError(f"Impossible d'obtenir le prix spot pour {symbol} ({proxy})")
expirations = t.options
if not expirations:
raise ValueError(f"Aucune chaîne d'options disponible pour {symbol} ({proxy})")
today = date.today()
dated = sorted(
expirations,
key=lambda e: abs((datetime.strptime(e, "%Y-%m-%d").date() - today).days - target_days),
)[:max(1, n_expiries)]
expiries_out = []
for exp in dated:
try:
chain = t.option_chain(exp)
days_to_expiry = (datetime.strptime(exp, "%Y-%m-%d").date() - today).days
expiries_out.append({
"expiry_date": exp,
"days_to_expiry": days_to_expiry,
"calls": _rows_from_df(chain.calls),
"puts": _rows_from_df(chain.puts),
})
except Exception as e:
logger.debug(f"[OptionChain] {proxy} {exp}: {e}")
if not expiries_out:
raise ValueError(f"Aucune chaîne exploitable pour {symbol} ({proxy})")
return {
"symbol": symbol.upper(),
"proxy": proxy,
"spot": round(float(spot), 4),
"expiries": expiries_out,
}
def find_quote(chain_slice: Dict[str, Any], expiry_date: str, strike: float, option_type: str) -> Optional[Dict[str, Any]]:
"""Look up a single contract's quote row within a previously fetched chain slice."""
for exp in chain_slice["expiries"]:
if exp["expiry_date"] != expiry_date:
continue
rows = exp["calls"] if option_type == "call" else exp["puts"]
for row in rows:
if abs(row["strike"] - strike) < 1e-6:
return row
return None

View File

@@ -0,0 +1,247 @@
"""
Generic N-leg (1-4) option strategy pricer: entry cost with real broker spread,
scenario repricing at a future horizon on a shocked vol surface, payoff curves,
greeks, and bounded-risk / non-directional checks.
A "leg" dict: {expiry_date, days_to_expiry, strike, option_type ("call"/"put"),
position ("long"/"short"), quantity}
"""
import math
from typing import Any, Dict, List, Optional
import numpy as np
from services.options_pricer import black_scholes
from services.option_chain import find_quote
from services.vol_surface import Surface, ScenarioSurface
DEFAULT_SPREAD_PCT = 0.05 # fallback relative bid/ask spread when no live quote is found
def _sign(leg: Dict[str, Any]) -> int:
return 1 if leg.get("position", "long") == "long" else -1
def _intrinsic(S: float, K: float, option_type: str) -> float:
return max(0.0, S - K) if option_type == "call" else max(0.0, K - S)
def _quote_spread_pct(quote: Optional[Dict[str, Any]]) -> float:
if not quote or quote["bid"] <= 0 or quote["ask"] <= 0:
return DEFAULT_SPREAD_PCT
mid = (quote["bid"] + quote["ask"]) / 2
if mid <= 0:
return DEFAULT_SPREAD_PCT
return (quote["ask"] - quote["bid"]) / mid
def entry_price(leg: Dict[str, Any], chain_slice: Dict[str, Any], surface_now: Surface, r: float) -> Dict[str, float]:
"""Real execution price (crossing the spread) + theoretical mid, for one leg today."""
quote = find_quote(chain_slice, leg["expiry_date"], leg["strike"], leg["option_type"])
T = max(leg["days_to_expiry"], 0.001) / 365
sigma = surface_now.iv_at(leg["strike"], leg["days_to_expiry"])
theo_mid = black_scholes(chain_slice["spot"], leg["strike"], T, r, sigma, leg["option_type"])["price"]
if quote and quote["bid"] > 0 and quote["ask"] > 0:
exec_price = quote["ask"] if leg.get("position", "long") == "long" else quote["bid"]
mid = quote["mid"] or theo_mid
else:
spread = theo_mid * DEFAULT_SPREAD_PCT
exec_price = theo_mid + spread / 2 if leg.get("position", "long") == "long" else max(0.0, theo_mid - spread / 2)
mid = theo_mid
return {"exec_price": exec_price, "mid": mid, "spread_pct": _quote_spread_pct(quote)}
def value_at(
legs: List[Dict[str, Any]],
S: float,
eval_days_from_now: float,
surface: Any,
r: float,
) -> float:
"""Signed portfolio value (BS reprice for unexpired legs, intrinsic for expired ones)."""
total = 0.0
for leg in legs:
remaining = leg["days_to_expiry"] - eval_days_from_now
qty = leg.get("quantity", 1)
sign = _sign(leg)
if remaining <= 0:
price = _intrinsic(S, leg["strike"], leg["option_type"])
else:
sigma = surface.iv_at(leg["strike"], remaining)
price = black_scholes(S, leg["strike"], remaining / 365, r, sigma, leg["option_type"])["price"]
total += sign * price * qty * 100
return total
def greeks_at(legs: List[Dict[str, Any]], S: float, eval_days_from_now: float, surface: Any, r: float) -> Dict[str, float]:
net = {"delta": 0.0, "gamma": 0.0, "theta": 0.0, "vega": 0.0}
for leg in legs:
remaining = max(leg["days_to_expiry"] - eval_days_from_now, 0.001)
qty = leg.get("quantity", 1)
sign = _sign(leg)
sigma = surface.iv_at(leg["strike"], remaining)
g = black_scholes(S, leg["strike"], remaining / 365, r, sigma, leg["option_type"])
for k in net:
net[k] += g[k] * qty * sign
return {k: round(v, 4) for k, v in net.items()}
def price_combo(
legs: List[Dict[str, Any]],
chain_slice: Dict[str, Any],
surface_now: Surface,
surface_scenario: ScenarioSurface,
horizon_days: int,
r: float = 0.05,
) -> Dict[str, Any]:
spot_now = chain_slice["spot"]
spot_scenario = surface_scenario.spot
entry_ref = 0.0
entry_ref_mid = 0.0
for leg in legs:
ep = entry_price(leg, chain_slice, surface_now, r)
sign = _sign(leg)
qty = leg.get("quantity", 1)
entry_ref += sign * ep["exec_price"] * qty * 100
entry_ref_mid += sign * ep["mid"] * qty * 100
# Scenario exit: apply each leg's own bid/ask spread (est. from entry quote) to the
# theoretical scenario value, since we don't have a live quote for the future date.
scenario_mid = 0.0
scenario_exec = 0.0
for leg in legs:
remaining = max(leg["days_to_expiry"] - horizon_days, 0.001)
qty = leg.get("quantity", 1)
sign = _sign(leg)
sigma = surface_scenario.iv_at(leg["strike"], remaining)
theo = black_scholes(spot_scenario, leg["strike"], remaining / 365, r, sigma, leg["option_type"])["price"]
quote = find_quote(chain_slice, leg["expiry_date"], leg["strike"], leg["option_type"])
spread_pct = _quote_spread_pct(quote)
exec_price = theo * (1 - spread_pct / 2) if leg.get("position", "long") == "long" else theo * (1 + spread_pct / 2)
scenario_mid += sign * theo * qty * 100
scenario_exec += sign * exec_price * qty * 100
net_pnl = scenario_exec - entry_ref
broker_cost = (entry_ref - entry_ref_mid) + (scenario_mid - scenario_exec)
bounded = check_bounded_risk(legs, entry_ref, surface_now, spot_now)
delta_now = greeks_at(legs, spot_now, 0, surface_now, r)["delta"]
delta_scenario = greeks_at(legs, spot_scenario, horizon_days, surface_scenario, r)["delta"]
return {
"entry_cost": round(entry_ref, 2),
"entry_cost_mid": round(entry_ref_mid, 2),
"scenario_value": round(scenario_exec, 2),
"scenario_value_mid": round(scenario_mid, 2),
"net_pnl": round(net_pnl, 2),
"broker_spread_cost": round(broker_cost, 2),
"max_gain": bounded["max_gain"],
"max_loss": bounded["max_loss"],
"bounded_risk": bounded["bounded"],
"greeks_now": greeks_at(legs, spot_now, 0, surface_now, r),
"greeks_scenario": greeks_at(legs, spot_scenario, horizon_days, surface_scenario, r),
"net_delta_now": delta_now,
"net_delta_scenario": delta_scenario,
}
def check_bounded_risk(legs: List[Dict[str, Any]], entry_ref: float, surface: Any, spot: float) -> Dict[str, Any]:
"""
Scan a wide log-spaced spot range at expiry and inspect both tails independently for LOSS
vs GAIN direction. "Bounded risk" only requires the loss side to be capped — a long
straddle (loss capped at the premium, gain uncapped) is a textbook risk-bounded,
non-directional trade and must not be excluded just because its gain is open-ended.
A tail is loss-bounded if moving further to that extreme does not make the P&L any worse
than a point already well into that tail; symmetrically for gain-bounded.
"""
eval_days = min(l["days_to_expiry"] for l in legs)
grid = np.geomspace(spot * 0.05, spot * 20, 300)
values = [value_at(legs, float(p), eval_days, surface, 0.05) - entry_ref for p in grid]
tail_n = max(3, len(values) // 30)
tol = max(abs(entry_ref), 1.0) * 0.01
lo_edge, lo_in = values[0], values[tail_n]
hi_edge, hi_in = values[-1], values[-1 - tail_n]
loss_bounded = (lo_edge >= lo_in - tol) and (hi_edge >= hi_in - tol)
gain_bounded = (lo_edge <= lo_in + tol) and (hi_edge <= hi_in + tol)
return {
"bounded": loss_bounded,
"max_loss": round(min(values), 2) if loss_bounded else None,
"max_gain": round(max(values), 2) if gain_bounded else None,
}
def payoff_curve_expiry(legs: List[Dict[str, Any]], surface_now: Surface, spot: float, n: int = 100) -> List[Dict[str, float]]:
eval_days = min(l["days_to_expiry"] for l in legs)
prices = np.linspace(spot * 0.5, spot * 1.5, n)
return [
{"underlying": round(float(p), 2), "pnl": round(float(value_at(legs, float(p), eval_days, surface_now, 0.05)), 2)}
for p in prices
]
def expected_pnl_scenario(
legs: List[Dict[str, Any]],
surface_scenario: ScenarioSurface,
horizon_days: int,
r: float,
entry_ref: float,
n: int = 200,
) -> float:
"""
Probability-weighted expected P&L at the scenario date: integrates the payoff over a
risk-neutral lognormal density for the underlying, centered on the scenario spot with a
variance driven by the scenario surface's ATM IV over the residual time to the nearest
leg's expiry (the remaining uncertainty once the scenario date is reached).
"""
spot_scenario = surface_scenario.spot
remaining = max(min(l["days_to_expiry"] for l in legs) - horizon_days, 1)
sigma = surface_scenario.iv_at(spot_scenario, remaining)
T = remaining / 365
sd = sigma * math.sqrt(T)
if sd <= 1e-6:
return value_at(legs, spot_scenario, horizon_days, surface_scenario, r) - entry_ref
mu = math.log(spot_scenario) + (r - 0.5 * sigma ** 2) * T
grid = np.geomspace(spot_scenario * 0.15, spot_scenario * 4, n)
log_grid = np.log(grid)
density = np.exp(-0.5 * ((log_grid - mu) / sd) ** 2) / (grid * sd * math.sqrt(2 * math.pi))
pnl = np.array([value_at(legs, float(s), horizon_days, surface_scenario, r) - entry_ref for s in grid])
numerator = np.trapz(pnl * density, grid)
denominator = np.trapz(density, grid)
return float(numerator / denominator) if denominator > 1e-12 else float(pnl.mean())
def payoff_curves(
legs: List[Dict[str, Any]],
chain_slice: Dict[str, Any],
surface_now: Surface,
surface_scenario: ScenarioSurface,
horizon_days: int,
r: float = 0.05,
n: int = 100,
) -> Dict[str, Any]:
spot = chain_slice["spot"]
priced = price_combo(legs, chain_slice, surface_now, surface_scenario, horizon_days, r)
entry_ref = priced["entry_cost"]
lo, hi = spot * 0.6, spot * 1.4
prices = np.linspace(lo, hi, n)
eval_days_expiry = min(l["days_to_expiry"] for l in legs)
at_expiry = [
{"underlying": round(float(p), 2), "pnl": round(float(value_at(legs, float(p), eval_days_expiry, surface_now, r) - entry_ref), 2)}
for p in prices
]
at_scenario = [
{"underlying": round(float(p), 2), "pnl": round(float(value_at(legs, float(p), horizon_days, surface_scenario, r) - entry_ref), 2)}
for p in prices
]
return {"at_expiry": at_expiry, "at_scenario": at_scenario, **priced}

View File

@@ -0,0 +1,171 @@
"""
Optimizer: template generation + bounded hill-climbing residual search + scoring/filtering.
Scans hundreds-to-thousands of candidate 1-4 leg structures (templates.generate_all,
plus off-template perturbations) and returns the top-N ranked by the user's chosen
objective, restricted to non-directional / bounded-risk candidates.
"""
import random
from typing import Any, Dict, List, Optional
from services.option_chain import get_chain_slice
from services.vol_surface import Surface, ScenarioSurface, build_surface, apply_scenario
from services.strategy_engine import price_combo, expected_pnl_scenario
from services.strategy_templates import generate_all, strikes_for
MAX_SEEDS_FOR_RESIDUAL_SEARCH = 40
RESIDUAL_ITERATIONS_PER_SEED = 8
RESIDUAL_MAX_EVALS = 400
def _score(priced: Dict[str, Any], legs: List[Dict[str, Any]], objective: str, surface_scenario: ScenarioSurface, horizon_days: int, r: float) -> Optional[float]:
if objective == "net_pnl":
return priced["net_pnl"]
if objective == "return_on_risk":
if not priced["max_loss"]:
return None
return priced["net_pnl"] / abs(priced["max_loss"])
if objective == "prob_weighted":
return expected_pnl_scenario(legs, surface_scenario, horizon_days, r, priced["entry_cost"])
raise ValueError(f"Objectif inconnu: {objective}")
def _passes_constraints(legs: List[Dict[str, Any]], priced: Dict[str, Any], constraints: Dict[str, Any]) -> bool:
if len(legs) > constraints["max_legs"]:
return False
if abs(priced["net_delta_now"]) > constraints["delta_threshold"]:
return False
if not priced["bounded_risk"]:
return False
cap = constraints.get("max_loss_cap")
if cap is not None and priced["max_loss"] is not None and abs(priced["max_loss"]) > cap:
return False
return True
def _evaluate(
name: str, legs: List[Dict[str, Any]], chain_slice: Dict[str, Any], surface_now: Surface,
surface_scenario: ScenarioSurface, horizon_days: int, r: float, constraints: Dict[str, Any], objective: str,
) -> Optional[Dict[str, Any]]:
if len(legs) > constraints["max_legs"] or len(legs) == 0:
return None
try:
priced = price_combo(legs, chain_slice, surface_now, surface_scenario, horizon_days, r)
except Exception:
return None
if not _passes_constraints(legs, priced, constraints):
return None
score = _score(priced, legs, objective, surface_scenario, horizon_days, r)
if score is None:
return None
return {"template_name": name, "legs": legs, "score": round(score, 2), "objective": objective, **priced}
def _perturb(legs: List[Dict[str, Any]], strikes_by_expiry: Dict[Any, List[float]]) -> List[Dict[str, Any]]:
new_legs = [dict(l) for l in legs]
idx = random.randrange(len(new_legs))
leg = new_legs[idx]
kind = random.choice(["strike", "strike", "quantity"])
if kind == "strike":
strikes = strikes_by_expiry.get((leg["expiry_date"], leg["option_type"]), [])
if not strikes:
return new_legs
try:
cur_idx = strikes.index(leg["strike"])
except ValueError:
cur_idx = min(range(len(strikes)), key=lambda i: abs(strikes[i] - leg["strike"]))
step = random.choice([-2, -1, 1, 2])
new_idx = max(0, min(len(strikes) - 1, cur_idx + step))
leg["strike"] = strikes[new_idx]
else:
leg["quantity"] = max(1, min(3, leg["quantity"] + random.choice([-1, 1])))
return new_legs
def _residual_search(
seeds: List[Dict[str, Any]], chain_slice: Dict[str, Any], surface_now: Surface, surface_scenario: ScenarioSurface,
horizon_days: int, r: float, constraints: Dict[str, Any], objective: str,
) -> List[Dict[str, Any]]:
strikes_by_expiry = {
(exp["expiry_date"], opt_type): strikes_for(exp, opt_type)
for exp in chain_slice["expiries"] for opt_type in ("call", "put")
}
found: List[Dict[str, Any]] = []
evals = 0
for seed in seeds:
current = seed
for _ in range(RESIDUAL_ITERATIONS_PER_SEED):
if evals >= RESIDUAL_MAX_EVALS:
break
candidate_legs = _perturb(current["legs"], strikes_by_expiry)
evals += 1
evaluated = _evaluate(
f"{seed['template_name']} (variante)", candidate_legs, chain_slice, surface_now,
surface_scenario, horizon_days, r, constraints, objective,
)
if evaluated and evaluated["score"] > current["score"]:
current = evaluated
found.append(evaluated)
if evals >= RESIDUAL_MAX_EVALS:
break
return found
def _dedup_top_n(scored: List[Dict[str, Any]], top_n: int) -> List[Dict[str, Any]]:
seen = set()
out = []
for c in scored:
sig = (
c["template_name"].replace(" (variante)", ""),
tuple(sorted(round(l["strike"]) for l in c["legs"])),
tuple(sorted(l["expiry_date"] for l in c["legs"])),
)
if sig in seen:
continue
seen.add(sig)
out.append(c)
if len(out) >= top_n:
break
return out
def optimize(
symbol: 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]]],
n_expiries: int,
rate: float,
constraints: Dict[str, Any],
objective: str,
top_n: int = 20,
) -> List[Dict[str, Any]]:
chain_slice = get_chain_slice(symbol, horizon_days, n_expiries)
surface_now = build_surface(chain_slice)
surface_scenario = apply_scenario(
surface_now, spot_shock_pct=spot_shock_pct, iv_level_shift=iv_level_shift,
skew_tilt=skew_tilt, term_shift=term_shift, manual_grid=manual_grid,
)
candidates = generate_all(chain_slice)
scored: List[Dict[str, Any]] = []
for name, legs in candidates:
evaluated = _evaluate(name, legs, chain_slice, surface_now, surface_scenario, horizon_days, rate, constraints, objective)
if evaluated:
scored.append(evaluated)
scored.sort(key=lambda c: c["score"], reverse=True)
seeds = scored[:MAX_SEEDS_FOR_RESIDUAL_SEARCH]
refined = _residual_search(seeds, chain_slice, surface_now, surface_scenario, horizon_days, rate, constraints, objective)
scored.extend(refined)
scored.sort(key=lambda c: c["score"], reverse=True)
return _dedup_top_n(scored, top_n)

View File

@@ -0,0 +1,222 @@
"""
Parametric generators for canonical non-directional / defined-risk option structures.
Each generator sweeps a small, bounded grid of strike offsets from ATM (not raw brute
force over every strike) so the candidate count stays in the hundreds-to-low-thousands
per expiry, not a combinatorial explosion. Every generator yields (template_name, legs).
Real chains are often asymmetric — a strike can be listed for puts but not for calls
(illiquid/untraded contract). Every generator therefore draws strikes from the
type-specific list (calls_strikes/put_strikes) for whichever leg it's building, never
from a call+put union — picking an unlisted strike would silently fall back to a
theoretical smile price instead of a real tradeable quote.
"""
from typing import Any, Dict, Iterator, List, Tuple
Leg = Dict[str, Any]
OFFSETS = [1, 2, 3, 4, 5, 6]
WIDTHS = [1, 2, 3, 4]
def call_strikes(expiry: Dict[str, Any]) -> List[float]:
return sorted({r["strike"] for r in expiry["calls"]})
def put_strikes(expiry: Dict[str, Any]) -> List[float]:
return sorted({r["strike"] for r in expiry["puts"]})
def strikes_for(expiry: Dict[str, Any], option_type: str) -> List[float]:
return call_strikes(expiry) if option_type == "call" else put_strikes(expiry)
def _atm_index(strikes: List[float], spot: float) -> int:
return min(range(len(strikes)), key=lambda i: abs(strikes[i] - spot))
def _leg(expiry: Dict[str, Any], strike: float, option_type: str, position: str, quantity: int = 1) -> Leg:
return {
"expiry_date": expiry["expiry_date"],
"days_to_expiry": expiry["days_to_expiry"],
"strike": strike,
"option_type": option_type,
"position": position,
"quantity": quantity,
}
def _at(strikes: List[float], idx: int) -> float | None:
return strikes[idx] if 0 <= idx < len(strikes) else None
def iron_condor(expiry: Dict[str, Any], spot: float) -> Iterator[Tuple[str, List[Leg]]]:
puts, calls = put_strikes(expiry), call_strikes(expiry)
if not puts or not calls:
return
atm_p, atm_c = _atm_index(puts, spot), _atm_index(calls, spot)
for po in OFFSETS[1:]:
for co in OFFSETS[1:]:
for w in WIDTHS[:3]:
sp, lp = _at(puts, atm_p - po), _at(puts, atm_p - po - w)
sc, lc = _at(calls, atm_c + co), _at(calls, atm_c + co + w)
if None in (sp, lp, sc, lc):
continue
yield "Iron Condor", [
_leg(expiry, sp, "put", "short"), _leg(expiry, lp, "put", "long"),
_leg(expiry, sc, "call", "short"), _leg(expiry, lc, "call", "long"),
]
def iron_butterfly(expiry: Dict[str, Any], spot: float) -> Iterator[Tuple[str, List[Leg]]]:
puts, calls = put_strikes(expiry), call_strikes(expiry)
common = sorted(set(puts) & set(calls))
if not common:
return
atm = _atm_index(common, spot)
for center in (0, 1):
center_strike = _at(common, atm + center)
if center_strike is None:
continue
p_idx, c_idx = puts.index(center_strike), calls.index(center_strike)
for w in OFFSETS:
lp, lc = _at(puts, p_idx - w), _at(calls, c_idx + w)
if None in (lp, lc):
continue
yield "Iron Butterfly", [
_leg(expiry, center_strike, "put", "short"), _leg(expiry, center_strike, "call", "short"),
_leg(expiry, lp, "put", "long"), _leg(expiry, lc, "call", "long"),
]
def butterfly(expiry: Dict[str, Any], spot: float) -> Iterator[Tuple[str, List[Leg]]]:
"""Call or put butterfly: long 1 low, short 2 mid, long 1 high (all same type, debit)."""
for opt_type in ("call", "put"):
strikes = strikes_for(expiry, opt_type)
if not strikes:
continue
atm = _atm_index(strikes, spot)
for center in (-1, 0, 1):
for w in OFFSETS:
mid, lo, hi = _at(strikes, atm + center), _at(strikes, atm + center - w), _at(strikes, atm + center + w)
if None in (mid, lo, hi):
continue
yield f"{opt_type.capitalize()} Butterfly", [
_leg(expiry, lo, opt_type, "long"), _leg(expiry, mid, opt_type, "short", 2),
_leg(expiry, hi, opt_type, "long"),
]
def condor(expiry: Dict[str, Any], spot: float) -> Iterator[Tuple[str, List[Leg]]]:
"""Call or put condor: long low, short mid-low, short mid-high, long high (same type)."""
for opt_type in ("call", "put"):
strikes = strikes_for(expiry, opt_type)
if not strikes:
continue
atm = _atm_index(strikes, spot)
for inner in (1, 2, 3):
for w in WIDTHS:
lo, mid_lo = _at(strikes, atm - inner - w), _at(strikes, atm - inner)
mid_hi, hi = _at(strikes, atm + inner), _at(strikes, atm + inner + w)
if None in (lo, mid_lo, mid_hi, hi):
continue
yield f"{opt_type.capitalize()} Condor", [
_leg(expiry, lo, opt_type, "long"), _leg(expiry, mid_lo, opt_type, "short"),
_leg(expiry, mid_hi, opt_type, "short"), _leg(expiry, hi, opt_type, "long"),
]
def straddle_strangle(expiry: Dict[str, Any], spot: float) -> Iterator[Tuple[str, List[Leg]]]:
puts, calls = put_strikes(expiry), call_strikes(expiry)
common = sorted(set(puts) & set(calls))
if common:
atm_strike = _at(common, _atm_index(common, spot))
if atm_strike is not None:
yield "Long Straddle", [_leg(expiry, atm_strike, "call", "long"), _leg(expiry, atm_strike, "put", "long")]
yield "Short Straddle", [_leg(expiry, atm_strike, "call", "short"), _leg(expiry, atm_strike, "put", "short")]
if not puts or not calls:
return
atm_p, atm_c = _atm_index(puts, spot), _atm_index(calls, spot)
for w in OFFSETS:
put_k, call_k = _at(puts, atm_p - w), _at(calls, atm_c + w)
if None in (put_k, call_k):
continue
yield "Long Strangle", [_leg(expiry, call_k, "call", "long"), _leg(expiry, put_k, "put", "long")]
yield "Short Strangle", [_leg(expiry, call_k, "call", "short"), _leg(expiry, put_k, "put", "short")]
def ratio_spread(expiry: Dict[str, Any], spot: float) -> Iterator[Tuple[str, List[Leg]]]:
for opt_type in ("call", "put"):
strikes = strikes_for(expiry, opt_type)
if not strikes:
continue
atm = _atm_index(strikes, spot)
sign = 1 if opt_type == "call" else -1
for near in (1, 2, 3):
for far in (2, 3, 4, 5):
if far <= near:
continue
near_k = _at(strikes, atm + sign * near)
far_k = _at(strikes, atm + sign * far)
if None in (near_k, far_k):
continue
yield f"{opt_type.capitalize()} Ratio Spread", [
_leg(expiry, near_k, opt_type, "long"), _leg(expiry, far_k, opt_type, "short", 2),
]
def calendar_spread(near_expiry: Dict[str, Any], far_expiry: Dict[str, Any], spot: float) -> Iterator[Tuple[str, List[Leg]]]:
for opt_type in ("call", "put"):
near_strikes = strikes_for(near_expiry, opt_type)
far_set = set(strikes_for(far_expiry, opt_type))
if not near_strikes or not far_set:
continue
atm = _atm_index(near_strikes, spot)
for offset in (-1, 0, 1):
k = _at(near_strikes, atm + offset)
if k is None or k not in far_set:
continue
yield "Calendar Spread", [
_leg(near_expiry, k, opt_type, "short"), _leg(far_expiry, k, opt_type, "long"),
]
def diagonal_spread(near_expiry: Dict[str, Any], far_expiry: Dict[str, Any], spot: float) -> Iterator[Tuple[str, List[Leg]]]:
for opt_type in ("call", "put"):
near_strikes = strikes_for(near_expiry, opt_type)
far_strikes = strikes_for(far_expiry, opt_type)
if not near_strikes or not far_strikes:
continue
atm_near, atm_far = _atm_index(near_strikes, spot), _atm_index(far_strikes, spot)
sign = 1 if opt_type == "call" else -1
for near_off in (1, 2, 3):
for far_off in (0, 1, 2):
near_k = _at(near_strikes, atm_near + sign * near_off)
far_k = _at(far_strikes, atm_far + sign * far_off)
if None in (near_k, far_k) or near_k == far_k:
continue
yield "Diagonal Spread", [
_leg(near_expiry, near_k, opt_type, "short"), _leg(far_expiry, far_k, opt_type, "long"),
]
def generate_all(chain_slice: Dict[str, Any]) -> List[Tuple[str, List[Leg]]]:
"""All template candidates across the fetched expiries. Single-expiry templates run per
expiry; calendar/diagonal templates pair the two nearest expiries."""
spot = chain_slice["spot"]
expiries = chain_slice["expiries"]
candidates: List[Tuple[str, List[Leg]]] = []
for exp in expiries:
for gen in (iron_condor, iron_butterfly, butterfly, condor, straddle_strangle, ratio_spread):
candidates.extend(gen(exp, spot))
by_date = sorted(expiries, key=lambda e: e["days_to_expiry"])
if len(by_date) >= 2:
near, far = by_date[0], by_date[1]
if far["days_to_expiry"] > near["days_to_expiry"]:
candidates.extend(calendar_spread(near, far, spot))
candidates.extend(diagonal_spread(near, far, spot))
return candidates

View File

@@ -0,0 +1,152 @@
"""
Vol surface model for the Strategy Builder.
Builds a smile-by-expiry model from a real option chain slice (option_chain.py)
and projects a shocked scenario surface (spot/IV-level/skew/term shocks +
optional manual per-cell overrides from the frontend grid).
"""
import math
from typing import Any, Callable, Dict, List, Optional, Tuple
import numpy as np
class Surface:
"""Current-market smile, interpolated per expiry over log-moneyness."""
def __init__(self, spot: float, expiries: List[Dict[str, Any]]):
self.spot = spot
self._tenors: List[Tuple[float, Callable[[float], float]]] = [] # (days, smile_fn)
for exp in expiries:
points = _smile_points(exp, spot)
if not points:
continue
self._tenors.append((exp["days_to_expiry"], _build_smile_fn(points)))
self._tenors.sort(key=lambda x: x[0])
def iv_at(self, strike: float, days: float) -> float:
if not self._tenors:
return 0.20
moneyness = math.log(max(strike, 1e-6) / max(self.spot, 1e-6))
if days <= self._tenors[0][0]:
return self._tenors[0][1](moneyness)
if days >= self._tenors[-1][0]:
return self._tenors[-1][1](moneyness)
for (d0, f0), (d1, f1) in zip(self._tenors, self._tenors[1:]):
if d0 <= days <= d1:
iv0, iv1 = f0(moneyness), f1(moneyness)
w = (days - d0) / max(d1 - d0, 1e-6)
return iv0 + (iv1 - iv0) * w
return self._tenors[-1][1](moneyness)
class ScenarioSurface:
"""Shocked surface at the scenario horizon: parametric shifts + manual overrides."""
def __init__(
self,
base: Surface,
scenario_spot: float,
iv_level_shift: float,
skew_tilt: float,
term_shift: float,
manual_overrides: Optional[Dict[Tuple[int, float], float]] = None,
):
self.base = base
self.spot = scenario_spot
self.iv_level_shift = iv_level_shift
self.skew_tilt = skew_tilt
self.term_shift = term_shift
self.manual_overrides = manual_overrides or {}
def iv_at(self, strike: float, days: float) -> float:
override = self._match_override(strike, days)
if override is not None:
return override
base_iv = self.base.iv_at(strike, days)
moneyness = math.log(max(strike, 1e-6) / max(self.spot, 1e-6))
shocked = (
base_iv
+ self.iv_level_shift
+ self.skew_tilt * moneyness
+ self.term_shift * (days / 30.0)
)
return max(0.01, shocked)
def _match_override(self, strike: float, days: float) -> Optional[float]:
if not self.manual_overrides:
return None
strike_pct = strike / max(self.spot, 1e-6)
best = None
best_dist = None
for (o_days, o_pct), iv in self.manual_overrides.items():
dist = abs(o_days - days) / 30.0 + abs(o_pct - strike_pct)
if best_dist is None or dist < best_dist:
best_dist, best = dist, iv
# Only snap to an override if it's reasonably close to the requested cell
if best_dist is not None and best_dist < 0.08:
return best
return None
def _smile_points(expiry: Dict[str, Any], spot: float) -> List[Tuple[float, float]]:
"""Average call/put IV per strike (filtering stale/zero quotes) -> [(log-moneyness, iv), ...]."""
by_strike: Dict[float, List[float]] = {}
for row in expiry.get("calls", []) + expiry.get("puts", []):
if row["iv"] and row["iv"] > 0.01:
by_strike.setdefault(row["strike"], []).append(row["iv"])
if not by_strike:
return []
points = [
(math.log(k / spot), sum(v) / len(v))
for k, v in sorted(by_strike.items())
]
return points
def _build_smile_fn(points: List[Tuple[float, float]]) -> Callable[[float], float]:
xs = np.array([p[0] for p in points])
ys = np.array([p[1] for p in points])
if len(xs) >= 4:
from scipy.interpolate import CubicSpline
spline = CubicSpline(xs, ys, extrapolate=False)
def fn(x: float) -> float:
if x <= xs[0]:
return float(ys[0])
if x >= xs[-1]:
return float(ys[-1])
v = spline(x)
return float(max(0.01, v))
return fn
def fn_linear(x: float) -> float:
return float(max(0.01, np.interp(x, xs, ys)))
return fn_linear
def build_surface(chain_slice: Dict[str, Any]) -> Surface:
return Surface(chain_slice["spot"], chain_slice["expiries"])
def apply_scenario(
surface: Surface,
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,
) -> ScenarioSurface:
"""
manual_grid: list of {days_to_expiry, strike_pct, iv} cells edited by the user in the
frontend grid — takes precedence over the parametric shock at nearby (days, strike_pct).
"""
scenario_spot = surface.spot * (1 + spot_shock_pct / 100.0)
overrides: Dict[Tuple[int, float], float] = {}
for cell in (manual_grid or []):
if cell.get("iv") is not None:
overrides[(int(cell["days_to_expiry"]), float(cell["strike_pct"]))] = float(cell["iv"])
return ScenarioSurface(surface, scenario_spot, iv_level_shift, skew_tilt, term_shift, overrides)