feat(UX-001): add full-width two-pane dashboard layout
This commit is contained in:
@@ -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()
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,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"
|
"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(
|
||||||
initial_metrics = strategy_metrics(selected["strategy"], selected["scenario_pct"], portfolio=portfolio)
|
"w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
|
||||||
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 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:
|
def render_summary() -> None:
|
||||||
metrics = strategy_metrics(selected["strategy"], selected["scenario_pct"], portfolio=portfolio)
|
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()
|
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))
|
||||||
|
|||||||
@@ -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,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"
|
"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")
|
with right_pane:
|
||||||
greeks = GreeksTable([])
|
chain_table = ui.html("").classes("w-full")
|
||||||
quick_add = ui.card().classes(
|
with ui.row().classes("w-full gap-6 max-xl:flex-col"):
|
||||||
"w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
|
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:
|
def sync_status() -> None:
|
||||||
current_data = chain_state["data"]
|
current_data = chain_state["data"]
|
||||||
@@ -96,10 +103,15 @@ 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
|
||||||
for contract in chosen_contracts[-3:]:
|
with ui.column().classes("w-full gap-3"):
|
||||||
ui.label(
|
for contract in chosen_contracts[-3:]:
|
||||||
f"{contract['symbol']} · premium ${float(contract['premium']):.2f} · IV {float(contract.get('impliedVolatility', 0.0)):.1%}"
|
with ui.card().classes(
|
||||||
).classes("text-sm text-slate-600 dark:text-slate-300")
|
"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:
|
def add_to_strategy(contract: dict[str, Any]) -> None:
|
||||||
chosen_contracts.append(contract)
|
chosen_contracts.append(contract)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,123 +153,134 @@ 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")
|
||||||
|
|
||||||
with ui.card().classes(
|
left_pane, right_pane = split_page_panes(
|
||||||
"w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
|
left_testid="overview-left-pane",
|
||||||
):
|
right_testid="overview-right-pane",
|
||||||
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"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
with ui.grid(columns=4).classes("w-full gap-4 max-lg:grid-cols-2 max-sm:grid-cols-1"):
|
with left_pane:
|
||||||
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 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 gap-3"):
|
||||||
ui.label("Current LTV Status").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
|
ui.label("Alert 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(alert_status.severity.upper()).classes(_alert_badge_classes(alert_status.severity))
|
||||||
"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.label(alert_status.message).classes("text-sm text-slate-600 dark:text-slate-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(
|
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."
|
f"Warning at {alert_status.warning_threshold:.0%} · Critical at {alert_status.critical_threshold:.0%} · "
|
||||||
).classes("text-sm text-slate-600 dark:text-slate-300")
|
f"Email alerts {'enabled' if alert_status.email_alerts_enabled else 'disabled'}"
|
||||||
ui.label(
|
).classes("text-sm text-slate-500 dark:text-slate-400")
|
||||||
"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")
|
|
||||||
if alert_status.history:
|
if alert_status.history:
|
||||||
for event in alert_status.history[:5]:
|
latest = alert_status.history[0]
|
||||||
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(
|
ui.label(
|
||||||
"No alert history yet. Alerts will be logged once the warning threshold is crossed."
|
f"Latest alert logged {_format_timestamp(latest.updated_at)} at collateral spot ${latest.spot_price:,.2f}"
|
||||||
).classes("text-sm text-slate-500 dark:text-slate-400")
|
).classes("text-xs text-slate-500 dark:text-slate-400")
|
||||||
|
|
||||||
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("Strategy Snapshot").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
|
ui.label("Portfolio Snapshot").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
|
||||||
for strategy in strategy_catalog():
|
with ui.grid(columns=1).classes("w-full gap-4 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2"):
|
||||||
with ui.row().classes(
|
summary_cards = [
|
||||||
"w-full items-start justify-between gap-4 border-b border-slate-100 py-3 last:border-b-0 dark:border-slate-800"
|
(
|
||||||
|
"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"):
|
with ui.row().classes("w-full items-center justify-between"):
|
||||||
ui.label(strategy["label"]).classes("font-semibold text-slate-900 dark:text-slate-100")
|
ui.label("Current LTV Status").classes(
|
||||||
ui.label(strategy["description"]).classes("text-sm text-slate-500 dark:text-slate-400")
|
"text-lg font-semibold text-slate-900 dark:text-slate-100"
|
||||||
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"
|
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.card().classes(
|
||||||
with ui.grid(columns=3).classes("w-full gap-4 max-lg:grid-cols-1"):
|
"w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
|
||||||
for rec in quick_recommendations(portfolio):
|
):
|
||||||
with ui.card().classes(f"rounded-2xl border shadow-sm {recommendation_style(rec['tone'])}"):
|
ui.label("Recent Alert History").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
|
||||||
ui.label(rec["title"]).classes("text-base font-semibold text-slate-900 dark:text-slate-100")
|
if alert_status.history:
|
||||||
ui.label(rec["summary"]).classes("text-sm text-slate-600 dark:text-slate-300")
|
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"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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,8 +228,19 @@ 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)
|
||||||
ui.button("Import settings", icon="upload").props("outline color=primary")
|
with ui.row().classes("w-full gap-3 max-sm:flex-col"):
|
||||||
ui.button("Export settings", icon="download").props("outline color=primary")
|
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:
|
def apply_entry_basis_mode() -> None:
|
||||||
mode = str(entry_basis_mode.value or "value_price")
|
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")
|
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")
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
24
docs/roadmap/backlog/UX-001-full-width-two-pane-layout.yaml
Normal file
24
docs/roadmap/backlog/UX-001-full-width-two-pane-layout.yaml
Normal 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.
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user