feat(DATA-004): add underlying instrument selector

This commit is contained in:
Bu5hm4nn
2026-03-28 16:40:18 +01:00
parent cdd091a468
commit 3b98ebae69
13 changed files with 378 additions and 15 deletions

View File

@@ -4,16 +4,43 @@ import math
from dataclasses import dataclass from dataclasses import dataclass
from datetime import date from datetime import date
from decimal import Decimal from decimal import Decimal
from enum import Enum
from app.domain.backtesting_math import AssetQuantity, PricePerAsset from app.domain.backtesting_math import AssetQuantity, PricePerAsset
from app.domain.units import BaseCurrency, PricePerWeight, Weight, WeightUnit from app.domain.units import BaseCurrency, PricePerWeight, Weight, WeightUnit
class Underlying(str, Enum):
"""Supported underlying instruments for options evaluation."""
GLD = "GLD"
GC_F = "GC=F"
def display_name(self) -> str:
"""Human-readable display name."""
return {
Underlying.GLD: "SPDR Gold Shares ETF",
Underlying.GC_F: "Gold Futures (COMEX)",
}.get(self, self.value)
def description(self) -> str:
"""Description of the underlying and data source."""
return {
Underlying.GLD: "SPDR Gold Shares ETF (live data via yfinance)",
Underlying.GC_F: "Gold Futures (coming soon)",
}.get(self, "")
# GLD expense ratio decay parameters (from docs/GLD_BASIS_RESEARCH.md) # GLD expense ratio decay parameters (from docs/GLD_BASIS_RESEARCH.md)
# Formula: ounces_per_share = 0.10 * e^(-0.004 * years_since_2004) # Formula: ounces_per_share = 0.10 * e^(-0.004 * years_since_2004)
GLD_INITIAL_OUNCES_PER_SHARE = Decimal("0.10") GLD_INITIAL_OUNCES_PER_SHARE = Decimal("0.10")
GLD_EXPENSE_DECAY_RATE = Decimal("0.004") # 0.4% annual decay GLD_EXPENSE_DECAY_RATE = Decimal("0.004") # 0.4% annual decay
GLD_LAUNCH_YEAR = 2004 GLD_LAUNCH_YEAR = 2004
# GC=F contract specifications
GC_F_OUNCES_PER_CONTRACT = Decimal("100") # 100 troy oz per contract
GC_F_QUOTE_CURRENCY = BaseCurrency.USD
def gld_ounces_per_share(reference_date: date | None = None) -> Decimal: def gld_ounces_per_share(reference_date: date | None = None) -> Decimal:
""" """
@@ -105,11 +132,23 @@ _GLD = InstrumentMetadata(
weight_per_share=Weight(amount=gld_ounces_per_share(), unit=WeightUnit.OUNCE_TROY), weight_per_share=Weight(amount=gld_ounces_per_share(), unit=WeightUnit.OUNCE_TROY),
) )
_GC_F = InstrumentMetadata(
symbol="GC=F",
quote_currency=GC_F_QUOTE_CURRENCY,
weight_per_share=Weight(amount=GC_F_OUNCES_PER_CONTRACT, unit=WeightUnit.OUNCE_TROY),
)
_INSTRUMENTS: dict[str, InstrumentMetadata] = { _INSTRUMENTS: dict[str, InstrumentMetadata] = {
_GLD.symbol: _GLD, _GLD.symbol: _GLD,
_GC_F.symbol: _GC_F,
} }
def supported_underlyings() -> list[Underlying]:
"""Return list of supported underlying instruments."""
return list(Underlying)
def instrument_metadata(symbol: str) -> InstrumentMetadata: def instrument_metadata(symbol: str) -> InstrumentMetadata:
normalized = str(symbol).strip().upper() normalized = str(symbol).strip().upper()
metadata = _INSTRUMENTS.get(normalized) metadata = _INSTRUMENTS.get(normalized)

View File

@@ -120,7 +120,7 @@ async def lifespan(app: FastAPI):
app.state.settings = settings app.state.settings = settings
app.state.cache = CacheService(settings.redis_url, default_ttl=settings.cache_ttl) app.state.cache = CacheService(settings.redis_url, default_ttl=settings.cache_ttl)
await app.state.cache.connect() await app.state.cache.connect()
app.state.data_service = DataService(app.state.cache, default_symbol=settings.default_symbol) app.state.data_service = DataService(app.state.cache, default_underlying=settings.default_symbol)
set_data_service(app.state.data_service) set_data_service(app.state.data_service)
app.state.ws_manager = ConnectionManager() app.state.ws_manager = ConnectionManager()
app.state.publisher_task = asyncio.create_task(publish_updates(app)) app.state.publisher_task = asyncio.create_task(publish_updates(app))

