feat(UX-001): add full-width two-pane dashboard layout

This commit is contained in:
Bu5hm4nn
2026-03-25 23:19:09 +01:00
parent 960e1e9215
commit a60c5fb1f2
10 changed files with 473 additions and 212 deletions

View File

@@ -7,7 +7,7 @@ from nicegui import ui
from app.domain.backtesting_math import asset_quantity_from_workspace_config from app.domain.backtesting_math import asset_quantity_from_workspace_config
from app.models.workspace import get_workspace_repository 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 from app.services.backtesting.ui_service import BacktestPageRunResult, BacktestPageService
@@ -79,9 +79,14 @@ def _render_backtests_page(workspace_id: str | None = None) -> None:
"backtests", "backtests",
workspace_id=workspace_id, 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( 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("Scenario Form").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
ui.label( 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") validation_label = ui.label("").classes("text-sm text-rose-600 dark:text-rose-300")
run_button = ui.button("Run backtest").props("color=primary") 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") result_panel = ui.column().classes("w-full gap-6")
def parse_iso_date(raw: object, field_name: str) -> date: 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: except ValueError as exc:
raise ValueError(f"{field_name} must be in YYYY-MM-DD format") from 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: def render_result(result: BacktestPageRunResult) -> None:
result_panel.clear() result_panel.clear()
template_result = result.run_result.template_results[0] template_result = result.run_result.template_results[0]
summary = template_result.summary_metrics summary = template_result.summary_metrics
entry_spot_hint.set_text(f"Auto-derived entry spot: ${result.entry_spot:,.2f}") 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 result_panel:
with ui.card().classes( with ui.card().classes(
"w-full 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 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( ui.label(f"Template: {template_result.template_name}").classes(
"text-sm text-slate-500 dark:text-slate-400" "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"), parse_iso_date(end_input.value, "End date"),
) )
except (ValueError, KeyError): except (ValueError, KeyError):
render_seeded_summary(entry_spot=None)
return return
units_input.value = asset_quantity_from_workspace_config(config, entry_spot=entry_spot, symbol="GLD") 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}") entry_spot_hint.set_text(f"Auto-derived entry spot: ${entry_spot:,.2f}")
render_seeded_summary(entry_spot=entry_spot)
def run_backtest() -> None: def run_backtest() -> None:
validation_label.set_text("") 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), margin_call_ltv=float(ltv_input.value or 0.0),
) )
except (ValueError, KeyError) as exc: except (ValueError, KeyError) as exc:
result_panel.clear() render_seeded_summary(entry_spot=None)
validation_label.set_text(str(exc)) validation_label.set_text(str(exc))
render_result_validation(str(exc))
return return
except Exception: except Exception:
result_panel.clear() render_seeded_summary(entry_spot=None)
validation_label.set_text("Backtest failed. Please verify the scenario inputs and try again.") message = "Backtest failed. Please verify the scenario inputs and try again."
validation_label.set_text(message)
render_result_validation(message)
return return
render_result(result) render_result(result)
if workspace_id: if workspace_id:
start_input.on_value_change(lambda _event: refresh_workspace_seeded_units()) start_input.on_value_change(lambda _event: refresh_workspace_seeded_units())
end_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()) run_button.on_click(lambda: run_backtest())
render_seeded_summary(entry_spot=default_entry_spot)
run_backtest() run_backtest()

View File

