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.
This commit is contained in:
Bu5hm4nn
2026-03-31 23:31:04 +02:00
parent c650cec159
commit 2b500dfcb3

View File

@@ -5,7 +5,7 @@ from datetime import date, datetime, timedelta
from typing import Any from typing import Any
from fastapi.responses import RedirectResponse 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.domain.backtesting_math import asset_quantity_from_workspace_config
from app.models.backtest import ProviderRef 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 # Keep existing entry spot, don't re-derive
mark_results_stale() 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("") validation_label.set_text("")
# Show loading state
run_button.props('loading')
validation_label.set_text("Running backtest...")
ui.notify("Running backtest...", type="info")
try: try:
# Validate date range for symbol # Validate date range for symbol
start_date = parse_iso_date(start_input.value, "Start date") 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." 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") render_result_state("Invalid start date", validation_label.text, tone="warning")
run_button.props(remove='loading')
return return
date_range_error = validate_date_range_for_symbol(start_date, end_date, symbol) date_range_error = validate_date_range_for_symbol(start_date, end_date, symbol)
if date_range_error: if date_range_error:
validation_label.set_text(date_range_error) validation_label.set_text(date_range_error)
render_result_state("Scenario validation failed", date_range_error, tone="warning") render_result_state("Scenario validation failed", date_range_error, tone="warning")
run_button.props(remove='loading')
return return
# Validate numeric inputs # Validate numeric inputs
@@ -846,12 +855,15 @@ def _render_backtests_page(workspace_id: str | None = None) -> None:
if numeric_error: if numeric_error:
validation_label.set_text(numeric_error) validation_label.set_text(numeric_error)
render_result_state("Input validation failed", numeric_error, tone="warning") render_result_state("Input validation failed", numeric_error, tone="warning")
run_button.props(remove='loading')
return return
# Save settings before running # Save settings before running
save_backtest_settings() 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, symbol=symbol,
start_date=start_date, start_date=start_date,
end_date=end_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 # Update cost in saved settings after successful run
if str(data_source_select.value) == "databento": if str(data_source_select.value) == "databento":
update_cost_estimate() 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: except (ValueError, KeyError) as exc:
run_button.props(remove='loading')
entry_spot, entry_error = derive_entry_spot() entry_spot, entry_error = derive_entry_spot()
render_seeded_summary(entry_spot=entry_spot, entry_spot_error=entry_error) render_seeded_summary(entry_spot=entry_spot, entry_spot_error=entry_error)
if entry_spot is None: if entry_spot is None:
entry_spot_hint.set_text("Entry spot unavailable until the scenario dates are valid.") entry_spot_hint.set_text("Entry spot unavailable until the scenario dates are valid.")
validation_label.set_text(str(exc)) validation_label.set_text(str(exc))
render_result_state("Scenario validation failed", str(exc), tone="warning") render_result_state("Scenario validation failed", str(exc), tone="warning")
return
except Exception as exc: except Exception as exc:
run_button.props(remove='loading')
entry_spot, entry_error = derive_entry_spot() entry_spot, entry_error = derive_entry_spot()
render_seeded_summary(entry_spot=entry_spot, entry_spot_error=entry_error) render_seeded_summary(entry_spot=entry_spot, entry_spot_error=entry_error)
if entry_spot is None: if entry_spot is None:
@@ -912,8 +932,6 @@ def _render_backtests_page(workspace_id: str | None = None) -> None:
) )
validation_label.set_text(message) validation_label.set_text(message)
render_result_state("Backtest failed", message, tone="error") render_result_state("Backtest failed", message, tone="error")
return
render_result(result)
# Wire up event handlers # Wire up event handlers
# Only call expensive derive_entry_spot on date changes # Only call expensive derive_entry_spot on date changes