diff --git a/app/domain/instruments.py b/app/domain/instruments.py index 2c223bc..f04ebf2 100644 --- a/app/domain/instruments.py +++ b/app/domain/instruments.py @@ -4,16 +4,43 @@ import math from dataclasses import dataclass from datetime import date from decimal import Decimal +from enum import Enum from app.domain.backtesting_math import AssetQuantity, PricePerAsset 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) # Formula: ounces_per_share = 0.10 * e^(-0.004 * years_since_2004) GLD_INITIAL_OUNCES_PER_SHARE = Decimal("0.10") GLD_EXPENSE_DECAY_RATE = Decimal("0.004") # 0.4% annual decay 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: """ @@ -105,11 +132,23 @@ _GLD = InstrumentMetadata( 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] = { _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: normalized = str(symbol).strip().upper() metadata = _INSTRUMENTS.get(normalized) diff --git a/app/main.py b/app/main.py index abaeb16..ac52adb 100644 --- a/app/main.py +++ b/app/main.py @@ -120,7 +120,7 @@ async def lifespan(app: FastAPI): app.state.settings = settings app.state.cache = CacheService(settings.redis_url, default_ttl=settings.cache_ttl) 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) app.state.ws_manager = ConnectionManager() app.state.publisher_task = asyncio.create_task(publish_updates(app)) diff --git a/app/models/portfolio.py b/app/models/portfolio.py index a19707a..1c63a25 100644 --- a/app/models/portfolio.py +++ b/app/models/portfolio.py @@ -109,6 +109,9 @@ class PortfolioConfig: fallback_source: str = "yfinance" refresh_interval: int = 5 + # Underlying instrument selection + underlying: str = "GLD" + # Alert settings volatility_spike: float = 0.25 spot_drawdown: float = 7.5 @@ -223,6 +226,7 @@ class PortfolioConfig: "primary_source": self.primary_source, "fallback_source": self.fallback_source, "refresh_interval": self.refresh_interval, + "underlying": self.underlying, "volatility_spike": self.volatility_spike, "spot_drawdown": self.spot_drawdown, "email_alerts": self.email_alerts, @@ -285,6 +289,7 @@ class PortfolioRepository: "primary_source", "fallback_source", "refresh_interval", + "underlying", # optional with default "GLD" "volatility_spike", "spot_drawdown", "email_alerts", @@ -344,10 +349,14 @@ class PortfolioRepository: upgraded = cls._upgrade_legacy_default_workspace(deserialized) 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 def _validate_portfolio_fields(cls, payload: dict[str, Any]) -> None: keys = set(payload.keys()) - missing = sorted(cls._PERSISTED_FIELDS - keys) + missing = sorted(cls._REQUIRED_FIELDS - keys) unknown = sorted(keys - cls._PERSISTED_FIELDS) if missing or unknown: details: list[str] = [] diff --git a/app/pages/hedge.py b/app/pages/hedge.py index cf2e660..5022b30 100644 --- a/app/pages/hedge.py +++ b/app/pages/hedge.py @@ -86,9 +86,10 @@ async def _resolve_hedge_spot(workspace_id: str | None = None) -> tuple[dict[str try: 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( - config, quote, fallback_symbol=data_service.default_symbol + config, quote, fallback_symbol=underlying ) portfolio = portfolio_snapshot(config, runtime_spot_price=spot) 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" + # 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( "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", 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_testid="hedge-left-pane", right_testid="hedge-right-pane", diff --git a/app/pages/overview.py b/app/pages/overview.py index 09ccb5b..d4359fd 100644 --- a/app/pages/overview.py +++ b/app/pages/overview.py @@ -141,7 +141,8 @@ async def overview_page(workspace_id: str) -> None: config = repo.load_portfolio_config(workspace_id) data_service = get_data_service() - symbol = data_service.default_symbol + underlying = config.underlying or "GLD" + symbol = underlying quote = await data_service.get_quote(symbol) overview_spot_price, overview_source, overview_updated_at = _resolve_overview_spot( config, quote, fallback_symbol=symbol @@ -199,14 +200,14 @@ async def overview_page(workspace_id: str) -> None: with dashboard_page( "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", 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(quote_status).classes("text-sm text-slate-500 dark:text-slate-400") 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") left_pane, right_pane = split_page_panes( diff --git a/app/pages/settings.py b/app/pages/settings.py index 426fbb4..32ba6b9 100644 --- a/app/pages/settings.py +++ b/app/pages/settings.py @@ -121,6 +121,7 @@ def settings_page(workspace_id: str) -> None: primary_source=str(primary_source.value), fallback_source=str(fallback_source.value), refresh_interval=parsed_refresh_interval, + underlying=str(underlying.value), volatility_spike=float(vol_alert.value), spot_drawdown=float(price_alert.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" ): 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( ["yfinance", "ibkr", "alpaca"], value=config.primary_source, diff --git a/app/services/data_service.py b/app/services/data_service.py index d32836a..fc1ce05 100644 --- a/app/services/data_service.py +++ b/app/services/data_service.py @@ -24,11 +24,16 @@ except ImportError: # pragma: no cover - optional dependency class DataService: """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.default_symbol = default_symbol + self.default_underlying = default_underlying 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]: ticker = (symbol or self.default_symbol).upper() cache_key = f"portfolio:{ticker}" @@ -51,6 +56,11 @@ class DataService: return portfolio 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() cache_key = f"quote:{normalized_symbol}" cached = await self.cache.get_json(cache_key) @@ -64,12 +74,17 @@ class DataService: await self.cache.set_json(cache_key, normalized_cached) return normalized_cached - quote = self._normalize_quote_payload(await self._fetch_quote(normalized_symbol), normalized_symbol) + # 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) + await self.cache.set_json(cache_key, quote) return quote 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" cached = await self.cache.get_json(cache_key) @@ -90,6 +105,18 @@ class DataService: await self.cache.set_json(cache_key, 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) if yf is None: payload = self._fallback_option_expirations( @@ -140,7 +167,7 @@ class DataService: async def get_options_chain_for_expiry( self, symbol: str | None = None, expiry: str | None = None ) -> 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 = list(expirations_data.get("expirations") or []) 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) 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: payload = self._fallback_options_chain( ticker_symbol, diff --git a/tests/test_data_service_normalization.py b/tests/test_data_service_normalization.py index 4608d34..8334811 100644 --- a/tests/test_data_service_normalization.py +++ b/tests/test_data_service_normalization.py @@ -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") diff --git a/tests/test_data_service_quote_units.py b/tests/test_data_service_quote_units.py index cebf8e3..bc52f14 100644 --- a/tests/test_data_service_quote_units.py +++ b/tests/test_data_service_quote_units.py @@ -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") diff --git a/tests/test_data_service_underlying.py b/tests/test_data_service_underlying.py new file mode 100644 index 0000000..a3e1d8c --- /dev/null +++ b/tests/test_data_service_underlying.py @@ -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 diff --git a/tests/test_instruments.py b/tests/test_instruments.py index a9c98a1..ac89e10 100644 --- a/tests/test_instruments.py +++ b/tests/test_instruments.py @@ -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 diff --git a/tests/test_portfolio.py b/tests/test_portfolio.py index ab9e71b..3d72a63 100644 --- a/tests/test_portfolio.py +++ b/tests/test_portfolio.py @@ -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, diff --git a/tests/test_settings_page.py b/tests/test_settings_page.py index 7f3f752..d74174a 100644 --- a/tests/test_settings_page.py +++ b/tests/test_settings_page.py @@ -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)