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)
|
cached = await self.cache.get_json(cache_key)
|
||||||
if cached and isinstance(cached, dict):
|
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)
|
quote = await self.get_quote(ticker_symbol)
|
||||||
if yf is None:
|
if yf is None:
|
||||||
@@ -98,13 +112,16 @@ class DataService:
|
|||||||
await self.cache.set_json(cache_key, payload)
|
await self.cache.set_json(cache_key, payload)
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
payload = {
|
payload = self._normalize_option_expirations_payload(
|
||||||
|
{
|
||||||
"symbol": ticker_symbol,
|
"symbol": ticker_symbol,
|
||||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
"expirations": expirations,
|
"expirations": expirations,
|
||||||
"underlying_price": quote["price"],
|
"underlying_price": quote["price"],
|
||||||
"source": "yfinance",
|
"source": "yfinance",
|
||||||
}
|
},
|
||||||
|
ticker_symbol,
|
||||||
|
)
|
||||||
await self.cache.set_json(cache_key, payload)
|
await self.cache.set_json(cache_key, payload)
|
||||||
return payload
|
return payload
|
||||||
except Exception as exc: # pragma: no cover - network dependent
|
except Exception as exc: # pragma: no cover - network dependent
|
||||||
@@ -140,7 +157,26 @@ class DataService:
|
|||||||
cache_key = f"options:{ticker_symbol}:{target_expiry}"
|
cache_key = f"options:{ticker_symbol}:{target_expiry}"
|
||||||
cached = await self.cache.get_json(cache_key)
|
cached = await self.cache.get_json(cache_key)
|
||||||
if cached and isinstance(cached, dict):
|
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:
|
if yf is None:
|
||||||
payload = self._fallback_options_chain(
|
payload = self._fallback_options_chain(
|
||||||
@@ -172,7 +208,8 @@ class DataService:
|
|||||||
await self.cache.set_json(cache_key, payload)
|
await self.cache.set_json(cache_key, payload)
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
payload = {
|
payload = self._normalize_options_chain_payload(
|
||||||
|
{
|
||||||
"symbol": ticker_symbol,
|
"symbol": ticker_symbol,
|
||||||
"selected_expiry": target_expiry,
|
"selected_expiry": target_expiry,
|
||||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
@@ -182,7 +219,9 @@ class DataService:
|
|||||||
"rows": sorted(calls + puts, key=lambda row: (row["strike"], row["type"])),
|
"rows": sorted(calls + puts, key=lambda row: (row["strike"], row["type"])),
|
||||||
"underlying_price": quote["price"],
|
"underlying_price": quote["price"],
|
||||||
"source": "yfinance",
|
"source": "yfinance",
|
||||||
}
|
},
|
||||||
|
ticker_symbol,
|
||||||
|
)
|
||||||
await self.cache.set_json(cache_key, payload)
|
await self.cache.set_json(cache_key, payload)
|
||||||
return payload
|
return payload
|
||||||
except Exception as exc: # pragma: no cover - network dependent
|
except Exception as exc: # pragma: no cover - network dependent
|
||||||
@@ -312,6 +351,99 @@ class DataService:
|
|||||||
options_chain["error"] = error
|
options_chain["error"] = error
|
||||||
return options_chain
|
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(
|
def _normalize_option_rows(
|
||||||
self,
|
self,
|
||||||
frame: Any,
|
frame: Any,
|
||||||
|
|||||||
@@ -26,12 +26,12 @@ priority_queue:
|
|||||||
- OPS-001
|
- OPS-001
|
||||||
- BT-003
|
- BT-003
|
||||||
recently_completed:
|
recently_completed:
|
||||||
|
- CORE-001D2B
|
||||||
- CORE-001D2A
|
- CORE-001D2A
|
||||||
- CORE-002B
|
- CORE-002B
|
||||||
- CORE-002A
|
- CORE-002A
|
||||||
- CORE-001D1
|
- CORE-001D1
|
||||||
- SEC-001
|
- SEC-001
|
||||||
- SEC-001A
|
|
||||||
states:
|
states:
|
||||||
backlog:
|
backlog:
|
||||||
- DATA-002A
|
- DATA-002A
|
||||||
@@ -66,6 +66,7 @@ states:
|
|||||||
- CORE-001B
|
- CORE-001B
|
||||||
- CORE-001C
|
- CORE-001C
|
||||||
- CORE-001D2A
|
- CORE-001D2A
|
||||||
|
- CORE-001D2B
|
||||||
- CORE-002A
|
- CORE-002A
|
||||||
- CORE-002B
|
- CORE-002B
|
||||||
blocked: []
|
blocked: []
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ acceptance_criteria:
|
|||||||
technical_notes:
|
technical_notes:
|
||||||
- `CORE-001D1` is complete: portfolio/workspace persistence now uses an explicit unit-aware schema with strict validation and atomic saves.
|
- `CORE-001D1` is complete: portfolio/workspace persistence now uses an explicit unit-aware schema with strict validation and atomic saves.
|
||||||
- `CORE-001D2A` is complete: DataService quote/provider cache normalization is now a named boundary adapter with explicit symbol mismatch rejection and GLD quote-unit repair.
|
- `CORE-001D2A` is complete: DataService quote/provider cache normalization is now a named boundary adapter with explicit symbol mismatch rejection and GLD quote-unit repair.
|
||||||
|
- `CORE-001D2B` is complete: option expirations and options-chain payloads now use explicit normalization boundaries with malformed cached payload discard/retry behavior.
|
||||||
- Remaining focus is the rest of `CORE-001D2` provider/cache normalization plus `CORE-001D3` service entrypoint tightening.
|
- Remaining focus is the rest of `CORE-001D2` provider/cache normalization plus `CORE-001D3` service entrypoint tightening.
|
||||||
- Pre-launch policy: unit-aware schema changes may be breaking until persistence is considered live; old flat payloads may fail loudly instead of being migrated.
|
- Pre-launch policy: unit-aware schema changes may be breaking until persistence is considered live; old flat payloads may fail loudly instead of being migrated.
|
||||||
- See `docs/CORE-001D_BOUNDARY_CLEANUP_PLAN.md` for the current hotspot inventory and proposed sub-slices.
|
- See `docs/CORE-001D_BOUNDARY_CLEANUP_PLAN.md` for the current hotspot inventory and proposed sub-slices.
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
id: CORE-001D2B
|
||||||
|
title: Options Chain and Expiration Boundary Normalization
|
||||||
|
status: done
|
||||||
|
priority: P1
|
||||||
|
effort: S
|
||||||
|
depends_on:
|
||||||
|
- CORE-001D2A
|
||||||
|
tags:
|
||||||
|
- core
|
||||||
|
- decimal
|
||||||
|
- options
|
||||||
|
- cache
|
||||||
|
- provider
|
||||||
|
summary: Option expirations and options-chain payloads now use explicit cache/provider normalization boundaries with malformed cached payload discard rules.
|
||||||
|
completed_notes:
|
||||||
|
- Added named normalization adapters for option expirations and options-chain payloads in `app/services/data_service.py`.
|
||||||
|
- Cached payload symbol mismatches are discarded instead of being silently reused.
|
||||||
|
- Malformed cached list-shape payloads are discarded and refreshed rather than being treated as valid empty results.
|
||||||
|
- Fresh provider payloads are normalized before caching.
|
||||||
|
- Added focused coverage in `tests/test_options_normalization.py`.
|
||||||
|
- Validated with focused pytest coverage, `make build`, and a targeted browser-visible `/options` check on `main`.
|
||||||
390
tests/test_options_normalization.py
Normal file
390
tests/test_options_normalization.py
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
"""Tests for DataService options normalization boundaries (CORE-001D2B)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services.cache import CacheService
|
||||||
|
from app.services.data_service import DataService
|
||||||
|
|
||||||
|
|
||||||
|
class _CacheStub(CacheService):
|
||||||
|
"""In-memory cache stub for unit testing."""
|
||||||
|
|
||||||
|
def __init__(self, initial: dict[str, object] | None = None) -> None:
|
||||||
|
self._store = dict(initial or {})
|
||||||
|
self.write_count = 0
|
||||||
|
|
||||||
|
async def get_json(self, key: str): # type: ignore[override]
|
||||||
|
return self._store.get(key)
|
||||||
|
|
||||||
|
async def set_json(self, key: str, value): # type: ignore[override]
|
||||||
|
self._store[key] = value
|
||||||
|
self.write_count += 1
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class TestOptionExpirationNormalization:
|
||||||
|
"""Test option expiration payload normalization and fail-closed handling."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_normalize_option_expirations_adds_missing_symbol(self) -> None:
|
||||||
|
"""Normalization should add symbol when missing from payload."""
|
||||||
|
payload = {"expirations": ["2026-06-19"], "underlying_price": 200.0}
|
||||||
|
result = DataService._normalize_option_expirations_payload(payload, "GLD")
|
||||||
|
assert result["symbol"] == "GLD"
|
||||||
|
assert result["expirations"] == ["2026-06-19"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_normalize_option_expirations_uppercases_symbol(self) -> None:
|
||||||
|
"""Normalization should uppercase symbol for consistency."""
|
||||||
|
payload = {"symbol": "gld", "expirations": ["2026-06-19"]}
|
||||||
|
result = DataService._normalize_option_expirations_payload(payload, "gld")
|
||||||
|
assert result["symbol"] == "GLD"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_normalize_option_expirations_rejects_symbol_mismatch(self) -> None:
|
||||||
|
"""Explicit payload/key mismatches should be rejected."""
|
||||||
|
payload = {"symbol": "SLV", "expirations": ["2026-06-19"]}
|
||||||
|
with pytest.raises(ValueError, match="Option expirations symbol mismatch"):
|
||||||
|
DataService._normalize_option_expirations_payload(payload, "GLD")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_normalize_option_expirations_ensures_list_type(self) -> None:
|
||||||
|
"""Expirations should be normalized to a list."""
|
||||||
|
payload = {"symbol": "GLD", "expirations": None}
|
||||||
|
result = DataService._normalize_option_expirations_payload(payload, "GLD")
|
||||||
|
assert result["expirations"] == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_normalize_option_expirations_coerces_non_list_to_empty_list(self) -> None:
|
||||||
|
payload = {"symbol": "GLD", "expirations": "2026-06-19"}
|
||||||
|
result = DataService._normalize_option_expirations_payload(payload, "GLD")
|
||||||
|
assert result["expirations"] == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_normalize_option_expirations_handles_empty_list(self) -> None:
|
||||||
|
"""Empty expirations list should be preserved."""
|
||||||
|
payload = {"symbol": "GLD", "expirations": []}
|
||||||
|
result = DataService._normalize_option_expirations_payload(payload, "GLD")
|
||||||
|
assert result["expirations"] == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_normalize_option_expirations_preserves_underlying_price(self) -> None:
|
||||||
|
"""Underlying price should pass through unchanged."""
|
||||||
|
payload = {"symbol": "GLD", "expirations": ["2026-06-19"], "underlying_price": 460.50}
|
||||||
|
result = DataService._normalize_option_expirations_payload(payload, "GLD")
|
||||||
|
assert result["underlying_price"] == 460.50
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_normalize_option_expirations_preserves_source(self) -> None:
|
||||||
|
"""Source metadata should pass through unchanged."""
|
||||||
|
payload = {"symbol": "GLD", "expirations": ["2026-06-19"], "source": "yfinance"}
|
||||||
|
result = DataService._normalize_option_expirations_payload(payload, "GLD")
|
||||||
|
assert result["source"] == "yfinance"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_normalize_option_expirations_preserves_error_field(self) -> None:
|
||||||
|
"""Error field should pass through for fail-closed handling."""
|
||||||
|
payload = {"symbol": "GLD", "expirations": [], "error": "Network timeout"}
|
||||||
|
result = DataService._normalize_option_expirations_payload(payload, "GLD")
|
||||||
|
assert result["error"] == "Network timeout"
|
||||||
|
|
||||||
|
|
||||||
|
class TestOptionRowNormalization:
|
||||||
|
"""Test option row normalization and fail-closed handling for malformed data."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def data_service(self) -> DataService:
|
||||||
|
"""Create a DataService instance for testing."""
|
||||||
|
return DataService(cache=_CacheStub())
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_normalize_option_rows_handles_empty_frame(self, data_service: DataService) -> None:
|
||||||
|
"""Empty DataFrame should return empty list."""
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
frame = pd.DataFrame()
|
||||||
|
result = data_service._normalize_option_rows(frame, "GLD", "2026-06-19", "call", 460.0)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_normalize_option_rows_handles_none_frame(self, data_service: DataService) -> None:
|
||||||
|
"""None frame should return empty list."""
|
||||||
|
result = data_service._normalize_option_rows(None, "GLD", "2026-06-19", "call", 460.0)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_normalize_option_rows_skips_invalid_strike(self, data_service: DataService) -> None:
|
||||||
|
"""Rows with invalid/zero/negative strikes should be skipped."""
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
frame = pd.DataFrame(
|
||||||
|
[
|
||||||
|
{"strike": 0.0, "bid": 1.0}, # invalid
|
||||||
|
{"strike": -10.0, "bid": 1.0}, # invalid
|
||||||
|
{"strike": 460.0, "bid": 1.0}, # valid
|
||||||
|
]
|
||||||
|
)
|
||||||
|
result = data_service._normalize_option_rows(frame, "GLD", "2026-06-19", "call", 460.0)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["strike"] == 460.0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_normalize_option_rows_handles_missing_bid_ask(self, data_service: DataService) -> None:
|
||||||
|
"""Missing bid/ask should default to 0.0, not crash."""
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
frame = pd.DataFrame([{"strike": 460.0}])
|
||||||
|
result = data_service._normalize_option_rows(frame, "GLD", "2026-06-19", "call", 460.0)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["bid"] == 0.0
|
||||||
|
assert result[0]["ask"] == 0.0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_normalize_option_rows_handles_nan_values(self, data_service: DataService) -> None:
|
||||||
|
"""NaN values should be normalized to 0.0."""
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
frame = pd.DataFrame([{"strike": 460.0, "bid": float("nan"), "ask": float("nan")}])
|
||||||
|
result = data_service._normalize_option_rows(frame, "GLD", "2026-06-19", "call", 460.0)
|
||||||
|
assert result[0]["bid"] == 0.0
|
||||||
|
assert result[0]["ask"] == 0.0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_normalize_option_rows_handles_missing_contract_symbol(self, data_service: DataService) -> None:
|
||||||
|
"""Missing contract symbol should generate a fallback symbol."""
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
frame = pd.DataFrame([{"strike": 460.0, "bid": 1.0}])
|
||||||
|
result = data_service._normalize_option_rows(frame, "GLD", "2026-06-19", "put", 460.0)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert "GLD" in result[0]["symbol"]
|
||||||
|
assert "2026-06-19" in result[0]["symbol"]
|
||||||
|
assert "PUT" in result[0]["symbol"]
|
||||||
|
assert "460.00" in result[0]["symbol"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_normalize_option_rows_preserves_valid_row_structure(self, data_service: DataService) -> None:
|
||||||
|
"""Valid rows should preserve all expected fields."""
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
frame = pd.DataFrame(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"contractSymbol": "GLD260619C00460000",
|
||||||
|
"strike": 460.0,
|
||||||
|
"bid": 18.5,
|
||||||
|
"ask": 19.5,
|
||||||
|
"lastPrice": 19.0,
|
||||||
|
"impliedVolatility": 0.22,
|
||||||
|
"openInterest": 100,
|
||||||
|
"volume": 50,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
result = data_service._normalize_option_rows(frame, "GLD", "2026-06-19", "call", 460.0)
|
||||||
|
assert len(result) == 1
|
||||||
|
row = result[0]
|
||||||
|
assert row["contractSymbol"] == "GLD260619C00460000"
|
||||||
|
assert row["strike"] == 460.0
|
||||||
|
assert row["bid"] == 18.5
|
||||||
|
assert row["ask"] == 19.5
|
||||||
|
assert row["premium"] == 19.0
|
||||||
|
assert row["lastPrice"] == 19.0
|
||||||
|
assert row["impliedVolatility"] == 0.22
|
||||||
|
assert row["openInterest"] == 100
|
||||||
|
assert row["volume"] == 50
|
||||||
|
assert row["expiry"] == "2026-06-19"
|
||||||
|
assert row["type"] == "call"
|
||||||
|
assert "delta" in row # Greeks added
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_normalize_option_rows_handles_string_strike(self, data_service: DataService) -> None:
|
||||||
|
"""String strikes should be converted to float."""
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
frame = pd.DataFrame([{"strike": "460.0", "bid": 1.0}])
|
||||||
|
result = data_service._normalize_option_rows(frame, "GLD", "2026-06-19", "call", 460.0)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["strike"] == 460.0
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_normalize_option_rows_handles_invalid_string_strike(self, data_service: DataService) -> None:
|
||||||
|
"""Invalid string strikes should result in 0.0 and be skipped."""
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
frame = pd.DataFrame([{"strike": "not-a-number", "bid": 1.0}])
|
||||||
|
result = data_service._normalize_option_rows(frame, "GLD", "2026-06-19", "call", 460.0)
|
||||||
|
# Should be skipped because strike becomes 0.0
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestOptionsChainPayloadNormalization:
|
||||||
|
"""Test full options chain payload normalization."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_normalize_options_chain_payload_adds_missing_symbol(self) -> None:
|
||||||
|
"""Normalization should add symbol when missing."""
|
||||||
|
payload = {
|
||||||
|
"selected_expiry": "2026-06-19",
|
||||||
|
"expirations": ["2026-06-19"],
|
||||||
|
"calls": [],
|
||||||
|
"puts": [],
|
||||||
|
"rows": [],
|
||||||
|
"underlying_price": 460.0,
|
||||||
|
}
|
||||||
|
result = DataService._normalize_options_chain_payload(payload, "GLD")
|
||||||
|
assert result["symbol"] == "GLD"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_normalize_options_chain_payload_uppercases_symbol(self) -> None:
|
||||||
|
"""Normalization should uppercase symbol."""
|
||||||
|
payload = {"symbol": "gld", "expirations": [], "calls": [], "puts": [], "rows": []}
|
||||||
|
result = DataService._normalize_options_chain_payload(payload, "gld")
|
||||||
|
assert result["symbol"] == "GLD"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_normalize_options_chain_payload_rejects_mismatch(self) -> None:
|
||||||
|
"""Symbol mismatches should be rejected."""
|
||||||
|
payload = {"symbol": "SLV", "expirations": [], "calls": [], "puts": [], "rows": []}
|
||||||
|
with pytest.raises(ValueError, match="Options chain symbol mismatch"):
|
||||||
|
DataService._normalize_options_chain_payload(payload, "GLD")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_normalize_options_chain_payload_ensures_lists(self) -> None:
|
||||||
|
"""None lists should be normalized to empty lists."""
|
||||||
|
payload = {
|
||||||
|
"symbol": "GLD",
|
||||||
|
"expirations": None,
|
||||||
|
"calls": None,
|
||||||
|
"puts": None,
|
||||||
|
"rows": None,
|
||||||
|
}
|
||||||
|
result = DataService._normalize_options_chain_payload(payload, "GLD")
|
||||||
|
assert result["expirations"] == []
|
||||||
|
assert result["calls"] == []
|
||||||
|
assert result["puts"] == []
|
||||||
|
assert result["rows"] == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_normalize_options_chain_payload_coerces_non_lists_to_empty_lists(self) -> None:
|
||||||
|
payload = {
|
||||||
|
"symbol": "GLD",
|
||||||
|
"expirations": "2026-06-19",
|
||||||
|
"calls": {"bad": True},
|
||||||
|
"puts": "bad",
|
||||||
|
"rows": 123,
|
||||||
|
}
|
||||||
|
result = DataService._normalize_options_chain_payload(payload, "GLD")
|
||||||
|
assert result["expirations"] == []
|
||||||
|
assert result["calls"] == []
|
||||||
|
assert result["puts"] == []
|
||||||
|
assert result["rows"] == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_normalize_options_chain_payload_preserves_structure(self) -> None:
|
||||||
|
"""Valid payloads should pass through with normalization."""
|
||||||
|
payload = {
|
||||||
|
"symbol": "GLD",
|
||||||
|
"selected_expiry": "2026-06-19",
|
||||||
|
"expirations": ["2026-06-19"],
|
||||||
|
"calls": [{"strike": 460.0, "type": "call"}],
|
||||||
|
"puts": [{"strike": 460.0, "type": "put"}],
|
||||||
|
"rows": [{"strike": 460.0, "type": "call"}],
|
||||||
|
"underlying_price": 460.0,
|
||||||
|
"source": "yfinance",
|
||||||
|
}
|
||||||
|
result = DataService._normalize_options_chain_payload(payload, "GLD")
|
||||||
|
assert result["symbol"] == "GLD"
|
||||||
|
assert result["selected_expiry"] == "2026-06-19"
|
||||||
|
assert len(result["calls"]) == 1
|
||||||
|
assert len(result["puts"]) == 1
|
||||||
|
assert result["source"] == "yfinance"
|
||||||
|
|
||||||
|
|
||||||
|
class TestCachedOptionsPayloadHandling:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_option_expirations_normalizes_cached_payload(self) -> None:
|
||||||
|
cache = _CacheStub({"options:GLD:expirations": {"symbol": "gld", "expirations": None, "source": "cache"}})
|
||||||
|
service = DataService(cache=cache)
|
||||||
|
|
||||||
|
payload = await service.get_option_expirations("GLD")
|
||||||
|
|
||||||
|
assert payload["symbol"] == "GLD"
|
||||||
|
assert payload["expirations"] == []
|
||||||
|
assert cache.write_count >= 1
|
||||||
|
assert cache._store["options:GLD:expirations"]["symbol"] == "GLD"
|
||||||
|
assert cache._store["options:GLD:expirations"]["expirations"] == []
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_option_expirations_discards_bad_cached_expirations_shape(self) -> None:
|
||||||
|
cache = _CacheStub({"options:GLD:expirations": {"symbol": "GLD", "expirations": "bad", "source": "cache"}})
|
||||||
|
service = DataService(cache=cache)
|
||||||
|
|
||||||
|
async def _fake_get_quote(symbol: str) -> dict[str, object]:
|
||||||
|
return {"symbol": symbol, "price": 404.19, "quote_unit": "share", "source": "provider"}
|
||||||
|
|
||||||
|
service.get_quote = _fake_get_quote # type: ignore[method-assign]
|
||||||
|
|
||||||
|
payload = await service.get_option_expirations("GLD")
|
||||||
|
|
||||||
|
assert payload["source"] != "cache"
|
||||||
|
assert isinstance(payload["expirations"], list)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_options_chain_for_expiry_ignores_mismatched_cached_symbol(self) -> None:
|
||||||
|
cache = _CacheStub({"options:GLD:2026-06-19": {"symbol": "SLV", "selected_expiry": "2026-06-19", "rows": []}})
|
||||||
|
service = DataService(cache=cache)
|
||||||
|
|
||||||
|
async def _fake_get_quote(symbol: str) -> dict[str, object]:
|
||||||
|
return {"symbol": symbol, "price": 404.19, "quote_unit": "share", "source": "provider"}
|
||||||
|
|
||||||
|
async def _fake_get_option_expirations(symbol: str | None = None) -> dict[str, object]:
|
||||||
|
return {"symbol": "GLD", "expirations": ["2026-06-19"], "source": "provider"}
|
||||||
|
|
||||||
|
service.get_quote = _fake_get_quote # type: ignore[method-assign]
|
||||||
|
service.get_option_expirations = _fake_get_option_expirations # type: ignore[method-assign]
|
||||||
|
|
||||||
|
payload = await service.get_options_chain_for_expiry("GLD", "2026-06-19")
|
||||||
|
|
||||||
|
assert payload["symbol"] == "GLD"
|
||||||
|
assert payload["selected_expiry"] == "2026-06-19"
|
||||||
|
assert payload["expirations"] == ["2026-06-19"]
|
||||||
|
assert payload["calls"] == []
|
||||||
|
assert payload["puts"] == []
|
||||||
|
assert payload["rows"] == []
|
||||||
|
assert payload["source"] == "fallback"
|
||||||
|
assert payload.get("error")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_options_chain_for_expiry_discards_bad_cached_list_shapes(self) -> None:
|
||||||
|
cache = _CacheStub(
|
||||||
|
{
|
||||||
|
"options:GLD:2026-06-19": {
|
||||||
|
"symbol": "GLD",
|
||||||
|
"selected_expiry": "2026-06-19",
|
||||||
|
"rows": {},
|
||||||
|
"calls": None,
|
||||||
|
"puts": None,
|
||||||
|
"expirations": ["2026-06-19"],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
service = DataService(cache=cache)
|
||||||
|
|
||||||
|
async def _fake_get_quote(symbol: str) -> dict[str, object]:
|
||||||
|
return {"symbol": symbol, "price": 404.19, "quote_unit": "share", "source": "provider"}
|
||||||
|
|
||||||
|
async def _fake_get_option_expirations(symbol: str | None = None) -> dict[str, object]:
|
||||||
|
return {"symbol": "GLD", "expirations": ["2026-06-19"], "source": "provider"}
|
||||||
|
|
||||||
|
service.get_quote = _fake_get_quote # type: ignore[method-assign]
|
||||||
|
service.get_option_expirations = _fake_get_option_expirations # type: ignore[method-assign]
|
||||||
|
|
||||||
|
payload = await service.get_options_chain_for_expiry("GLD", "2026-06-19")
|
||||||
|
|
||||||
|
assert payload["symbol"] == "GLD"
|
||||||
|
assert payload["source"] == "fallback"
|
||||||
|
assert payload["rows"] == []
|
||||||
|
assert payload.get("error")
|
||||||
Reference in New Issue
Block a user