feat(CORE-001D2B): normalize options cache boundaries

This commit is contained in:
Bu5hm4nn
2026-03-25 19:05:00 +01:00
parent 442a0cd702
commit 5217304624
5 changed files with 566 additions and 21 deletions

View File

@@ -72,7 +72,21 @@ class DataService:
cached = await self.cache.get_json(cache_key)
if cached and isinstance(cached, dict):
return cached
malformed_list_shape = (
not isinstance(cached.get("expirations"), list) and cached.get("expirations") is not None
)
try:
normalized_cached = self._normalize_option_expirations_payload(cached, ticker_symbol)
except ValueError as exc:
logger.warning("Discarding cached option expirations payload for %s: %s", ticker_symbol, exc)
normalized_cached = None
if malformed_list_shape:
logger.warning("Discarding malformed cached option expirations payload for %s", ticker_symbol)
normalized_cached = None
if normalized_cached is not None:
if normalized_cached != cached:
await self.cache.set_json(cache_key, normalized_cached)
return normalized_cached
quote = await self.get_quote(ticker_symbol)
if yf is None:
@@ -98,13 +112,16 @@ class DataService:
await self.cache.set_json(cache_key, payload)
return payload
payload = {
"symbol": ticker_symbol,
"updated_at": datetime.now(timezone.utc).isoformat(),
"expirations": expirations,
"underlying_price": quote["price"],
"source": "yfinance",
}
payload = self._normalize_option_expirations_payload(
{
"symbol": ticker_symbol,
"updated_at": datetime.now(timezone.utc).isoformat(),
"expirations": expirations,
"underlying_price": quote["price"],
"source": "yfinance",
},
ticker_symbol,
)
await self.cache.set_json(cache_key, payload)
return payload
except Exception as exc: # pragma: no cover - network dependent
@@ -140,7 +157,26 @@ class DataService:
cache_key = f"options:{ticker_symbol}:{target_expiry}"
cached = await self.cache.get_json(cache_key)
if cached and isinstance(cached, dict):
return cached
malformed_list_shape = any(
not isinstance(cached.get(field), list) and cached.get(field) is not None
for field in ("expirations", "calls", "puts", "rows")
)
try:
normalized_cached = self._normalize_options_chain_payload(cached, ticker_symbol)
except ValueError as exc:
logger.warning(
"Discarding cached options chain payload for %s %s: %s", ticker_symbol, target_expiry, exc
)
normalized_cached = None
if malformed_list_shape:
logger.warning(
"Discarding malformed cached options chain payload for %s %s", ticker_symbol, target_expiry
)
normalized_cached = None
if normalized_cached is not None:
if normalized_cached != cached:
await self.cache.set_json(cache_key, normalized_cached)
return normalized_cached
if yf is None:
payload = self._fallback_options_chain(
@@ -172,17 +208,20 @@ class DataService:
await self.cache.set_json(cache_key, payload)
return payload
payload = {
"symbol": ticker_symbol,
"selected_expiry": target_expiry,
"updated_at": datetime.now(timezone.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",
}
payload = self._normalize_options_chain_payload(
{
"symbol": ticker_symbol,
"selected_expiry": target_expiry,
"updated_at": datetime.now(timezone.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",
},
ticker_symbol,
)
await self.cache.set_json(cache_key, payload)
return payload
except Exception as exc: # pragma: no cover - network dependent
@@ -312,6 +351,99 @@ class DataService:
options_chain["error"] = error
return options_chain
@staticmethod
def _normalize_option_expirations_payload(payload: dict[str, Any], symbol: str) -> dict[str, Any]:
"""Normalize option expirations payload to explicit contract.
This is the named boundary adapter between external provider/cache
payloads and internal option expirations handling. It ensures:
- symbol is always present and uppercased
- expirations is always a list (empty if None/missing)
- Explicit symbol mismatches are rejected (fail-closed)
Args:
payload: Raw expirations dict from cache or provider
symbol: Expected symbol (used as fallback if missing from payload)
Returns:
Normalized expirations dict with explicit symbol and list type
Raises:
ValueError: If payload symbol explicitly conflicts with requested symbol
"""
normalized: dict[str, Any] = dict(payload)
normalized_symbol = symbol.upper()
# Ensure symbol is always present and normalized.
# Missing symbol is repaired from the requested key; explicit mismatches are rejected.
raw_symbol = normalized.get("symbol", normalized_symbol)
normalized_payload_symbol = str(raw_symbol).upper() if raw_symbol is not None else normalized_symbol
if raw_symbol is not None and normalized_payload_symbol != normalized_symbol:
raise ValueError(
f"Option expirations symbol mismatch: expected {normalized_symbol}, got {normalized_payload_symbol}"
)
normalized["symbol"] = normalized_payload_symbol
# Ensure expirations is always a list
expirations = normalized.get("expirations")
if not isinstance(expirations, list):
logger.warning(
"Repairing malformed option expirations payload for %s: expirations was %r",
normalized_symbol,
type(expirations).__name__,
)
normalized["expirations"] = []
return normalized
@staticmethod
def _normalize_options_chain_payload(payload: dict[str, Any], symbol: str) -> dict[str, Any]:
"""Normalize options chain payload to explicit contract.
This is the named boundary adapter between external provider/cache
payloads and internal options chain handling. It ensures:
- symbol is always present and uppercased
- calls, puts, rows, and expirations are always lists (empty if None/missing)
- Explicit symbol mismatches are rejected (fail-closed)
Args:
payload: Raw options chain dict from cache or provider
symbol: Expected symbol (used as fallback if missing from payload)
Returns:
Normalized options chain dict with explicit symbol and list types
Raises:
ValueError: If payload symbol explicitly conflicts with requested symbol
"""
normalized: dict[str, Any] = dict(payload)
normalized_symbol = symbol.upper()
# Ensure symbol is always present and normalized.
# Missing symbol is repaired from the requested key; explicit mismatches are rejected.
raw_symbol = normalized.get("symbol", normalized_symbol)
normalized_payload_symbol = str(raw_symbol).upper() if raw_symbol is not None else normalized_symbol
if raw_symbol is not None and normalized_payload_symbol != normalized_symbol:
raise ValueError(
f"Options chain symbol mismatch: expected {normalized_symbol}, got {normalized_payload_symbol}"
)
normalized["symbol"] = normalized_payload_symbol
# Ensure list fields are always lists
for field in ("expirations", "calls", "puts", "rows"):
if not isinstance(normalized.get(field), list):
logger.warning(
"Repairing malformed options chain payload for %s: %s was %r",
normalized_symbol,
field,
type(normalized.get(field)).__name__,
)
normalized[field] = []
return normalized
def _normalize_option_rows(
self,
frame: Any,