feat(DATA-004): add underlying instrument selector
This commit is contained in:
@@ -183,7 +183,7 @@ class TestLegacyCachedQuoteHandling:
|
||||
}
|
||||
}
|
||||
)
|
||||
service = DataService(cache=cache, default_symbol="BTC-USD")
|
||||
service = DataService(cache=cache, default_underlying="BTC-USD")
|
||||
|
||||
quote = await service.get_quote("BTC-USD")
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ async def test_get_quote_preserves_missing_quote_unit_for_unsupported_symbols()
|
||||
}
|
||||
}
|
||||
)
|
||||
service = DataService(cache=cache, default_symbol="BTC-USD")
|
||||
service = DataService(cache=cache, default_underlying="BTC-USD")
|
||||
|
||||
quote = await service.get_quote("BTC-USD")
|
||||
|
||||
|
||||
164
tests/test_data_service_underlying.py
Normal file
164
tests/test_data_service_underlying.py
Normal file
@@ -0,0 +1,164 @@
|
||||
"""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
|
||||
@@ -7,13 +7,16 @@ import pytest
|
||||
|
||||
from app.domain.backtesting_math import AssetQuantity, PricePerAsset
|
||||
from app.domain.instruments import (
|
||||
GC_F_OUNCES_PER_CONTRACT,
|
||||
GLD_EXPENSE_DECAY_RATE,
|
||||
GLD_INITIAL_OUNCES_PER_SHARE,
|
||||
GLD_LAUNCH_YEAR,
|
||||
Underlying,
|
||||
asset_quantity_from_weight,
|
||||
gld_ounces_per_share,
|
||||
instrument_metadata,
|
||||
price_per_weight_from_asset_price,
|
||||
supported_underlyings,
|
||||
weight_from_asset_quantity,
|
||||
)
|
||||
from app.domain.units import BaseCurrency, Weight, WeightUnit
|
||||
@@ -117,3 +120,65 @@ def test_instrument_conversions_fail_closed_for_unsupported_symbols() -> None:
|
||||
|
||||
with pytest.raises(ValueError, match="Unsupported instrument metadata"):
|
||||
asset_quantity_from_weight("SLV", Weight(amount=Decimal("1"), unit=WeightUnit.OUNCE_TROY))
|
||||
|
||||
|
||||
# DATA-004: Underlying Instrument Selector tests
|
||||
|
||||
|
||||
def test_underlying_enum_has_gld_and_gc_f() -> None:
|
||||
"""Verify Underlying enum contains GLD and GC=F."""
|
||||
assert Underlying.GLD.value == "GLD"
|
||||
assert Underlying.GC_F.value == "GC=F"
|
||||
|
||||
|
||||
def test_underlying_display_names() -> None:
|
||||
"""Verify Underlying display names are descriptive."""
|
||||
assert Underlying.GLD.display_name() == "SPDR Gold Shares ETF"
|
||||
assert Underlying.GC_F.display_name() == "Gold Futures (COMEX)"
|
||||
|
||||
|
||||
def test_underlying_descriptions() -> None:
|
||||
"""Verify Underlying descriptions indicate data source status."""
|
||||
assert Underlying.GLD.description() == "SPDR Gold Shares ETF (live data via yfinance)"
|
||||
assert Underlying.GC_F.description() == "Gold Futures (coming soon)"
|
||||
|
||||
|
||||
def test_supported_underlyings_returns_all() -> None:
|
||||
"""Verify supported_underlyings() returns all available choices."""
|
||||
underlyings = supported_underlyings()
|
||||
assert len(underlyings) == 2
|
||||
assert Underlying.GLD in underlyings
|
||||
assert Underlying.GC_F in underlyings
|
||||
|
||||
|
||||
def test_gc_f_metadata() -> None:
|
||||
"""Verify GC=F instrument metadata is correct."""
|
||||
gc_f_meta = instrument_metadata("GC=F")
|
||||
|
||||
assert gc_f_meta.symbol == "GC=F"
|
||||
assert gc_f_meta.quote_currency is BaseCurrency.USD
|
||||
assert gc_f_meta.weight_per_share == Weight(amount=GC_F_OUNCES_PER_CONTRACT, unit=WeightUnit.OUNCE_TROY)
|
||||
assert gc_f_meta.weight_per_share.amount == Decimal("100") # 100 troy oz per contract
|
||||
|
||||
|
||||
def test_gc_f_contract_specs() -> None:
|
||||
"""Verify GC=F contract specifications."""
|
||||
assert GC_F_OUNCES_PER_CONTRACT == Decimal("100")
|
||||
|
||||
# 1 contract = 100 oz
|
||||
one_contract = AssetQuantity(amount=Decimal("1"), symbol="GC=F")
|
||||
weight = weight_from_asset_quantity(one_contract)
|
||||
assert weight == Weight(amount=Decimal("100"), unit=WeightUnit.OUNCE_TROY)
|
||||
|
||||
|
||||
def test_gc_f_price_per_weight_conversion() -> None:
|
||||
"""Verify GC=F price converts correctly to price per weight."""
|
||||
# GC=F quoted at $2700/oz (already per ounce)
|
||||
quote = PricePerAsset(amount=Decimal("270000"), currency=BaseCurrency.USD, symbol="GC=F") # $2700/oz * 100 oz
|
||||
|
||||
spot = price_per_weight_from_asset_price(quote, per_unit=WeightUnit.OUNCE_TROY)
|
||||
|
||||
# Should be $2700/oz
|
||||
assert spot.amount == Decimal("2700")
|
||||
assert spot.currency is BaseCurrency.USD
|
||||
assert spot.per_unit is WeightUnit.OUNCE_TROY
|
||||
|
||||
@@ -97,6 +97,7 @@ def test_portfolio_repository_persists_explicit_schema_metadata(tmp_path) -> Non
|
||||
}
|
||||
assert payload["portfolio"]["gold_ounces"] == {"value": "220.0", "unit": "ozt"}
|
||||
assert payload["portfolio"]["loan_amount"] == {"value": "145000.0", "currency": "USD"}
|
||||
assert payload["portfolio"]["underlying"] == "GLD"
|
||||
|
||||
|
||||
def test_portfolio_repository_loads_explicit_schema_payload_and_converts_units(tmp_path) -> None:
|
||||
@@ -121,6 +122,7 @@ def test_portfolio_repository_loads_explicit_schema_payload_and_converts_units(t
|
||||
"primary_source": "yfinance",
|
||||
"fallback_source": "yfinance",
|
||||
"refresh_interval": {"value": 5, "unit": "seconds"},
|
||||
"underlying": "GLD",
|
||||
"volatility_spike": {"value": "25.0", "unit": "percent"},
|
||||
"spot_drawdown": {"value": "0.075", "unit": "ratio"},
|
||||
"email_alerts": False,
|
||||
@@ -182,6 +184,7 @@ def test_portfolio_repository_upgrades_legacy_default_workspace_footprint(tmp_pa
|
||||
"primary_source": "yfinance",
|
||||
"fallback_source": "yfinance",
|
||||
"refresh_interval": {"value": 5, "unit": "seconds"},
|
||||
"underlying": "GLD",
|
||||
"volatility_spike": {"value": "0.25", "unit": "ratio"},
|
||||
"spot_drawdown": {"value": "7.5", "unit": "percent"},
|
||||
"email_alerts": False,
|
||||
@@ -237,6 +240,7 @@ def test_portfolio_repository_rejects_incomplete_schema_payload(tmp_path) -> Non
|
||||
)
|
||||
)
|
||||
|
||||
# Should fail because underlying field is missing
|
||||
with pytest.raises(ValueError, match="Invalid portfolio payload fields"):
|
||||
PortfolioRepository(config_path=config_path).load()
|
||||
|
||||
@@ -259,6 +263,7 @@ def test_portfolio_repository_rejects_unsupported_field_units(tmp_path) -> None:
|
||||
"primary_source": "yfinance",
|
||||
"fallback_source": "yfinance",
|
||||
"refresh_interval": {"value": 5, "unit": "seconds"},
|
||||
"underlying": "GLD",
|
||||
"volatility_spike": {"value": "0.25", "unit": "ratio"},
|
||||
"spot_drawdown": {"value": "7.5", "unit": "percent"},
|
||||
"email_alerts": False,
|
||||
|
||||
@@ -4,6 +4,23 @@ from app.models.portfolio import PortfolioConfig
|
||||
from app.pages.settings import _save_card_status_text
|
||||
|
||||
|
||||
def test_portfolio_config_underlying_defaults_to_gld() -> None:
|
||||
"""Verify PortfolioConfig underlying field defaults to GLD."""
|
||||
config = PortfolioConfig(gold_value=215_000.0, entry_price=215.0, loan_amount=145_000.0)
|
||||
assert config.underlying == "GLD"
|
||||
|
||||
|
||||
def test_portfolio_config_underlying_can_be_set_to_gc_f() -> None:
|
||||
"""Verify PortfolioConfig underlying can be set to GC=F."""
|
||||
config = PortfolioConfig(
|
||||
gold_value=215_000.0,
|
||||
entry_price=215.0,
|
||||
loan_amount=145_000.0,
|
||||
underlying="GC=F",
|
||||
)
|
||||
assert config.underlying == "GC=F"
|
||||
|
||||
|
||||
def test_save_card_status_text_for_clean_state() -> None:
|
||||
config = PortfolioConfig(gold_value=215_000.0, entry_price=215.0, loan_amount=145_000.0)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user