From 2b500dfcb304e6a96fcdfb0fa4cdbf9f51a42b3a Mon Sep 17 00:00:00 2001 From: Bu5hm4nn Date: Tue, 31 Mar 2026 23:31:04 +0200 Subject: [PATCH] fix(backtest): run backtest asynchronously to prevent WebSocket timeout - Use run.io_bound() from NiceGUI to run Databento API calls in background thread - Add loading state to Run Backtest button - Show notification when backtest starts and completes - Remove loading state on completion/error This prevents 'Connection lost' errors when the backtest takes longer than the WebSocket timeout. --- app/pages/backtests.py | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/app/pages/backtests.py b/app/pages/backtests.py index cb3e6bc..11e5b85 100644 --- a/app/pages/backtests.py +++ b/app/pages/backtests.py @@ -5,7 +5,7 @@ from datetime import date, datetime, timedelta from typing import Any from fastapi.responses import RedirectResponse -from nicegui import ui +from nicegui import run, ui from app.domain.backtesting_math import asset_quantity_from_workspace_config from app.models.backtest import ProviderRef @@ -811,8 +811,15 @@ def _render_backtests_page(workspace_id: str | None = None) -> None: # Keep existing entry spot, don't re-derive mark_results_stale() - def run_backtest() -> None: + async def run_backtest() -> None: + """Run the backtest asynchronously to avoid blocking the WebSocket.""" validation_label.set_text("") + + # Show loading state + run_button.props('loading') + validation_label.set_text("Running backtest...") + ui.notify("Running backtest...", type="info") + try: # Validate date range for symbol start_date = parse_iso_date(start_input.value, "Start date") @@ -830,12 +837,14 @@ def _render_backtests_page(workspace_id: str | None = None) -> None: f"Selected start date {start_date.strftime('%Y-%m-%d')} is before available data." ) render_result_state("Invalid start date", validation_label.text, tone="warning") + run_button.props(remove='loading') return date_range_error = validate_date_range_for_symbol(start_date, end_date, symbol) if date_range_error: validation_label.set_text(date_range_error) render_result_state("Scenario validation failed", date_range_error, tone="warning") + run_button.props(remove='loading') return # Validate numeric inputs @@ -846,12 +855,15 @@ def _render_backtests_page(workspace_id: str | None = None) -> None: if numeric_error: validation_label.set_text(numeric_error) render_result_state("Input validation failed", numeric_error, tone="warning") + run_button.props(remove='loading') return # Save settings before running save_backtest_settings() - result = service.run_read_only_scenario( + # Run backtest in background thread to avoid blocking WebSocket + result = await run.io_bound( + service.run_read_only_scenario, symbol=symbol, start_date=start_date, end_date=end_date, @@ -864,15 +876,23 @@ def _render_backtests_page(workspace_id: str | None = None) -> None: # Update cost in saved settings after successful run if str(data_source_select.value) == "databento": update_cost_estimate() + + render_result(result) + run_button.props(remove='loading') + validation_label.set_text("") + ui.notify("Backtest completed!", type="positive") + except (ValueError, KeyError) as exc: + run_button.props(remove='loading') entry_spot, entry_error = derive_entry_spot() render_seeded_summary(entry_spot=entry_spot, entry_spot_error=entry_error) if entry_spot is None: entry_spot_hint.set_text("Entry spot unavailable until the scenario dates are valid.") validation_label.set_text(str(exc)) render_result_state("Scenario validation failed", str(exc), tone="warning") - return + except Exception as exc: + run_button.props(remove='loading') entry_spot, entry_error = derive_entry_spot() render_seeded_summary(entry_spot=entry_spot, entry_spot_error=entry_error) if entry_spot is None: @@ -912,8 +932,6 @@ def _render_backtests_page(workspace_id: str | None = None) -> None: ) validation_label.set_text(message) render_result_state("Backtest failed", message, tone="error") - return - render_result(result) # Wire up event handlers # Only call expensive derive_entry_spot on date changes