diff --git a/app/pages/common.py b/app/pages/common.py index 5095c12..2ac37f0 100644 --- a/app/pages/common.py +++ b/app/pages/common.py @@ -23,6 +23,10 @@ def nav_items(workspace_id: str | None = None) -> list[tuple[str, str, str]]: return NAV_ITEMS return [ ("overview", f"/{workspace_id}", "Overview"), + ("hedge", "/hedge", "Hedge Analysis"), + ("options", "/options", "Options Chain"), + ("backtests", "/backtests", "Backtests"), + ("event-comparison", "/event-comparison", "Event Comparison"), ("settings", f"/{workspace_id}/settings", "Settings"), ] diff --git a/app/pages/overview.py b/app/pages/overview.py index 0c57fd3..a3e77b4 100644 --- a/app/pages/overview.py +++ b/app/pages/overview.py @@ -15,6 +15,22 @@ from app.services.runtime import get_data_service _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 _format_timestamp(value: str | None) -> str: if not value: return "Unavailable" @@ -96,20 +112,24 @@ 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) portfolio = build_portfolio_alert_context( config, - spot_price=float(quote.get("price", float(config.entry_price or 0.0))), - source=str(quote.get("source", "unknown")), - updated_at=str(quote.get("updated_at", "")), + spot_price=overview_spot_price, + source=overview_source, + updated_at=overview_updated_at, ) configured_gold_value = float(config.gold_value or 0.0) portfolio["cash_buffer"] = max(float(portfolio["gold_value"]) - configured_gold_value, 0.0) + _DEFAULT_CASH_BUFFER portfolio["hedge_budget"] = float(config.monthly_budget) alert_status = AlertService().evaluate(config, portfolio) - quote_status = ( - f"Live quote source: {portfolio['quote_source']} · " - f"Last updated {_format_timestamp(str(portfolio['quote_updated_at']))}" - ) + if portfolio["quote_source"] == "configured_entry_price": + quote_status = "Live quote source: configured entry price fallback · Last updated Unavailable" + else: + quote_status = ( + f"Live quote source: {portfolio['quote_source']} · " + f"Last updated {_format_timestamp(str(portfolio['quote_updated_at']))}" + ) with dashboard_page( "Overview", diff --git a/tests/test_e2e_playwright.py b/tests/test_e2e_playwright.py index 54ce6fa..5f10e0c 100644 --- a/tests/test_e2e_playwright.py +++ b/tests/test_e2e_playwright.py @@ -97,17 +97,34 @@ def test_homepage_and_options_page_render() -> None: expect(page.locator("text=Settings").first).to_be_visible(timeout=15000) expect(page.locator("text=Collateral entry basis").first).to_be_visible(timeout=15000) expect(page.locator("text=Entry price ($/oz)").first).to_be_visible(timeout=15000) + + page.get_by_label("Collateral entry basis").click() + page.get_by_text("Gold weight + entry price", exact=True).click() + page.get_by_label("Entry price ($/oz)").fill("4400") + page.get_by_label("Gold weight (oz)").fill("220") budget_input = page.get_by_label("Monthly hedge budget ($)") budget_input.fill("12345") page.get_by_role("button", name="Save settings").click() expect(page.locator("text=Settings saved successfully").first).to_be_visible(timeout=15000) page.reload(wait_until="domcontentloaded", timeout=30000) expect(page.get_by_label("Monthly hedge budget ($)")).to_have_value("12345") + expect(page.get_by_label("Entry price ($/oz)")).to_have_value("4400") settings_text = page.locator("body").inner_text(timeout=15000) assert "RuntimeError" not in settings_text assert "Server error" not in settings_text page.screenshot(path=str(ARTIFACTS / "settings.png"), full_page=True) + page.goto(workspace_url, wait_until="domcontentloaded", timeout=30000) + overview_text = page.locator("body").inner_text(timeout=15000) + assert "Hedge Analysis" in overview_text + 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 "$878.79" in overview_text + assert "$968,000.00" in overview_text + assert "$823,000.00" in overview_text + second_context = browser.new_context(viewport={"width": 1440, "height": 1000}) second_page = second_context.new_page() second_page.goto(BASE_URL, wait_until="domcontentloaded", timeout=30000) diff --git a/tests/test_overview_workspace.py b/tests/test_overview_workspace.py new file mode 100644 index 0000000..7eb6e8e --- /dev/null +++ b/tests/test_overview_workspace.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +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: + 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( + config, + {"price": 404.19, "source": "yfinance", "updated_at": "2026-03-24T00:00:00+00:00"}, + ) + portfolio = build_portfolio_alert_context( + config, + spot_price=spot_price, + source=source, + updated_at=updated_at, + ) + + 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