diff --git a/app/pages/backtests.py b/app/pages/backtests.py index eacf0ad..0ae9eec 100644 --- a/app/pages/backtests.py +++ b/app/pages/backtests.py @@ -2,9 +2,12 @@ from __future__ import annotations from datetime import date, datetime +from fastapi import Request +from fastapi.responses import RedirectResponse from nicegui import ui -from app.pages.common import dashboard_page +from app.models.workspace import WORKSPACE_COOKIE, get_workspace_repository +from app.pages.common import dashboard_page, render_workspace_recovery from app.services.backtesting.ui_service import BacktestPageRunResult, BacktestPageService @@ -47,16 +50,45 @@ def _chart_options(result: BacktestPageRunResult) -> dict: @ui.page("/backtests") -def backtests_page() -> None: +def legacy_backtests_page(request: Request): + repo = get_workspace_repository() + workspace_id = request.cookies.get(WORKSPACE_COOKIE, "") + if workspace_id and repo.workspace_exists(workspace_id): + return RedirectResponse(url=f"/{workspace_id}/backtests", status_code=307) + _render_backtests_page() + + +@ui.page("/{workspace_id}/backtests") +def workspace_backtests_page(workspace_id: str) -> None: + repo = get_workspace_repository() + if not repo.workspace_exists(workspace_id): + render_workspace_recovery() + return + _render_backtests_page(workspace_id=workspace_id) + + + +def _render_backtests_page(workspace_id: str | None = None) -> None: service = BacktestPageService() template_options = service.template_options("GLD") select_options = {str(option["slug"]): str(option["label"]) for option in template_options} default_template_slug = str(template_options[0]["slug"]) if template_options else None + repo = get_workspace_repository() + config = repo.load_portfolio_config(workspace_id) if workspace_id else None + default_entry_spot = service.derive_entry_spot("GLD", date(2024, 1, 2), date(2024, 1, 8)) + default_units = ( + float(config.gold_value or 0.0) / default_entry_spot + if config and config.gold_value is not None and default_entry_spot > 0 + else 1000.0 + ) + default_loan = float(config.loan_amount) if config else 68000.0 + default_margin_call_ltv = float(config.margin_threshold) if config else 0.75 with dashboard_page( "Backtests", "Run a thin read-only historical scenario over the BT-001 synthetic engine and inspect the daily path.", "backtests", + workspace_id=workspace_id, ): with ui.row().classes("w-full gap-6 max-lg:flex-col"): with ui.card().classes( @@ -69,15 +101,25 @@ def backtests_page() -> None: ui.label("BT-001A currently supports GLD only for this thin read-only page.").classes( "text-sm text-slate-500 dark:text-slate-400" ) + if workspace_id: + ui.label("Workspace defaults seed underlying units, loan amount, and margin threshold.").classes( + "text-sm text-slate-500 dark:text-slate-400" + ) symbol_input = ui.input("Symbol", value="GLD").props("readonly").classes("w-full") start_input = ui.input("Start date", value="2024-01-02").classes("w-full") end_input = ui.input("End date", value="2024-01-08").classes("w-full") template_select = ui.select(select_options, value=default_template_slug, label="Template").classes( "w-full" ) - units_input = ui.number("Underlying units", value=1000.0, min=0.0001, step=1).classes("w-full") - loan_input = ui.number("Loan amount", value=68000.0, min=0, step=1000).classes("w-full") - ltv_input = ui.number("Margin call LTV", value=0.75, min=0.01, max=0.99, step=0.01).classes("w-full") + units_input = ui.number("Underlying units", value=default_units, min=0.0001, step=1).classes("w-full") + loan_input = ui.number("Loan amount", value=default_loan, min=0, step=1000).classes("w-full") + ltv_input = ui.number( + "Margin call LTV", + value=default_margin_call_ltv, + min=0.01, + max=0.99, + step=0.01, + ).classes("w-full") entry_spot_hint = ui.label("Entry spot will be auto-derived on run.").classes( "text-sm text-slate-500 dark:text-slate-400" ) diff --git a/app/pages/common.py b/app/pages/common.py index 2ac37f0..cc387f7 100644 --- a/app/pages/common.py +++ b/app/pages/common.py @@ -6,6 +6,7 @@ from typing import Any from nicegui import ui +from app.models.portfolio import PortfolioConfig from app.services.strategy_templates import StrategyTemplateService NAV_ITEMS: list[tuple[str, str, str]] = [ @@ -23,10 +24,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"), + ("hedge", f"/{workspace_id}/hedge", "Hedge Analysis"), ("options", "/options", "Options Chain"), - ("backtests", "/backtests", "Backtests"), - ("event-comparison", "/event-comparison", "Event Comparison"), + ("backtests", f"/{workspace_id}/backtests", "Backtests"), + ("event-comparison", f"/{workspace_id}/event-comparison", "Event Comparison"), ("settings", f"/{workspace_id}/settings", "Settings"), ] @@ -35,22 +36,33 @@ def demo_spot_price() -> float: return 215.0 -def portfolio_snapshot() -> dict[str, float]: - gold_units = 1_000.0 - spot = demo_spot_price() - gold_value = gold_units * spot - loan_amount = 145_000.0 - margin_call_ltv = 0.75 +def portfolio_snapshot(config: PortfolioConfig | None = None) -> dict[str, float]: + if config is None: + gold_units = 1_000.0 + spot = demo_spot_price() + gold_value = gold_units * spot + loan_amount = 145_000.0 + margin_call_ltv = 0.75 + hedge_budget = 8_000.0 + else: + gold_units = float(config.gold_ounces or 0.0) + spot = float(config.entry_price or 0.0) + gold_value = float(config.gold_value or gold_units * spot) + loan_amount = float(config.loan_amount) + margin_call_ltv = float(config.margin_threshold) + hedge_budget = float(config.monthly_budget) + return { "gold_value": gold_value, "loan_amount": loan_amount, "ltv_ratio": loan_amount / gold_value, "net_equity": gold_value - loan_amount, "spot_price": spot, + "gold_units": gold_units, "margin_call_ltv": margin_call_ltv, "margin_call_price": loan_amount / (margin_call_ltv * gold_units), "cash_buffer": 18_500.0, - "hedge_budget": 8_000.0, + "hedge_budget": hedge_budget, } @@ -122,12 +134,17 @@ def option_chain() -> list[dict[str, Any]]: return rows -def strategy_metrics(strategy_name: str, scenario_pct: int) -> dict[str, Any]: +def strategy_metrics( + strategy_name: str, + scenario_pct: int, + *, + portfolio: dict[str, Any] | None = None, +) -> dict[str, Any]: strategy = next( (item for item in strategy_catalog() if item["name"] == strategy_name), strategy_catalog()[0], ) - portfolio = portfolio_snapshot() + portfolio = portfolio or portfolio_snapshot() spot = float(portfolio["spot_price"]) underlying_units = portfolio["gold_value"] / spot loan_amount = float(portfolio["loan_amount"]) @@ -209,6 +226,21 @@ def dashboard_page(title: str, subtitle: str, current: str, workspace_id: str | yield container +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." + 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") + ui.label(resolved_message).classes("text-base text-slate-500 dark:text-slate-400") + with ui.row().classes("mx-auto gap-3"): + ui.link("Get started", "/workspaces/bootstrap").classes( + "rounded-lg bg-slate-900 px-5 py-3 text-sm font-semibold text-white no-underline dark:bg-slate-100 dark:text-slate-900" + ) + ui.link("Go to welcome page", "/").classes( + "rounded-lg border border-slate-300 px-5 py-3 text-sm font-semibold text-slate-700 no-underline dark:border-slate-700 dark:text-slate-200" + ) + + def recommendation_style(tone: str) -> str: return { "positive": "border-emerald-200 bg-emerald-50 dark:border-emerald-900/60 dark:bg-emerald-950/30", diff --git a/app/pages/event_comparison.py b/app/pages/event_comparison.py index 2e01f76..2958430 100644 --- a/app/pages/event_comparison.py +++ b/app/pages/event_comparison.py @@ -1,8 +1,11 @@ from __future__ import annotations +from fastapi import Request +from fastapi.responses import RedirectResponse from nicegui import ui -from app.pages.common import dashboard_page +from app.models.workspace import WORKSPACE_COOKIE, get_workspace_repository +from app.pages.common import dashboard_page, render_workspace_recovery from app.services.event_comparison_ui import EventComparisonPageService @@ -25,21 +28,59 @@ def _chart_options(dates: tuple[str, ...], series: tuple[dict[str, object], ...] @ui.page("/event-comparison") -def event_comparison_page() -> None: +def legacy_event_comparison_page(request: Request): + repo = get_workspace_repository() + workspace_id = request.cookies.get(WORKSPACE_COOKIE, "") + if workspace_id and repo.workspace_exists(workspace_id): + return RedirectResponse(url=f"/{workspace_id}/event-comparison", status_code=307) + _render_event_comparison_page() + + +@ui.page("/{workspace_id}/event-comparison") +def workspace_event_comparison_page(workspace_id: str) -> None: + repo = get_workspace_repository() + if not repo.workspace_exists(workspace_id): + render_workspace_recovery() + return + _render_event_comparison_page(workspace_id=workspace_id) + + +def _render_event_comparison_page(workspace_id: str | None = None) -> None: service = EventComparisonPageService() preset_options = service.preset_options("GLD") template_options = service.template_options("GLD") + repo = get_workspace_repository() + config = repo.load_portfolio_config(workspace_id) if workspace_id else None + default_preset_slug = str(preset_options[0]["slug"]) if preset_options else None + default_template_slugs = list(preset_options[0]["default_template_slugs"]) if preset_options else [] + default_entry_spot = 100.0 + if default_preset_slug is not None: + default_preview = service.preview_scenario( + preset_slug=default_preset_slug, + template_slugs=tuple(default_template_slugs), + underlying_units=1.0, + loan_amount=0.0, + margin_call_ltv=0.75, + ) + default_entry_spot = default_preview.initial_portfolio.entry_spot + default_units = ( + float(config.gold_value or 0.0) / default_entry_spot + if config and config.gold_value is not None and default_entry_spot > 0 + else 1000.0 + ) + + default_loan = float(config.loan_amount) if config else 68000.0 + default_margin_call_ltv = float(config.margin_threshold) if config else 0.75 preset_select_options = {str(option["slug"]): str(option["label"]) for option in preset_options} template_select_options = {str(option["slug"]): str(option["label"]) for option in template_options} - default_preset_slug = str(preset_options[0]["slug"]) if preset_options else None - default_template_slugs = list(preset_options[0]["default_template_slugs"]) if preset_options else [] preset_lookup = {str(option["slug"]): option for option in preset_options} with dashboard_page( "Event Comparison", "Thin BT-003A read-only UI over EventComparisonService with deterministic seeded GLD presets.", "event-comparison", + workspace_id=workspace_id, ): with ui.row().classes("w-full gap-6 max-lg:flex-col"): with ui.card().classes( @@ -49,6 +90,10 @@ def event_comparison_page() -> None: ui.label( "Preset selection is deterministic and read-only in the sense that runs reuse seeded event windows and existing BT-003 ranking logic." ).classes("text-sm text-slate-500 dark:text-slate-400") + if workspace_id: + ui.label("Workspace defaults seed underlying units, loan amount, and margin threshold.").classes( + "text-sm text-slate-500 dark:text-slate-400" + ) preset_select = ui.select( preset_select_options, value=default_preset_slug, @@ -60,12 +105,18 @@ def event_comparison_page() -> None: label="Strategy templates", multiple=True, ).classes("w-full") - ui.label("Changing the preset resets strategy templates to that preset's default comparison set.").classes( - "text-xs text-slate-500 dark:text-slate-400" - ) - units_input = ui.number("Underlying units", value=1000.0, min=0.0001, step=1).classes("w-full") - loan_input = ui.number("Loan amount", value=68000.0, min=0, step=1000).classes("w-full") - ltv_input = ui.number("Margin call LTV", value=0.75, min=0.01, max=0.99, step=0.01).classes("w-full") + ui.label( + "Changing the preset resets strategy templates to that preset's default comparison set." + ).classes("text-xs text-slate-500 dark:text-slate-400") + units_input = ui.number("Underlying units", value=default_units, min=0.0001, step=1).classes("w-full") + loan_input = ui.number("Loan amount", value=default_loan, min=0, step=1000).classes("w-full") + ltv_input = ui.number( + "Margin call LTV", + value=default_margin_call_ltv, + min=0.01, + max=0.99, + step=0.01, + ).classes("w-full") metadata_label = ui.label("").classes("text-sm text-slate-500 dark:text-slate-400") scenario_label = ui.label("").classes("text-sm text-slate-500 dark:text-slate-400") validation_label = ui.label("").classes("text-sm text-rose-600 dark:text-rose-300") @@ -154,7 +205,10 @@ def event_comparison_page() -> None: "Anchor date", preset.anchor_date.isoformat() if preset.anchor_date is not None else "None", ), - ("Scenario dates used", f"{scenario.start_date.isoformat()} → {scenario.end_date.isoformat()}"), + ( + "Scenario dates used", + f"{scenario.start_date.isoformat()} → {scenario.end_date.isoformat()}", + ), ("Underlying units", f"{scenario.initial_portfolio.underlying_units:,.0f}"), ("Loan amount", f"${scenario.initial_portfolio.loan_amount:,.0f}"), ("Margin call LTV", f"{scenario.initial_portfolio.margin_call_ltv:.1%}"), @@ -219,16 +273,16 @@ def event_comparison_page() -> None: with ui.card().classes( "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" ): - ui.label("Portfolio Value Paths").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") + ui.label("Portfolio Value Paths").classes( + "text-lg font-semibold text-slate-900 dark:text-slate-100" + ) ui.label( "Baseline series shows the unhedged collateral value path for the same seeded event window." ).classes("text-sm text-slate-500 dark:text-slate-400") ui.echart( _chart_options( chart_model.dates, - tuple( - {"name": item.name, "values": list(item.values)} for item in chart_model.series - ), + tuple({"name": item.name, "values": list(item.values)} for item in chart_model.series), ) ).classes("h-96 w-full") diff --git a/app/pages/hedge.py b/app/pages/hedge.py index 519a5fc..e5c53ad 100644 --- a/app/pages/hedge.py +++ b/app/pages/hedge.py @@ -1,11 +1,15 @@ from __future__ import annotations +from fastapi import Request +from fastapi.responses import RedirectResponse from nicegui import ui +from app.models.workspace import WORKSPACE_COOKIE, get_workspace_repository from app.pages.common import ( dashboard_page, demo_spot_price, portfolio_snapshot, + render_workspace_recovery, strategy_catalog, strategy_metrics, ) @@ -32,6 +36,7 @@ def _cost_benefit_options(metrics: dict) -> dict: } + def _waterfall_options(metrics: dict) -> dict: steps = metrics["waterfall_steps"] values: list[dict[str, object]] = [] @@ -53,7 +58,28 @@ def _waterfall_options(metrics: dict) -> dict: @ui.page("/hedge") -def hedge_page() -> None: +def legacy_hedge_page(request: Request): + repo = get_workspace_repository() + workspace_id = request.cookies.get(WORKSPACE_COOKIE, "") + if workspace_id and repo.workspace_exists(workspace_id): + return RedirectResponse(url=f"/{workspace_id}/hedge", status_code=307) + _render_hedge_page() + + +@ui.page("/{workspace_id}/hedge") +def workspace_hedge_page(workspace_id: str) -> None: + repo = get_workspace_repository() + if not repo.workspace_exists(workspace_id): + render_workspace_recovery() + return + _render_hedge_page(workspace_id=workspace_id) + + + +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 = portfolio_snapshot(config) 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} @@ -62,6 +88,7 @@ def hedge_page() -> None: "Hedge Analysis", "Compare hedge structures across scenarios, visualize cost-benefit tradeoffs, and inspect net equity impacts.", "hedge", + workspace_id=workspace_id, ): with ui.row().classes("w-full gap-6 max-lg:flex-col"): with ui.card().classes( @@ -71,9 +98,17 @@ def hedge_page() -> 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: ${demo_spot_price():,.2f}").classes( + ui.label(f"Current spot reference: ${portfolio['spot_price']:,.2f}").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" + ) + else: + ui.label(f"Demo spot reference: ${demo_spot_price():,.2f}").classes( + "text-xs text-slate-500 dark:text-slate-400" + ) summary = ui.card().classes( "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" @@ -81,22 +116,17 @@ def hedge_page() -> None: charts_row = ui.row().classes("w-full gap-6 max-lg:flex-col") with charts_row: - cost_chart = ui.echart( - _cost_benefit_options(strategy_metrics(selected["strategy"], selected["scenario_pct"])) - ).classes( + initial_metrics = strategy_metrics(selected["strategy"], selected["scenario_pct"], portfolio=portfolio) + cost_chart = ui.echart(_cost_benefit_options(initial_metrics)).classes( "h-96 w-full rounded-2xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900" ) - waterfall_chart = ui.echart( - _waterfall_options(strategy_metrics(selected["strategy"], selected["scenario_pct"])) - ).classes( + waterfall_chart = ui.echart(_waterfall_options(initial_metrics)).classes( "h-96 w-full rounded-2xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900" ) def render_summary() -> None: - metrics = strategy_metrics(selected["strategy"], selected["scenario_pct"]) + metrics = strategy_metrics(selected["strategy"], selected["scenario_pct"], portfolio=portfolio) strategy = metrics["strategy"] - portfolio = portfolio_snapshot() - starting_weight = portfolio["gold_value"] / portfolio["spot_price"] summary.clear() with summary: ui.label("Scenario Summary").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") @@ -104,7 +134,10 @@ def hedge_page() -> None: cards = [ ("Start value", f"${portfolio['gold_value']:,.0f}"), ("Start price", f"${portfolio['spot_price']:,.2f}/oz"), - ("Weight", f"{starting_weight:,.0f} oz"), + ("Weight", f"{portfolio['gold_units']:,.0f} oz"), + ("Loan amount", f"${portfolio['loan_amount']:,.0f}"), + ("Margin call LTV", f"{portfolio['margin_call_ltv']:.1%}"), + ("Monthly hedge budget", f"${portfolio['hedge_budget']:,.0f}"), ("Scenario spot", f"${metrics['scenario_price']:,.2f}"), ("Hedge cost", f"${strategy['estimated_cost']:,.2f}/oz"), ("Unhedged equity", f"${metrics['unhedged_equity']:,.0f}"), diff --git a/tests/test_backtest_ui.py b/tests/test_backtest_ui.py index f3af5cb..3d81827 100644 --- a/tests/test_backtest_ui.py +++ b/tests/test_backtest_ui.py @@ -54,6 +54,32 @@ def test_backtest_non_default_template_slug_runs_successfully() -> None: assert result.run_result.template_results[0].template_slug == non_default_slug +def test_backtest_page_service_keeps_fixture_history_while_using_caller_portfolio_inputs() -> None: + service = BacktestPageService() + + result = service.run_read_only_scenario( + symbol="GLD", + start_date=date(2024, 1, 2), + end_date=date(2024, 1, 8), + template_slug="protective-put-atm-12m", + underlying_units=9680.0, + loan_amount=222000.0, + margin_call_ltv=0.80, + ) + + assert result.entry_spot == 100.0 + assert result.scenario.initial_portfolio.underlying_units == 9680.0 + assert result.scenario.initial_portfolio.loan_amount == 222000.0 + assert result.scenario.initial_portfolio.margin_call_ltv == 0.80 + assert [point.date.isoformat() for point in result.run_result.template_results[0].daily_path] == [ + "2024-01-02", + "2024-01-03", + "2024-01-04", + "2024-01-05", + "2024-01-08", + ] + + @pytest.mark.parametrize( ("kwargs", "message"), [ diff --git a/tests/test_e2e_playwright.py b/tests/test_e2e_playwright.py index 5f10e0c..1750e59 100644 --- a/tests/test_e2e_playwright.py +++ b/tests/test_e2e_playwright.py @@ -36,6 +36,7 @@ def test_homepage_and_options_page_render() -> None: expect(page.locator("text=Alert Status").first).to_be_visible(timeout=15000) page.goto(f"{BASE_URL}/backtests", wait_until="networkidle", timeout=30000) + page.wait_for_url(f"{workspace_url}/backtests", timeout=15000) expect(page.locator("text=Backtests").first).to_be_visible(timeout=15000) expect(page.locator("text=Scenario Form").first).to_be_visible(timeout=15000) expect(page.locator("text=Scenario Summary").first).to_be_visible(timeout=15000) @@ -58,6 +59,7 @@ def test_homepage_and_options_page_render() -> None: assert "Server error" not in rerun_text page.goto(f"{BASE_URL}/event-comparison", wait_until="networkidle", timeout=30000) + page.wait_for_url(f"{workspace_url}/event-comparison", timeout=15000) expect(page.locator("text=Event Comparison").first).to_be_visible(timeout=15000) expect(page.locator("text=Comparison Form").first).to_be_visible(timeout=15000) expect(page.locator("text=Ranked Results").first).to_be_visible(timeout=15000) @@ -102,6 +104,8 @@ def test_homepage_and_options_page_render() -> None: 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") + page.get_by_label("Loan amount ($)").fill("222000") + page.get_by_label("Margin call LTV threshold").fill("0.8") budget_input = page.get_by_label("Monthly hedge budget ($)") budget_input.fill("12345") page.get_by_role("button", name="Save settings").click() @@ -109,11 +113,30 @@ def test_homepage_and_options_page_render() -> None: 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") + expect(page.get_by_label("Loan amount ($)")).to_have_value("222000") + expect(page.get_by_label("Margin call LTV threshold")).to_have_value("0.8") 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(f"{workspace_url}/backtests", wait_until="networkidle", timeout=30000) + expect(page.get_by_label("Underlying units")).to_have_value("9680") + expect(page.get_by_label("Loan amount")).to_have_value("222000") + expect(page.get_by_label("Margin call LTV")).to_have_value("0.8") + backtests_workspace_text = page.locator("body").inner_text(timeout=15000) + assert "Scenario Summary" in backtests_workspace_text + assert "$968,000" in backtests_workspace_text + + page.goto(f"{workspace_url}/event-comparison", wait_until="networkidle", timeout=30000) + expect(page.get_by_label("Underlying units")).to_have_value("9680") + expect(page.get_by_label("Loan amount")).to_have_value("222000") + expect(page.get_by_label("Margin call LTV")).to_have_value("0.8") + event_workspace_text = page.locator("body").inner_text(timeout=15000) + assert "$222,000" in event_workspace_text + assert "9,680" in event_workspace_text + assert "80.0%" in event_workspace_text + page.goto(workspace_url, wait_until="domcontentloaded", timeout=30000) overview_text = page.locator("body").inner_text(timeout=15000) assert "Hedge Analysis" in overview_text @@ -121,9 +144,14 @@ def test_homepage_and_options_page_render() -> None: 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 "$1,261.36" in overview_text assert "$968,000.00" in overview_text - assert "$823,000.00" in overview_text + assert "$746,000.00" 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( + "href", f"/{workspace_id}/event-comparison" + ) second_context = browser.new_context(viewport={"width": 1440, "height": 1000}) second_page = second_context.new_page() @@ -139,6 +167,7 @@ def test_homepage_and_options_page_render() -> None: second_context.close() page.goto(f"{BASE_URL}/hedge", wait_until="domcontentloaded", timeout=30000) + page.wait_for_url(f"{workspace_url}/hedge", timeout=15000) expect(page.locator("text=Hedge Analysis").first).to_be_visible(timeout=15000) expect(page.locator("text=Strategy selector").first).to_be_visible(timeout=15000) expect(page.locator("text=Strategy Controls").first).to_be_visible(timeout=15000) @@ -156,13 +185,16 @@ def test_homepage_and_options_page_render() -> None: assert "Start value" in hedge_text assert "Start price" in hedge_text assert "Weight" in hedge_text - assert "$215,000" in hedge_text - assert "$215.00/oz" in hedge_text - assert "1,000 oz" 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 "220 oz" in hedge_text + assert "$222,000" in hedge_text + assert "80.0%" in hedge_text + assert "$12,345" in hedge_text assert "Unhedged equity" in hedge_text assert "Hedged equity" in hedge_text - assert "$27,000" in hedge_text - assert "$58,750" in hedge_text page.screenshot(path=str(ARTIFACTS / "hedge.png"), full_page=True) browser.close() diff --git a/tests/test_event_comparison_ui.py b/tests/test_event_comparison_ui.py index de7a1a6..397b5d4 100644 --- a/tests/test_event_comparison_ui.py +++ b/tests/test_event_comparison_ui.py @@ -66,6 +66,25 @@ def test_event_comparison_page_service_exposes_seeded_preset_options() -> None: ) +def test_event_comparison_page_service_keeps_fixture_window_while_using_caller_portfolio_inputs() -> None: + service = EventComparisonPageService() + + report = service.run_read_only_comparison( + preset_slug="gld-jan-2024-selloff", + template_slugs=("protective-put-atm-12m",), + underlying_units=9680.0, + loan_amount=222000.0, + margin_call_ltv=0.80, + ) + + assert report.scenario.start_date.isoformat() == "2024-01-02" + assert report.scenario.end_date.isoformat() == "2024-01-08" + assert report.scenario.initial_portfolio.entry_spot == 100.0 + assert report.scenario.initial_portfolio.underlying_units == 9680.0 + assert report.scenario.initial_portfolio.loan_amount == 222000.0 + assert report.scenario.initial_portfolio.margin_call_ltv == 0.80 + + def test_event_comparison_page_service_resets_template_selection_to_preset_defaults() -> None: service = EventComparisonPageService() diff --git a/tests/test_workspace.py b/tests/test_workspace.py index cbc30de..110f289 100644 --- a/tests/test_workspace.py +++ b/tests/test_workspace.py @@ -98,3 +98,64 @@ def test_workspace_settings_round_trip_uses_workspace_storage(tmp_path, monkeypa assert response.status_code == 200 assert "Settings" in response.text assert "9,999" in response.text or "9999" in response.text + + +def test_workspace_pages_use_workspace_scoped_navigation_links(tmp_path, monkeypatch) -> None: + repo = _install_workspace_repo(tmp_path, monkeypatch) + workspace_id = str(uuid4()) + repo.create_workspace(workspace_id) + + with TestClient(app) as client: + response = client.get(f"/{workspace_id}") + + assert response.status_code == 200 + assert f"/{workspace_id}/hedge" in response.text + assert f"/{workspace_id}/backtests" in response.text + assert f"/{workspace_id}/event-comparison" in response.text + assert f"/{workspace_id}/settings" in response.text + + +def test_workspace_routes_seed_page_defaults_from_workspace_portfolio_config(tmp_path, monkeypatch) -> None: + repo = _install_workspace_repo(tmp_path, monkeypatch) + workspace_id = str(uuid4()) + config = repo.create_workspace(workspace_id) + config.entry_basis_mode = "weight" + 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) + + with TestClient(app) as client: + hedge_response = client.get(f"/{workspace_id}/hedge") + backtests_response = client.get(f"/{workspace_id}/backtests") + event_response = client.get(f"/{workspace_id}/event-comparison") + redirect_response = client.get("/backtests", cookies={"workspace_id": workspace_id}, follow_redirects=False) + + 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 + assert "9680" in backtests_response.text or "9,680" in backtests_response.text + assert "222000" in backtests_response.text or "222,000" in backtests_response.text + assert "0.8" in backtests_response.text or "80.0%" in backtests_response.text + + assert event_response.status_code == 200 + assert "Workspace defaults seed underlying units, loan amount, and margin threshold." in event_response.text + assert "Underlying units" in event_response.text + assert "Loan amount" in event_response.text + 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 + + assert redirect_response.status_code in {302, 303, 307} + assert redirect_response.headers["location"] == f"/{workspace_id}/backtests"