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.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()

View File

@@ -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")

View File

@@ -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()

View File

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

View File

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

View File

@@ -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"
)

View File

@@ -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")

View File

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

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)
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