feat(DATA-002): add live GLD options chain data via yfinance
This commit is contained in:
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import math
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
@@ -57,46 +58,77 @@ class DataService:
|
||||
return quote
|
||||
|
||||
async def get_options_chain(self, symbol: str | None = None) -> dict[str, Any]:
|
||||
ticker = (symbol or self.default_symbol).upper()
|
||||
cache_key = f"options:{ticker}"
|
||||
ticker_symbol = (symbol or self.default_symbol).upper()
|
||||
cache_key = f"options:{ticker_symbol}"
|
||||
|
||||
cached = await self.cache.get_json(cache_key)
|
||||
if cached and isinstance(cached, dict):
|
||||
return cached
|
||||
|
||||
quote = await self.get_quote(ticker)
|
||||
base_price = quote["price"]
|
||||
options_chain = {
|
||||
"symbol": ticker,
|
||||
"updated_at": datetime.now(UTC).isoformat(),
|
||||
"calls": [
|
||||
{
|
||||
"strike": round(base_price * 1.05, 2),
|
||||
"premium": round(base_price * 0.03, 2),
|
||||
"expiry": "2026-06-19",
|
||||
},
|
||||
{
|
||||
"strike": round(base_price * 1.10, 2),
|
||||
"premium": round(base_price * 0.02, 2),
|
||||
"expiry": "2026-09-18",
|
||||
},
|
||||
],
|
||||
"puts": [
|
||||
{
|
||||
"strike": round(base_price * 0.95, 2),
|
||||
"premium": round(base_price * 0.028, 2),
|
||||
"expiry": "2026-06-19",
|
||||
},
|
||||
{
|
||||
"strike": round(base_price * 0.90, 2),
|
||||
"premium": round(base_price * 0.018, 2),
|
||||
"expiry": "2026-09-18",
|
||||
},
|
||||
],
|
||||
"source": quote["source"],
|
||||
}
|
||||
await self.cache.set_json(cache_key, options_chain)
|
||||
return options_chain
|
||||
quote = await self.get_quote(ticker_symbol)
|
||||
if yf is None:
|
||||
options_chain = self._fallback_options_chain(ticker_symbol, quote, source="fallback")
|
||||
await self.cache.set_json(cache_key, options_chain)
|
||||
return options_chain
|
||||
|
||||
try:
|
||||
ticker = yf.Ticker(ticker_symbol)
|
||||
expirations = await asyncio.to_thread(lambda: list(ticker.options or []))
|
||||
if not expirations:
|
||||
options_chain = self._fallback_options_chain(
|
||||
ticker_symbol,
|
||||
quote,
|
||||
source="fallback",
|
||||
error="No option expirations returned by yfinance",
|
||||
)
|
||||
await self.cache.set_json(cache_key, options_chain)
|
||||
return options_chain
|
||||
|
||||
calls: list[dict[str, Any]] = []
|
||||
puts: list[dict[str, Any]] = []
|
||||
|
||||
for expiry in expirations:
|
||||
try:
|
||||
chain = await asyncio.to_thread(ticker.option_chain, expiry)
|
||||
except Exception as exc: # pragma: no cover - network dependent
|
||||
logger.warning("Failed to fetch option chain for %s %s: %s", ticker_symbol, expiry, exc)
|
||||
continue
|
||||
|
||||
calls.extend(self._normalize_option_rows(chain.calls, ticker_symbol, expiry, "call"))
|
||||
puts.extend(self._normalize_option_rows(chain.puts, ticker_symbol, expiry, "put"))
|
||||
|
||||
if not calls and not puts:
|
||||
options_chain = self._fallback_options_chain(
|
||||
ticker_symbol,
|
||||
quote,
|
||||
source="fallback",
|
||||
error="No option contracts returned by yfinance",
|
||||
)
|
||||
await self.cache.set_json(cache_key, options_chain)
|
||||
return options_chain
|
||||
|
||||
options_chain = {
|
||||
"symbol": ticker_symbol,
|
||||
"updated_at": datetime.now(UTC).isoformat(),
|
||||
"expirations": expirations,
|
||||
"calls": calls,
|
||||
"puts": puts,
|
||||
"rows": sorted(calls + puts, key=lambda row: (row["expiry"], row["strike"], row["type"])),
|
||||
"underlying_price": quote["price"],
|
||||
"source": "yfinance",
|
||||
}
|
||||
await self.cache.set_json(cache_key, options_chain)
|
||||
return options_chain
|
||||
except Exception as exc: # pragma: no cover - network dependent
|
||||
logger.warning("Failed to fetch options chain for %s from yfinance: %s", ticker_symbol, exc)
|
||||
options_chain = self._fallback_options_chain(
|
||||
ticker_symbol,
|
||||
quote,
|
||||
source="fallback",
|
||||
error=str(exc),
|
||||
)
|
||||
await self.cache.set_json(cache_key, options_chain)
|
||||
return options_chain
|
||||
|
||||
async def get_strategies(self, symbol: str | None = None) -> dict[str, Any]:
|
||||
ticker = (symbol or self.default_symbol).upper()
|
||||
@@ -149,6 +181,81 @@ class DataService:
|
||||
logger.warning("Failed to fetch %s from yfinance: %s", symbol, exc)
|
||||
return self._fallback_quote(symbol, source="fallback")
|
||||
|
||||
def _fallback_options_chain(
|
||||
self,
|
||||
symbol: str,
|
||||
quote: dict[str, Any],
|
||||
*,
|
||||
source: str,
|
||||
error: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
options_chain = {
|
||||
"symbol": symbol,
|
||||
"updated_at": datetime.now(UTC).isoformat(),
|
||||
"expirations": [],
|
||||
"calls": [],
|
||||
"puts": [],
|
||||
"rows": [],
|
||||
"underlying_price": quote["price"],
|
||||
"source": source,
|
||||
}
|
||||
if error:
|
||||
options_chain["error"] = error
|
||||
return options_chain
|
||||
|
||||
def _normalize_option_rows(self, frame: Any, symbol: str, expiry: str, option_type: str) -> list[dict[str, Any]]:
|
||||
if frame is None or getattr(frame, "empty", True):
|
||||
return []
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
for item in frame.to_dict(orient="records"):
|
||||
strike = self._safe_float(item.get("strike"))
|
||||
if strike <= 0:
|
||||
continue
|
||||
|
||||
bid = self._safe_float(item.get("bid"))
|
||||
ask = self._safe_float(item.get("ask"))
|
||||
last_price = self._safe_float(item.get("lastPrice"))
|
||||
implied_volatility = self._safe_float(item.get("impliedVolatility"))
|
||||
contract_symbol = str(item.get("contractSymbol") or "").strip()
|
||||
|
||||
rows.append(
|
||||
{
|
||||
"contractSymbol": contract_symbol,
|
||||
"symbol": contract_symbol or f"{symbol} {expiry} {option_type.upper()} {strike:.2f}",
|
||||
"strike": strike,
|
||||
"bid": bid,
|
||||
"ask": ask,
|
||||
"premium": last_price or self._midpoint(bid, ask),
|
||||
"lastPrice": last_price,
|
||||
"impliedVolatility": implied_volatility,
|
||||
"expiry": expiry,
|
||||
"type": option_type,
|
||||
"openInterest": int(self._safe_float(item.get("openInterest"))),
|
||||
"volume": int(self._safe_float(item.get("volume"))),
|
||||
"delta": 0.0,
|
||||
"gamma": 0.0,
|
||||
"theta": 0.0,
|
||||
"vega": 0.0,
|
||||
"rho": 0.0,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
@staticmethod
|
||||
def _safe_float(value: Any) -> float:
|
||||
try:
|
||||
result = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
return 0.0 if math.isnan(result) else result
|
||||
|
||||
@staticmethod
|
||||
def _midpoint(bid: float, ask: float) -> float:
|
||||
if bid > 0 and ask > 0:
|
||||
return round((bid + ask) / 2, 4)
|
||||
return max(bid, ask, 0.0)
|
||||
|
||||
@staticmethod
|
||||
def _fallback_quote(symbol: str, source: str) -> dict[str, Any]:
|
||||
return {
|
||||
|
||||
18
app/services/runtime.py
Normal file
18
app/services/runtime.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""Runtime service registry for UI pages and background tasks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.services.data_service import DataService
|
||||
|
||||
_data_service: DataService | None = None
|
||||
|
||||
|
||||
def set_data_service(service: DataService) -> None:
|
||||
global _data_service
|
||||
_data_service = service
|
||||
|
||||
|
||||
def get_data_service() -> DataService:
|
||||
if _data_service is None:
|
||||
raise RuntimeError("DataService has not been initialized")
|
||||
return _data_service
|
||||
Reference in New Issue
Block a user