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 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