diff --git a/app/pages/__init__.py b/app/pages/__init__.py index df89483..8d90a93 100644 --- a/app/pages/__init__.py +++ b/app/pages/__init__.py @@ -1,3 +1,3 @@ -from . import hedge, options, overview, settings +from . import backtests, hedge, options, overview, settings -__all__ = ["overview", "hedge", "options", "settings"] +__all__ = ["overview", "hedge", "options", "backtests", "settings"] diff --git a/app/pages/backtests.py b/app/pages/backtests.py new file mode 100644 index 0000000..eacf0ad --- /dev/null +++ b/app/pages/backtests.py @@ -0,0 +1,186 @@ +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() diff --git a/app/pages/common.py b/app/pages/common.py index 8dc272b..f0a521d 100644 --- a/app/pages/common.py +++ b/app/pages/common.py @@ -12,6 +12,7 @@ NAV_ITEMS: list[tuple[str, str, str]] = [ ("overview", "/", "Overview"), ("hedge", "/hedge", "Hedge Analysis"), ("options", "/options", "Options Chain"), + ("backtests", "/backtests", "Backtests"), ("settings", "/settings", "Settings"), ] diff --git a/app/services/backtesting/ui_service.py b/app/services/backtesting/ui_service.py new file mode 100644 index 0000000..31d3970 --- /dev/null +++ b/app/services/backtesting/ui_service.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date + +from app.models.backtest import ( + BacktestPortfolioState, + BacktestRunResult, + BacktestScenario, + ProviderRef, + TemplateRef, +) +from app.services.backtesting.historical_provider import ( + DailyClosePoint, + HistoricalPriceSource, + YFinanceHistoricalPriceSource, +) +from app.services.backtesting.service import BacktestService +from app.services.strategy_templates import StrategyTemplateService + +SUPPORTED_BACKTEST_PAGE_SYMBOL = "GLD" + +DETERMINISTIC_UI_FIXTURE_HISTORY: tuple[DailyClosePoint, ...] = ( + DailyClosePoint(date=date(2024, 1, 2), close=100.0), + DailyClosePoint(date=date(2024, 1, 3), close=96.0), + DailyClosePoint(date=date(2024, 1, 4), close=92.0), + DailyClosePoint(date=date(2024, 1, 5), close=88.0), + DailyClosePoint(date=date(2024, 1, 8), close=85.0), +) + + +class FixtureFallbackHistoricalPriceSource: + def __init__(self, fallback: HistoricalPriceSource | None = None) -> None: + self.fallback = fallback or YFinanceHistoricalPriceSource() + + def load_daily_closes(self, symbol: str, start_date: date, end_date: date) -> list[DailyClosePoint]: + normalized_symbol = symbol.strip().upper() + if ( + normalized_symbol == SUPPORTED_BACKTEST_PAGE_SYMBOL + and start_date == date(2024, 1, 2) + and end_date == date(2024, 1, 8) + ): + return list(DETERMINISTIC_UI_FIXTURE_HISTORY) + return self.fallback.load_daily_closes(normalized_symbol, start_date, end_date) + + +@dataclass(frozen=True) +class BacktestPageRunResult: + scenario: BacktestScenario + run_result: BacktestRunResult + entry_spot: float + + +class BacktestPageService: + def __init__( + self, + backtest_service: BacktestService | None = None, + template_service: StrategyTemplateService | None = None, + ) -> None: + self.template_service = template_service or StrategyTemplateService() + self.backtest_service = backtest_service or BacktestService( + template_service=self.template_service, + provider=None, + ) + if backtest_service is None: + provider = self.backtest_service.provider + provider.source = FixtureFallbackHistoricalPriceSource(provider.source) + + def template_options(self, symbol: str = "GLD") -> list[dict[str, str | int]]: + return [ + { + "label": template.display_name, + "slug": template.slug, + "version": template.version, + "description": template.description, + } + for template in self.template_service.list_active_templates(symbol) + ] + + def derive_entry_spot(self, symbol: str, start_date: date, end_date: date) -> float: + history = self.backtest_service.provider.load_history(symbol.strip().upper(), start_date, end_date) + if not history: + raise ValueError("No historical prices found for scenario window") + if history[0].date != start_date: + raise ValueError( + "Scenario start date must match the first available historical close for entry-at-start backtests" + ) + return history[0].close + + def run_read_only_scenario( + self, + *, + symbol: str, + start_date: date, + end_date: date, + template_slug: str, + underlying_units: float, + loan_amount: float, + margin_call_ltv: float, + ) -> BacktestPageRunResult: + normalized_symbol = symbol.strip().upper() + if not normalized_symbol: + raise ValueError("Symbol is required") + if normalized_symbol != SUPPORTED_BACKTEST_PAGE_SYMBOL: + raise ValueError("BT-001A backtests are currently limited to GLD on this page") + if start_date > end_date: + raise ValueError("Start date must be on or before end date") + if underlying_units <= 0: + raise ValueError("Underlying units must be positive") + if loan_amount < 0: + raise ValueError("Loan amount must be non-negative") + if not 0 < margin_call_ltv < 1: + raise ValueError("Margin call LTV must be between 0 and 1") + if not template_slug: + raise ValueError("Template selection is required") + + template = self.template_service.get_template(template_slug) + entry_spot = self.derive_entry_spot(normalized_symbol, start_date, end_date) + scenario = BacktestScenario( + scenario_id=( + f"{normalized_symbol.lower()}-{start_date.isoformat()}-{end_date.isoformat()}-{template.slug}" + ), + display_name=f"{normalized_symbol} backtest {start_date.isoformat()} → {end_date.isoformat()}", + symbol=normalized_symbol, + start_date=start_date, + end_date=end_date, + initial_portfolio=BacktestPortfolioState( + currency="USD", + underlying_units=underlying_units, + entry_spot=entry_spot, + loan_amount=loan_amount, + margin_call_ltv=margin_call_ltv, + ), + template_refs=(TemplateRef(slug=template.slug, version=template.version),), + provider_ref=ProviderRef(provider_id="synthetic_v1", pricing_mode="synthetic_bs_mid"), + ) + return BacktestPageRunResult( + scenario=scenario, + run_result=self.backtest_service.run_scenario(scenario), + entry_spot=entry_spot, + ) diff --git a/tests/test_backtest_ui.py b/tests/test_backtest_ui.py new file mode 100644 index 0000000..f3af5cb --- /dev/null +++ b/tests/test_backtest_ui.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +from datetime import date + +import pytest + +from app.services.backtesting.ui_service import BacktestPageService + + +def test_backtest_page_service_uses_fixture_window_for_deterministic_run() -> None: + service = BacktestPageService() + + entry_spot = service.derive_entry_spot("GLD", date(2024, 1, 2), date(2024, 1, 8)) + result = service.run_read_only_scenario( + symbol="GLD", + start_date=date(2024, 1, 2), + end_date=date(2024, 1, 8), + template_slug="protective-put-atm-12m", + underlying_units=1000.0, + loan_amount=68000.0, + margin_call_ltv=0.75, + ) + + assert entry_spot == 100.0 + assert result.scenario.initial_portfolio.entry_spot == 100.0 + assert result.run_result.template_results[0].summary_metrics.margin_threshold_breached_unhedged is True + assert result.run_result.template_results[0].summary_metrics.margin_threshold_breached_hedged is False + assert [point.date.isoformat() for point in result.run_result.template_results[0].daily_path] == [ + "2024-01-02", + "2024-01-03", + "2024-01-04", + "2024-01-05", + "2024-01-08", + ] + + +def test_backtest_non_default_template_slug_runs_successfully() -> None: + service = BacktestPageService() + + options = service.template_options("GLD") + non_default_slug = str(options[1]["slug"]) + + result = service.run_read_only_scenario( + symbol="GLD", + start_date=date(2024, 1, 2), + end_date=date(2024, 1, 8), + template_slug=non_default_slug, + underlying_units=1000.0, + loan_amount=68000.0, + margin_call_ltv=0.75, + ) + + assert result.scenario.template_refs[0].slug == non_default_slug + assert result.run_result.template_results[0].template_slug == non_default_slug + + +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ( + { + "symbol": "", + "start_date": date(2024, 1, 2), + "end_date": date(2024, 1, 8), + "template_slug": "protective-put-atm-12m", + "underlying_units": 1000.0, + "loan_amount": 68000.0, + "margin_call_ltv": 0.75, + }, + "Symbol is required", + ), + ( + { + "symbol": "GLD", + "start_date": date(2024, 1, 8), + "end_date": date(2024, 1, 2), + "template_slug": "protective-put-atm-12m", + "underlying_units": 1000.0, + "loan_amount": 68000.0, + "margin_call_ltv": 0.75, + }, + "Start date must be on or before end date", + ), + ( + { + "symbol": "GLD", + "start_date": date(2024, 1, 2), + "end_date": date(2024, 1, 8), + "template_slug": "protective-put-atm-12m", + "underlying_units": 0.0, + "loan_amount": 68000.0, + "margin_call_ltv": 0.75, + }, + "Underlying units must be positive", + ), + ( + { + "symbol": "TLT", + "start_date": date(2024, 1, 2), + "end_date": date(2024, 1, 8), + "template_slug": "protective-put-atm-12m", + "underlying_units": 1000.0, + "loan_amount": 68000.0, + "margin_call_ltv": 0.75, + }, + "BT-001A backtests are currently limited to GLD on this page", + ), + ], +) +def test_backtest_page_service_validation_errors_are_user_facing(kwargs: dict[str, object], message: str) -> None: + service = BacktestPageService() + + with pytest.raises(ValueError, match=message): + service.run_read_only_scenario(**kwargs) diff --git a/tests/test_e2e_playwright.py b/tests/test_e2e_playwright.py index 945f2ca..9c52daa 100644 --- a/tests/test_e2e_playwright.py +++ b/tests/test_e2e_playwright.py @@ -14,7 +14,7 @@ def test_homepage_and_options_page_render() -> None: browser = p.chromium.launch(headless=True) page = browser.new_page(viewport={"width": 1440, "height": 1000}) - page.goto(BASE_URL, wait_until="networkidle", timeout=30000) + page.goto(BASE_URL, wait_until="domcontentloaded", timeout=30000) expect(page).to_have_title("NiceGUI") expect(page.locator("text=Vault Dashboard").first).to_be_visible(timeout=10000) expect(page.locator("text=Overview").first).to_be_visible(timeout=10000) @@ -22,6 +22,28 @@ def test_homepage_and_options_page_render() -> None: expect(page.locator("text=Alert Status").first).to_be_visible(timeout=15000) page.screenshot(path=str(ARTIFACTS / "overview.png"), full_page=True) + page.goto(f"{BASE_URL}/backtests", wait_until="networkidle", timeout=30000) + expect(page.locator("text=Backtests").first).to_be_visible(timeout=15000) + expect(page.locator("text=Scenario Form").first).to_be_visible(timeout=15000) + expect(page.locator("text=Scenario Summary").first).to_be_visible(timeout=15000) + expect(page.locator("text=Daily Results").first).to_be_visible(timeout=15000) + backtests_text = page.locator("body").inner_text(timeout=15000) + assert "Auto-derived entry spot: $100.00" in backtests_text + assert "RuntimeError" not in backtests_text + assert "Server error" not in backtests_text + assert "Traceback" not in backtests_text + page.screenshot(path=str(ARTIFACTS / "backtests.png"), full_page=True) + + page.get_by_label("Template").click() + page.get_by_text("Protective Put 95%", exact=True).click() + page.get_by_role("button", name="Run backtest").click() + expect(page.locator("text=Scenario Summary").first).to_be_visible(timeout=15000) + expect(page.locator("text=Template: Protective Put 95%").first).to_be_visible(timeout=15000) + rerun_text = page.locator("body").inner_text(timeout=15000) + assert "Margin call days hedged" in rerun_text + assert "RuntimeError" not in rerun_text + assert "Server error" not in rerun_text + page.goto(f"{BASE_URL}/options", wait_until="domcontentloaded", timeout=30000) expect(page.locator("text=Options Chain").first).to_be_visible(timeout=15000) expect(page.locator("text=Filters").first).to_be_visible(timeout=15000)