feat: saxo
This commit is contained in:
@@ -16,12 +16,39 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from services.options_pricer import black_scholes
|
||||
from services.saxo_auth import SAXO_API_BASE_URL, get_valid_access_token
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_OPTION_ASSET_TYPES = "StockOption,StockIndexOption,FuturesOption,FxVanillaOption"
|
||||
|
||||
# Applied around a Black-Scholes theoretical price when Saxo returns no live Bid/Ask (FX
|
||||
# options in particular go quiet outside FX market hours — closed over the weekend) but
|
||||
# still supplies MidVolatility/Greeks from its own model, so IV/spot/strike are usable.
|
||||
_SYNTHETIC_SPREAD_PCT = 0.05
|
||||
_SYNTHETIC_RATE = 0.02
|
||||
|
||||
|
||||
def _synthesize_quote(
|
||||
spot: Optional[float], strike: Optional[float], expiry_date: Optional[str],
|
||||
snapshot_date: str, iv_pct: Optional[float], option_type: str,
|
||||
) -> tuple:
|
||||
if not spot or not strike or not expiry_date or not iv_pct or iv_pct <= 0:
|
||||
return None, None, None
|
||||
try:
|
||||
days_to_expiry = (date.fromisoformat(expiry_date) - date.fromisoformat(snapshot_date)).days
|
||||
except ValueError:
|
||||
return None, None, None
|
||||
T = max(days_to_expiry, 1) / 365
|
||||
theo = float(black_scholes(spot, strike, T, _SYNTHETIC_RATE, iv_pct / 100.0, option_type)["price"])
|
||||
if theo <= 0:
|
||||
return None, None, None
|
||||
half_spread = theo * _SYNTHETIC_SPREAD_PCT / 2
|
||||
bid = round(max(0.0, theo - half_spread), 6)
|
||||
ask = round(theo + half_spread, 6)
|
||||
return bid, ask, round((bid + ask) / 2, 6)
|
||||
|
||||
# Bounded, stable catalogs worth fully caching in our own DB (StockOption/StockIndexOption
|
||||
# are far too large to bulk-fetch — those stay resolved on demand via Keywords search).
|
||||
CATALOG_ASSET_TYPES = ["FuturesOption", "FxVanillaOption"]
|
||||
@@ -242,7 +269,9 @@ 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}
|
||||
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).
|
||||
"""
|
||||
instrument = resolve_instrument(symbol)
|
||||
root_uic = instrument["uic"]
|
||||
@@ -270,23 +299,36 @@ 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({
|
||||
"symbol": symbol.upper(),
|
||||
"snapshot_date": snapshot_date,
|
||||
"spot": float(spot) if spot is not None else None,
|
||||
"expiry_date": expiry_date,
|
||||
"strike": float(strike) if strike is not None else None,
|
||||
"option_type": "put" if side_key == "Put" else "call",
|
||||
"option_type": option_type,
|
||||
"bid": bid,
|
||||
"ask": ask,
|
||||
"mid": round((bid + ask) / 2, 6) if (bid is not None and ask is not None) else None,
|
||||
"mid": mid,
|
||||
# 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": round(mid_vol * 100, 4) if mid_vol is not None else None,
|
||||
"volatility_pct": vol_pct,
|
||||
"delta": greeks.get("Delta"),
|
||||
"gamma": greeks.get("Gamma"),
|
||||
"theta": greeks.get("Theta"),
|
||||
"vega": greeks.get("Vega"),
|
||||
"is_synthetic": is_synthetic,
|
||||
})
|
||||
|
||||
if not rows:
|
||||
|
||||
Reference in New Issue
Block a user