126 lines
4.5 KiB
Python
126 lines
4.5 KiB
Python
"""
|
|
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` — yfinance by default, with an automatic
|
|
Saxo fallback for instruments yfinance can't handle (FX/futures options).
|
|
|
|
Dispatch: a Saxo-formatted symbol (exchange suffix, e.g. "OG:xcme") goes straight to
|
|
Saxo; otherwise yfinance is tried first (unchanged, proven path for stocks/ETFs), and
|
|
only falls back to Saxo if yfinance fails AND a Saxo connection is available.
|
|
"""
|
|
if ":" in symbol:
|
|
from services.saxo_client import get_chain_slice_saxo
|
|
return get_chain_slice_saxo(symbol, target_days, n_expiries)
|
|
|
|
try:
|
|
return _get_chain_slice_yfinance(symbol, target_days, n_expiries)
|
|
except ValueError:
|
|
from services import saxo_auth
|
|
from services.saxo_client import get_chain_slice_saxo
|
|
if saxo_auth.get_status().get("connected"):
|
|
try:
|
|
return get_chain_slice_saxo(symbol, target_days, n_expiries)
|
|
except Exception as e:
|
|
logger.debug(f"[OptionChain] Saxo fallback failed for {symbol}: {e}")
|
|
raise
|
|
|
|
|
|
def _get_chain_slice_yfinance(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
|