@@ -123,6 +123,17 @@ def strategy_metrics(
return strategy_metrics_from_snapshot(strategy, scenario_pct, portfolio) 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 @contextmanager
def dashboard_page(title: str, subtitle: str, current: str, workspace_id: str | None = None) -> Iterator[ui.column]: 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") 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) 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.row().classes("w-full items-end justify-between gap-4 max-md:flex-col max-md:items-start"):
with ui.column().classes("gap-1"): with ui.column().classes("gap-1"):
ui.label(title).classes("text-3xl font-bold text-slate-900 dark:text-slate-50") ui.label(title).classes("text-3xl font-bold text-slate-900 dark:text-slate-50")

View File

@@ -5,7 +5,7 @@ from nicegui import ui
from app.domain.backtesting_math import asset_quantity_from_workspace_config from app.domain.backtesting_math import asset_quantity_from_workspace_config
from app.models.workspace import get_workspace_repository 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 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", "event-comparison",
workspace_id=workspace_id, 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( 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("Comparison Form").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
ui.label( 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") validation_label = ui.label("").classes("text-sm text-rose-600 dark:text-rose-300")
run_button = ui.button("Run comparison").props("color=primary") 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") result_panel = ui.column().classes("w-full gap-6")
def selected_template_slugs() -> tuple[str, ...]: 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 (raw_value,) if raw_value else ()
return tuple(str(item) for item in raw_value if item) 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: def refresh_preset_details() -> None:
option = preset_lookup.get(str(preset_select.value or "")) option = preset_lookup.get(str(preset_select.value or ""))
if option is None: if option is None:
metadata_label.set_text("") metadata_label.set_text("")
scenario_label.set_text("") scenario_label.set_text("")
render_selected_summary(entry_spot=None)
return return
template_select.value = list(service.default_template_selection(str(option["slug"]))) template_select.value = list(service.default_template_selection(str(option["slug"])))
try: try:
@@ -153,6 +187,7 @@ def _render_event_comparison_page(workspace_id: str | None = None) -> None:
except (ValueError, KeyError) as exc: except (ValueError, KeyError) as exc:
metadata_label.set_text(f"Preset: {option['label']}{option['description']}") metadata_label.set_text(f"Preset: {option['label']}{option['description']}")
scenario_label.set_text(str(exc)) scenario_label.set_text(str(exc))
render_selected_summary(entry_spot=None)
return return
preset = service.event_preset_service.get_preset(str(option["slug"])) preset = service.event_preset_service.get_preset(str(option["slug"]))
metadata_label.set_text(f"Preset: {option['label']}{option['description']}") 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}" + 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: def render_report() -> None:
validation_label.set_text("") validation_label.set_text("")
@@ -180,9 +225,12 @@ def _render_event_comparison_page(workspace_id: str | None = None) -> None:
) )
except (ValueError, KeyError) as exc: except (ValueError, KeyError) as exc:
validation_label.set_text(str(exc)) validation_label.set_text(str(exc))
render_result_validation(str(exc))
return return
except Exception: 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 return
preset = report.event_preset 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"{scenario.start_date.isoformat()}{scenario.end_date.isoformat()} · "
f"Entry spot: ${scenario.initial_portfolio.entry_spot:,.2f}" 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) chart_model = service.chart_model(report)
with result_panel: with result_panel:
with ui.card().classes( with ui.card().classes(
"w-full 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 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"): with ui.grid(columns=4).classes("w-full gap-4 max-lg:grid-cols-2 max-sm:grid-cols-1"):
cards = [ cards = [
("Symbol", scenario.symbol), ("Symbol", scenario.symbol),
@@ -292,6 +341,10 @@ def _render_event_comparison_page(workspace_id: str | None = None) -> None:
).classes("h-96 w-full") ).classes("h-96 w-full")
preset_select.on_value_change(lambda _: refresh_preset_details()) 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()) run_button.on_click(lambda: render_report())
refresh_preset_details() refresh_preset_details()
render_report() render_report()

View File

