feat: strategy builder
This commit is contained in:
@@ -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()))
|
||||
|
||||
Reference in New Issue
Block a user