diff --git a/app/pages/backtests.py b/app/pages/backtests.py index 6cfa2e0..1d34229 100644 --- a/app/pages/backtests.py +++ b/app/pages/backtests.py @@ -7,7 +7,7 @@ from nicegui import ui from app.domain.backtesting_math import asset_quantity_from_workspace_config from app.models.workspace import get_workspace_repository -from app.pages.common import dashboard_page, render_workspace_recovery +from app.pages.common import dashboard_page, split_page_panes from app.services.backtesting.ui_service import BacktestPageRunResult, BacktestPageService @@ -79,9 +79,14 @@ def _render_backtests_page(workspace_id: str | None = None) -> None: "backtests", workspace_id=workspace_id, ): - with ui.row().classes("w-full gap-6 max-lg:flex-col"): + left_pane, right_pane = split_page_panes( + left_testid="backtests-left-pane", + right_testid="backtests-right-pane", + ) + + with left_pane: with ui.card().classes( - "w-full max-w-xl rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" + "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" ): ui.label("Scenario Form").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") ui.label( @@ -115,6 +120,11 @@ def _render_backtests_page(workspace_id: str | None = None) -> None: validation_label = ui.label("").classes("text-sm text-rose-600 dark:text-rose-300") run_button = ui.button("Run backtest").props("color=primary") + seeded_summary = ui.card().classes( + "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" + ) + + with right_pane: result_panel = ui.column().classes("w-full gap-6") def parse_iso_date(raw: object, field_name: str) -> date: @@ -123,16 +133,62 @@ def _render_backtests_page(workspace_id: str | None = None) -> None: except ValueError as exc: raise ValueError(f"{field_name} must be in YYYY-MM-DD format") from exc + def render_seeded_summary(*, entry_spot: float | None = None) -> None: + seeded_summary.clear() + resolved_entry_spot = entry_spot + if resolved_entry_spot is None: + try: + resolved_entry_spot = service.derive_entry_spot( + str(symbol_input.value or "GLD"), + parse_iso_date(start_input.value, "Start date"), + parse_iso_date(end_input.value, "End date"), + ) + except (ValueError, KeyError): + resolved_entry_spot = None + with seeded_summary: + ui.label("Scenario Summary").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") + with ui.grid(columns=1).classes("w-full gap-4 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2"): + cards = [ + ("Template", select_options.get(str(template_select.value), str(template_select.value or "—"))), + ("Underlying units", f"{float(units_input.value or 0.0):,.0f}"), + ("Loan amount", f"${float(loan_input.value or 0.0):,.0f}"), + ("Margin call LTV", f"{float(ltv_input.value or 0.0):.1%}"), + ( + "Date range", + f"{str(start_input.value or '—')} → {str(end_input.value or '—')}", + ), + ( + "Entry spot", + f"${resolved_entry_spot:,.2f}" if resolved_entry_spot is not None else "Pending", + ), + ] + for label, value in cards: + with ui.card().classes( + "rounded-xl border border-slate-200 bg-slate-50 p-4 shadow-none dark:border-slate-800 dark:bg-slate-950" + ): + ui.label(label).classes("text-sm text-slate-500 dark:text-slate-400") + ui.label(value).classes("text-xl font-bold text-slate-900 dark:text-slate-100") + + def render_result_validation(message: str) -> None: + result_panel.clear() + with result_panel: + with ui.card().classes( + "w-full rounded-2xl border border-amber-200 bg-amber-50 shadow-sm dark:border-amber-900/60 dark:bg-amber-950/30" + ): + ui.label("Scenario Results").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") + ui.label(message).classes("text-sm text-amber-800 dark:text-amber-200") + def render_result(result: BacktestPageRunResult) -> None: result_panel.clear() template_result = result.run_result.template_results[0] summary = template_result.summary_metrics entry_spot_hint.set_text(f"Auto-derived entry spot: ${result.entry_spot:,.2f}") + render_seeded_summary(entry_spot=result.entry_spot) with result_panel: 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("Scenario Summary").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") + ui.label("Scenario Results").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") ui.label(f"Template: {template_result.template_name}").classes( "text-sm text-slate-500 dark:text-slate-400" ) @@ -216,9 +272,11 @@ def _render_backtests_page(workspace_id: str | None = None) -> None: parse_iso_date(end_input.value, "End date"), ) except (ValueError, KeyError): + render_seeded_summary(entry_spot=None) return units_input.value = asset_quantity_from_workspace_config(config, entry_spot=entry_spot, symbol="GLD") entry_spot_hint.set_text(f"Auto-derived entry spot: ${entry_spot:,.2f}") + render_seeded_summary(entry_spot=entry_spot) def run_backtest() -> None: validation_label.set_text("") @@ -233,17 +291,25 @@ def _render_backtests_page(workspace_id: str | None = None) -> None: margin_call_ltv=float(ltv_input.value or 0.0), ) except (ValueError, KeyError) as exc: - result_panel.clear() + render_seeded_summary(entry_spot=None) validation_label.set_text(str(exc)) + render_result_validation(str(exc)) return except Exception: - result_panel.clear() - validation_label.set_text("Backtest failed. Please verify the scenario inputs and try again.") + render_seeded_summary(entry_spot=None) + message = "Backtest failed. Please verify the scenario inputs and try again." + validation_label.set_text(message) + render_result_validation(message) return render_result(result) if workspace_id: start_input.on_value_change(lambda _event: refresh_workspace_seeded_units()) end_input.on_value_change(lambda _event: refresh_workspace_seeded_units()) + template_select.on_value_change(lambda _event: render_seeded_summary()) + units_input.on_value_change(lambda _event: render_seeded_summary()) + loan_input.on_value_change(lambda _event: render_seeded_summary()) + ltv_input.on_value_change(lambda _event: render_seeded_summary()) run_button.on_click(lambda: run_backtest()) + render_seeded_summary(entry_spot=default_entry_spot) run_backtest() diff --git a/app/pages/common.py b/app/pages/common.py index e449995..0c490a8 100644 --- a/app/pages/common.py +++ b/app/pages/common.py @@ -123,6 +123,17 @@ def strategy_metrics( return strategy_metrics_from_snapshot(strategy, scenario_pct, portfolio) +def split_page_panes(*, left_testid: str, right_testid: str) -> tuple[ui.column, ui.column]: + with ui.row().classes("w-full items-start gap-6 max-lg:flex-col lg:flex-nowrap"): + left = ui.column().classes("min-w-0 w-full gap-6 lg:w-1/3 lg:flex-none").props( + f"data-testid={left_testid}" + ) + right = ui.column().classes("min-w-0 w-full gap-6 lg:w-2/3 lg:flex-none").props( + f"data-testid={right_testid}" + ) + return left, right + + @contextmanager def dashboard_page(title: str, subtitle: str, current: str, workspace_id: str | None = None) -> Iterator[ui.column]: ui.colors(primary="#0f172a", secondary="#1e293b", accent="#0ea5e9") @@ -146,7 +157,7 @@ def dashboard_page(title: str, subtitle: str, current: str, workspace_id: str | ) ui.link(label, href).classes(link_classes) - with ui.column().classes("mx-auto w-full max-w-7xl gap-6 bg-slate-50 p-6 dark:bg-slate-950") as container: + with ui.column().classes("w-full gap-6 bg-slate-50 p-6 dark:bg-slate-950") as container: with ui.row().classes("w-full items-end justify-between gap-4 max-md:flex-col max-md:items-start"): with ui.column().classes("gap-1"): ui.label(title).classes("text-3xl font-bold text-slate-900 dark:text-slate-50") diff --git a/app/pages/event_comparison.py b/app/pages/event_comparison.py index d273437..0fd2952 100644 --- a/app/pages/event_comparison.py +++ b/app/pages/event_comparison.py @@ -5,7 +5,7 @@ from nicegui import ui from app.domain.backtesting_math import asset_quantity_from_workspace_config from app.models.workspace import get_workspace_repository -from app.pages.common import dashboard_page, render_workspace_recovery +from app.pages.common import dashboard_page, split_page_panes from app.services.event_comparison_ui import EventComparisonPageService @@ -72,9 +72,14 @@ def _render_event_comparison_page(workspace_id: str | None = None) -> None: "event-comparison", workspace_id=workspace_id, ): - with ui.row().classes("w-full gap-6 max-lg:flex-col"): + left_pane, right_pane = split_page_panes( + left_testid="event-comparison-left-pane", + right_testid="event-comparison-right-pane", + ) + + with left_pane: with ui.card().classes( - "w-full max-w-xl rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" + "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" ): ui.label("Comparison Form").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") ui.label( @@ -112,6 +117,11 @@ def _render_event_comparison_page(workspace_id: str | None = None) -> None: validation_label = ui.label("").classes("text-sm text-rose-600 dark:text-rose-300") run_button = ui.button("Run comparison").props("color=primary") + selected_summary = ui.card().classes( + "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" + ) + + with right_pane: result_panel = ui.column().classes("w-full gap-6") def selected_template_slugs() -> tuple[str, ...]: @@ -120,11 +130,35 @@ def _render_event_comparison_page(workspace_id: str | None = None) -> None: return (raw_value,) if raw_value else () return tuple(str(item) for item in raw_value if item) + def render_selected_summary(entry_spot: float | None = None) -> None: + selected_summary.clear() + with selected_summary: + ui.label("Scenario Summary").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") + with ui.grid(columns=1).classes("w-full gap-4 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2"): + cards = [ + ( + "Preset", + preset_select_options.get(str(preset_select.value), str(preset_select.value or "—")), + ), + ("Templates", str(len(selected_template_slugs()))), + ("Underlying units", f"{float(units_input.value or 0.0):,.0f}"), + ("Loan amount", f"${float(loan_input.value or 0.0):,.0f}"), + ("Margin call LTV", f"{float(ltv_input.value or 0.0):.1%}"), + ("Entry spot", f"${entry_spot:,.2f}" if entry_spot is not None else "Pending"), + ] + for label, value in cards: + with ui.card().classes( + "rounded-xl border border-slate-200 bg-slate-50 p-4 shadow-none dark:border-slate-800 dark:bg-slate-950" + ): + ui.label(label).classes("text-sm text-slate-500 dark:text-slate-400") + ui.label(value).classes("text-xl font-bold text-slate-900 dark:text-slate-100") + def refresh_preset_details() -> None: option = preset_lookup.get(str(preset_select.value or "")) if option is None: metadata_label.set_text("") scenario_label.set_text("") + render_selected_summary(entry_spot=None) return template_select.value = list(service.default_template_selection(str(option["slug"]))) try: @@ -153,6 +187,7 @@ def _render_event_comparison_page(workspace_id: str | None = None) -> None: except (ValueError, KeyError) as exc: metadata_label.set_text(f"Preset: {option['label']} — {option['description']}") scenario_label.set_text(str(exc)) + render_selected_summary(entry_spot=None) return preset = service.event_preset_service.get_preset(str(option["slug"])) metadata_label.set_text(f"Preset: {option['label']} — {option['description']}") @@ -166,6 +201,16 @@ def _render_event_comparison_page(workspace_id: str | None = None) -> None: ) + f" · Entry spot: ${scenario.initial_portfolio.entry_spot:,.2f}" ) + render_selected_summary(entry_spot=float(scenario.initial_portfolio.entry_spot)) + + def render_result_validation(message: str) -> None: + result_panel.clear() + with result_panel: + with ui.card().classes( + "w-full rounded-2xl border border-amber-200 bg-amber-50 shadow-sm dark:border-amber-900/60 dark:bg-amber-950/30" + ): + ui.label("Scenario Results").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") + ui.label(message).classes("text-sm text-amber-800 dark:text-amber-200") def render_report() -> None: validation_label.set_text("") @@ -180,9 +225,12 @@ def _render_event_comparison_page(workspace_id: str | None = None) -> None: ) except (ValueError, KeyError) as exc: validation_label.set_text(str(exc)) + render_result_validation(str(exc)) return except Exception: - validation_label.set_text("Event comparison failed. Please verify the seeded inputs and try again.") + message = "Event comparison failed. Please verify the seeded inputs and try again." + validation_label.set_text(message) + render_result_validation(message) return preset = report.event_preset @@ -195,13 +243,14 @@ def _render_event_comparison_page(workspace_id: str | None = None) -> None: f"{scenario.start_date.isoformat()} → {scenario.end_date.isoformat()} · " f"Entry spot: ${scenario.initial_portfolio.entry_spot:,.2f}" ) + render_selected_summary(entry_spot=float(scenario.initial_portfolio.entry_spot)) chart_model = service.chart_model(report) with result_panel: 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("Scenario Metadata").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") + ui.label("Scenario Results").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") with ui.grid(columns=4).classes("w-full gap-4 max-lg:grid-cols-2 max-sm:grid-cols-1"): cards = [ ("Symbol", scenario.symbol), @@ -292,6 +341,10 @@ def _render_event_comparison_page(workspace_id: str | None = None) -> None: ).classes("h-96 w-full") preset_select.on_value_change(lambda _: refresh_preset_details()) + template_select.on_value_change(lambda _: refresh_preset_details()) + units_input.on_value_change(lambda _: refresh_preset_details()) + loan_input.on_value_change(lambda _: refresh_preset_details()) + ltv_input.on_value_change(lambda _: refresh_preset_details()) run_button.on_click(lambda: render_report()) refresh_preset_details() render_report() diff --git a/app/pages/hedge.py b/app/pages/hedge.py index 7db304a..6c4a539 100644 --- a/app/pages/hedge.py +++ b/app/pages/hedge.py @@ -11,6 +11,7 @@ from app.pages.common import ( dashboard_page, demo_spot_price, portfolio_snapshot, + split_page_panes, strategy_catalog, strategy_metrics, ) @@ -111,6 +112,7 @@ async def _render_hedge_page(workspace_id: str | None = None) -> None: spot_label = ( f"Current spot reference: ${portfolio['spot_price']:,.2f}/oz (converted collateral spot via {quote_source})" ) + updated_label = f"Quote timestamp: {quote_updated_at}" if quote_updated_at else "Quote timestamp: unavailable" with dashboard_page( "Hedge Analysis", @@ -118,7 +120,12 @@ async def _render_hedge_page(workspace_id: str | None = None) -> None: "hedge", workspace_id=workspace_id, ): - with ui.row().classes("w-full gap-6 max-lg:flex-col"): + left_pane, right_pane = split_page_panes( + left_testid="hedge-left-pane", + right_testid="hedge-right-pane", + ) + + with left_pane: with ui.card().classes( "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" ): @@ -127,6 +134,7 @@ async def _render_hedge_page(workspace_id: str | None = None) -> None: 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(spot_label).classes("text-sm text-slate-500 dark:text-slate-400") + ui.label(updated_label).classes("text-xs 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" @@ -140,15 +148,18 @@ async def _render_hedge_page(workspace_id: str | None = None) -> None: "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" ) - charts_row = ui.row().classes("w-full gap-6 max-lg:flex-col") - with charts_row: - 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(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" + with right_pane: + scenario_results = ui.card().classes( + "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" ) + with ui.row().classes("w-full gap-6 max-xl:flex-col"): + 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(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"], portfolio=portfolio) @@ -156,7 +167,8 @@ async def _render_hedge_page(workspace_id: str | None = None) -> None: summary.clear() with summary: ui.label("Scenario Summary").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") - with ui.grid(columns=3).classes("w-full gap-4 max-lg:grid-cols-2 max-sm:grid-cols-1"): + ui.label(strategy["description"]).classes("text-sm text-slate-600 dark:text-slate-300") + with ui.grid(columns=1).classes("w-full gap-4 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2"): cards = [ ("Start value", f"${portfolio['gold_value']:,.0f}"), ("Start price", f"${portfolio['spot_price']:,.2f}/oz"), @@ -164,10 +176,6 @@ async def _render_hedge_page(workspace_id: str | None = None) -> None: ("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}"), - ("Hedged equity", f"${metrics['hedged_equity']:,.0f}"), ] for label, value in cards: with ui.card().classes( @@ -175,7 +183,25 @@ async def _render_hedge_page(workspace_id: str | None = None) -> None: ): ui.label(label).classes("text-sm text-slate-500 dark:text-slate-400") ui.label(value).classes("text-2xl font-bold text-slate-900 dark:text-slate-100") - ui.label(strategy["description"]).classes("text-sm text-slate-600 dark:text-slate-300") + + scenario_results.clear() + with scenario_results: + ui.label("Scenario Results").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") + with ui.grid(columns=2).classes("w-full gap-4 max-md:grid-cols-1"): + result_cards = [ + ("Scenario spot", f"${metrics['scenario_price']:,.2f}"), + ("Hedge cost", f"${strategy['estimated_cost']:,.2f}/oz"), + ("Unhedged equity", f"${metrics['unhedged_equity']:,.0f}"), + ("Hedged equity", f"${metrics['hedged_equity']:,.0f}"), + ("Net hedge benefit", f"${metrics['hedged_equity'] - metrics['unhedged_equity']:,.0f}"), + ("Scenario move", f"{selected['scenario_pct']:+d}%"), + ] + for label, value in result_cards: + with ui.card().classes( + "rounded-xl border border-slate-200 bg-slate-50 p-4 shadow-none dark:border-slate-800 dark:bg-slate-950" + ): + ui.label(label).classes("text-sm text-slate-500 dark:text-slate-400") + ui.label(value).classes("text-2xl font-bold text-slate-900 dark:text-slate-100") cost_chart.options.clear() cost_chart.options.update(_cost_benefit_options(metrics)) diff --git a/app/pages/options.py b/app/pages/options.py index 3d6818c..a3dd677 100644 --- a/app/pages/options.py +++ b/app/pages/options.py @@ -5,7 +5,7 @@ from typing import Any from nicegui import ui from app.components import GreeksTable -from app.pages.common import dashboard_page, strategy_catalog +from app.pages.common import dashboard_page, split_page_panes, strategy_catalog from app.services.runtime import get_data_service @@ -39,7 +39,12 @@ async def options_page() -> None: "Browse GLD contracts, filter by expiry and strike range, inspect Greeks, and attach contracts to hedge workflows.", "options", ): - with ui.row().classes("w-full gap-6 max-lg:flex-col"): + left_pane, right_pane = split_page_panes( + left_testid="options-left-pane", + right_testid="options-right-pane", + ) + + with left_pane: with ui.card().classes( "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" ): @@ -61,11 +66,13 @@ async def options_page() -> None: "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" ) - chain_table = ui.html("").classes("w-full") - greeks = GreeksTable([]) - quick_add = ui.card().classes( - "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" - ) + with right_pane: + chain_table = ui.html("").classes("w-full") + with ui.row().classes("w-full gap-6 max-xl:flex-col"): + greeks = GreeksTable([]) + quick_add = ui.card().classes( + "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" + ) def sync_status() -> None: current_data = chain_state["data"] @@ -96,10 +103,15 @@ async def options_page() -> None: if not chosen_contracts: ui.label("No contracts added yet.").classes("text-sm text-slate-500 dark:text-slate-400") return - for contract in chosen_contracts[-3:]: - ui.label( - f"{contract['symbol']} · premium ${float(contract['premium']):.2f} · IV {float(contract.get('impliedVolatility', 0.0)):.1%}" - ).classes("text-sm text-slate-600 dark:text-slate-300") + with ui.column().classes("w-full gap-3"): + for contract in chosen_contracts[-3:]: + with ui.card().classes( + "rounded-xl border border-slate-200 bg-slate-50 p-4 shadow-none dark:border-slate-800 dark:bg-slate-950" + ): + ui.label(contract["symbol"]).classes("font-semibold text-slate-900 dark:text-slate-100") + ui.label( + f"Premium ${float(contract['premium']):.2f} · IV {float(contract.get('impliedVolatility', 0.0)):.1%}" + ).classes("text-sm text-slate-600 dark:text-slate-300") def add_to_strategy(contract: dict[str, Any]) -> None: chosen_contracts.append(contract) @@ -165,7 +177,7 @@ async def options_page() -> None: f"Add {row['type'].upper()} {float(row['strike']):.0f}", on_click=lambda _, contract=row: add_to_strategy(contract), ).props("outline color=primary") - greeks.set_options(rows[:6]) + greeks.set_options(chosen_contracts[-6:] if chosen_contracts else rows[:6]) async def load_expiry_chain(expiry: str | None) -> None: selected_expiry["value"] = expiry diff --git a/app/pages/overview.py b/app/pages/overview.py index baadf41..65b3556 100644 --- a/app/pages/overview.py +++ b/app/pages/overview.py @@ -9,7 +9,13 @@ 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.pages.common import ( + dashboard_page, + quick_recommendations, + recommendation_style, + split_page_panes, + strategy_catalog, +) from app.services.alerts import AlertService, build_portfolio_alert_context from app.services.runtime import get_data_service from app.services.turnstile import load_turnstile_settings @@ -129,6 +135,12 @@ async def overview_page(workspace_id: str) -> None: f"Last updated {_format_timestamp(str(portfolio['quote_updated_at']))}" ) + 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 dashboard_page( "Overview", "Portfolio health, LTV risk, and quick strategy guidance for the current GLD-backed loan.", @@ -141,123 +153,134 @@ async def overview_page(workspace_id: str) -> None: f"Configured collateral baseline: ${config.gold_value:,.0f} · Loan ${config.loan_amount:,.0f}" ).classes("text-sm text-slate-500 dark:text-slate-400") - with ui.card().classes( - "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" - ): - with ui.row().classes("w-full items-center justify-between gap-3"): - ui.label("Alert Status").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") - ui.label(alert_status.severity.upper()).classes(_alert_badge_classes(alert_status.severity)) - ui.label(alert_status.message).classes("text-sm text-slate-600 dark:text-slate-300") - ui.label( - f"Warning at {alert_status.warning_threshold:.0%} · Critical at {alert_status.critical_threshold:.0%} · " - f"Email alerts {'enabled' if alert_status.email_alerts_enabled else 'disabled'}" - ).classes("text-sm text-slate-500 dark:text-slate-400") - if alert_status.history: - latest = alert_status.history[0] - ui.label( - 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" + left_pane, right_pane = split_page_panes( + left_testid="overview-left-pane", + right_testid="overview-right-pane", ) - with ui.grid(columns=4).classes("w-full gap-4 max-lg:grid-cols-2 max-sm:grid-cols-1"): - summary_cards = [ - ( - "Collateral Spot Price", - f"${portfolio['spot_price']:,.2f}", - spot_caption, - ), - ( - "Margin Call Price", - f"${portfolio['margin_call_price']:,.2f}", - "Implied trigger level from persisted portfolio settings", - ), - ( - "Cash Buffer", - f"${portfolio['cash_buffer']:,.0f}", - "Base liquidity plus unrealized gain cushion vs configured baseline", - ), - ( - "Hedge Budget", - f"${portfolio['hedge_budget']:,.0f}", - "Monthly budget from saved settings", - ), - ] - for title, value, caption in summary_cards: - with ui.card().classes( - "rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" - ): - ui.label(title).classes("text-sm font-medium text-slate-500 dark:text-slate-400") - ui.label(value).classes("text-3xl font-bold text-slate-900 dark:text-slate-50") - ui.label(caption).classes("text-sm text-slate-500 dark:text-slate-400") - - portfolio_view = PortfolioOverview(margin_call_ltv=float(portfolio["margin_call_ltv"])) - portfolio_view.update(portfolio) - - with ui.row().classes("w-full gap-6 max-lg:flex-col"): + with left_pane: with ui.card().classes( "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" ): - with ui.row().classes("w-full items-center justify-between"): - ui.label("Current LTV Status").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") - ui.label(f"Threshold {float(portfolio['margin_call_ltv']) * 100:.0f}%").classes( - "rounded-full bg-rose-100 px-3 py-1 text-xs font-semibold text-rose-700 dark:bg-rose-500/15 dark:text-rose-300" - ) - ui.linear_progress( - value=float(portfolio["ltv_ratio"]) / max(float(portfolio["margin_call_ltv"]), 0.01), - show_value=False, - ).props("color=warning track-color=grey-3 rounded") + with ui.row().classes("w-full items-center justify-between gap-3"): + ui.label("Alert Status").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") + ui.label(alert_status.severity.upper()).classes(_alert_badge_classes(alert_status.severity)) + ui.label(alert_status.message).classes("text-sm text-slate-600 dark:text-slate-300") ui.label( - f"Current LTV is {float(portfolio['ltv_ratio']) * 100:.1f}% with a margin buffer of {(float(portfolio['margin_call_ltv']) - float(portfolio['ltv_ratio'])) * 100:.1f} percentage points." - ).classes("text-sm text-slate-600 dark:text-slate-300") - ui.label( - "Warning: if GLD approaches the margin-call price, collateral remediation or hedge monetization will be required." - ).classes("text-sm font-medium text-amber-700 dark:text-amber-300") - - 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("Recent Alert History").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") + f"Warning at {alert_status.warning_threshold:.0%} · Critical at {alert_status.critical_threshold:.0%} · " + f"Email alerts {'enabled' if alert_status.email_alerts_enabled else 'disabled'}" + ).classes("text-sm text-slate-500 dark:text-slate-400") if alert_status.history: - for event in alert_status.history[:5]: - with ui.row().classes( - "w-full items-start justify-between gap-4 border-b border-slate-100 py-3 last:border-b-0 dark:border-slate-800" - ): - with ui.column().classes("gap-1"): - ui.label(event.message).classes( - "text-sm font-medium text-slate-900 dark:text-slate-100" - ) - ui.label( - f"Logged {_format_timestamp(event.updated_at)} · Spot ${event.spot_price:,.2f} · LTV {event.ltv_ratio:.1%}" - ).classes("text-xs text-slate-500 dark:text-slate-400") - ui.label(event.severity.upper()).classes(_alert_badge_classes(event.severity)) - else: + latest = alert_status.history[0] ui.label( - "No alert history yet. Alerts will be logged once the warning threshold is crossed." - ).classes("text-sm text-slate-500 dark:text-slate-400") + 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") - 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("Strategy Snapshot").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") - for strategy in strategy_catalog(): - with ui.row().classes( - "w-full items-start justify-between gap-4 border-b border-slate-100 py-3 last:border-b-0 dark:border-slate-800" + 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 Snapshot").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") + with ui.grid(columns=1).classes("w-full gap-4 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2"): + summary_cards = [ + ( + "Collateral Spot Price", + f"${portfolio['spot_price']:,.2f}", + spot_caption, + ), + ( + "Margin Call Price", + f"${portfolio['margin_call_price']:,.2f}", + "Implied trigger level from persisted portfolio settings", + ), + ( + "Cash Buffer", + f"${portfolio['cash_buffer']:,.0f}", + "Base liquidity plus unrealized gain cushion vs configured baseline", + ), + ( + "Hedge Budget", + f"${portfolio['hedge_budget']:,.0f}", + "Monthly budget from saved settings", + ), + ] + for title, value, caption in summary_cards: + with ui.card().classes( + "rounded-xl border border-slate-200 bg-slate-50 p-4 shadow-none dark:border-slate-800 dark:bg-slate-950" + ): + ui.label(title).classes("text-sm font-medium text-slate-500 dark:text-slate-400") + ui.label(value).classes("text-2xl font-bold text-slate-900 dark:text-slate-50") + ui.label(caption).classes("text-sm text-slate-500 dark:text-slate-400") + + with ui.card().classes( + f"w-full rounded-2xl border shadow-sm {recommendation_style('info')}" + ): + ui.label("Quick Strategy Recommendations").classes( + "text-lg font-semibold text-slate-900 dark:text-slate-100" + ) + for rec in quick_recommendations(portfolio): + with ui.card().classes(f"rounded-xl border shadow-none {recommendation_style(rec['tone'])}"): + ui.label(rec["title"]).classes("text-base font-semibold text-slate-900 dark:text-slate-100") + ui.label(rec["summary"]).classes("text-sm text-slate-600 dark:text-slate-300") + + with right_pane: + portfolio_view = PortfolioOverview(margin_call_ltv=float(portfolio["margin_call_ltv"])) + portfolio_view.update(portfolio) + + with ui.row().classes("w-full gap-6 max-xl:flex-col"): + with ui.card().classes( + "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" ): - with ui.column().classes("gap-1"): - ui.label(strategy["label"]).classes("font-semibold text-slate-900 dark:text-slate-100") - ui.label(strategy["description"]).classes("text-sm text-slate-500 dark:text-slate-400") - ui.label(f"${strategy['estimated_cost']:.2f}/oz").classes( - "rounded-full bg-sky-100 px-3 py-1 text-xs font-semibold text-sky-700 dark:bg-sky-500/15 dark:text-sky-300" - ) + with ui.row().classes("w-full items-center justify-between"): + ui.label("Current LTV Status").classes( + "text-lg font-semibold text-slate-900 dark:text-slate-100" + ) + ui.label(f"Threshold {float(portfolio['margin_call_ltv']) * 100:.0f}%").classes( + "rounded-full bg-rose-100 px-3 py-1 text-xs font-semibold text-rose-700 dark:bg-rose-500/15 dark:text-rose-300" + ) + ui.linear_progress( + value=float(portfolio["ltv_ratio"]) / max(float(portfolio["margin_call_ltv"]), 0.01), + show_value=False, + ).props("color=warning track-color=grey-3 rounded") + ui.label( + f"Current LTV is {float(portfolio['ltv_ratio']) * 100:.1f}% with a margin buffer of {(float(portfolio['margin_call_ltv']) - float(portfolio['ltv_ratio'])) * 100:.1f} percentage points." + ).classes("text-sm text-slate-600 dark:text-slate-300") + ui.label( + "Warning: if GLD approaches the margin-call price, collateral remediation or hedge monetization will be required." + ).classes("text-sm font-medium text-amber-700 dark:text-amber-300") - ui.label("Quick Strategy Recommendations").classes("text-xl font-semibold text-slate-900 dark:text-slate-100") - with ui.grid(columns=3).classes("w-full gap-4 max-lg:grid-cols-1"): - for rec in quick_recommendations(portfolio): - with ui.card().classes(f"rounded-2xl border shadow-sm {recommendation_style(rec['tone'])}"): - ui.label(rec["title"]).classes("text-base font-semibold text-slate-900 dark:text-slate-100") - ui.label(rec["summary"]).classes("text-sm text-slate-600 dark:text-slate-300") + 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("Recent Alert History").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") + if alert_status.history: + for event in alert_status.history[:5]: + with ui.row().classes( + "w-full items-start justify-between gap-4 border-b border-slate-100 py-3 last:border-b-0 dark:border-slate-800" + ): + with ui.column().classes("gap-1"): + ui.label(event.message).classes( + "text-sm font-medium text-slate-900 dark:text-slate-100" + ) + ui.label( + f"Logged {_format_timestamp(event.updated_at)} · Spot ${event.spot_price:,.2f} · LTV {event.ltv_ratio:.1%}" + ).classes("text-xs text-slate-500 dark:text-slate-400") + ui.label(event.severity.upper()).classes(_alert_badge_classes(event.severity)) + else: + ui.label( + "No alert history yet. Alerts will be logged once the warning threshold is crossed." + ).classes("text-sm text-slate-500 dark:text-slate-400") + + 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("Strategy Snapshot").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") + for strategy in strategy_catalog(): + with ui.row().classes( + "w-full items-start justify-between gap-4 border-b border-slate-100 py-3 last:border-b-0 dark:border-slate-800" + ): + with ui.column().classes("gap-1"): + ui.label(strategy["label"]).classes("font-semibold text-slate-900 dark:text-slate-100") + ui.label(strategy["description"]).classes("text-sm text-slate-500 dark:text-slate-400") + ui.label(f"${strategy['estimated_cost']:.2f}/oz").classes( + "rounded-full bg-sky-100 px-3 py-1 text-xs font-semibold text-sky-700 dark:bg-sky-500/15 dark:text-sky-300" + ) diff --git a/app/pages/settings.py b/app/pages/settings.py index 4bd1405..85efcdd 100644 --- a/app/pages/settings.py +++ b/app/pages/settings.py @@ -5,7 +5,7 @@ from nicegui import ui from app.models.portfolio import PortfolioConfig from app.models.workspace import get_workspace_repository -from app.pages.common import dashboard_page +from app.pages.common import dashboard_page, split_page_panes from app.services.alerts import AlertService, build_portfolio_alert_context from app.services.settings_status import save_status_text @@ -84,7 +84,12 @@ def settings_page(workspace_id: str) -> None: "settings", workspace_id=workspace_id, ): - with ui.row().classes("w-full gap-6 max-lg:flex-col"): + left_pane, right_pane = split_page_panes( + left_testid="settings-left-pane", + right_testid="settings-right-pane", + ) + + with left_pane: with ui.card().classes( "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" ): @@ -154,28 +159,6 @@ def settings_page(workspace_id: str) -> None: ui.label("Margin call at:").classes("ml-4 font-medium") margin_price_display = ui.label() - 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("Data Sources").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") - primary_source = ui.select( - ["yfinance", "ibkr", "alpaca"], - value=config.primary_source, - label="Primary source", - ).classes("w-full") - fallback_source = ui.select( - ["fallback", "yfinance", "manual"], - value=config.fallback_source, - label="Fallback source", - ).classes("w-full") - refresh_interval = ui.number( - "Refresh interval (seconds)", - value=config.refresh_interval, - min=1, - step=1, - ).classes("w-full") - - with ui.row().classes("w-full gap-6 max-lg:flex-col"): with ui.card().classes( "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" ): @@ -206,6 +189,28 @@ def settings_page(workspace_id: str) -> None: "text-sm text-slate-500 dark:text-slate-400" ) + with right_pane: + 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("Data Sources").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") + primary_source = ui.select( + ["yfinance", "ibkr", "alpaca"], + value=config.primary_source, + label="Primary source", + ).classes("w-full") + fallback_source = ui.select( + ["fallback", "yfinance", "manual"], + value=config.fallback_source, + label="Fallback source", + ).classes("w-full") + refresh_interval = ui.number( + "Refresh interval (seconds)", + value=config.refresh_interval, + min=1, + step=1, + ).classes("w-full") + with ui.card().classes( "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" ): @@ -216,7 +221,6 @@ def settings_page(workspace_id: str) -> None: alert_message = ui.label().classes("text-sm text-slate-600 dark:text-slate-300") alert_history_column = ui.column().classes("w-full gap-2") - with ui.row().classes("w-full gap-6 max-lg:flex-col"): with ui.card().classes( "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" ): @@ -224,8 +228,19 @@ def settings_page(workspace_id: str) -> None: ui.select(["json", "csv", "yaml"], value="json", label="Export format").classes("w-full") ui.switch("Include scenario history", value=True) ui.switch("Include option selections", value=True) - ui.button("Import settings", icon="upload").props("outline color=primary") - ui.button("Export settings", icon="download").props("outline color=primary") + with ui.row().classes("w-full gap-3 max-sm:flex-col"): + ui.button("Import settings", icon="upload").props("outline color=primary") + ui.button("Export settings", icon="download").props("outline color=primary") + + 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("Save Workspace Settings").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") + status = ui.label( + f"Current: start=${config.gold_value:,.0f}, entry=${config.entry_price:,.2f}/oz, " + f"weight={config.gold_ounces:,.2f} oz, current LTV={config.current_ltv:.1%}" + ).classes("text-sm text-slate-500 dark:text-slate-400") + ui.button("Save settings", on_click=lambda: save_settings()).props("color=primary") def apply_entry_basis_mode() -> None: mode = str(entry_basis_mode.value or "value_price") @@ -357,10 +372,3 @@ def settings_page(workspace_id: str) -> None: ui.notify(f"Validation error: {e}", color="negative") except Exception as e: ui.notify(f"Failed to save: {e}", color="negative") - - with ui.row().classes("mt-6 w-full items-center justify-between gap-4"): - status = ui.label( - f"Current: start=${config.gold_value:,.0f}, entry=${config.entry_price:,.2f}/oz, " - f"weight={config.gold_ounces:,.2f} oz, current LTV={config.current_ltv:.1%}" - ).classes("text-sm text-slate-500 dark:text-slate-400") - ui.button("Save settings", on_click=save_settings).props("color=primary") diff --git a/docs/roadmap/ROADMAP.yaml b/docs/roadmap/ROADMAP.yaml index 6e8368e..700685e 100644 --- a/docs/roadmap/ROADMAP.yaml +++ b/docs/roadmap/ROADMAP.yaml @@ -11,6 +11,7 @@ notes: - One task lives in one YAML file and changes state by moving between status folders. - Priority ordering is maintained here so agents can parse one short file first. priority_queue: + - UX-001 - CORE-001D - BT-003B - PORT-003 @@ -42,6 +43,7 @@ states: - BT-003B - BT-001C - CORE-001D + - UX-001 in_progress: [] done: - DATA-001 diff --git a/docs/roadmap/backlog/UX-001-full-width-two-pane-layout.yaml b/docs/roadmap/backlog/UX-001-full-width-two-pane-layout.yaml new file mode 100644 index 0000000..8751dbd --- /dev/null +++ b/docs/roadmap/backlog/UX-001-full-width-two-pane-layout.yaml @@ -0,0 +1,24 @@ +id: UX-001 +title: Full-Width Two-Pane Page Layout +status: backlog +priority: P1 +effort: M +depends_on: + - PORT-004 + - BT-001A + - BT-003A +tags: + - ui + - layout + - nicegui +summary: Use the full available browser width and standardize primary pages on a 1/3 control-summary pane and 2/3 charts-results pane. +acceptance_criteria: + - Dashboard pages use the full available browser width instead of the current centered max-width container. + - Scenario-heavy pages use a consistent desktop two-pane layout with an approximately 1/3 left pane and 2/3 right pane. + - Left pane contains controls and summary content; right pane contains charts, tables, and scenario results. + - Mobile and narrow widths still stack cleanly without clipping. + - Browser-visible tests cover the split-pane structure on representative pages. +technical_notes: + - Likely file targets include `app/pages/common.py`, `app/pages/hedge.py`, `app/pages/backtests.py`, `app/pages/event_comparison.py`, `app/pages/options.py`, and possibly `app/pages/overview.py` / `app/pages/settings.py` for consistent full-width layout. + - Prefer shared layout helpers in `app/pages/common.py` over page-specific one-off width classes. + - Add stable DOM hooks (for example `data-testid`) so layout structure can be asserted in browser tests. diff --git a/tests/test_e2e_playwright.py b/tests/test_e2e_playwright.py index 5b472de..e269c01 100644 --- a/tests/test_e2e_playwright.py +++ b/tests/test_e2e_playwright.py @@ -9,6 +9,21 @@ ARTIFACTS = Path("tests/artifacts") ARTIFACTS.mkdir(parents=True, exist_ok=True) +def assert_two_pane_layout(page, left_testid: str, right_testid: str) -> None: + left = page.locator(f'[data-testid="{left_testid}"]:visible').first + right = page.locator(f'[data-testid="{right_testid}"]:visible').first + expect(left).to_be_visible(timeout=15000) + expect(right).to_be_visible(timeout=15000) + left_box = left.bounding_box() + right_box = right.bounding_box() + assert left_box is not None + assert right_box is not None + assert left_box["x"] < right_box["x"] + assert abs(left_box["y"] - right_box["y"]) < 120 + width_ratio = right_box["width"] / left_box["width"] + assert 1.6 <= width_ratio <= 2.4 + + def test_homepage_and_options_page_render() -> None: with sync_playwright() as p: browser = p.chromium.launch(headless=True) @@ -29,6 +44,7 @@ def test_homepage_and_options_page_render() -> None: expect(page.locator("text=Overview").first).to_be_visible(timeout=10000) expect(page.locator("text=Live quote source:").first).to_be_visible(timeout=15000) expect(page.locator("text=Alert Status").first).to_be_visible(timeout=15000) + assert_two_pane_layout(page, "overview-left-pane", "overview-right-pane") page.screenshot(path=str(ARTIFACTS / "overview.png"), full_page=True) page.goto(BASE_URL, wait_until="domcontentloaded", timeout=30000) @@ -39,54 +55,34 @@ def test_homepage_and_options_page_render() -> None: 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) - expect(page.locator("text=Daily Results").first).to_be_visible(timeout=15000) + assert_two_pane_layout(page, "backtests-left-pane", "backtests-right-pane") backtests_text = page.locator("body").inner_text(timeout=15000) - assert "Auto-derived entry spot: $100.00" in backtests_text + assert "Historical scenario starts undercollateralized:" in backtests_text + assert "loan_amount must be less than initial collateral value" not in backtests_text assert "RuntimeError" not in backtests_text assert "Server error" not in backtests_text assert "Traceback" not in backtests_text page.screenshot(path=str(ARTIFACTS / "backtests.png"), full_page=True) - page.get_by_label("Template").click() - page.get_by_text("Protective Put 95%", exact=True).click() - page.get_by_role("button", name="Run backtest").click() - expect(page.locator("text=Scenario Summary").first).to_be_visible(timeout=15000) - expect(page.locator("text=Template: Protective Put 95%").first).to_be_visible(timeout=15000) - rerun_text = page.locator("body").inner_text(timeout=15000) - assert "Margin call days hedged" in rerun_text - assert "RuntimeError" not in rerun_text - assert "Server error" not in rerun_text - page.goto(f"{workspace_url}/event-comparison", wait_until="networkidle", timeout=30000) 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) - expect(page.locator("text=Scenario Metadata").first).to_be_visible(timeout=15000) - expect(page.locator("text=Portfolio Value Paths").first).to_be_visible(timeout=15000) + expect(page.locator("text=Scenario Summary").first).to_be_visible(timeout=15000) + assert_two_pane_layout(page, "event-comparison-left-pane", "event-comparison-right-pane") event_text = page.locator("body").inner_text(timeout=15000) assert "GLD January 2024 Selloff" in event_text assert "Protective Put ATM" in event_text - assert "Baseline series shows the unhedged collateral value path" in event_text - assert "Hedged margin call days" in event_text - assert "Templates compared" in event_text and "4" in event_text + assert "Historical scenario starts undercollateralized:" in event_text + assert "loan_amount must be less than initial collateral value" not in event_text assert "RuntimeError" not in event_text assert "Server error" not in event_text assert "Traceback" not in event_text - - page.get_by_label("Event preset").click() - page.get_by_text("GLD January 2024 Drawdown", exact=True).click() - page.get_by_role("button", name="Run comparison").click() - expect(page.locator("text=GLD January 2024 Drawdown").first).to_be_visible(timeout=15000) - rerun_event_text = page.locator("body").inner_text(timeout=15000) - assert "Laddered Puts 33/33/33 ATM + 95% + 90%" in rerun_event_text - assert "Templates compared" in rerun_event_text and "3" in rerun_event_text - assert "RuntimeError" not in rerun_event_text - assert "Server error" not in rerun_event_text page.screenshot(path=str(ARTIFACTS / "event-comparison.png"), full_page=True) page.goto(f"{BASE_URL}/options", wait_until="domcontentloaded", timeout=30000) expect(page.locator("text=Options Chain").first).to_be_visible(timeout=15000) expect(page.locator("text=Filters").first).to_be_visible(timeout=15000) + assert_two_pane_layout(page, "options-left-pane", "options-right-pane") body_text = page.locator("body").inner_text(timeout=15000) assert "Server error" not in body_text assert "RuntimeError" not in body_text @@ -97,6 +93,7 @@ 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) + assert_two_pane_layout(page, "settings-left-pane", "settings-right-pane") page.get_by_label("Collateral entry basis").click() page.get_by_text("Gold weight + entry price", exact=True).click() @@ -122,18 +119,56 @@ def test_homepage_and_options_page_render() -> None: expect(page.get_by_label("Underlying units")).to_have_value("2200") expect(page.get_by_label("Loan amount")).to_have_value("222000") expect(page.get_by_label("Margin call LTV")).to_have_value("0.8") + assert_two_pane_layout(page, "backtests-left-pane", "backtests-right-pane") backtests_workspace_text = page.locator("body").inner_text(timeout=15000) assert "Scenario Summary" in backtests_workspace_text + assert "Scenario Results" in backtests_workspace_text assert "$220,000" in backtests_workspace_text + assert "Historical scenario starts undercollateralized:" in backtests_workspace_text + + page.get_by_label("Underlying units").fill("3000") + page.get_by_label("Template").click() + page.get_by_text("Protective Put 95%", exact=True).click() + page.get_by_role("button", name="Run backtest").click() + expect(page.locator("text=Daily Results").first).to_be_visible(timeout=15000) + expect(page.locator("text=Scenario Results").first).to_be_visible(timeout=15000) + expect(page.locator("text=Template: Protective Put 95%").first).to_be_visible(timeout=15000) + rerun_text = page.locator("body").inner_text(timeout=15000) + assert "Margin call days hedged" in rerun_text + assert "Historical scenario starts undercollateralized:" not in rerun_text + assert "RuntimeError" not in rerun_text + assert "Server error" not in rerun_text page.goto(f"{workspace_url}/event-comparison", wait_until="networkidle", timeout=30000) expect(page.get_by_label("Underlying units")).to_have_value("2200") expect(page.get_by_label("Loan amount")).to_have_value("222000") expect(page.get_by_label("Margin call LTV")).to_have_value("0.8") + assert_two_pane_layout(page, "event-comparison-left-pane", "event-comparison-right-pane") event_workspace_text = page.locator("body").inner_text(timeout=15000) assert "$222,000" in event_workspace_text assert "2,200" in event_workspace_text assert "80.0%" in event_workspace_text + assert "Historical scenario starts undercollateralized:" in event_workspace_text + + page.get_by_label("Underlying units").fill("3000") + page.get_by_role("button", name="Run comparison").click() + expect(page.locator("text=Ranked Results").first).to_be_visible(timeout=15000) + expect(page.locator("text=Scenario Results").first).to_be_visible(timeout=15000) + expect(page.locator("text=Portfolio Value Paths").first).to_be_visible(timeout=15000) + rerun_event_text = page.locator("body").inner_text(timeout=15000) + assert "Baseline series shows the unhedged collateral value path" in rerun_event_text + assert "Templates compared" in rerun_event_text and "4" in rerun_event_text + assert "Historical scenario starts undercollateralized:" not in rerun_event_text + + page.get_by_label("Event preset").click() + page.get_by_text("GLD January 2024 Drawdown", exact=True).click() + page.get_by_role("button", name="Run comparison").click() + expect(page.locator("text=GLD January 2024 Drawdown").first).to_be_visible(timeout=15000) + rerun_event_text = page.locator("body").inner_text(timeout=15000) + assert "Laddered Puts 33/33/33 ATM + 95% + 90%" in rerun_event_text + assert "Templates compared" in rerun_event_text and "3" in rerun_event_text + assert "RuntimeError" not in rerun_event_text + assert "Server error" not in rerun_event_text page.goto(workspace_url, wait_until="domcontentloaded", timeout=30000) overview_text = page.locator("body").inner_text(timeout=15000) @@ -181,6 +216,7 @@ def test_homepage_and_options_page_render() -> None: 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) + assert_two_pane_layout(page, "hedge-left-pane", "hedge-right-pane") hedge_text = page.locator("body").inner_text(timeout=15000) assert "Scenario Summary" in hedge_text assert "RuntimeError" not in hedge_text