View File

@@ -109,6 +109,9 @@ class PortfolioConfig:
fallback_source: str = "yfinance" fallback_source: str = "yfinance"
refresh_interval: int = 5 refresh_interval: int = 5
# Underlying instrument selection
underlying: str = "GLD"
# Alert settings # Alert settings
volatility_spike: float = 0.25 volatility_spike: float = 0.25
spot_drawdown: float = 7.5 spot_drawdown: float = 7.5
@@ -223,6 +226,7 @@ class PortfolioConfig:
"primary_source": self.primary_source, "primary_source": self.primary_source,
"fallback_source": self.fallback_source, "fallback_source": self.fallback_source,
"refresh_interval": self.refresh_interval, "refresh_interval": self.refresh_interval,
"underlying": self.underlying,
"volatility_spike": self.volatility_spike, "volatility_spike": self.volatility_spike,
"spot_drawdown": self.spot_drawdown, "spot_drawdown": self.spot_drawdown,
"email_alerts": self.email_alerts, "email_alerts": self.email_alerts,
@@ -285,6 +289,7 @@ class PortfolioRepository:
"primary_source", "primary_source",
"fallback_source", "fallback_source",
"refresh_interval", "refresh_interval",
"underlying", # optional with default "GLD"
"volatility_spike", "volatility_spike",
"spot_drawdown", "spot_drawdown",
"email_alerts", "email_alerts",
@@ -344,10 +349,14 @@ class PortfolioRepository:
upgraded = cls._upgrade_legacy_default_workspace(deserialized) upgraded = cls._upgrade_legacy_default_workspace(deserialized)
return PortfolioConfig.from_dict(upgraded) return PortfolioConfig.from_dict(upgraded)
# Fields that must be present in persisted payloads
# (underlying is optional with default "GLD")
_REQUIRED_FIELDS = _PERSISTED_FIELDS - {"underlying"}
@classmethod @classmethod
def _validate_portfolio_fields(cls, payload: dict[str, Any]) -> None: def _validate_portfolio_fields(cls, payload: dict[str, Any]) -> None:
keys = set(payload.keys()) keys = set(payload.keys())
missing = sorted(cls._PERSISTED_FIELDS - keys) missing = sorted(cls._REQUIRED_FIELDS - keys)
unknown = sorted(keys - cls._PERSISTED_FIELDS) unknown = sorted(keys - cls._PERSISTED_FIELDS)
if missing or unknown: if missing or unknown:
details: list[str] = [] details: list[str] = []

View File

