diff --git a/app/services/data_service.py b/app/services/data_service.py index 8cd964d..b8986a9 100644 --- a/app/services/data_service.py +++ b/app/services/data_service.py @@ -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, diff --git a/docs/roadmap/ROADMAP.yaml b/docs/roadmap/ROADMAP.yaml index 3240125..73babca 100644 --- a/docs/roadmap/ROADMAP.yaml +++ b/docs/roadmap/ROADMAP.yaml @@ -26,12 +26,12 @@ priority_queue: - OPS-001 - BT-003 recently_completed: + - CORE-001D2B - CORE-001D2A - CORE-002B - CORE-002A - CORE-001D1 - SEC-001 - - SEC-001A states: backlog: - DATA-002A @@ -66,6 +66,7 @@ states: - CORE-001B - CORE-001C - CORE-001D2A + - CORE-001D2B - CORE-002A - CORE-002B blocked: [] diff --git a/docs/roadmap/backlog/CORE-001D-decimal-boundary-cleanup.yaml b/docs/roadmap/backlog/CORE-001D-decimal-boundary-cleanup.yaml index a0855e2..a814084 100644 --- a/docs/roadmap/backlog/CORE-001D-decimal-boundary-cleanup.yaml +++ b/docs/roadmap/backlog/CORE-001D-decimal-boundary-cleanup.yaml @@ -16,6 +16,7 @@ acceptance_criteria: technical_notes: - `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-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. - 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. diff --git a/docs/roadmap/done/CORE-001D2B-options-boundary-normalization.yaml b/docs/roadmap/done/CORE-001D2B-options-boundary-normalization.yaml new file mode 100644 index 0000000..43df8c6 --- /dev/null +++ b/docs/roadmap/done/CORE-001D2B-options-boundary-normalization.yaml @@ -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`. diff --git a/tests/test_options_normalization.py b/tests/test_options_normalization.py new file mode 100644 index 0000000..d69e971 --- /dev/null +++ b/tests/test_options_normalization.py @@ -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")