165 lines
6.1 KiB
Python
165 lines
6.1 KiB
Python
"""Tests for DATA-004: Underlying Instrument Selector routing in DataService."""
|
|
|
|
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 TestUnderlyingRouting:
|
|
"""Test underlying instrument routing in DataService."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_data_service_default_underlying_is_gld(self) -> None:
|
|
"""DataService defaults to GLD underlying."""
|
|
cache = _CacheStub()
|
|
service = DataService(cache)
|
|
assert service.default_underlying == "GLD"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_data_service_default_symbol_alias(self) -> None:
|
|
"""default_symbol is an alias for default_underlying (backward compatibility)."""
|
|
cache = _CacheStub()
|
|
service = DataService(cache, default_underlying="GC=F")
|
|
assert service.default_symbol == "GC=F"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_data_service_with_custom_underlying(self) -> None:
|
|
"""DataService can be initialized with custom underlying."""
|
|
cache = _CacheStub()
|
|
service = DataService(cache, default_underlying="GC=F")
|
|
assert service.default_underlying == "GC=F"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_quote_routes_gc_f_to_futures_method(self) -> None:
|
|
"""get_quote routes GC=F to _fetch_gc_futures."""
|
|
cache = _CacheStub()
|
|
service = DataService(cache)
|
|
|
|
# GC=F should return data with symbol GC=F
|
|
# (source may be 'yfinance' if available, or 'fallback' if not)
|
|
quote = await service.get_quote("GC=F")
|
|
assert quote["symbol"] == "GC=F"
|
|
assert quote["source"] in {"yfinance", "fallback"}
|
|
assert "price" in quote
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_option_expirations_gc_f_returns_placeholder(self) -> None:
|
|
"""get_option_expirations returns placeholder for GC=F."""
|
|
cache = _CacheStub()
|
|
service = DataService(cache)
|
|
|
|
# Mock a quote in cache to avoid network call
|
|
await cache.set_json("quote:GC=F", {"symbol": "GC=F", "price": 2700.0, "source": "test"})
|
|
|
|
expirations = await service.get_option_expirations("GC=F")
|
|
|
|
assert expirations["symbol"] == "GC=F"
|
|
assert expirations["expirations"] == []
|
|
assert "coming soon" in expirations.get("error", "").lower()
|
|
assert expirations["source"] == "placeholder"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_options_chain_for_expiry_gc_f_returns_placeholder(self) -> None:
|
|
"""get_options_chain_for_expiry returns placeholder for GC=F."""
|
|
cache = _CacheStub()
|
|
service = DataService(cache)
|
|
|
|
# Mock quote and expirations in cache
|
|
await cache.set_json("quote:GC=F", {"symbol": "GC=F", "price": 2700.0, "source": "test"})
|
|
await cache.set_json(
|
|
"options:GC=F:expirations", {"symbol": "GC=F", "expirations": ["2026-04-01"], "source": "test"}
|
|
)
|
|
|
|
chain = await service.get_options_chain_for_expiry("GC=F", "2026-04-01")
|
|
|
|
assert chain["symbol"] == "GC=F"
|
|
assert chain["calls"] == []
|
|
assert chain["puts"] == []
|
|
assert chain["rows"] == []
|
|
assert "coming soon" in chain.get("error", "").lower()
|
|
assert chain["source"] == "placeholder"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_option_expirations_gld_uses_yfinance_path(self) -> None:
|
|
"""get_option_expirations for GLD follows normal yfinance path."""
|
|
cache = _CacheStub()
|
|
service = DataService(cache)
|
|
|
|
# Without yfinance, should fall back
|
|
expirations = await service.get_option_expirations("GLD")
|
|
|
|
assert expirations["symbol"] == "GLD"
|
|
# Should not be marked as "placeholder" (that's GC=F specific)
|
|
assert expirations["source"] != "placeholder"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_supported_underlyings_in_domain(self) -> None:
|
|
"""Verify supported_underlyings is accessible from domain."""
|
|
from app.domain.instruments import Underlying, supported_underlyings
|
|
|
|
underlyings = supported_underlyings()
|
|
assert len(underlyings) == 2
|
|
assert Underlying.GLD in underlyings
|
|
assert Underlying.GC_F in underlyings
|
|
|
|
|
|
class TestPortfolioConfigUnderlying:
|
|
"""Test PortfolioConfig underlying field."""
|
|
|
|
def test_portfolio_config_has_underlying_field(self) -> None:
|
|
"""PortfolioConfig has underlying field defaulting to GLD."""
|
|
from app.models.portfolio import PortfolioConfig
|
|
|
|
config = PortfolioConfig()
|
|
assert hasattr(config, "underlying")
|
|
assert config.underlying == "GLD"
|
|
|
|
def test_portfolio_config_underlying_in_to_dict(self) -> None:
|
|
"""PortfolioConfig.to_dict includes underlying field."""
|
|
from app.models.portfolio import PortfolioConfig
|
|
|
|
config = PortfolioConfig(underlying="GC=F")
|
|
data = config.to_dict()
|
|
|
|
assert "underlying" in data
|
|
assert data["underlying"] == "GC=F"
|
|
|
|
def test_portfolio_config_underlying_from_dict(self) -> None:
|
|
"""PortfolioConfig.from_dict preserves underlying field."""
|
|
from app.models.portfolio import PortfolioConfig
|
|
|
|
data = {
|
|
"gold_value": 215000.0,
|
|
"entry_price": 2150.0,
|
|
"gold_ounces": 100.0,
|
|
"underlying": "GC=F",
|
|
}
|
|
config = PortfolioConfig.from_dict(data)
|
|
|
|
assert config.underlying == "GC=F"
|
|
|
|
def test_portfolio_config_underlying_persisted_fields(self) -> None:
|
|
"""PortfolioRepository includes underlying in persisted fields."""
|
|
from app.models.portfolio import PortfolioRepository
|
|
|
|
assert "underlying" in PortfolioRepository._PERSISTED_FIELDS
|