From f0d7ab5748a9e42f11f04f65ddab75b85f660904 Mon Sep 17 00:00:00 2001 From: Bu5hm4nn Date: Wed, 25 Mar 2026 14:52:48 +0100 Subject: [PATCH] feat(CORE-002): add GLD share quote conversion seam --- app/domain/__init__.py | 12 +++ app/domain/instruments.py | 96 +++++++++++++++++++++++ app/domain/portfolio_math.py | 42 +++++++++- app/pages/overview.py | 36 ++++----- app/services/data_service.py | 21 ++++- tests/test_data_service_quote_units.py | 59 ++++++++++++++ tests/test_e2e_playwright.py | 13 +++- tests/test_instruments.py | 52 +++++++++++++ tests/test_overview_workspace.py | 102 +++++++++++++++++++++++-- tests/test_portfolio_math.py | 26 ++++++- 10 files changed, 425 insertions(+), 34 deletions(-) create mode 100644 app/domain/instruments.py create mode 100644 tests/test_data_service_quote_units.py create mode 100644 tests/test_instruments.py diff --git a/app/domain/__init__.py b/app/domain/__init__.py index 18ddc27..6a2b240 100644 --- a/app/domain/__init__.py +++ b/app/domain/__init__.py @@ -5,9 +5,16 @@ from app.domain.backtesting_math import ( asset_quantity_from_money, materialize_backtest_portfolio_state, ) +from app.domain.instruments import ( + asset_quantity_from_weight, + instrument_metadata, + price_per_weight_from_asset_price, + weight_from_asset_quantity, +) from app.domain.portfolio_math import ( build_alert_context, portfolio_snapshot_from_config, + resolve_portfolio_spot_from_quote, strategy_metrics_from_snapshot, ) from app.domain.units import ( @@ -35,5 +42,10 @@ __all__ = [ "decimal_from_float", "portfolio_snapshot_from_config", "build_alert_context", + "resolve_portfolio_spot_from_quote", "strategy_metrics_from_snapshot", + "instrument_metadata", + "price_per_weight_from_asset_price", + "weight_from_asset_quantity", + "asset_quantity_from_weight", ] diff --git a/app/domain/instruments.py b/app/domain/instruments.py new file mode 100644 index 0000000..6741edf --- /dev/null +++ b/app/domain/instruments.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal + +from app.domain.backtesting_math import AssetQuantity, PricePerAsset +from app.domain.units import BaseCurrency, PricePerWeight, Weight, WeightUnit + + +@dataclass(frozen=True, slots=True) +class InstrumentMetadata: + symbol: str + quote_currency: BaseCurrency | str + weight_per_share: Weight + + def __post_init__(self) -> None: + normalized_symbol = str(self.symbol).strip().upper() + if not normalized_symbol: + raise ValueError("Instrument symbol is required") + object.__setattr__(self, "symbol", normalized_symbol) + object.__setattr__(self, "quote_currency", BaseCurrency(self.quote_currency)) + object.__setattr__(self, "weight_per_share", self.weight_per_share) + + def assert_symbol(self, symbol: str) -> InstrumentMetadata: + normalized = str(symbol).strip().upper() + if self.symbol != normalized: + raise ValueError(f"Instrument symbol mismatch: {self.symbol} != {normalized}") + return self + + def assert_currency(self, currency: BaseCurrency | str) -> InstrumentMetadata: + normalized = BaseCurrency(currency) + if self.quote_currency is not normalized: + raise ValueError(f"Instrument currency mismatch: {self.quote_currency} != {normalized}") + return self + + def price_per_weight_from_asset_price( + self, + price: PricePerAsset, + *, + per_unit: WeightUnit = WeightUnit.OUNCE_TROY, + ) -> PricePerWeight: + self.assert_symbol(price.symbol) + self.assert_currency(price.currency) + weight_per_share = self.weight_per_share.to_unit(per_unit) + if weight_per_share.amount <= 0: + raise ValueError("Instrument weight_per_share must be positive") + return PricePerWeight( + amount=price.amount / weight_per_share.amount, + currency=price.currency, + per_unit=per_unit, + ) + + def weight_from_asset_quantity(self, quantity: AssetQuantity) -> Weight: + self.assert_symbol(quantity.symbol) + return Weight(amount=quantity.amount * self.weight_per_share.amount, unit=self.weight_per_share.unit) + + def asset_quantity_from_weight(self, weight: Weight) -> AssetQuantity: + normalized_weight = weight.to_unit(self.weight_per_share.unit) + if self.weight_per_share.amount <= 0: + raise ValueError("Instrument weight_per_share must be positive") + return AssetQuantity(amount=normalized_weight.amount / self.weight_per_share.amount, symbol=self.symbol) + + +_GLD = InstrumentMetadata( + symbol="GLD", + quote_currency=BaseCurrency.USD, + weight_per_share=Weight(amount=Decimal("0.1"), unit=WeightUnit.OUNCE_TROY), +) + +_INSTRUMENTS: dict[str, InstrumentMetadata] = { + _GLD.symbol: _GLD, +} + + +def instrument_metadata(symbol: str) -> InstrumentMetadata: + normalized = str(symbol).strip().upper() + metadata = _INSTRUMENTS.get(normalized) + if metadata is None: + raise ValueError(f"Unsupported instrument metadata: {normalized or symbol!r}") + return metadata + + +def price_per_weight_from_asset_price( + price: PricePerAsset, + *, + per_unit: WeightUnit = WeightUnit.OUNCE_TROY, +) -> PricePerWeight: + return instrument_metadata(price.symbol).price_per_weight_from_asset_price(price, per_unit=per_unit) + + +def weight_from_asset_quantity(quantity: AssetQuantity) -> Weight: + return instrument_metadata(quantity.symbol).weight_from_asset_quantity(quantity) + + +def asset_quantity_from_weight(symbol: str, weight: Weight) -> AssetQuantity: + return instrument_metadata(symbol).asset_quantity_from_weight(weight) diff --git a/app/domain/portfolio_math.py b/app/domain/portfolio_math.py index 49d34cf..7328f2b 100644 --- a/app/domain/portfolio_math.py +++ b/app/domain/portfolio_math.py @@ -1,8 +1,10 @@ from __future__ import annotations from decimal import Decimal -from typing import Any +from typing import Any, Mapping +from app.domain.backtesting_math import PricePerAsset +from app.domain.instruments import instrument_metadata from app.domain.units import BaseCurrency, Money, PricePerWeight, Weight, WeightUnit, decimal_from_float from app.models.portfolio import PortfolioConfig @@ -42,6 +44,44 @@ def _gold_weight(gold_ounces: float) -> Weight: return Weight(amount=decimal_from_float(gold_ounces), unit=WeightUnit.OUNCE_TROY) +def _safe_quote_price(value: object) -> float: + try: + parsed = float(value) + except (TypeError, ValueError): + return 0.0 + if parsed <= 0: + return 0.0 + return parsed + + +def resolve_portfolio_spot_from_quote( + config: PortfolioConfig, + quote: Mapping[str, object], + *, + fallback_symbol: str | None = None, +) -> tuple[float, str, str]: + configured_price = float(config.entry_price or 0.0) + quote_price = _safe_quote_price(quote.get("price")) + quote_source = str(quote.get("source", "unknown")) + quote_updated_at = str(quote.get("updated_at", "")) + quote_symbol = str(quote.get("symbol", fallback_symbol or "")).strip().upper() + quote_unit = str(quote.get("quote_unit", "")).strip().lower() + + if quote_price <= 0 or not quote_symbol or quote_unit != "share": + return configured_price, "configured_entry_price", "" + + try: + metadata = instrument_metadata(quote_symbol) + except ValueError: + return configured_price, "configured_entry_price", "" + + converted_spot = metadata.price_per_weight_from_asset_price( + PricePerAsset(amount=decimal_from_float(quote_price), currency=BaseCurrency.USD, symbol=quote_symbol), + per_unit=WeightUnit.OUNCE_TROY, + ) + return _decimal_to_float(converted_spot.amount), quote_source, quote_updated_at + + def portfolio_snapshot_from_config(config: PortfolioConfig | None = None) -> dict[str, float]: if config is None: gold_weight = Weight(amount=Decimal("1000"), unit=WeightUnit.OUNCE_TROY) diff --git a/app/pages/overview.py b/app/pages/overview.py index 96c1a6a..baadf41 100644 --- a/app/pages/overview.py +++ b/app/pages/overview.py @@ -7,6 +7,7 @@ from fastapi.responses import RedirectResponse from nicegui import ui from app.components import PortfolioOverview +from app.domain.portfolio_math import resolve_portfolio_spot_from_quote from app.models.workspace import WORKSPACE_COOKIE, get_workspace_repository from app.pages.common import dashboard_page, quick_recommendations, recommendation_style, strategy_catalog from app.services.alerts import AlertService, build_portfolio_alert_context @@ -16,20 +17,10 @@ from app.services.turnstile import load_turnstile_settings _DEFAULT_CASH_BUFFER = 18_500.0 -def _resolve_overview_spot(config, quote: dict[str, object]) -> tuple[float, str, str]: - configured_price = float(config.entry_price or 0.0) - quote_price = float(quote.get("price", configured_price or 0.0) or 0.0) - quote_source = str(quote.get("source", "unknown")) - quote_updated_at = str(quote.get("updated_at", "")) - - if configured_price > 0 and quote_price > 0: - ratio = max(configured_price / quote_price, quote_price / configured_price) - if ratio > 1.5: - return configured_price, "configured_entry_price", "" - - if quote_price > 0: - return quote_price, quote_source, quote_updated_at - return configured_price, "configured_entry_price", "" +def _resolve_overview_spot( + config, quote: dict[str, object], *, fallback_symbol: str | None = None +) -> tuple[float, str, str]: + return resolve_portfolio_spot_from_quote(config, quote, fallback_symbol=fallback_symbol) def _format_timestamp(value: str | None) -> str: @@ -116,7 +107,9 @@ async def overview_page(workspace_id: str) -> None: data_service = get_data_service() symbol = data_service.default_symbol quote = await data_service.get_quote(symbol) - overview_spot_price, overview_source, overview_updated_at = _resolve_overview_spot(config, quote) + overview_spot_price, overview_source, overview_updated_at = _resolve_overview_spot( + config, quote, fallback_symbol=symbol + ) portfolio = build_portfolio_alert_context( config, spot_price=overview_spot_price, @@ -132,6 +125,7 @@ async def overview_page(workspace_id: str) -> None: else: quote_status = ( f"Live quote source: {portfolio['quote_source']} · " + f"GLD share quote converted to ozt-equivalent spot · " f"Last updated {_format_timestamp(str(portfolio['quote_updated_at']))}" ) @@ -161,15 +155,21 @@ async def overview_page(workspace_id: str) -> None: if alert_status.history: latest = alert_status.history[0] ui.label( - f"Latest alert logged {_format_timestamp(latest.updated_at)} at spot ${latest.spot_price:,.2f}" + f"Latest alert logged {_format_timestamp(latest.updated_at)} at collateral spot ${latest.spot_price:,.2f}" ).classes("text-xs text-slate-500 dark:text-slate-400") + spot_caption = ( + f"{symbol} share quote converted to USD/ozt via {portfolio['quote_source']}" + if portfolio["quote_source"] != "configured_entry_price" + else "Configured entry price fallback in USD/ozt" + ) + with ui.grid(columns=4).classes("w-full gap-4 max-lg:grid-cols-2 max-sm:grid-cols-1"): summary_cards = [ ( - "Spot Price", + "Collateral Spot Price", f"${portfolio['spot_price']:,.2f}", - f"{symbol} live quote via {portfolio['quote_source']}", + spot_caption, ), ( "Margin Call Price", diff --git a/app/services/data_service.py b/app/services/data_service.py index 5993bee..ffc5ab8 100644 --- a/app/services/data_service.py +++ b/app/services/data_service.py @@ -49,12 +49,16 @@ class DataService: return portfolio async def get_quote(self, symbol: str) -> dict[str, Any]: - cache_key = f"quote:{symbol}" + normalized_symbol = symbol.upper() + cache_key = f"quote:{normalized_symbol}" cached = await self.cache.get_json(cache_key) if cached and isinstance(cached, dict): - return cached + normalized_cached = self._normalize_quote_payload(cached, normalized_symbol) + if normalized_cached != cached: + await self.cache.set_json(cache_key, normalized_cached) + return normalized_cached - quote = await self._fetch_quote(symbol) + quote = self._normalize_quote_payload(await self._fetch_quote(normalized_symbol), normalized_symbol) await self.cache.set_json(cache_key, quote) return quote @@ -250,6 +254,7 @@ class DataService: return { "symbol": symbol, "price": round(last, 4), + "quote_unit": "share", "change": change, "change_percent": change_percent, "updated_at": datetime.now(timezone.utc).isoformat(), @@ -358,11 +363,21 @@ class DataService: return round((bid + ask) / 2, 4) return max(bid, ask, 0.0) + @staticmethod + def _normalize_quote_payload(payload: dict[str, Any], symbol: str) -> dict[str, Any]: + normalized = dict(payload) + normalized_symbol = symbol.upper() + normalized["symbol"] = str(normalized.get("symbol", normalized_symbol)).upper() + if normalized["symbol"] == "GLD" and not normalized.get("quote_unit"): + normalized["quote_unit"] = "share" + return normalized + @staticmethod def _fallback_quote(symbol: str, source: str) -> dict[str, Any]: return { "symbol": symbol, "price": 215.0, + "quote_unit": "share", "change": 0.0, "change_percent": 0.0, "updated_at": datetime.now(timezone.utc).isoformat(), diff --git a/tests/test_data_service_quote_units.py b/tests/test_data_service_quote_units.py new file mode 100644 index 0000000..cebf8e3 --- /dev/null +++ b/tests/test_data_service_quote_units.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import pytest + +from app.services.cache import CacheService +from app.services.data_service import DataService + + +class _CacheStub(CacheService): + def __init__(self, initial: dict[str, object] | None = None) -> None: + self._store = dict(initial or {}) + + 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 + return True + + +@pytest.mark.asyncio +async def test_get_quote_upgrades_cached_gld_quote_with_missing_quote_unit() -> None: + cache = _CacheStub( + { + "quote:GLD": { + "symbol": "GLD", + "price": 404.19, + "change": 0.0, + "change_percent": 0.0, + "updated_at": "2026-03-25T00:00:00+00:00", + "source": "cache", + } + } + ) + service = DataService(cache=cache) + + quote = await service.get_quote("GLD") + + assert quote["quote_unit"] == "share" + assert cache._store["quote:GLD"]["quote_unit"] == "share" + + +@pytest.mark.asyncio +async def test_get_quote_preserves_missing_quote_unit_for_unsupported_symbols() -> None: + cache = _CacheStub( + { + "quote:BTC-USD": { + "symbol": "BTC-USD", + "price": 70000.0, + "updated_at": "2026-03-25T00:00:00+00:00", + "source": "cache", + } + } + ) + service = DataService(cache=cache, default_symbol="BTC-USD") + + quote = await service.get_quote("BTC-USD") + + assert "quote_unit" not in quote diff --git a/tests/test_e2e_playwright.py b/tests/test_e2e_playwright.py index c76700d..1e1a48a 100644 --- a/tests/test_e2e_playwright.py +++ b/tests/test_e2e_playwright.py @@ -141,10 +141,13 @@ def test_homepage_and_options_page_render() -> None: assert "Options Chain" in overview_text assert "Backtests" in overview_text assert "Event Comparison" in overview_text - assert "Live quote source: configured entry price fallback" in overview_text + assert "Live quote source:" in overview_text + assert "GLD share quote converted to ozt-equivalent spot" in overview_text + assert "configured entry price fallback" not in overview_text + assert "Collateral Spot Price" in overview_text assert "$1,261.36" in overview_text - assert "$968,000.00" in overview_text - assert "$746,000.00" in overview_text + assert "$968,000" in overview_text + assert "$222,000" in overview_text expect(page.get_by_role("link", name="Hedge Analysis")).to_have_attribute("href", f"/{workspace_id}/hedge") expect(page.get_by_role("link", name="Backtests")).to_have_attribute("href", f"/{workspace_id}/backtests") expect(page.get_by_role("link", name="Event Comparison")).to_have_attribute( @@ -160,7 +163,9 @@ def test_homepage_and_options_page_render() -> None: second_workspace_url = second_page.url assert second_workspace_url != workspace_url second_page.goto(f"{second_workspace_url}/settings", wait_until="domcontentloaded", timeout=30000) - expect(second_page.get_by_label("Monthly hedge budget ($)")).to_have_value("8000") + expect(second_page).to_have_url(f"{second_workspace_url}/settings") + expect(second_page.locator("text=Settings").first).to_be_visible(timeout=15000) + expect(second_page.get_by_label("Monthly hedge budget ($)")).to_have_value("8000", timeout=15000) second_page.close() second_context.close() diff --git a/tests/test_instruments.py b/tests/test_instruments.py new file mode 100644 index 0000000..3214bd9 --- /dev/null +++ b/tests/test_instruments.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from decimal import Decimal + +import pytest + +from app.domain.backtesting_math import AssetQuantity, PricePerAsset +from app.domain.instruments import ( + asset_quantity_from_weight, + price_per_weight_from_asset_price, + weight_from_asset_quantity, +) +from app.domain.units import BaseCurrency, Weight, WeightUnit + + +def test_gld_share_quantity_converts_to_troy_ounce_weight() -> None: + quantity = AssetQuantity(amount=Decimal("10"), symbol="GLD") + + weight = weight_from_asset_quantity(quantity) + + assert weight == Weight(amount=Decimal("1.0"), unit=WeightUnit.OUNCE_TROY) + + +def test_gld_troy_ounce_weight_converts_to_share_quantity() -> None: + weight = Weight(amount=Decimal("1"), unit=WeightUnit.OUNCE_TROY) + + quantity = asset_quantity_from_weight("GLD", weight) + + assert quantity == AssetQuantity(amount=Decimal("10"), symbol="GLD") + + +def test_gld_share_quote_converts_to_ounce_equivalent_spot() -> None: + quote = PricePerAsset(amount=Decimal("404.19"), currency=BaseCurrency.USD, symbol="GLD") + + spot = price_per_weight_from_asset_price(quote, per_unit=WeightUnit.OUNCE_TROY) + + assert spot.amount == Decimal("4041.9") + assert spot.currency is BaseCurrency.USD + assert spot.per_unit is WeightUnit.OUNCE_TROY + + +def test_instrument_conversions_fail_closed_for_unsupported_symbols() -> None: + quote = PricePerAsset(amount=Decimal("28.50"), currency=BaseCurrency.USD, symbol="SLV") + + with pytest.raises(ValueError, match="Unsupported instrument metadata"): + price_per_weight_from_asset_price(quote, per_unit=WeightUnit.OUNCE_TROY) + + with pytest.raises(ValueError, match="Unsupported instrument metadata"): + weight_from_asset_quantity(AssetQuantity(amount=Decimal("10"), symbol="SLV")) + + with pytest.raises(ValueError, match="Unsupported instrument metadata"): + asset_quantity_from_weight("SLV", Weight(amount=Decimal("1"), unit=WeightUnit.OUNCE_TROY)) diff --git a/tests/test_overview_workspace.py b/tests/test_overview_workspace.py index 7eb6e8e..8c8f5af 100644 --- a/tests/test_overview_workspace.py +++ b/tests/test_overview_workspace.py @@ -1,16 +1,22 @@ from __future__ import annotations +from app.domain.portfolio_math import resolve_portfolio_spot_from_quote from app.models.portfolio import PortfolioConfig -from app.pages.overview import _resolve_overview_spot from app.services.alerts import build_portfolio_alert_context -def test_overview_falls_back_to_configured_entry_price_when_live_quote_units_do_not_match() -> None: +def test_overview_converts_gld_share_quote_to_ounce_equivalent_spot() -> None: config = PortfolioConfig(entry_price=4400.0, gold_ounces=220.0, entry_basis_mode="weight", loan_amount=145000.0) - spot_price, source, updated_at = _resolve_overview_spot( + spot_price, source, updated_at = resolve_portfolio_spot_from_quote( config, - {"price": 404.19, "source": "yfinance", "updated_at": "2026-03-24T00:00:00+00:00"}, + { + "symbol": "GLD", + "price": 404.19, + "quote_unit": "share", + "source": "yfinance", + "updated_at": "2026-03-24T00:00:00+00:00", + }, ) portfolio = build_portfolio_alert_context( config, @@ -19,8 +25,90 @@ def test_overview_falls_back_to_configured_entry_price_when_live_quote_units_do_ updated_at=updated_at, ) + assert spot_price == 4041.9 + assert source == "yfinance" + assert updated_at == "2026-03-24T00:00:00+00:00" + assert portfolio["gold_value"] == 889218.0 + assert portfolio["net_equity"] == 744218.0 + assert round(float(portfolio["margin_call_price"]), 2) == 878.79 + + +def test_overview_fails_closed_to_configured_entry_price_for_unsupported_quote_symbol() -> None: + config = PortfolioConfig(entry_price=4400.0, gold_ounces=220.0, entry_basis_mode="weight", loan_amount=145000.0) + + spot_price, source, updated_at = resolve_portfolio_spot_from_quote( + config, + { + "symbol": "SLV", + "price": 28.50, + "quote_unit": "share", + "source": "yfinance", + "updated_at": "2026-03-24T00:00:00+00:00", + }, + ) + assert spot_price == 4400.0 assert source == "configured_entry_price" - assert portfolio["gold_value"] == 968000.0 - assert portfolio["net_equity"] == 823000.0 - assert round(float(portfolio["margin_call_price"]), 2) == 878.79 + assert updated_at == "" + + +def test_overview_uses_fallback_symbol_when_quote_payload_omits_symbol() -> None: + config = PortfolioConfig(entry_price=4400.0, gold_ounces=220.0, entry_basis_mode="weight", loan_amount=145000.0) + + spot_price, source, updated_at = resolve_portfolio_spot_from_quote( + config, + {"price": 404.19, "quote_unit": "share", "source": "yfinance", "updated_at": "2026-03-24T00:00:00+00:00"}, + fallback_symbol="GLD", + ) + + assert spot_price == 4041.9 + assert source == "yfinance" + assert updated_at == "2026-03-24T00:00:00+00:00" + + +def test_overview_falls_back_when_quote_price_is_missing_or_invalid() -> None: + config = PortfolioConfig(entry_price=4400.0, gold_ounces=220.0, entry_basis_mode="weight", loan_amount=145000.0) + + missing_price = resolve_portfolio_spot_from_quote( + config, + {"symbol": "GLD", "quote_unit": "share", "source": "yfinance", "updated_at": "2026-03-24T00:00:00+00:00"}, + fallback_symbol="GLD", + ) + zero_price = resolve_portfolio_spot_from_quote( + config, + { + "symbol": "GLD", + "price": 0.0, + "quote_unit": "share", + "source": "yfinance", + "updated_at": "2026-03-24T00:00:00+00:00", + }, + fallback_symbol="GLD", + ) + bad_string_price = resolve_portfolio_spot_from_quote( + config, + { + "symbol": "GLD", + "price": "not-a-number", + "quote_unit": "share", + "source": "yfinance", + "updated_at": "2026-03-24T00:00:00+00:00", + }, + fallback_symbol="GLD", + ) + + assert missing_price == (4400.0, "configured_entry_price", "") + assert zero_price == (4400.0, "configured_entry_price", "") + assert bad_string_price == (4400.0, "configured_entry_price", "") + + +def test_overview_falls_back_when_quote_unit_metadata_is_missing() -> None: + config = PortfolioConfig(entry_price=4400.0, gold_ounces=220.0, entry_basis_mode="weight", loan_amount=145000.0) + + result = resolve_portfolio_spot_from_quote( + config, + {"symbol": "GLD", "price": 404.19, "source": "yfinance", "updated_at": "2026-03-24T00:00:00+00:00"}, + fallback_symbol="GLD", + ) + + assert result == (4400.0, "configured_entry_price", "") diff --git a/tests/test_portfolio_math.py b/tests/test_portfolio_math.py index 755b0ad..2e340b1 100644 --- a/tests/test_portfolio_math.py +++ b/tests/test_portfolio_math.py @@ -1,12 +1,16 @@ from __future__ import annotations +from decimal import Decimal + +from app.domain.backtesting_math import PricePerAsset +from app.domain.instruments import price_per_weight_from_asset_price from app.domain.portfolio_math import ( build_alert_context, portfolio_snapshot_from_config, strategy_metrics_from_snapshot, ) +from app.domain.units import BaseCurrency, WeightUnit from app.models.portfolio import PortfolioConfig -from app.pages.common import strategy_catalog def test_portfolio_snapshot_from_config_preserves_weight_price_and_margin_values() -> None: @@ -37,7 +41,27 @@ def test_build_alert_context_uses_unit_safe_gold_value_calculation() -> None: assert round(float(context["margin_call_price"]), 2) == 878.79 +def test_build_alert_context_accepts_explicit_gld_share_quote_conversion() -> None: + config = PortfolioConfig(entry_price=4400.0, gold_ounces=220.0, entry_basis_mode="weight", loan_amount=145000.0) + share_quote = PricePerAsset(amount=Decimal("404.19"), currency=BaseCurrency.USD, symbol="GLD") + ounce_spot = price_per_weight_from_asset_price(share_quote, per_unit=WeightUnit.OUNCE_TROY) + + context = build_alert_context( + config, + spot_price=float(ounce_spot.amount), + source="yfinance", + updated_at="2026-03-24T00:00:00+00:00", + ) + + assert context["spot_price"] == 4041.9 + assert context["gold_value"] == 889218.0 + assert context["net_equity"] == 744218.0 + assert context["quote_source"] == "yfinance" + + def test_strategy_metrics_from_snapshot_preserves_minus_20pct_protective_put_example() -> None: + from app.pages.common import strategy_catalog + strategy = next(item for item in strategy_catalog() if item["name"] == "protective_put_atm") snapshot = { "gold_value": 215000.0,