feat: strategy builder

This commit is contained in:
OpenSquared
2026-07-19 09:39:09 +02:00
parent e7247d4c4c
commit 3417bb6075
6 changed files with 142 additions and 62 deletions

View File

@@ -18,6 +18,7 @@ import httpx
from services.options_pricer import black_scholes
from services.saxo_auth import SAXO_API_BASE_URL, get_valid_access_token
from services.vol_surface import Surface
logger = logging.getLogger(__name__)
@@ -278,8 +279,12 @@ def snapshot_options_chain(symbol: str, target_days: int = 30) -> List[Dict[str,
Returns normalized rows ready for services/database.save_saxo_snapshot_rows:
{symbol, snapshot_date, spot, expiry_date, strike, option_type, bid, ask, mid,
volatility_pct, delta, gamma, theta, vega, is_synthetic}. bid/ask/mid are
Black-Scholes-synthesized from IV (is_synthetic=True) whenever Saxo returns no live
Bid/Ask for that contract (e.g. FX options outside market hours).
Black-Scholes-synthesized (is_synthetic=True) whenever Saxo returns no live Bid/Ask for
that contract (e.g. FX options outside market hours) — using that contract's own IV
when Saxo quoted it, or otherwise an IV borrowed from a smile built across whatever
strikes/expiries in this same snapshot DID carry a live MidVolatility (Saxo's "active
quoting window" is often just the near-the-money strikes on the nearest expiry; the
rest of the chain has no Greeks/MidVolatility at all, not just no Bid/Ask).
"""
instrument = resolve_instrument(symbol)
root_uic = instrument["uic"]
@@ -295,10 +300,15 @@ def snapshot_options_chain(symbol: str, target_days: int = 30) -> List[Dict[str,
# real payload) — MidStrikePrice on the nearest expiry is the best available proxy.
spot = next((eb.get("MidStrikePrice") for eb in expiry_blocks if eb.get("MidStrikePrice") is not None), None)
rows: List[Dict[str, Any]] = []
# First pass: take exactly what Saxo quoted, no synthesis yet.
raw: List[Dict[str, Any]] = []
for expiry_block in expiry_blocks:
expiry_date = (expiry_block.get("Expiry") or "")[:10] or None
for strike_block in (strike_block for strike_block in (expiry_block.get("Strikes") or [])):
try:
days_to_expiry = (date.fromisoformat(expiry_date) - date.fromisoformat(snapshot_date)).days if expiry_date else None
except ValueError:
days_to_expiry = None
for strike_block in (expiry_block.get("Strikes") or []):
strike = strike_block.get("Strike")
for side_key in ("Call", "Put"):
side = strike_block.get(side_key)
@@ -307,38 +317,68 @@ def snapshot_options_chain(symbol: str, target_days: int = 30) -> List[Dict[str,
greeks = side.get("Greeks") or {}
bid, ask = side.get("Bid"), side.get("Ask")
mid_vol = greeks.get("MidVolatility")
option_type = "put" if side_key == "Put" else "call"
vol_pct = round(mid_vol * 100, 4) if mid_vol is not None else None
mid = round((bid + ask) / 2, 6) if (bid is not None and ask is not None) else None
is_synthetic = False
if not bid and not ask:
syn_bid, syn_ask, syn_mid = _synthesize_quote(
spot, strike, expiry_date, snapshot_date, vol_pct, option_type,
)
if syn_bid is not None:
bid, ask, mid, is_synthetic = syn_bid, syn_ask, syn_mid, True
rows.append({
raw.append({
"symbol": symbol.upper(),
"snapshot_date": snapshot_date,
"spot": float(spot) if spot is not None else None,
"expiry_date": expiry_date,
"days_to_expiry": days_to_expiry,
"strike": float(strike) if strike is not None else None,
"option_type": option_type,
"option_type": "put" if side_key == "Put" else "call",
"bid": bid,
"ask": ask,
"mid": mid,
"mid": round((bid + ask) / 2, 6) if (bid is not None and ask is not None) else None,
# MidVolatility comes back as a decimal fraction (0.05 = 5%) — store as an
# actual percentage to match the volatility_pct column's name/convention.
"volatility_pct": vol_pct,
"volatility_pct": round(mid_vol * 100, 4) if mid_vol is not None else None,
"delta": greeks.get("Delta"),
"gamma": greeks.get("Gamma"),
"theta": greeks.get("Theta"),
"vega": greeks.get("Vega"),
"is_synthetic": is_synthetic,
})
if not rows:
if not raw:
raise ValueError(f"Snapshot Saxo vide pour '{symbol}' (clés reçues: {list(snapshot.keys())})")
fallback_surface = _build_fallback_surface(spot, raw)
rows: List[Dict[str, Any]] = []
for r in raw:
bid, ask, mid, vol_pct = r["bid"], r["ask"], r["mid"], r["volatility_pct"]
is_synthetic = False
if not bid and not ask:
iv_for_synth = vol_pct
if iv_for_synth is None and fallback_surface is not None and r["strike"] and r["days_to_expiry"]:
iv_for_synth = round(fallback_surface.iv_at(r["strike"], max(r["days_to_expiry"], 1)) * 100, 4)
syn_bid, syn_ask, syn_mid = _synthesize_quote(
r["spot"], r["strike"], r["expiry_date"], snapshot_date, iv_for_synth, r["option_type"],
)
if syn_bid is not None:
bid, ask, mid, is_synthetic = syn_bid, syn_ask, syn_mid, True
if vol_pct is None:
vol_pct = iv_for_synth
rows.append({
**{k: v for k, v in r.items() if k != "days_to_expiry"},
"bid": bid, "ask": ask, "mid": mid, "volatility_pct": vol_pct,
"is_synthetic": is_synthetic,
})
return rows
def _build_fallback_surface(spot: Optional[float], raw_rows: List[Dict[str, Any]]) -> Optional[Surface]:
"""A smile built only from strikes/expiries that carried a live MidVolatility in this
same snapshot — used to borrow a plausible IV for contracts Saxo didn't quote at all."""
if not spot:
return None
by_days: Dict[float, Dict[str, Any]] = {}
for r in raw_rows:
if r["volatility_pct"] is None or r["days_to_expiry"] is None or r["strike"] is None:
continue
exp = by_days.setdefault(r["days_to_expiry"], {"days_to_expiry": r["days_to_expiry"], "calls": [], "puts": []})
entry = {"strike": r["strike"], "iv": r["volatility_pct"] / 100.0}
(exp["calls"] if r["option_type"] == "call" else exp["puts"]).append(entry)
if not by_days:
return None
return Surface(spot, list(by_days.values()))