- add real browser test for overview and options pages - document engineering learnings in AGENTS.md - commit NiceGUI header layout fix - limit options initial expirations for faster first render
272 lines
10 KiB
Python
272 lines
10 KiB
Python
"""Market data access layer with caching support."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import math
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
|
|
from app.services.cache import CacheService
|
|
from app.strategies.engine import StrategySelectionEngine
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
try:
|
|
import yfinance as yf
|
|
except ImportError: # pragma: no cover - optional dependency
|
|
yf = None
|
|
|
|
|
|
class DataService:
|
|
"""Fetches portfolio and market data, using Redis when available."""
|
|
|
|
def __init__(self, cache: CacheService, default_symbol: str = "GLD") -> None:
|
|
self.cache = cache
|
|
self.default_symbol = default_symbol
|
|
|
|
async def get_portfolio(self, symbol: str | None = None) -> dict[str, Any]:
|
|
ticker = (symbol or self.default_symbol).upper()
|
|
cache_key = f"portfolio:{ticker}"
|
|
|
|
cached = await self.cache.get_json(cache_key)
|
|
if cached and isinstance(cached, dict):
|
|
return cached
|
|
|
|
quote = await self.get_quote(ticker)
|
|
portfolio = {
|
|
"symbol": ticker,
|
|
"spot_price": quote["price"],
|
|
"portfolio_value": round(quote["price"] * 1000, 2),
|
|
"loan_amount": 600_000.0,
|
|
"ltv_ratio": round(600_000.0 / max(quote["price"] * 1000, 1), 4),
|
|
"updated_at": datetime.now(UTC).isoformat(),
|
|
"source": quote["source"],
|
|
}
|
|
await self.cache.set_json(cache_key, portfolio)
|
|
return portfolio
|
|
|
|
async def get_quote(self, symbol: str) -> dict[str, Any]:
|
|
cache_key = f"quote:{symbol}"
|
|
cached = await self.cache.get_json(cache_key)
|
|
if cached and isinstance(cached, dict):
|
|
return cached
|
|
|
|
quote = await self._fetch_quote(symbol)
|
|
await self.cache.set_json(cache_key, quote)
|
|
return quote
|
|
|
|
async def get_options_chain(self, symbol: str | None = None) -> dict[str, Any]:
|
|
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_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
|
|
|
|
# Limit initial load to the nearest expirations so the page can render quickly.
|
|
expirations = expirations[:3]
|
|
|
|
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()
|
|
quote = await self.get_quote(ticker)
|
|
engine = StrategySelectionEngine(spot_price=quote["price"] if ticker != "GLD" else 460.0)
|
|
|
|
return {
|
|
"symbol": ticker,
|
|
"updated_at": datetime.now(UTC).isoformat(),
|
|
"paper_parameters": {
|
|
"portfolio_value": engine.portfolio_value,
|
|
"loan_amount": engine.loan_amount,
|
|
"margin_call_threshold": engine.margin_call_threshold,
|
|
"spot_price": engine.spot_price,
|
|
"volatility": engine.volatility,
|
|
"risk_free_rate": engine.risk_free_rate,
|
|
},
|
|
"strategies": engine.compare_all_strategies(),
|
|
"recommendations": {
|
|
profile: engine.recommend(profile) # type: ignore[arg-type]
|
|
for profile in ("conservative", "balanced", "cost_sensitive")
|
|
},
|
|
"sensitivity_analysis": engine.sensitivity_analysis(),
|
|
}
|
|
|
|
async def _fetch_quote(self, symbol: str) -> dict[str, Any]:
|
|
if yf is None:
|
|
return self._fallback_quote(symbol, source="fallback")
|
|
|
|
try:
|
|
ticker = yf.Ticker(symbol)
|
|
history = await asyncio.to_thread(ticker.history, period="5d", interval="1d")
|
|
if history.empty:
|
|
return self._fallback_quote(symbol, source="fallback")
|
|
|
|
closes = history["Close"]
|
|
last = float(closes.iloc[-1])
|
|
previous = float(closes.iloc[-2]) if len(closes) > 1 else last
|
|
change = round(last - previous, 4)
|
|
change_percent = round((change / previous) * 100, 4) if previous else 0.0
|
|
return {
|
|
"symbol": symbol,
|
|
"price": round(last, 4),
|
|
"change": change,
|
|
"change_percent": change_percent,
|
|
"updated_at": datetime.now(UTC).isoformat(),
|
|
"source": "yfinance",
|
|
}
|
|
except Exception as exc: # pragma: no cover - network dependent
|
|
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 {
|
|
"symbol": symbol,
|
|
"price": 215.0,
|
|
"change": 0.0,
|
|
"change_percent": 0.0,
|
|
"updated_at": datetime.now(UTC).isoformat(),
|
|
"source": source,
|
|
}
|