Files
vault-dash/app/pages/backtests.py
2026-03-24 19:00:22 +01:00

187 lines
9.9 KiB
Python

from __future__ import annotations
from datetime import date, datetime
from nicegui import ui
from app.pages.common import dashboard_page
from app.services.backtesting.ui_service import BacktestPageRunResult, BacktestPageService
def _chart_options(result: BacktestPageRunResult) -> dict:
template_result = result.run_result.template_results[0]
return {
"tooltip": {"trigger": "axis"},
"legend": {"data": ["Spot", "LTV hedged", "LTV unhedged"]},
"xAxis": {"type": "category", "data": [point.date.isoformat() for point in template_result.daily_path]},
"yAxis": [
{"type": "value", "name": "Spot"},
{"type": "value", "name": "LTV", "min": 0, "max": 1, "axisLabel": {"formatter": "{value}"}},
],
"series": [
{
"name": "Spot",
"type": "line",
"smooth": True,
"data": [round(point.spot_close, 2) for point in template_result.daily_path],
"lineStyle": {"color": "#0ea5e9"},
},
{
"name": "LTV hedged",
"type": "line",
"yAxisIndex": 1,
"smooth": True,
"data": [round(point.ltv_hedged, 4) for point in template_result.daily_path],
"lineStyle": {"color": "#22c55e"},
},
{
"name": "LTV unhedged",
"type": "line",
"yAxisIndex": 1,
"smooth": True,
"data": [round(point.ltv_unhedged, 4) for point in template_result.daily_path],
"lineStyle": {"color": "#ef4444"},
},
],
}
@ui.page("/backtests")
def backtests_page() -> None:
service = BacktestPageService()
template_options = service.template_options("GLD")
select_options = {str(option["slug"]): str(option["label"]) for option in template_options}
default_template_slug = str(template_options[0]["slug"]) if template_options else None
with dashboard_page(
"Backtests",
"Run a thin read-only historical scenario over the BT-001 synthetic engine and inspect the daily path.",
"backtests",
):
with ui.row().classes("w-full gap-6 max-lg:flex-col"):
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"
):
ui.label("Scenario Form").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
ui.label(
"Entry spot is auto-derived from the first historical close in the selected window so the scenario stays consistent with BT-001 entry timing."
).classes("text-sm text-slate-500 dark:text-slate-400")
ui.label("BT-001A currently supports GLD only for this thin read-only page.").classes(
"text-sm text-slate-500 dark:text-slate-400"
)
symbol_input = ui.input("Symbol", value="GLD").props("readonly").classes("w-full")
start_input = ui.input("Start date", value="2024-01-02").classes("w-full")
end_input = ui.input("End date", value="2024-01-08").classes("w-full")
template_select = ui.select(select_options, value=default_template_slug, label="Template").classes(
"w-full"
)
units_input = ui.number("Underlying units", value=1000.0, min=0.0001, step=1).classes("w-full")
loan_input = ui.number("Loan amount", value=68000.0, min=0, step=1000).classes("w-full")
ltv_input = ui.number("Margin call LTV", value=0.75, min=0.01, max=0.99, step=0.01).classes("w-full")
entry_spot_hint = ui.label("Entry spot will be auto-derived on run.").classes(
"text-sm text-slate-500 dark:text-slate-400"
)
validation_label = ui.label("").classes("text-sm text-rose-600 dark:text-rose-300")
run_button = ui.button("Run backtest").props("color=primary")
result_panel = ui.column().classes("w-full gap-6")
def parse_iso_date(raw: object, field_name: str) -> date:
try:
return datetime.strptime(str(raw), "%Y-%m-%d").date()
except ValueError as exc:
raise ValueError(f"{field_name} must be in YYYY-MM-DD format") from exc
def render_result(result: BacktestPageRunResult) -> None:
result_panel.clear()
template_result = result.run_result.template_results[0]
summary = template_result.summary_metrics
entry_spot_hint.set_text(f"Auto-derived entry spot: ${result.entry_spot:,.2f}")
with result_panel:
with ui.card().classes(
"w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
):
ui.label("Scenario Summary").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
ui.label(f"Template: {template_result.template_name}").classes(
"text-sm text-slate-500 dark:text-slate-400"
)
with ui.grid(columns=4).classes("w-full gap-4 max-lg:grid-cols-2 max-sm:grid-cols-1"):
cards = [
("Start value", f"${summary.start_value:,.0f}"),
("End value hedged", f"${summary.end_value_hedged_net:,.0f}"),
("Max LTV hedged", f"{summary.max_ltv_hedged:.1%}"),
("Hedge cost", f"${summary.total_hedge_cost:,.0f}"),
("Margin call days hedged", str(summary.margin_call_days_hedged)),
("Margin call days unhedged", str(summary.margin_call_days_unhedged)),
(
"Hedged survived",
"Yes" if not summary.margin_threshold_breached_hedged else "No",
),
(
"Unhedged breached",
"Yes" if summary.margin_threshold_breached_unhedged else "No",
),
]
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-2xl font-bold text-slate-900 dark:text-slate-100")
ui.echart(_chart_options(result)).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.card().classes(
"w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
):
ui.label("Daily Results").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
ui.table(
columns=[
{"name": "date", "label": "Date", "field": "date", "align": "left"},
{"name": "spot_close", "label": "Spot", "field": "spot_close", "align": "right"},
{"name": "net_portfolio_value", "label": "Net hedged", "field": "net_portfolio_value", "align": "right"},
{"name": "ltv_unhedged", "label": "LTV unhedged", "field": "ltv_unhedged", "align": "right"},
{"name": "ltv_hedged", "label": "LTV hedged", "field": "ltv_hedged", "align": "right"},
{"name": "margin_call_hedged", "label": "Hedged breach", "field": "margin_call_hedged", "align": "center"},
],
rows=[
{
"date": point.date.isoformat(),
"spot_close": f"${point.spot_close:,.2f}",
"net_portfolio_value": f"${point.net_portfolio_value:,.0f}",
"ltv_unhedged": f"{point.ltv_unhedged:.1%}",
"ltv_hedged": f"{point.ltv_hedged:.1%}",
"margin_call_hedged": "Yes" if point.margin_call_hedged else "No",
}
for point in template_result.daily_path
],
row_key="date",
).classes("w-full")
def run_backtest() -> None:
validation_label.set_text("")
try:
result = service.run_read_only_scenario(
symbol=str(symbol_input.value or ""),
start_date=parse_iso_date(start_input.value, "Start date"),
end_date=parse_iso_date(end_input.value, "End date"),
template_slug=str(template_select.value or ""),
underlying_units=float(units_input.value or 0.0),
loan_amount=float(loan_input.value or 0.0),
margin_call_ltv=float(ltv_input.value or 0.0),
)
except (ValueError, KeyError) as exc:
result_panel.clear()
validation_label.set_text(str(exc))
return
except Exception:
result_panel.clear()
validation_label.set_text("Backtest failed. Please verify the scenario inputs and try again.")
return
render_result(result)
run_button.on_click(lambda: run_backtest())
run_backtest()