Files
vault-dash/tests/test_options_normalization.py

391 lines
17 KiB
Python

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