diff --git a/backend/services/database.py b/backend/services/database.py index 692fb23..0d6631b 100644 --- a/backend/services/database.py +++ b/backend/services/database.py @@ -6138,6 +6138,27 @@ def get_saxo_snapshot_symbols() -> List[Dict[str, Any]]: return [dict(r) for r in rows] +def get_latest_saxo_snapshot_rows(symbol: str) -> List[Dict[str, Any]]: + """One row per (expiry_date, strike, option_type) — the freshest of the accumulated + 5-min snapshots for that contract, not the whole history. Powers the Strategy Builder, + which reads exclusively from this accumulated history (no live yfinance/Saxo call).""" + conn = get_conn() + rows = conn.execute(""" + SELECT s.* FROM saxo_option_snapshots s + JOIN ( + SELECT expiry_date, strike, option_type, MAX(created_at) AS max_created + FROM saxo_option_snapshots WHERE symbol = ? + GROUP BY expiry_date, strike, option_type + ) latest + ON s.expiry_date = latest.expiry_date AND s.strike = latest.strike + AND s.option_type = latest.option_type AND s.created_at = latest.max_created + WHERE s.symbol = ? + ORDER BY s.expiry_date, s.strike + """, (symbol, symbol)).fetchall() + conn.close() + return [dict(r) for r in rows] + + def upsert_saxo_catalog_rows(rows: List[Dict[str, Any]]): if not rows: return diff --git a/backend/services/option_chain.py b/backend/services/option_chain.py index 964e53c..e74d507 100644 --- a/backend/services/option_chain.py +++ b/backend/services/option_chain.py @@ -1,114 +1,82 @@ """ -Real option chain fetcher for the Strategy Builder — reuses the same yfinance -proxy/resolution logic as iv_engine.py (futures/indices → optionable ETFs). +Option chain fetcher for the Strategy Builder — reads exclusively from our own +accumulated Saxo history (saxo_option_snapshots, refreshed every ~5 min by +services/saxo_scheduler.py). No live yfinance/Saxo call here: by the time the +Strategy Builder needs a chain, it's already been fetched and parsed correctly +by the periodic snapshot poller, so this is a fast, reliable DB read instead of +repeating the whole resolve/subscribe/parse dance per pricing request. """ -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. + Builds a chain slice from the latest accumulated Saxo snapshot rows for `symbol` + (services/database.get_latest_saxo_snapshot_rows). Returns the `n_expiries` + expirations closest to target_days, each with calls/puts rows shaped + {strike, bid, ask, mid, last, iv, open_interest, volume} — same shape regardless + of source, so vol_surface.py/strategy_engine.py need no changes. """ - if ":" in symbol: - from services.saxo_client import get_chain_slice_saxo - return get_chain_slice_saxo(symbol, target_days, n_expiries) + from services.database import get_latest_saxo_snapshot_rows - 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})") + flat_rows = get_latest_saxo_snapshot_rows(symbol.upper()) + if not flat_rows: + raise ValueError( + f"Aucun historique Saxo pour '{symbol}' — ajoutez-le à la watchlist " + f"(Config → Saxo) et attendez le prochain cycle de snapshot (~5 min)." + ) + spot = next((r["spot"] for r in flat_rows if r.get("spot") is not None), None) 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)] + + by_expiry: Dict[str, List[Dict[str, Any]]] = {} + for r in flat_rows: + if r.get("expiry_date"): + by_expiry.setdefault(r["expiry_date"], []).append(r) + + def _days_to(expiry_date: str) -> int: + return (datetime.strptime(expiry_date[:10], "%Y-%m-%d").date() - today).days + + selected = sorted(by_expiry.keys(), key=lambda e: abs(_days_to(e) - target_days))[:max(1, n_expiries)] + + def _row_shape(r: Dict[str, Any]) -> Dict[str, Any]: + bid = r.get("bid") or 0.0 + ask = r.get("ask") or 0.0 + mid = r.get("mid") or (round((bid + ask) / 2, 6) if (bid > 0 and ask > 0) else 0.0) + vol_pct = r.get("volatility_pct") + return { + "strike": float(r["strike"]), + "bid": float(bid), + "ask": float(ask), + "mid": float(mid), + "last": float(mid), + "iv": float(vol_pct) / 100.0 if vol_pct is not None else 0.0, + "open_interest": 0, + "volume": 0, + } 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}") + for expiry_date in sorted(selected, key=_days_to): + rows = by_expiry[expiry_date] + calls = sorted([_row_shape(r) for r in rows if r["option_type"] == "call"], key=lambda x: x["strike"]) + puts = sorted([_row_shape(r) for r in rows if r["option_type"] == "put"], key=lambda x: x["strike"]) + if not calls and not puts: + continue + expiries_out.append({ + "expiry_date": expiry_date, + "days_to_expiry": _days_to(expiry_date), + "calls": calls, + "puts": puts, + }) if not expiries_out: - raise ValueError(f"Aucune chaîne exploitable pour {symbol} ({proxy})") + raise ValueError(f"Historique Saxo présent pour '{symbol}' mais aucune échéance exploitable (pas de cotation Call/Put dans la fenêtre active de Saxo).") return { "symbol": symbol.upper(), - "proxy": proxy, - "spot": round(float(spot), 4), + "proxy": symbol.upper(), + "spot": round(float(spot), 6) if spot is not None else None, "expiries": expiries_out, } diff --git a/backend/services/saxo_client.py b/backend/services/saxo_client.py index 4fdb720..a8030e6 100644 --- a/backend/services/saxo_client.py +++ b/backend/services/saxo_client.py @@ -292,65 +292,3 @@ def snapshot_options_chain(symbol: str, target_days: int = 30) -> List[Dict[str, if not rows: raise ValueError(f"Snapshot Saxo vide pour '{symbol}' (clés reçues: {list(snapshot.keys())})") return rows - - -def get_chain_slice_saxo(symbol: str, target_days: int = 8, n_expiries: int = 3) -> Dict[str, Any]: - """ - Saxo-backed equivalent of services/option_chain.get_chain_slice — same output shape - ({symbol, proxy, spot, expiries: [{expiry_date, days_to_expiry, calls, puts}]}, each row - {strike, bid, ask, mid, last, iv, open_interest, volume}) so vol_surface.py/strategy_engine.py - work unchanged regardless of data source. Reuses snapshot_options_chain (already flat, - already bid/ask/mid/greeks per contract) and reshapes/filters it down to n_expiries. - """ - instrument = resolve_instrument(symbol) - flat_rows = snapshot_options_chain(symbol) - spot = flat_rows[0]["spot"] if flat_rows else None - today = date.today() - - by_expiry: Dict[str, List[Dict[str, Any]]] = {} - for r in flat_rows: - if r.get("expiry_date"): - by_expiry.setdefault(r["expiry_date"], []).append(r) - - def _days_to(expiry_date: str) -> int: - return (datetime.strptime(expiry_date[:10], "%Y-%m-%d").date() - today).days - - selected = sorted(by_expiry.keys(), key=lambda e: abs(_days_to(e) - target_days))[:max(1, n_expiries)] - - def _row_shape(r: Dict[str, Any]) -> Dict[str, Any]: - bid = r.get("bid") or 0.0 - ask = r.get("ask") or 0.0 - mid = r.get("mid") or (round((bid + ask) / 2, 4) if (bid > 0 and ask > 0) else 0.0) - vol_pct = r.get("volatility_pct") - return { - "strike": float(r["strike"]), - "bid": float(bid), - "ask": float(ask), - "mid": float(mid), - "last": float(mid), # Saxo's chain snapshot has no separate last-traded field - "iv": float(vol_pct) / 100.0 if vol_pct is not None else 0.0, - "open_interest": 0, - "volume": 0, - } - - expiries_out = [] - for expiry_date in sorted(selected, key=_days_to): - rows = by_expiry[expiry_date] - calls = sorted([_row_shape(r) for r in rows if r["option_type"] == "call"], key=lambda x: x["strike"]) - puts = sorted([_row_shape(r) for r in rows if r["option_type"] == "put"], key=lambda x: x["strike"]) - expiries_out.append({ - "expiry_date": expiry_date, - "days_to_expiry": _days_to(expiry_date), - "calls": calls, - "puts": puts, - }) - - if not expiries_out: - raise ValueError(f"Aucune chaîne exploitable pour '{symbol}' via Saxo") - - return { - "symbol": symbol.upper(), - "proxy": instrument["symbol"] or symbol.upper(), - "spot": round(float(spot), 4) if spot is not None else None, - "expiries": expiries_out, - }