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:
@@ -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": [],
|
||||
|
||||
Reference in New Issue
Block a user