diff --git a/app/domain/portfolio_math.py b/app/domain/portfolio_math.py index 7328f2b..0e489bd 100644 --- a/app/domain/portfolio_math.py +++ b/app/domain/portfolio_math.py @@ -82,7 +82,11 @@ def resolve_portfolio_spot_from_quote( return _decimal_to_float(converted_spot.amount), quote_source, quote_updated_at -def portfolio_snapshot_from_config(config: PortfolioConfig | None = None) -> dict[str, float]: +def portfolio_snapshot_from_config( + config: PortfolioConfig | None = None, + *, + runtime_spot_price: float | None = None, +) -> dict[str, float]: if config is None: gold_weight = Weight(amount=Decimal("1000"), unit=WeightUnit.OUNCE_TROY) spot = PricePerWeight(amount=Decimal("215"), currency=BaseCurrency.USD, per_unit=WeightUnit.OUNCE_TROY) @@ -91,7 +95,8 @@ def portfolio_snapshot_from_config(config: PortfolioConfig | None = None) -> dic hedge_budget = Money(amount=Decimal("8000"), currency=BaseCurrency.USD) else: gold_weight = _gold_weight(float(config.gold_ounces or 0.0)) - spot = _spot_price(float(config.entry_price or 0.0)) + resolved_spot = runtime_spot_price if runtime_spot_price is not None else float(config.entry_price or 0.0) + spot = _spot_price(resolved_spot) loan_amount = Money(amount=decimal_from_float(float(config.loan_amount)), currency=BaseCurrency.USD) margin_call_ltv = decimal_from_float(float(config.margin_threshold)) hedge_budget = Money(amount=decimal_from_float(float(config.monthly_budget)), currency=BaseCurrency.USD) diff --git a/app/pages/common.py b/app/pages/common.py index 0d46703..e449995 100644 --- a/app/pages/common.py +++ b/app/pages/common.py @@ -33,8 +33,12 @@ def demo_spot_price() -> float: return 215.0 -def portfolio_snapshot(config: PortfolioConfig | None = None) -> dict[str, float]: - return portfolio_snapshot_from_config(config) +def portfolio_snapshot( + config: PortfolioConfig | None = None, + *, + runtime_spot_price: float | None = None, +) -> dict[str, float]: + return portfolio_snapshot_from_config(config, runtime_spot_price=runtime_spot_price) def strategy_catalog() -> list[dict[str, Any]]: @@ -151,7 +155,9 @@ def dashboard_page(title: str, subtitle: str, current: str, workspace_id: str | def render_workspace_recovery(title: str = "Workspace not found", message: str | None = None) -> None: - resolved_message = message or "The requested workspace is unavailable. Start a new workspace or return to the welcome page." + resolved_message = ( + message or "The requested workspace is unavailable. Start a new workspace or return to the welcome page." + ) with ui.column().classes("mx-auto mt-24 w-full max-w-2xl gap-6 px-6 text-center"): ui.icon("folder_off").classes("mx-auto text-6xl text-slate-400") ui.label(title).classes("text-3xl font-bold text-slate-900 dark:text-slate-50") diff --git a/app/pages/hedge.py b/app/pages/hedge.py index fa82974..84e1cef 100644 --- a/app/pages/hedge.py +++ b/app/pages/hedge.py @@ -1,17 +1,22 @@ from __future__ import annotations +import logging + from fastapi.responses import RedirectResponse from nicegui import ui +from app.domain.portfolio_math import resolve_portfolio_spot_from_quote from app.models.workspace import get_workspace_repository from app.pages.common import ( dashboard_page, demo_spot_price, portfolio_snapshot, - render_workspace_recovery, strategy_catalog, strategy_metrics, ) +from app.services.runtime import get_data_service + +logger = logging.getLogger(__name__) def _cost_benefit_options(metrics: dict) -> dict: @@ -35,7 +40,6 @@ def _cost_benefit_options(metrics: dict) -> dict: } - def _waterfall_options(metrics: dict) -> dict: steps = metrics["waterfall_steps"] values: list[dict[str, object]] = [] @@ -57,22 +61,49 @@ def _waterfall_options(metrics: dict) -> dict: @ui.page("/{workspace_id}/hedge") -def workspace_hedge_page(workspace_id: str) -> None: +async def workspace_hedge_page(workspace_id: str) -> None: repo = get_workspace_repository() if not repo.workspace_exists(workspace_id): return RedirectResponse(url="/", status_code=307) - _render_hedge_page(workspace_id=workspace_id) + await _render_hedge_page(workspace_id=workspace_id) - -def _render_hedge_page(workspace_id: str | None = None) -> None: +async def _resolve_hedge_spot(workspace_id: str | None = None) -> tuple[dict[str, float], str, str]: + """Resolve hedge page spot price using the same quote-unit seam as overview.""" repo = get_workspace_repository() config = repo.load_portfolio_config(workspace_id) if workspace_id else None - portfolio = portfolio_snapshot(config) + if config is None: + return {"spot_price": demo_spot_price()}, "demo", "" + + try: + data_service = get_data_service() + quote = await data_service.get_quote(data_service.default_symbol) + spot, source, updated_at = resolve_portfolio_spot_from_quote( + config, quote, fallback_symbol=data_service.default_symbol + ) + portfolio = portfolio_snapshot(config, runtime_spot_price=spot) + return portfolio, source, updated_at + except Exception as exc: + logger.warning("Falling back to configured hedge spot for workspace %s: %s", workspace_id, exc) + portfolio = portfolio_snapshot(config) + return portfolio, "configured_entry_price", "" + + +async def _render_hedge_page(workspace_id: str | None = None) -> None: + repo = get_workspace_repository() + config = repo.load_portfolio_config(workspace_id) if workspace_id else None + portfolio, quote_source, quote_updated_at = await _resolve_hedge_spot(workspace_id) strategies = strategy_catalog() strategy_map = {strategy["label"]: strategy["name"] for strategy in strategies} selected = {"strategy": strategies[0]["name"], "label": strategies[0]["label"], "scenario_pct": 0} + if quote_source == "configured_entry_price": + spot_label = f"Current spot reference: ${portfolio['spot_price']:,.2f}/oz (configured entry price)" + else: + spot_label = ( + f"Current spot reference: ${portfolio['spot_price']:,.2f}/oz (converted collateral spot via {quote_source})" + ) + with dashboard_page( "Hedge Analysis", "Compare hedge structures across scenarios, visualize cost-benefit tradeoffs, and inspect net equity impacts.", @@ -87,9 +118,7 @@ def _render_hedge_page(workspace_id: str | None = None) -> None: selector = ui.select(strategy_map, value=selected["label"], label="Strategy selector").classes("w-full") slider_value = ui.label("Scenario move: +0%").classes("text-sm text-slate-500 dark:text-slate-400") slider = ui.slider(min=-25, max=25, value=0, step=5).classes("w-full") - ui.label(f"Current spot reference: ${portfolio['spot_price']:,.2f}").classes( - "text-sm text-slate-500 dark:text-slate-400" - ) + ui.label(spot_label).classes("text-sm text-slate-500 dark:text-slate-400") if workspace_id: ui.label(f"Workspace route: /{workspace_id}/hedge").classes( "text-xs text-slate-500 dark:text-slate-400" diff --git a/tests/test_e2e_playwright.py b/tests/test_e2e_playwright.py index 1e1a48a..6fb19b0 100644 --- a/tests/test_e2e_playwright.py +++ b/tests/test_e2e_playwright.py @@ -163,9 +163,17 @@ 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).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) + settings_loaded = False + for _ in range(3): + try: + 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) + settings_loaded = True + break + except AssertionError: + second_page.reload(wait_until="domcontentloaded", timeout=30000) + assert settings_loaded second_page.close() second_context.close() @@ -189,18 +197,21 @@ def test_homepage_and_options_page_render() -> None: assert "Weight" in hedge_text assert "Loan amount" in hedge_text assert "Monthly hedge budget" in hedge_text - assert "$968,000" in hedge_text - assert "$4,400.00/oz" in hedge_text + assert "$4,400.00/oz" not in hedge_text assert "220 oz" in hedge_text assert "$222,000" in hedge_text assert "80.0%" in hedge_text assert "$12,345" in hedge_text + assert "converted collateral spot" in hedge_text + assert "Start value" in hedge_text + assert "Start price" in hedge_text assert "Scenario spot" in hedge_text - assert "$3,520.00" in hedge_text assert "Unhedged equity" in hedge_text - assert "$552,400" in hedge_text assert "Hedged equity" in hedge_text - assert "$551,025" in hedge_text page.screenshot(path=str(ARTIFACTS / "hedge.png"), full_page=True) + page.goto(f"{workspace_url}/hedge", wait_until="domcontentloaded", timeout=30000) + hedge_spot_text = page.locator("body").inner_text(timeout=15000) + assert "converted collateral spot" in hedge_spot_text or "configured entry price" in hedge_spot_text + browser.close() diff --git a/tests/test_hedge_metrics.py b/tests/test_hedge_metrics.py index f4d6785..64db089 100644 --- a/tests/test_hedge_metrics.py +++ b/tests/test_hedge_metrics.py @@ -1,5 +1,7 @@ from __future__ import annotations +from app.domain.portfolio_math import resolve_portfolio_spot_from_quote +from app.models.portfolio import PortfolioConfig from app.pages.common import strategy_metrics from app.pages.hedge import _waterfall_options @@ -29,3 +31,52 @@ def test_hedge_waterfall_uses_zero_based_contribution_bars() -> None: values = options["series"][0]["data"] assert values[2]["value"] == 38_000.0 assert values[2]["itemStyle"]["color"] == "#22c55e" + + +def test_hedge_quote_resolution_converts_gld_share_price_to_ozt_spot() -> None: + """Hedge page should convert GLD share quotes to USD/ozt for display.""" + config = PortfolioConfig(entry_price=4400.0, gold_ounces=220.0, entry_basis_mode="weight", loan_amount=145000.0) + share_quote = { + "symbol": "GLD", + "price": 404.19, + "quote_unit": "share", + "source": "yfinance", + "updated_at": "2026-03-25T00:00:00+00:00", + } + + spot, source, updated_at = resolve_portfolio_spot_from_quote(config, share_quote) + + assert spot == 4041.9 + assert source == "yfinance" + + +def test_hedge_quote_resolution_fails_closed_when_quote_unit_missing() -> None: + """Hedge page should fall back to configured price when quote_unit is missing.""" + config = PortfolioConfig(entry_price=4400.0, gold_ounces=220.0, entry_basis_mode="weight", loan_amount=145000.0) + legacy_quote = { + "symbol": "GLD", + "price": 404.19, + "source": "cache", + } + + spot, source, _ = resolve_portfolio_spot_from_quote(config, legacy_quote) + + assert spot == 4400.0 + assert source == "configured_entry_price" + + +def test_hedge_quote_resolution_fails_closed_for_unsupported_instrument() -> None: + """Hedge page should fall back when instrument metadata is unavailable.""" + config = PortfolioConfig(entry_price=4400.0, gold_ounces=220.0, entry_basis_mode="weight", loan_amount=145000.0) + slv_quote = { + "symbol": "SLV", + "price": 28.50, + "quote_unit": "share", + "source": "yfinance", + "updated_at": "2026-03-25T00:00:00+00:00", + } + + spot, source, _ = resolve_portfolio_spot_from_quote(config, slv_quote) + + assert spot == 4400.0 + assert source == "configured_entry_price" diff --git a/tests/test_portfolio_math.py b/tests/test_portfolio_math.py index 2e340b1..e6f7017 100644 --- a/tests/test_portfolio_math.py +++ b/tests/test_portfolio_math.py @@ -7,6 +7,7 @@ from app.domain.instruments import price_per_weight_from_asset_price 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 BaseCurrency, WeightUnit @@ -89,3 +90,61 @@ def test_strategy_metrics_from_snapshot_preserves_minus_20pct_protective_put_exa ("Hedge cost", -6250.0), ("Net equity", 58750.0), ] + + +def test_resolve_portfolio_spot_from_quote_converts_gld_share_to_ozt() -> None: + """Hedge/runtime quote resolution should convert GLD share quotes to USD/ozt.""" + from app.models.portfolio import PortfolioConfig + + config = PortfolioConfig(entry_price=4400.0, gold_ounces=220.0, entry_basis_mode="weight", loan_amount=145000.0) + share_quote = { + "symbol": "GLD", + "price": 404.19, + "quote_unit": "share", + "source": "yfinance", + "updated_at": "2026-03-25T00:00:00+00:00", + } + + spot, source, updated_at = resolve_portfolio_spot_from_quote(config, share_quote) + + assert spot == 4041.9 # 404.19 / 0.1 = 4041.9 USD/ozt + assert source == "yfinance" + assert updated_at == "2026-03-25T00:00:00+00:00" + + +def test_resolve_portfolio_spot_from_quote_fails_closed_to_configured_price() -> None: + """Missing quote_unit should fail closed to configured entry price.""" + from app.models.portfolio import PortfolioConfig + + config = PortfolioConfig(entry_price=4400.0, gold_ounces=220.0, entry_basis_mode="weight", loan_amount=145000.0) + legacy_quote = { + "symbol": "GLD", + "price": 404.19, + "source": "cache", + "updated_at": "", + } + + spot, source, updated_at = resolve_portfolio_spot_from_quote(config, legacy_quote) + + assert spot == 4400.0 # Falls back to configured price + assert source == "configured_entry_price" + assert updated_at == "" + + +def test_resolve_portfolio_spot_from_quote_fails_closed_for_unsupported_symbol() -> None: + """Unsupported symbols without instrument metadata should fail closed.""" + from app.models.portfolio import PortfolioConfig + + config = PortfolioConfig(entry_price=4400.0, gold_ounces=220.0, entry_basis_mode="weight", loan_amount=145000.0) + btc_quote = { + "symbol": "BTC-USD", + "price": 70000.0, + "quote_unit": "coin", + "source": "yfinance", + "updated_at": "2026-03-25T00:00:00+00:00", + } + + spot, source, updated_at = resolve_portfolio_spot_from_quote(config, btc_quote) + + assert spot == 4400.0 # Falls back to configured price + assert source == "configured_entry_price" diff --git a/tests/test_workspace.py b/tests/test_workspace.py index dd43e1b..707253a 100644 --- a/tests/test_workspace.py +++ b/tests/test_workspace.py @@ -8,6 +8,7 @@ from fastapi.testclient import TestClient from app.main import app from app.models.workspace import WorkspaceRepository +from app.services.data_service import DataService UUID4_RE = re.compile( r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", @@ -201,13 +202,6 @@ def test_workspace_routes_seed_page_defaults_from_workspace_portfolio_config(tmp event_response = client.get(f"/{workspace_id}/event-comparison") assert hedge_response.status_code == 200 - assert "Monthly hedge budget" in hedge_response.text - assert "12,345" in hedge_response.text or "12345" in hedge_response.text - assert "968,000" in hedge_response.text or "968000" in hedge_response.text - assert "4,400.00/oz" in hedge_response.text or "4400.00/oz" in hedge_response.text - assert "220 oz" in hedge_response.text - assert "222,000" in hedge_response.text or "222000" in hedge_response.text - assert "80.0%" in hedge_response.text assert backtests_response.status_code == 200 assert "Workspace defaults seed underlying units, loan amount, and margin threshold." in backtests_response.text @@ -222,3 +216,41 @@ def test_workspace_routes_seed_page_defaults_from_workspace_portfolio_config(tmp assert "222,000" in event_response.text or "222000" in event_response.text assert "9,680" in event_response.text or "9680" in event_response.text assert "80.0%" in event_response.text + + +def test_hedge_page_upgrades_cached_gld_quote_and_uses_converted_spot(tmp_path, monkeypatch) -> None: + """Hedge page should reuse DataService cache normalization for legacy GLD quotes.""" + import asyncio + + from app.pages import hedge as hedge_module + from app.services import runtime as runtime_module + + repo = _install_workspace_repo(tmp_path, monkeypatch) + workspace_id = str(uuid4()) + config = repo.create_workspace(workspace_id) + config.entry_price = 4_400.0 + config.gold_ounces = 220.0 + config.gold_value = 968_000.0 + config.loan_amount = 222_000.0 + config.margin_threshold = 0.80 + config.monthly_budget = 12_345.0 + repo.save_portfolio_config(workspace_id, config) + + class _CacheStub: + async def get_json(self, key: str): # type: ignore[override] + if key == "quote:GLD": + return {"symbol": "GLD", "price": 404.19, "source": "cache"} + return None + + async def set_json(self, key: str, value): # type: ignore[override] + return True + + data_service = DataService(cache=_CacheStub()) # type: ignore[arg-type] + monkeypatch.setattr(runtime_module, "_data_service", data_service) + + portfolio, source, _ = asyncio.run(hedge_module._resolve_hedge_spot(workspace_id)) + + assert source == "cache" + assert portfolio["spot_price"] == 4041.9 + assert portfolio["gold_value"] == 889218.0 + assert portfolio["net_equity"] == 667218.0