feat(CORE-001D2B): normalize options cache boundaries
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user