@@ -86,9 +86,10 @@ async def _resolve_hedge_spot(workspace_id: str | None = None) -> tuple[dict[str
try: try:
data_service = get_data_service() data_service = get_data_service()
quote = await data_service.get_quote(data_service.default_symbol) underlying = config.underlying or "GLD"
quote = await data_service.get_quote(underlying)
spot, source, updated_at = resolve_portfolio_spot_from_quote( spot, source, updated_at = resolve_portfolio_spot_from_quote(
config, quote, fallback_symbol=data_service.default_symbol config, quote, fallback_symbol=underlying
) )
portfolio = portfolio_snapshot(config, runtime_spot_price=spot) portfolio = portfolio_snapshot(config, runtime_spot_price=spot)
return portfolio, source, updated_at return portfolio, source, updated_at
@@ -120,12 +121,25 @@ async def _render_hedge_page(workspace_id: str | None = None) -> None:
) )
updated_label = f"Quote timestamp: {quote_updated_at}" if quote_updated_at else "Quote timestamp: unavailable" updated_label = f"Quote timestamp: {quote_updated_at}" if quote_updated_at else "Quote timestamp: unavailable"
# Get underlying for display
underlying = "GLD"
if workspace_id:
try:
repo = get_workspace_repository()
config = repo.load_portfolio_config(workspace_id)
underlying = config.underlying or "GLD"
except Exception:
pass
with dashboard_page( with dashboard_page(
"Hedge Analysis", "Hedge Analysis",
"Compare hedge structures across scenarios, visualize cost-benefit tradeoffs, and inspect net equity impacts.", f"Compare hedge structures across scenarios, visualize cost-benefit tradeoffs, and inspect net equity impacts for {underlying}.",
"hedge", "hedge",
workspace_id=workspace_id, workspace_id=workspace_id,
): ):
with ui.row().classes("w-full items-center justify-between gap-4 max-md:flex-col max-md:items-start"):
ui.label(f"Active underlying: {underlying}").classes("text-sm text-slate-500 dark:text-slate-400")
left_pane, right_pane = split_page_panes( left_pane, right_pane = split_page_panes(
left_testid="hedge-left-pane", left_testid="hedge-left-pane",
right_testid="hedge-right-pane", right_testid="hedge-right-pane",

View File

@@ -141,7 +141,8 @@ async def overview_page(workspace_id: str) -> None:
config = repo.load_portfolio_config(workspace_id) config = repo.load_portfolio_config(workspace_id)
data_service = get_data_service() data_service = get_data_service()
symbol = data_service.default_symbol underlying = config.underlying or "GLD"
symbol = underlying
quote = await data_service.get_quote(symbol) quote = await data_service.get_quote(symbol)
overview_spot_price, overview_source, overview_updated_at = _resolve_overview_spot( overview_spot_price, overview_source, overview_updated_at = _resolve_overview_spot(
config, quote, fallback_symbol=symbol config, quote, fallback_symbol=symbol
@@ -199,14 +200,14 @@ async def overview_page(workspace_id: str) -> None:
with dashboard_page( with dashboard_page(
"Overview", "Overview",
"Portfolio health, LTV risk, and quick strategy guidance for the current GLD-backed loan.", f"Portfolio health, LTV risk, and quick strategy guidance for the current {underlying}-backed loan.",
"overview", "overview",
workspace_id=workspace_id, workspace_id=workspace_id,
): ):
with ui.row().classes("w-full items-center justify-between gap-4 max-md:flex-col max-md:items-start"): with ui.row().classes("w-full items-center justify-between gap-4 max-md:flex-col max-md:items-start"):
ui.label(quote_status).classes("text-sm text-slate-500 dark:text-slate-400") ui.label(quote_status).classes("text-sm text-slate-500 dark:text-slate-400")
ui.label( ui.label(
f"Configured collateral baseline: ${config.gold_value:,.0f} · Loan ${config.loan_amount:,.0f}" f"Active underlying: {underlying} · Configured collateral baseline: ${config.gold_value:,.0f} · Loan ${config.loan_amount:,.0f}"
).classes("text-sm text-slate-500 dark:text-slate-400") ).classes("text-sm text-slate-500 dark:text-slate-400")
left_pane, right_pane = split_page_panes( left_pane, right_pane = split_page_panes(

View File

@@ -121,6 +121,7 @@ def settings_page(workspace_id: str) -> None:
primary_source=str(primary_source.value), primary_source=str(primary_source.value),
fallback_source=str(fallback_source.value), fallback_source=str(fallback_source.value),
refresh_interval=parsed_refresh_interval, refresh_interval=parsed_refresh_interval,
underlying=str(underlying.value),
volatility_spike=float(vol_alert.value), volatility_spike=float(vol_alert.value),
spot_drawdown=float(price_alert.value), spot_drawdown=float(price_alert.value),
email_alerts=bool(email_alerts.value), email_alerts=bool(email_alerts.value),
@@ -244,6 +245,14 @@ def settings_page(workspace_id: str) -> None:
"w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
): ):
ui.label("Data Sources").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") ui.label("Data Sources").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
underlying = ui.select(
{
"GLD": "SPDR Gold Shares ETF (live data via yfinance)",
"GC=F": "Gold Futures (coming soon)",
},
value=config.underlying,
label="Underlying instrument",
).classes("w-full")
primary_source = ui.select( primary_source = ui.select(
["yfinance", "ibkr", "alpaca"], ["yfinance", "ibkr", "alpaca"],
value=config.primary_source, value=config.primary_source,

View File

@@ -24,11 +24,16 @@ except ImportError: # pragma: no cover - optional dependency
class DataService: class DataService:
"""Fetches portfolio and market data, using Redis when available.""" """Fetches portfolio and market data, using Redis when available."""
def __init__(self, cache: CacheService, default_symbol: str = "GLD") -> None: def __init__(self, cache: CacheService, default_underlying: str = "GLD") -> None:
self.cache = cache self.cache = cache
self.default_symbol = default_symbol self.default_underlying = default_underlying
self.gc_f_symbol = "GC=F" # COMEX Gold Futures self.gc_f_symbol = "GC=F" # COMEX Gold Futures
@property
def default_symbol(self) -> str:
"""Backward compatibility alias for default_underlying."""
return self.default_underlying
async def get_portfolio(self, symbol: str | None = None) -> dict[str, Any]: async def get_portfolio(self, symbol: str | None = None) -> dict[str, Any]:
ticker = (symbol or self.default_symbol).upper() ticker = (symbol or self.default_symbol).upper()
cache_key = f"portfolio:{ticker}" cache_key = f"portfolio:{ticker}"
@@ -51,6 +56,11 @@ class DataService:
return portfolio return portfolio
async def get_quote(self, symbol: str) -> dict[str, Any]: async def get_quote(self, symbol: str) -> dict[str, Any]:
"""Fetch quote for the given symbol, routing to appropriate data source.
For GLD: fetches from yfinance (ETF share price)
For GC=F: fetches from yfinance (futures price) or returns placeholder
"""
normalized_symbol = symbol.upper() normalized_symbol = symbol.upper()
cache_key = f"quote:{normalized_symbol}" cache_key = f"quote:{normalized_symbol}"
cached = await self.cache.get_json(cache_key) cached = await self.cache.get_json(cache_key)
@@ -64,12 +74,17 @@ class DataService:
await self.cache.set_json(cache_key, normalized_cached) await self.cache.set_json(cache_key, normalized_cached)
return normalized_cached return normalized_cached
# Route based on underlying
if normalized_symbol == "GC=F":
quote = self._normalize_quote_payload(await self._fetch_gc_futures(), normalized_symbol)
else:
quote = self._normalize_quote_payload(await self._fetch_quote(normalized_symbol), normalized_symbol) quote = self._normalize_quote_payload(await self._fetch_quote(normalized_symbol), normalized_symbol)
await self.cache.set_json(cache_key, quote) await self.cache.set_json(cache_key, quote)
return quote return quote
async def get_option_expirations(self, symbol: str | None = None) -> dict[str, Any]: async def get_option_expirations(self, symbol: str | None = None) -> dict[str, Any]:
ticker_symbol = (symbol or self.default_symbol).upper() ticker_symbol = (symbol or self.default_underlying).upper()
cache_key = f"options:{ticker_symbol}:expirations" cache_key = f"options:{ticker_symbol}:expirations"
cached = await self.cache.get_json(cache_key) cached = await self.cache.get_json(cache_key)
@@ -90,6 +105,18 @@ class DataService:
await self.cache.set_json(cache_key, normalized_cached) await self.cache.set_json(cache_key, normalized_cached)
return normalized_cached return normalized_cached
# GC=F options not yet implemented - return placeholder
if ticker_symbol == "GC=F":
quote = await self.get_quote(ticker_symbol)
payload = self._fallback_option_expirations(
ticker_symbol,
quote,
source="placeholder",
error="Options data for GC=F coming soon",
)
await self.cache.set_json(cache_key, payload)
return payload
quote = await self.get_quote(ticker_symbol) quote = await self.get_quote(ticker_symbol)
if yf is None: if yf is None:
payload = self._fallback_option_expirations( payload = self._fallback_option_expirations(
@@ -140,7 +167,7 @@ class DataService:
async def get_options_chain_for_expiry( async def get_options_chain_for_expiry(
self, symbol: str | None = None, expiry: str | None = None self, symbol: str | None = None, expiry: str | None = None
) -> dict[str, Any]: ) -> dict[str, Any]:
ticker_symbol = (symbol or self.default_symbol).upper() ticker_symbol = (symbol or self.default_underlying).upper()
expirations_data = await self.get_option_expirations(ticker_symbol) expirations_data = await self.get_option_expirations(ticker_symbol)
expirations = list(expirations_data.get("expirations") or []) expirations = list(expirations_data.get("expirations") or [])
target_expiry = expiry or (expirations[0] if expirations else None) target_expiry = expiry or (expirations[0] if expirations else None)
@@ -180,6 +207,19 @@ class DataService:
await self.cache.set_json(cache_key, normalized_cached) await self.cache.set_json(cache_key, normalized_cached)
return normalized_cached return normalized_cached
# GC=F options not yet implemented - return placeholder
if ticker_symbol == "GC=F":
payload = self._fallback_options_chain(
ticker_symbol,
quote,
expirations=expirations,
selected_expiry=target_expiry,
source="placeholder",
error="Options data for GC=F coming soon",
)
await self.cache.set_json(cache_key, payload)
return payload
if yf is None: if yf is None:
payload = self._fallback_options_chain( payload = self._fallback_options_chain(
ticker_symbol, ticker_symbol,

View File

@@ -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") quote = await service.get_quote("BTC-USD")

View File

@@ -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") quote = await service.get_quote("BTC-USD")

View 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

View File

@@ -7,13 +7,16 @@ import pytest
from app.domain.backtesting_math import AssetQuantity, PricePerAsset from app.domain.backtesting_math import AssetQuantity, PricePerAsset
from app.domain.instruments import ( from app.domain.instruments import (
GC_F_OUNCES_PER_CONTRACT,
GLD_EXPENSE_DECAY_RATE, GLD_EXPENSE_DECAY_RATE,
GLD_INITIAL_OUNCES_PER_SHARE, GLD_INITIAL_OUNCES_PER_SHARE,
GLD_LAUNCH_YEAR, GLD_LAUNCH_YEAR,
Underlying,
asset_quantity_from_weight, asset_quantity_from_weight,
gld_ounces_per_share, gld_ounces_per_share,
instrument_metadata, instrument_metadata,
price_per_weight_from_asset_price, price_per_weight_from_asset_price,
supported_underlyings,
weight_from_asset_quantity, weight_from_asset_quantity,
) )
from app.domain.units import BaseCurrency, Weight, WeightUnit 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"): with pytest.raises(ValueError, match="Unsupported instrument metadata"):
asset_quantity_from_weight("SLV", Weight(amount=Decimal("1"), unit=WeightUnit.OUNCE_TROY)) 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

View File

@@ -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"]["gold_ounces"] == {"value": "220.0", "unit": "ozt"}
assert payload["portfolio"]["loan_amount"] == {"value": "145000.0", "currency": "USD"} 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: 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", "primary_source": "yfinance",
"fallback_source": "yfinance", "fallback_source": "yfinance",
"refresh_interval": {"value": 5, "unit": "seconds"}, "refresh_interval": {"value": 5, "unit": "seconds"},
"underlying": "GLD",
"volatility_spike": {"value": "25.0", "unit": "percent"}, "volatility_spike": {"value": "25.0", "unit": "percent"},
"spot_drawdown": {"value": "0.075", "unit": "ratio"}, "spot_drawdown": {"value": "0.075", "unit": "ratio"},
"email_alerts": False, "email_alerts": False,
@@ -182,6 +184,7 @@ def test_portfolio_repository_upgrades_legacy_default_workspace_footprint(tmp_pa
"primary_source": "yfinance", "primary_source": "yfinance",
"fallback_source": "yfinance", "fallback_source": "yfinance",
"refresh_interval": {"value": 5, "unit": "seconds"}, "refresh_interval": {"value": 5, "unit": "seconds"},
"underlying": "GLD",
"volatility_spike": {"value": "0.25", "unit": "ratio"}, "volatility_spike": {"value": "0.25", "unit": "ratio"},
"spot_drawdown": {"value": "7.5", "unit": "percent"}, "spot_drawdown": {"value": "7.5", "unit": "percent"},
"email_alerts": False, "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"): with pytest.raises(ValueError, match="Invalid portfolio payload fields"):
PortfolioRepository(config_path=config_path).load() PortfolioRepository(config_path=config_path).load()
@@ -259,6 +263,7 @@ def test_portfolio_repository_rejects_unsupported_field_units(tmp_path) -> None:
"primary_source": "yfinance", "primary_source": "yfinance",
"fallback_source": "yfinance", "fallback_source": "yfinance",
"refresh_interval": {"value": 5, "unit": "seconds"}, "refresh_interval": {"value": 5, "unit": "seconds"},
"underlying": "GLD",
"volatility_spike": {"value": "0.25", "unit": "ratio"}, "volatility_spike": {"value": "0.25", "unit": "ratio"},
"spot_drawdown": {"value": "7.5", "unit": "percent"}, "spot_drawdown": {"value": "7.5", "unit": "percent"},
"email_alerts": False, "email_alerts": False,

View File

@@ -4,6 +4,23 @@ from app.models.portfolio import PortfolioConfig
from app.pages.settings import _save_card_status_text 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: 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) config = PortfolioConfig(gold_value=215_000.0, entry_price=215.0, loan_amount=145_000.0)