"""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")