feat(DATA-004): add underlying instrument selector
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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] = []
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
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.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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user