@@ -11,6 +11,7 @@ from app.pages.common import (
dashboard_page, dashboard_page,
demo_spot_price, demo_spot_price,
portfolio_snapshot, portfolio_snapshot,
split_page_panes,
strategy_catalog, strategy_catalog,
strategy_metrics, strategy_metrics,
) )
@@ -111,6 +112,7 @@ async def _render_hedge_page(workspace_id: str | None = None) -> None:
spot_label = ( spot_label = (
f"Current spot reference: ${portfolio['spot_price']:,.2f}/oz (converted collateral spot via {quote_source})" 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( with dashboard_page(
"Hedge Analysis", "Hedge Analysis",
@@ -118,7 +120,12 @@ async def _render_hedge_page(workspace_id: str | None = None) -> None:
"hedge", "hedge",
workspace_id=workspace_id, 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( with ui.card().classes(
"w-full 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"
): ):
@@ -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_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") 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(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: if workspace_id:
ui.label(f"Workspace route: /{workspace_id}/hedge").classes( ui.label(f"Workspace route: /{workspace_id}/hedge").classes(
"text-xs text-slate-500 dark:text-slate-400" "text-xs text-slate-500 dark:text-slate-400"
@@ -140,8 +148,11 @@ 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" "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 right_pane:
with charts_row: 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) initial_metrics = strategy_metrics(selected["strategy"], selected["scenario_pct"], portfolio=portfolio)
cost_chart = ui.echart(_cost_benefit_options(initial_metrics)).classes( 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" "h-96 w-full rounded-2xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900"
@@ -156,7 +167,8 @@ async def _render_hedge_page(workspace_id: str | None = None) -> None:
summary.clear() summary.clear()
with summary: with summary:
ui.label("Scenario Summary").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") 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 = [ cards = [
("Start value", f"${portfolio['gold_value']:,.0f}"), ("Start value", f"${portfolio['gold_value']:,.0f}"),
("Start price", f"${portfolio['spot_price']:,.2f}/oz"), ("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}"), ("Loan amount", f"${portfolio['loan_amount']:,.0f}"),
("Margin call LTV", f"{portfolio['margin_call_ltv']:.1%}"), ("Margin call LTV", f"{portfolio['margin_call_ltv']:.1%}"),
("Monthly hedge budget", f"${portfolio['hedge_budget']:,.0f}"), ("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: for label, value in cards:
with ui.card().classes( 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(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(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.clear()
cost_chart.options.update(_cost_benefit_options(metrics)) cost_chart.options.update(_cost_benefit_options(metrics))

View File

@@ -5,7 +5,7 @@ from typing import Any
from nicegui import ui from nicegui import ui
from app.components import GreeksTable 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 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.", "Browse GLD contracts, filter by expiry and strike range, inspect Greeks, and attach contracts to hedge workflows.",
"options", "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( with ui.card().classes(
"w-full 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"
): ):
@@ -61,7 +66,9 @@ 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" "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") chain_table = ui.html("").classes("w-full")
with ui.row().classes("w-full gap-6 max-xl:flex-col"):
greeks = GreeksTable([]) greeks = GreeksTable([])
quick_add = ui.card().classes( 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" "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
@@ -96,9 +103,14 @@ async def options_page() -> None:
if not chosen_contracts: if not chosen_contracts:
ui.label("No contracts added yet.").classes("text-sm text-slate-500 dark:text-slate-400") ui.label("No contracts added yet.").classes("text-sm text-slate-500 dark:text-slate-400")
return return
with ui.column().classes("w-full gap-3"):
for contract in chosen_contracts[-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( ui.label(
f"{contract['symbol']} · premium ${float(contract['premium']):.2f} · IV {float(contract.get('impliedVolatility', 0.0)):.1%}" f"Premium ${float(contract['premium']):.2f} · IV {float(contract.get('impliedVolatility', 0.0)):.1%}"
).classes("text-sm text-slate-600 dark:text-slate-300") ).classes("text-sm text-slate-600 dark:text-slate-300")
def add_to_strategy(contract: dict[str, Any]) -> None: def add_to_strategy(contract: dict[str, Any]) -> None:
@@ -165,7 +177,7 @@ async def options_page() -> None:
f"Add {row['type'].upper()} {float(row['strike']):.0f}", f"Add {row['type'].upper()} {float(row['strike']):.0f}",
on_click=lambda _, contract=row: add_to_strategy(contract), on_click=lambda _, contract=row: add_to_strategy(contract),
).props("outline color=primary") ).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: async def load_expiry_chain(expiry: str | None) -> None:
selected_expiry["value"] = expiry selected_expiry["value"] = expiry

View File

@@ -9,7 +9,13 @@ from nicegui import ui
from app.components import PortfolioOverview from app.components import PortfolioOverview
from app.domain.portfolio_math import resolve_portfolio_spot_from_quote from app.domain.portfolio_math import resolve_portfolio_spot_from_quote
from app.models.workspace import WORKSPACE_COOKIE, get_workspace_repository 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.alerts import AlertService, build_portfolio_alert_context
from app.services.runtime import get_data_service from app.services.runtime import get_data_service
from app.services.turnstile import load_turnstile_settings 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']))}" 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( with dashboard_page(
"Overview", "Overview",
"Portfolio health, LTV risk, and quick strategy guidance for the current GLD-backed loan.", "Portfolio health, LTV risk, and quick strategy guidance for the current GLD-backed loan.",
@@ -141,6 +153,12 @@ async def overview_page(workspace_id: str) -> None:
f"Configured collateral baseline: ${config.gold_value:,.0f} · Loan ${config.loan_amount:,.0f}" f"Configured collateral baseline: ${config.gold_value:,.0f} · Loan ${config.loan_amount:,.0f}"
).classes("text-sm text-slate-500 dark:text-slate-400") ).classes("text-sm text-slate-500 dark:text-slate-400")
left_pane, right_pane = split_page_panes(
left_testid="overview-left-pane",
right_testid="overview-right-pane",
)
with left_pane:
with ui.card().classes( with ui.card().classes(
"w-full 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"
): ):
@@ -158,13 +176,11 @@ async def overview_page(workspace_id: str) -> None:
f"Latest alert logged {_format_timestamp(latest.updated_at)} at collateral spot ${latest.spot_price:,.2f}" f"Latest alert logged {_format_timestamp(latest.updated_at)} at collateral spot ${latest.spot_price:,.2f}"
).classes("text-xs text-slate-500 dark:text-slate-400") ).classes("text-xs text-slate-500 dark:text-slate-400")
spot_caption = ( with ui.card().classes(
f"{symbol} share quote converted to USD/ozt via {portfolio['quote_source']}" "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
if portfolio["quote_source"] != "configured_entry_price" ):
else "Configured entry price fallback in USD/ozt" 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"):
with ui.grid(columns=4).classes("w-full gap-4 max-lg:grid-cols-2 max-sm:grid-cols-1"):
summary_cards = [ summary_cards = [
( (
"Collateral Spot Price", "Collateral Spot Price",
@@ -189,21 +205,35 @@ async def overview_page(workspace_id: str) -> None:
] ]
for title, value, caption in summary_cards: for title, value, caption in summary_cards:
with ui.card().classes( with ui.card().classes(
"rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" "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(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(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") 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 = PortfolioOverview(margin_call_ltv=float(portfolio["margin_call_ltv"]))
portfolio_view.update(portfolio) portfolio_view.update(portfolio)
with ui.row().classes("w-full gap-6 max-lg:flex-col"): with ui.row().classes("w-full gap-6 max-xl:flex-col"):
with ui.card().classes( with ui.card().classes(
"w-full 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"
): ):
with ui.row().classes("w-full items-center justify-between"): 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("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( 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" "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"
) )
@@ -254,10 +284,3 @@ async def overview_page(workspace_id: str) -> None:
ui.label(f"${strategy['estimated_cost']:.2f}/oz").classes( 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" "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"
) )
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")

View File

@@ -5,7 +5,7 @@ from nicegui import ui
from app.models.portfolio import PortfolioConfig from app.models.portfolio import PortfolioConfig
from app.models.workspace import get_workspace_repository 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.alerts import AlertService, build_portfolio_alert_context
from app.services.settings_status import save_status_text from app.services.settings_status import save_status_text
@@ -84,7 +84,12 @@ def settings_page(workspace_id: str) -> None:
"settings", "settings",
workspace_id=workspace_id, 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( with ui.card().classes(
"w-full 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"
): ):
@@ -154,28 +159,6 @@ def settings_page(workspace_id: str) -> None:
ui.label("Margin call at:").classes("ml-4 font-medium") ui.label("Margin call at:").classes("ml-4 font-medium")
margin_price_display = ui.label() 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( with ui.card().classes(
"w-full 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"
): ):
@@ -206,6 +189,28 @@ def settings_page(workspace_id: str) -> None:
"text-sm text-slate-500 dark:text-slate-400" "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( with ui.card().classes(
"w-full 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"
): ):
@@ -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_message = ui.label().classes("text-sm text-slate-600 dark:text-slate-300")
alert_history_column = ui.column().classes("w-full gap-2") 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( with ui.card().classes(
"w-full 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"
): ):
@@ -224,9 +228,20 @@ def settings_page(workspace_id: str) -> None:
ui.select(["json", "csv", "yaml"], value="json", label="Export format").classes("w-full") ui.select(["json", "csv", "yaml"], value="json", label="Export format").classes("w-full")
ui.switch("Include scenario history", value=True) ui.switch("Include scenario history", value=True)
ui.switch("Include option selections", value=True) ui.switch("Include option selections", value=True)
with ui.row().classes("w-full gap-3 max-sm:flex-col"):
ui.button("Import settings", icon="upload").props("outline color=primary") ui.button("Import settings", icon="upload").props("outline color=primary")
ui.button("Export settings", icon="download").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: def apply_entry_basis_mode() -> None:
mode = str(entry_basis_mode.value or "value_price") mode = str(entry_basis_mode.value or "value_price")
if mode == "weight": if mode == "weight":
@@ -357,10 +372,3 @@ def settings_page(workspace_id: str) -> None:
ui.notify(f"Validation error: {e}", color="negative") ui.notify(f"Validation error: {e}", color="negative")
except Exception as e: except Exception as e:
ui.notify(f"Failed to save: {e}", color="negative") 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")

View File

@@ -11,6 +11,7 @@ notes:
- One task lives in one YAML file and changes state by moving between status folders. - 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 ordering is maintained here so agents can parse one short file first.
priority_queue: priority_queue:
- UX-001
- CORE-001D - CORE-001D
- BT-003B - BT-003B
- PORT-003 - PORT-003
@@ -42,6 +43,7 @@ states:
- BT-003B - BT-003B
- BT-001C - BT-001C
- CORE-001D - CORE-001D
- UX-001
in_progress: [] in_progress: []
done: done:
- DATA-001 - DATA-001

View File

@@ -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.

View File

@@ -9,6 +9,21 @@ ARTIFACTS = Path("tests/artifacts")
ARTIFACTS.mkdir(parents=True, exist_ok=True) 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: def test_homepage_and_options_page_render() -> None:
with sync_playwright() as p: with sync_playwright() as p:
browser = p.chromium.launch(headless=True) 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=Overview").first).to_be_visible(timeout=10000)
expect(page.locator("text=Live quote source:").first).to_be_visible(timeout=15000) 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) 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.screenshot(path=str(ARTIFACTS / "overview.png"), full_page=True)
page.goto(BASE_URL, wait_until="domcontentloaded", timeout=30000) 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=Backtests").first).to_be_visible(timeout=15000)
expect(page.locator("text=Scenario Form").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=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) 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 "RuntimeError" not in backtests_text
assert "Server error" not in backtests_text assert "Server error" not in backtests_text
assert "Traceback" not in backtests_text assert "Traceback" not in backtests_text
page.screenshot(path=str(ARTIFACTS / "backtests.png"), full_page=True) 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) 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=Event Comparison").first).to_be_visible(timeout=15000)
expect(page.locator("text=Comparison Form").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 Summary").first).to_be_visible(timeout=15000)
expect(page.locator("text=Scenario Metadata").first).to_be_visible(timeout=15000) assert_two_pane_layout(page, "event-comparison-left-pane", "event-comparison-right-pane")
expect(page.locator("text=Portfolio Value Paths").first).to_be_visible(timeout=15000)
event_text = page.locator("body").inner_text(timeout=15000) event_text = page.locator("body").inner_text(timeout=15000)
assert "GLD January 2024 Selloff" in event_text assert "GLD January 2024 Selloff" in event_text
assert "Protective Put ATM" in event_text assert "Protective Put ATM" in event_text
assert "Baseline series shows the unhedged collateral value path" in event_text assert "Historical scenario starts undercollateralized:" in event_text
assert "Hedged margin call days" in event_text assert "loan_amount must be less than initial collateral value" not in event_text
assert "Templates compared" in event_text and "4" in event_text
assert "RuntimeError" not in event_text assert "RuntimeError" not in event_text
assert "Server error" not in event_text assert "Server error" not in event_text
assert "Traceback" 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.screenshot(path=str(ARTIFACTS / "event-comparison.png"), full_page=True)
page.goto(f"{BASE_URL}/options", wait_until="domcontentloaded", timeout=30000) 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=Options Chain").first).to_be_visible(timeout=15000)
expect(page.locator("text=Filters").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) body_text = page.locator("body").inner_text(timeout=15000)
assert "Server error" not in body_text assert "Server error" not in body_text
assert "RuntimeError" 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=Settings").first).to_be_visible(timeout=15000)
expect(page.locator("text=Collateral entry basis").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) 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_label("Collateral entry basis").click()
page.get_by_text("Gold weight + entry price", exact=True).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("Underlying units")).to_have_value("2200")
expect(page.get_by_label("Loan amount")).to_have_value("222000") expect(page.get_by_label("Loan amount")).to_have_value("222000")
expect(page.get_by_label("Margin call LTV")).to_have_value("0.8") 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) backtests_workspace_text = page.locator("body").inner_text(timeout=15000)
assert "Scenario Summary" in backtests_workspace_text assert "Scenario Summary" in backtests_workspace_text
assert "Scenario Results" in backtests_workspace_text
assert "$220,000" 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) 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("Underlying units")).to_have_value("2200")
expect(page.get_by_label("Loan amount")).to_have_value("222000") expect(page.get_by_label("Loan amount")).to_have_value("222000")
expect(page.get_by_label("Margin call LTV")).to_have_value("0.8") 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) event_workspace_text = page.locator("body").inner_text(timeout=15000)
assert "$222,000" in event_workspace_text assert "$222,000" in event_workspace_text
assert "2,200" in event_workspace_text assert "2,200" in event_workspace_text
assert "80.0%" 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) page.goto(workspace_url, wait_until="domcontentloaded", timeout=30000)
overview_text = page.locator("body").inner_text(timeout=15000) 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=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 selector").first).to_be_visible(timeout=15000)
expect(page.locator("text=Strategy Controls").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) hedge_text = page.locator("body").inner_text(timeout=15000)
assert "Scenario Summary" in hedge_text assert "Scenario Summary" in hedge_text
assert "RuntimeError" not in hedge_text assert "RuntimeError" not in hedge_text