fix(UX-001): address layout review findings
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
|
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
@@ -10,6 +11,8 @@ from app.models.workspace import get_workspace_repository
|
|||||||
from app.pages.common import dashboard_page, split_page_panes
|
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
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _chart_options(result: BacktestPageRunResult) -> dict:
|
def _chart_options(result: BacktestPageRunResult) -> dict:
|
||||||
template_result = result.run_result.template_results[0]
|
template_result = result.run_result.template_results[0]
|
||||||
@@ -133,18 +136,23 @@ 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:
|
def derive_entry_spot() -> tuple[float | None, str | 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) as exc:
|
||||||
|
return None, str(exc)
|
||||||
|
return resolved_entry_spot, None
|
||||||
|
|
||||||
|
def render_seeded_summary(*, entry_spot: float | None = None, entry_spot_error: str | None = None) -> None:
|
||||||
seeded_summary.clear()
|
seeded_summary.clear()
|
||||||
resolved_entry_spot = entry_spot
|
resolved_entry_spot = entry_spot
|
||||||
if resolved_entry_spot is None:
|
resolved_error = entry_spot_error
|
||||||
try:
|
if resolved_entry_spot is None and resolved_error is None:
|
||||||
resolved_entry_spot = service.derive_entry_spot(
|
resolved_entry_spot, resolved_error = 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:
|
with seeded_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=1).classes("w-full gap-4 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2"):
|
with ui.grid(columns=1).classes("w-full gap-4 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2"):
|
||||||
@@ -159,7 +167,7 @@ def _render_backtests_page(workspace_id: str | None = None) -> None:
|
|||||||
),
|
),
|
||||||
(
|
(
|
||||||
"Entry spot",
|
"Entry spot",
|
||||||
f"${resolved_entry_spot:,.2f}" if resolved_entry_spot is not None else "Pending",
|
f"${resolved_entry_spot:,.2f}" if resolved_entry_spot is not None else "Unavailable",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
for label, value in cards:
|
for label, value in cards:
|
||||||
@@ -168,15 +176,34 @@ def _render_backtests_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-xl font-bold text-slate-900 dark:text-slate-100")
|
ui.label(value).classes("text-xl font-bold text-slate-900 dark:text-slate-100")
|
||||||
|
if resolved_error:
|
||||||
|
ui.label(resolved_error).classes("text-sm text-amber-700 dark:text-amber-300")
|
||||||
|
|
||||||
def render_result_validation(message: str) -> None:
|
def render_result_state(title: str, message: str, *, tone: str = "info") -> None:
|
||||||
|
tone_classes = {
|
||||||
|
"info": "border-sky-200 bg-sky-50 dark:border-sky-900/60 dark:bg-sky-950/30",
|
||||||
|
"warning": "border-amber-200 bg-amber-50 dark:border-amber-900/60 dark:bg-amber-950/30",
|
||||||
|
"error": "border-rose-200 bg-rose-50 dark:border-rose-900/60 dark:bg-rose-950/30",
|
||||||
|
}
|
||||||
|
text_classes = {
|
||||||
|
"info": "text-sky-800 dark:text-sky-200",
|
||||||
|
"warning": "text-amber-800 dark:text-amber-200",
|
||||||
|
"error": "text-rose-800 dark:text-rose-200",
|
||||||
|
}
|
||||||
result_panel.clear()
|
result_panel.clear()
|
||||||
with result_panel:
|
with result_panel:
|
||||||
with ui.card().classes(
|
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"
|
f"w-full rounded-2xl border shadow-sm {tone_classes.get(tone, tone_classes['info'])}"
|
||||||
):
|
):
|
||||||
ui.label("Scenario Results").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
|
ui.label(title).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")
|
ui.label(message).classes(f"text-sm {text_classes.get(tone, text_classes['info'])}")
|
||||||
|
|
||||||
|
def mark_results_stale() -> None:
|
||||||
|
render_result_state(
|
||||||
|
"Results out of date",
|
||||||
|
"Inputs changed. Run backtest again to refresh charts and daily results for the current scenario.",
|
||||||
|
tone="info",
|
||||||
|
)
|
||||||
|
|
||||||
def render_result(result: BacktestPageRunResult) -> None:
|
def render_result(result: BacktestPageRunResult) -> None:
|
||||||
result_panel.clear()
|
result_panel.clear()
|
||||||
@@ -263,20 +290,23 @@ def _render_backtests_page(workspace_id: str | None = None) -> None:
|
|||||||
).classes("w-full")
|
).classes("w-full")
|
||||||
|
|
||||||
def refresh_workspace_seeded_units() -> None:
|
def refresh_workspace_seeded_units() -> None:
|
||||||
if not workspace_id or config is None or config.gold_value is None:
|
entry_spot, entry_error = derive_entry_spot()
|
||||||
return
|
if (
|
||||||
try:
|
workspace_id
|
||||||
entry_spot = service.derive_entry_spot(
|
and config is not None
|
||||||
str(symbol_input.value or "GLD"),
|
and config.gold_value is not None
|
||||||
parse_iso_date(start_input.value, "Start date"),
|
and entry_spot is not None
|
||||||
parse_iso_date(end_input.value, "End date"),
|
):
|
||||||
)
|
units_input.value = asset_quantity_from_workspace_config(config, entry_spot=entry_spot, symbol="GLD")
|
||||||
except (ValueError, KeyError):
|
entry_spot_hint.set_text(f"Auto-derived entry spot: ${entry_spot:,.2f}")
|
||||||
render_seeded_summary(entry_spot=None)
|
else:
|
||||||
return
|
entry_spot_hint.set_text("Entry spot unavailable until the scenario dates are valid.")
|
||||||
units_input.value = asset_quantity_from_workspace_config(config, entry_spot=entry_spot, symbol="GLD")
|
render_seeded_summary(entry_spot=entry_spot, entry_spot_error=entry_error)
|
||||||
entry_spot_hint.set_text(f"Auto-derived entry spot: ${entry_spot:,.2f}")
|
mark_results_stale()
|
||||||
render_seeded_summary(entry_spot=entry_spot)
|
|
||||||
|
def on_form_change() -> None:
|
||||||
|
render_seeded_summary()
|
||||||
|
mark_results_stale()
|
||||||
|
|
||||||
def run_backtest() -> None:
|
def run_backtest() -> None:
|
||||||
validation_label.set_text("")
|
validation_label.set_text("")
|
||||||
@@ -291,25 +321,39 @@ 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:
|
||||||
render_seeded_summary(entry_spot=None)
|
render_seeded_summary(entry_spot=None, entry_spot_error=None)
|
||||||
validation_label.set_text(str(exc))
|
validation_label.set_text(str(exc))
|
||||||
render_result_validation(str(exc))
|
render_result_state("Scenario validation failed", str(exc), tone="warning")
|
||||||
return
|
return
|
||||||
except Exception:
|
except Exception:
|
||||||
render_seeded_summary(entry_spot=None)
|
render_seeded_summary(entry_spot=None, entry_spot_error=None)
|
||||||
message = "Backtest failed. Please verify the scenario inputs and try again."
|
message = "Backtest failed. Please verify the scenario inputs and try again."
|
||||||
|
logger.exception(
|
||||||
|
"Backtest page run failed for workspace=%s symbol=%s start=%s end=%s template=%s units=%s loan=%s margin_call_ltv=%s",
|
||||||
|
workspace_id,
|
||||||
|
symbol_input.value,
|
||||||
|
start_input.value,
|
||||||
|
end_input.value,
|
||||||
|
template_select.value,
|
||||||
|
units_input.value,
|
||||||
|
loan_input.value,
|
||||||
|
ltv_input.value,
|
||||||
|
)
|
||||||
validation_label.set_text(message)
|
validation_label.set_text(message)
|
||||||
render_result_validation(message)
|
render_result_state("Backtest failed", message, tone="error")
|
||||||
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())
|
else:
|
||||||
units_input.on_value_change(lambda _event: render_seeded_summary())
|
start_input.on_value_change(lambda _event: on_form_change())
|
||||||
loan_input.on_value_change(lambda _event: render_seeded_summary())
|
end_input.on_value_change(lambda _event: on_form_change())
|
||||||
ltv_input.on_value_change(lambda _event: render_seeded_summary())
|
template_select.on_value_change(lambda _event: on_form_change())
|
||||||
|
units_input.on_value_change(lambda _event: on_form_change())
|
||||||
|
loan_input.on_value_change(lambda _event: on_form_change())
|
||||||
|
ltv_input.on_value_change(lambda _event: on_form_change())
|
||||||
run_button.on_click(lambda: run_backtest())
|
run_button.on_click(lambda: run_backtest())
|
||||||
render_seeded_summary(entry_spot=default_entry_spot)
|
render_seeded_summary(entry_spot=default_entry_spot)
|
||||||
run_backtest()
|
run_backtest()
|
||||||
|
|||||||
@@ -124,13 +124,10 @@ def strategy_metrics(
|
|||||||
|
|
||||||
|
|
||||||
def split_page_panes(*, left_testid: str, right_testid: str) -> tuple[ui.column, ui.column]:
|
def split_page_panes(*, left_testid: str, right_testid: str) -> tuple[ui.column, ui.column]:
|
||||||
|
"""Render responsive page panes with a desktop 1:2 flex split and stable test hooks."""
|
||||||
with ui.row().classes("w-full items-start gap-6 max-lg:flex-col lg:flex-nowrap"):
|
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(
|
left = ui.column().classes("min-w-0 w-full gap-6 lg:flex-[1_1_0%]").props(f"data-testid={left_testid}")
|
||||||
f"data-testid={left_testid}"
|
right = ui.column().classes("min-w-0 w-full gap-6 lg:flex-[2_1_0%]").props(f"data-testid={right_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
|
return left, right
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
|
|
||||||
@@ -8,6 +10,8 @@ from app.models.workspace import get_workspace_repository
|
|||||||
from app.pages.common import dashboard_page, split_page_panes
|
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
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _chart_options(dates: tuple[str, ...], series: tuple[dict[str, object], ...]) -> dict:
|
def _chart_options(dates: tuple[str, ...], series: tuple[dict[str, object], ...]) -> dict:
|
||||||
return {
|
return {
|
||||||
@@ -124,13 +128,15 @@ def _render_event_comparison_page(workspace_id: str | None = None) -> None:
|
|||||||
with right_pane:
|
with right_pane:
|
||||||
result_panel = ui.column().classes("w-full gap-6")
|
result_panel = ui.column().classes("w-full gap-6")
|
||||||
|
|
||||||
|
syncing_controls = {"value": False}
|
||||||
|
|
||||||
def selected_template_slugs() -> tuple[str, ...]:
|
def selected_template_slugs() -> tuple[str, ...]:
|
||||||
raw_value = template_select.value or []
|
raw_value = template_select.value or []
|
||||||
if isinstance(raw_value, str):
|
if isinstance(raw_value, str):
|
||||||
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:
|
def render_selected_summary(entry_spot: float | None = None, entry_spot_error: str | None = None) -> None:
|
||||||
selected_summary.clear()
|
selected_summary.clear()
|
||||||
with selected_summary:
|
with selected_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")
|
||||||
@@ -144,7 +150,7 @@ def _render_event_comparison_page(workspace_id: str | None = None) -> None:
|
|||||||
("Underlying units", f"{float(units_input.value or 0.0):,.0f}"),
|
("Underlying units", f"{float(units_input.value or 0.0):,.0f}"),
|
||||||
("Loan amount", f"${float(loan_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%}"),
|
("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"),
|
("Entry spot", f"${entry_spot:,.2f}" if entry_spot is not None else "Unavailable"),
|
||||||
]
|
]
|
||||||
for label, value in cards:
|
for label, value in cards:
|
||||||
with ui.card().classes(
|
with ui.card().classes(
|
||||||
@@ -152,18 +158,53 @@ def _render_event_comparison_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-xl font-bold text-slate-900 dark:text-slate-100")
|
ui.label(value).classes("text-xl font-bold text-slate-900 dark:text-slate-100")
|
||||||
|
if entry_spot_error:
|
||||||
|
ui.label(entry_spot_error).classes("text-sm text-amber-700 dark:text-amber-300")
|
||||||
|
|
||||||
def refresh_preset_details() -> None:
|
def render_result_state(title: str, message: str, *, tone: str = "info") -> None:
|
||||||
|
tone_classes = {
|
||||||
|
"info": "border-sky-200 bg-sky-50 dark:border-sky-900/60 dark:bg-sky-950/30",
|
||||||
|
"warning": "border-amber-200 bg-amber-50 dark:border-amber-900/60 dark:bg-amber-950/30",
|
||||||
|
"error": "border-rose-200 bg-rose-50 dark:border-rose-900/60 dark:bg-rose-950/30",
|
||||||
|
}
|
||||||
|
text_classes = {
|
||||||
|
"info": "text-sky-800 dark:text-sky-200",
|
||||||
|
"warning": "text-amber-800 dark:text-amber-200",
|
||||||
|
"error": "text-rose-800 dark:text-rose-200",
|
||||||
|
}
|
||||||
|
result_panel.clear()
|
||||||
|
with result_panel:
|
||||||
|
with ui.card().classes(
|
||||||
|
f"w-full rounded-2xl border shadow-sm {tone_classes.get(tone, tone_classes['info'])}"
|
||||||
|
):
|
||||||
|
ui.label(title).classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
|
||||||
|
ui.label(message).classes(f"text-sm {text_classes.get(tone, text_classes['info'])}")
|
||||||
|
|
||||||
|
def mark_results_stale() -> None:
|
||||||
|
render_result_state(
|
||||||
|
"Results out of date",
|
||||||
|
"Inputs changed. Run comparison again to refresh rankings and portfolio paths for the current scenario.",
|
||||||
|
tone="info",
|
||||||
|
)
|
||||||
|
|
||||||
|
def refresh_preview(*, reset_templates: bool = False, reseed_units: bool = False) -> 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)
|
render_selected_summary(entry_spot=None)
|
||||||
return
|
return
|
||||||
template_select.value = list(service.default_template_selection(str(option["slug"])))
|
|
||||||
|
if reset_templates:
|
||||||
|
syncing_controls["value"] = True
|
||||||
|
try:
|
||||||
|
template_select.value = list(service.default_template_selection(str(option["slug"])))
|
||||||
|
finally:
|
||||||
|
syncing_controls["value"] = False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
preview_units = float(units_input.value or 0.0)
|
preview_units = float(units_input.value or 0.0)
|
||||||
if workspace_id and config is not None:
|
if workspace_id and config is not None and reseed_units:
|
||||||
preview_scenario = service.preview_scenario(
|
preview_scenario = service.preview_scenario(
|
||||||
preset_slug=str(option["slug"]),
|
preset_slug=str(option["slug"]),
|
||||||
template_slugs=selected_template_slugs(),
|
template_slugs=selected_template_slugs(),
|
||||||
@@ -176,7 +217,11 @@ def _render_event_comparison_page(workspace_id: str | None = None) -> None:
|
|||||||
entry_spot=float(preview_scenario.initial_portfolio.entry_spot),
|
entry_spot=float(preview_scenario.initial_portfolio.entry_spot),
|
||||||
symbol="GLD",
|
symbol="GLD",
|
||||||
)
|
)
|
||||||
units_input.value = preview_units
|
syncing_controls["value"] = True
|
||||||
|
try:
|
||||||
|
units_input.value = preview_units
|
||||||
|
finally:
|
||||||
|
syncing_controls["value"] = False
|
||||||
scenario = service.preview_scenario(
|
scenario = service.preview_scenario(
|
||||||
preset_slug=str(option["slug"]),
|
preset_slug=str(option["slug"]),
|
||||||
template_slugs=selected_template_slugs(),
|
template_slugs=selected_template_slugs(),
|
||||||
@@ -187,7 +232,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)
|
render_selected_summary(entry_spot=None, entry_spot_error=str(exc))
|
||||||
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']}")
|
||||||
@@ -203,15 +248,6 @@ def _render_event_comparison_page(workspace_id: str | None = None) -> None:
|
|||||||
)
|
)
|
||||||
render_selected_summary(entry_spot=float(scenario.initial_portfolio.entry_spot))
|
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("")
|
||||||
result_panel.clear()
|
result_panel.clear()
|
||||||
@@ -225,12 +261,21 @@ 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))
|
render_result_state("Scenario validation failed", str(exc), tone="warning")
|
||||||
return
|
return
|
||||||
except Exception:
|
except Exception:
|
||||||
message = "Event comparison failed. Please verify the seeded inputs and try again."
|
message = "Event comparison failed. Please verify the seeded inputs and try again."
|
||||||
|
logger.exception(
|
||||||
|
"Event comparison page run failed for workspace=%s preset=%s templates=%s units=%s loan=%s margin_call_ltv=%s",
|
||||||
|
workspace_id,
|
||||||
|
preset_select.value,
|
||||||
|
selected_template_slugs(),
|
||||||
|
units_input.value,
|
||||||
|
loan_input.value,
|
||||||
|
ltv_input.value,
|
||||||
|
)
|
||||||
validation_label.set_text(message)
|
validation_label.set_text(message)
|
||||||
render_result_validation(message)
|
render_result_state("Event comparison failed", message, tone="error")
|
||||||
return
|
return
|
||||||
|
|
||||||
preset = report.event_preset
|
preset = report.event_preset
|
||||||
@@ -340,11 +385,23 @@ 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())
|
def on_preset_change() -> None:
|
||||||
template_select.on_value_change(lambda _: refresh_preset_details())
|
if syncing_controls["value"]:
|
||||||
units_input.on_value_change(lambda _: refresh_preset_details())
|
return
|
||||||
loan_input.on_value_change(lambda _: refresh_preset_details())
|
refresh_preview(reset_templates=True, reseed_units=True)
|
||||||
ltv_input.on_value_change(lambda _: refresh_preset_details())
|
mark_results_stale()
|
||||||
|
|
||||||
|
def on_preview_input_change() -> None:
|
||||||
|
if syncing_controls["value"]:
|
||||||
|
return
|
||||||
|
refresh_preview()
|
||||||
|
mark_results_stale()
|
||||||
|
|
||||||
|
preset_select.on_value_change(lambda _: on_preset_change())
|
||||||
|
template_select.on_value_change(lambda _: on_preview_input_change())
|
||||||
|
units_input.on_value_change(lambda _: on_preview_input_change())
|
||||||
|
loan_input.on_value_change(lambda _: on_preview_input_change())
|
||||||
|
ltv_input.on_value_change(lambda _: on_preview_input_change())
|
||||||
run_button.on_click(lambda: render_report())
|
run_button.on_click(lambda: render_report())
|
||||||
refresh_preset_details()
|
refresh_preview(reset_templates=False, reseed_units=False)
|
||||||
render_report()
|
render_report()
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ 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
|
||||||
@@ -24,6 +23,7 @@ priority_queue:
|
|||||||
- OPS-001
|
- OPS-001
|
||||||
- BT-003
|
- BT-003
|
||||||
recently_completed:
|
recently_completed:
|
||||||
|
- UX-001
|
||||||
- CORE-002
|
- CORE-002
|
||||||
- CORE-002C
|
- CORE-002C
|
||||||
- CORE-001D2B
|
- CORE-001D2B
|
||||||
@@ -43,7 +43,6 @@ 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
|
||||||
@@ -68,5 +67,6 @@ states:
|
|||||||
- CORE-002A
|
- CORE-002A
|
||||||
- CORE-002B
|
- CORE-002B
|
||||||
- CORE-002C
|
- CORE-002C
|
||||||
|
- UX-001
|
||||||
blocked: []
|
blocked: []
|
||||||
cancelled: []
|
cancelled: []
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
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.
|
|
||||||
21
docs/roadmap/done/UX-001-full-width-two-pane-layout.yaml
Normal file
21
docs/roadmap/done/UX-001-full-width-two-pane-layout.yaml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
id: UX-001
|
||||||
|
title: Full-Width Two-Pane Page Layout
|
||||||
|
status: done
|
||||||
|
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.
|
||||||
|
completed_notes:
|
||||||
|
- Added a shared `split_page_panes(...)` helper in `app/pages/common.py` and removed the old centered max-width dashboard container.
|
||||||
|
- Applied the full-width two-pane layout to overview, hedge, backtests, event comparison, options, and settings pages.
|
||||||
|
- Fixed desktop pane overflow by switching the shared helper to a shrinkable 1:2 flex split instead of fixed width fractions plus gap.
|
||||||
|
- Historical scenario pages now mark prior right-pane output as stale when inputs change instead of silently showing old results beside new left-pane summaries.
|
||||||
|
- Event comparison now preserves manual template/unit edits across preview refreshes except when the preset itself changes and intentionally resets defaults.
|
||||||
|
- Added browser-visible Playwright coverage for desktop pane ratios, no-horizontal-overflow checks, stacked narrow-width behavior, and stale-result states.
|
||||||
@@ -9,6 +9,13 @@ ARTIFACTS = Path("tests/artifacts")
|
|||||||
ARTIFACTS.mkdir(parents=True, exist_ok=True)
|
ARTIFACTS.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def assert_no_horizontal_overflow(page) -> None:
|
||||||
|
scroll_width = page.evaluate("document.documentElement.scrollWidth")
|
||||||
|
viewport_width = page.evaluate("window.innerWidth")
|
||||||
|
assert scroll_width <= viewport_width + 1
|
||||||
|
|
||||||
|
|
||||||
|
# UX-001 expects an approximately 1/3 and 2/3 desktop split without horizontal overflow.
|
||||||
def assert_two_pane_layout(page, left_testid: str, right_testid: str) -> None:
|
def assert_two_pane_layout(page, left_testid: str, right_testid: str) -> None:
|
||||||
left = page.locator(f'[data-testid="{left_testid}"]:visible').first
|
left = page.locator(f'[data-testid="{left_testid}"]:visible').first
|
||||||
right = page.locator(f'[data-testid="{right_testid}"]:visible').first
|
right = page.locator(f'[data-testid="{right_testid}"]:visible').first
|
||||||
@@ -21,7 +28,22 @@ def assert_two_pane_layout(page, left_testid: str, right_testid: str) -> None:
|
|||||||
assert left_box["x"] < right_box["x"]
|
assert left_box["x"] < right_box["x"]
|
||||||
assert abs(left_box["y"] - right_box["y"]) < 120
|
assert abs(left_box["y"] - right_box["y"]) < 120
|
||||||
width_ratio = right_box["width"] / left_box["width"]
|
width_ratio = right_box["width"] / left_box["width"]
|
||||||
assert 1.6 <= width_ratio <= 2.4
|
assert 1.4 <= width_ratio <= 2.6
|
||||||
|
assert_no_horizontal_overflow(page)
|
||||||
|
|
||||||
|
|
||||||
|
def assert_stacked_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 abs(left_box["x"] - right_box["x"]) < 40
|
||||||
|
assert right_box["y"] > left_box["y"] + left_box["height"] * 0.5
|
||||||
|
assert_no_horizontal_overflow(page)
|
||||||
|
|
||||||
|
|
||||||
def test_homepage_and_options_page_render() -> None:
|
def test_homepage_and_options_page_render() -> None:
|
||||||
@@ -37,8 +59,14 @@ def test_homepage_and_options_page_render() -> None:
|
|||||||
workspace_url = page.url
|
workspace_url = page.url
|
||||||
workspace_id = workspace_url.removeprefix(f"{BASE_URL}/")
|
workspace_id = workspace_url.removeprefix(f"{BASE_URL}/")
|
||||||
assert workspace_id
|
assert workspace_id
|
||||||
cookies = page.context.cookies()
|
workspace_cookie = None
|
||||||
workspace_cookie = next(cookie for cookie in cookies if cookie["name"] == "workspace_id")
|
for _ in range(5):
|
||||||
|
cookies = page.context.cookies()
|
||||||
|
workspace_cookie = next((cookie for cookie in cookies if cookie["name"] == "workspace_id"), None)
|
||||||
|
if workspace_cookie is not None:
|
||||||
|
break
|
||||||
|
page.wait_for_timeout(200)
|
||||||
|
assert workspace_cookie is not None
|
||||||
assert workspace_cookie["value"] == workspace_id
|
assert workspace_cookie["value"] == workspace_id
|
||||||
expect(page.locator("text=Vault Dashboard").first).to_be_visible(timeout=10000)
|
expect(page.locator("text=Vault Dashboard").first).to_be_visible(timeout=10000)
|
||||||
expect(page.locator("text=Overview").first).to_be_visible(timeout=10000)
|
expect(page.locator("text=Overview").first).to_be_visible(timeout=10000)
|
||||||
@@ -47,6 +75,11 @@ def test_homepage_and_options_page_render() -> None:
|
|||||||
assert_two_pane_layout(page, "overview-left-pane", "overview-right-pane")
|
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.set_viewport_size({"width": 900, "height": 1000})
|
||||||
|
page.goto(workspace_url, wait_until="domcontentloaded", timeout=30000)
|
||||||
|
assert_stacked_pane_layout(page, "overview-left-pane", "overview-right-pane")
|
||||||
|
page.set_viewport_size({"width": 1440, "height": 1000})
|
||||||
|
|
||||||
page.goto(BASE_URL, wait_until="domcontentloaded", timeout=30000)
|
page.goto(BASE_URL, wait_until="domcontentloaded", timeout=30000)
|
||||||
page.wait_for_url(workspace_url, timeout=15000)
|
page.wait_for_url(workspace_url, 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)
|
||||||
@@ -122,13 +155,15 @@ def test_homepage_and_options_page_render() -> None:
|
|||||||
assert_two_pane_layout(page, "backtests-left-pane", "backtests-right-pane")
|
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 "Scenario validation failed" 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
|
assert "Historical scenario starts undercollateralized:" in backtests_workspace_text
|
||||||
|
|
||||||
page.get_by_label("Underlying units").fill("3000")
|
page.get_by_label("Underlying units").fill("3000")
|
||||||
|
expect(page.locator("text=Results out of date").first).to_be_visible(timeout=15000)
|
||||||
page.get_by_label("Template").click()
|
page.get_by_label("Template").click()
|
||||||
page.get_by_text("Protective Put 95%", exact=True).click()
|
page.get_by_text("Protective Put 95%", exact=True).click()
|
||||||
|
expect(page.locator("text=Results out of date").first).to_be_visible(timeout=15000)
|
||||||
page.get_by_role("button", name="Run backtest").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=Daily Results").first).to_be_visible(timeout=15000)
|
||||||
expect(page.locator("text=Scenario Results").first).to_be_visible(timeout=15000)
|
expect(page.locator("text=Scenario Results").first).to_be_visible(timeout=15000)
|
||||||
@@ -148,9 +183,16 @@ def test_homepage_and_options_page_render() -> None:
|
|||||||
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 "Scenario validation failed" in event_workspace_text
|
||||||
assert "Historical scenario starts undercollateralized:" in event_workspace_text
|
assert "Historical scenario starts undercollateralized:" in event_workspace_text
|
||||||
|
|
||||||
page.get_by_label("Underlying units").fill("3000")
|
page.get_by_label("Underlying units").fill("3000")
|
||||||
|
expect(page.locator("text=Results out of date").first).to_be_visible(timeout=15000)
|
||||||
|
event_before_run_text = page.locator("body").inner_text(timeout=15000)
|
||||||
|
assert (
|
||||||
|
"Protective Put ATM, Protective Put 95%, Protective Put 90%, Laddered Puts 50/50 ATM + 95%"
|
||||||
|
in event_before_run_text
|
||||||
|
)
|
||||||
page.get_by_role("button", name="Run comparison").click()
|
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=Ranked Results").first).to_be_visible(timeout=15000)
|
||||||
expect(page.locator("text=Scenario Results").first).to_be_visible(timeout=15000)
|
expect(page.locator("text=Scenario Results").first).to_be_visible(timeout=15000)
|
||||||
@@ -162,9 +204,12 @@ def test_homepage_and_options_page_render() -> None:
|
|||||||
|
|
||||||
page.get_by_label("Event preset").click()
|
page.get_by_label("Event preset").click()
|
||||||
page.get_by_text("GLD January 2024 Drawdown", exact=True).click()
|
page.get_by_text("GLD January 2024 Drawdown", exact=True).click()
|
||||||
|
expect(page.locator("text=Results out of date").first).to_be_visible(timeout=15000)
|
||||||
page.get_by_role("button", name="Run comparison").click()
|
page.get_by_role("button", name="Run comparison").click()
|
||||||
expect(page.locator("text=GLD January 2024 Drawdown").first).to_be_visible(timeout=15000)
|
expect(page.locator("text=Ranked 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)
|
rerun_event_text = page.locator("body").inner_text(timeout=15000)
|
||||||
|
assert "GLD January 2024 Drawdown" in rerun_event_text
|
||||||
assert "Laddered Puts 33/33/33 ATM + 95% + 90%" in rerun_event_text
|
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 "Templates compared" in rerun_event_text and "3" in rerun_event_text
|
||||||
assert "RuntimeError" not in rerun_event_text
|
assert "RuntimeError" not in rerun_event_text
|
||||||
|
|||||||
Reference in New Issue
Block a user