feat: prioritize lazy options loading and live overview wiring

- queue OPS-001 Caddy route for vd1.uncloud.vpn
- lazy-load options expirations/chains per expiry
- wire overview to live quote data and persisted portfolio config
- extend browser test to verify live quote metadata
This commit is contained in:
Bu5hm4nn
2026-03-23 23:23:59 +01:00
parent d51fa05d5a
commit 133908dd36
6 changed files with 320 additions and 121 deletions

View File

@@ -57,9 +57,9 @@ class DataService:
await self.cache.set_json(cache_key, quote)
return quote
async def get_options_chain(self, symbol: str | None = None) -> dict[str, Any]:
async def get_option_expirations(self, symbol: str | None = None) -> dict[str, Any]:
ticker_symbol = (symbol or self.default_symbol).upper()
cache_key = f"options:{ticker_symbol}"
cache_key = f"options:{ticker_symbol}:expirations"
cached = await self.cache.get_json(cache_key)
if cached and isinstance(cached, dict):
@@ -67,71 +67,141 @@ class DataService:
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
payload = self._fallback_option_expirations(
ticker_symbol,
quote,
source="fallback",
error="yfinance is not installed",
)
await self.cache.set_json(cache_key, payload)
return payload
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(
payload = self._fallback_option_expirations(
ticker_symbol,
quote,
source="fallback",
error="No option expirations returned by yfinance",
)
await self.cache.set_json(cache_key, options_chain)
return options_chain
await self.cache.set_json(cache_key, payload)
return payload
# 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 = {
payload = {
"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
await self.cache.set_json(cache_key, payload)
return payload
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(
logger.warning("Failed to fetch option expirations for %s from yfinance: %s", ticker_symbol, exc)
payload = self._fallback_option_expirations(
ticker_symbol,
quote,
source="fallback",
error=str(exc),
)
await self.cache.set_json(cache_key, options_chain)
return options_chain
await self.cache.set_json(cache_key, payload)
return payload
async def get_options_chain_for_expiry(self, symbol: str | None = None, expiry: str | None = None) -> dict[str, Any]:
ticker_symbol = (symbol or self.default_symbol).upper()
expirations_data = await self.get_option_expirations(ticker_symbol)
expirations = list(expirations_data.get("expirations") or [])
target_expiry = expiry or (expirations[0] if expirations else None)
quote = await self.get_quote(ticker_symbol)
if not target_expiry:
return self._fallback_options_chain(
ticker_symbol,
quote,
expirations=expirations,
selected_expiry=None,
source=expirations_data.get("source", quote.get("source", "fallback")),
error=expirations_data.get("error"),
)
cache_key = f"options:{ticker_symbol}:{target_expiry}"
cached = await self.cache.get_json(cache_key)
if cached and isinstance(cached, dict):
return cached
if yf is None:
payload = self._fallback_options_chain(
ticker_symbol,
quote,
expirations=expirations,
selected_expiry=target_expiry,
source="fallback",
error="yfinance is not installed",
)
await self.cache.set_json(cache_key, payload)
return payload
try:
ticker = yf.Ticker(ticker_symbol)
chain = await asyncio.to_thread(ticker.option_chain, target_expiry)
calls = self._normalize_option_rows(chain.calls, ticker_symbol, target_expiry, "call")
puts = self._normalize_option_rows(chain.puts, ticker_symbol, target_expiry, "put")
if not calls and not puts:
payload = self._fallback_options_chain(
ticker_symbol,
quote,
expirations=expirations,
selected_expiry=target_expiry,
source="fallback",
error="No option contracts returned by yfinance",
)
await self.cache.set_json(cache_key, payload)
return payload
payload = {
"symbol": ticker_symbol,
"selected_expiry": target_expiry,
"updated_at": datetime.now(UTC).isoformat(),
"expirations": expirations,
"calls": calls,
"puts": puts,
"rows": sorted(calls + puts, key=lambda row: (row["strike"], row["type"])),
"underlying_price": quote["price"],
"source": "yfinance",
}
await self.cache.set_json(cache_key, payload)
return payload
except Exception as exc: # pragma: no cover - network dependent
logger.warning("Failed to fetch options chain for %s %s from yfinance: %s", ticker_symbol, target_expiry, exc)
payload = self._fallback_options_chain(
ticker_symbol,
quote,
expirations=expirations,
selected_expiry=target_expiry,
source="fallback",
error=str(exc),
)
await self.cache.set_json(cache_key, payload)
return payload
async def get_options_chain(self, symbol: str | None = None) -> dict[str, Any]:
ticker_symbol = (symbol or self.default_symbol).upper()
expirations_data = await self.get_option_expirations(ticker_symbol)
expirations = list(expirations_data.get("expirations") or [])
if not expirations:
quote = await self.get_quote(ticker_symbol)
return self._fallback_options_chain(
ticker_symbol,
quote,
expirations=[],
selected_expiry=None,
source=expirations_data.get("source", quote.get("source", "fallback")),
error=expirations_data.get("error"),
)
return await self.get_options_chain_for_expiry(ticker_symbol, expirations[0])
async def get_strategies(self, symbol: str | None = None) -> dict[str, Any]:
ticker = (symbol or self.default_symbol).upper()
@@ -184,7 +254,7 @@ class DataService:
logger.warning("Failed to fetch %s from yfinance: %s", symbol, exc)
return self._fallback_quote(symbol, source="fallback")
def _fallback_options_chain(
def _fallback_option_expirations(
self,
symbol: str,
quote: dict[str, Any],
@@ -192,10 +262,32 @@ class DataService:
source: str,
error: str | None = None,
) -> dict[str, Any]:
options_chain = {
payload = {
"symbol": symbol,
"updated_at": datetime.now(UTC).isoformat(),
"expirations": [],
"underlying_price": quote["price"],
"source": source,
}
if error:
payload["error"] = error
return payload
def _fallback_options_chain(
self,
symbol: str,
quote: dict[str, Any],
*,
expirations: list[str],
selected_expiry: str | None,
source: str,
error: str | None = None,
) -> dict[str, Any]:
options_chain = {
"symbol": symbol,
"selected_expiry": selected_expiry,
"updated_at": datetime.now(UTC).isoformat(),
"expirations": expirations,
"calls": [],
"puts": [],
"rows": [],