Improve backtest lazy loading and test automation

This commit is contained in:
Bu5hm4nn
2026-04-07 12:18:50 +02:00
parent ccc10923d9
commit b2bc4db41a
18 changed files with 504 additions and 300 deletions

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
import logging
from datetime import date, datetime
from datetime import date, datetime, timedelta
from typing import TYPE_CHECKING, Any
from fastapi.responses import RedirectResponse
@@ -13,7 +13,7 @@ from app.models.backtest_settings import BacktestSettings
from app.models.backtest_settings_repository import BacktestSettingsRepository
from app.models.workspace import get_workspace_repository
from app.pages.common import dashboard_page, split_page_panes
from app.services.backtesting.databento_source import DatabentoHistoricalPriceSource
from app.services.backtesting.databento_source import DatabentoHistoricalPriceSource, DatabentoSourceConfig
from app.services.backtesting.jobs import (
JobStatus,
job_store,
@@ -34,7 +34,6 @@ DATABENTO_DATASETS = {
DATABENTO_SCHEMAS = {
"ohlcv-1d": "Daily bars (ohlcv-1d)",
"ohlcv-1h": "Hourly bars (ohlcv-1h)",
}
UNDERLYING_SYMBOLS = {
@@ -62,12 +61,38 @@ DATABENTO_DATASET_MIN_DATES = {
"GLBX.MDP3": date(2010, 1, 1), # GLBX.MDP3 futures data from 2010
}
DEFAULT_DATABENTO_DATASET_BY_SYMBOL = {
"GLD": "XNAS.BASIC",
"GC": "GLBX.MDP3",
"XAU": "XNAS.BASIC",
}
def get_default_backtest_dates() -> tuple[date, date]:
"""Get default backtest date range (March 2026 for testing).
Returns dates (start, end) for March 2026.
"""
def recommended_databento_dataset(symbol: str) -> str:
return DEFAULT_DATABENTO_DATASET_BY_SYMBOL.get(symbol.upper(), "XNAS.BASIC")
def _most_recent_completed_friday(reference: date | None = None) -> date:
anchor = (reference or date.today()) - timedelta(days=1)
while anchor.weekday() != 4:
anchor -= timedelta(days=1)
return anchor
def get_default_backtest_dates(*, data_source: str = "databento", dataset: str = "XNAS.BASIC") -> tuple[date, date]:
# """Return a recent completed Monday-Friday window that is valid for the selected source."""
# end = _most_recent_completed_friday()
# if data_source == "databento":
# min_date = DATABENTO_DATASET_MIN_DATES.get(dataset)
# if min_date and end < min_date:
# end = min_date
# start = end - timedelta(days=4)
# if data_source == "databento":
# min_date = DATABENTO_DATASET_MIN_DATES.get(dataset)
# if min_date and start < min_date:
# start = min_date
# For pre-alpha testing, always use the same range
start = date(2026, 3, 2)
end = date(2026, 3, 25)
return start, end
@@ -411,19 +436,21 @@ def _render_backtests_page(workspace_id: str | None = None) -> None:
default_margin_call_ltv = saved_settings.margin_call_ltv
else:
default_data_source = "databento"
default_dataset = "XNAS.BASIC"
default_schema = "ohlcv-1d"
# Default to March 2026 for testing
default_start_date = date(2026, 3, 2).isoformat()
default_end_date = date(2026, 3, 25).isoformat()
default_symbol = "GLD"
default_dataset = recommended_databento_dataset(default_symbol)
default_schema = "ohlcv-1d"
default_start, default_end = get_default_backtest_dates(
data_source=default_data_source,
dataset=default_dataset,
)
default_start_date = default_start.isoformat()
default_end_date = default_end.isoformat()
default_start_price = 0.0
# Use a reasonable default GLD price for initial render (will be derived async)
# This prevents blocking page load with Databento API call
default_entry_spot = 230.0 # Approximate GLD price
# Keep first paint fast by using a static reference spot for derived default sizing.
default_entry_spot = 230.0
default_units = (
asset_quantity_from_workspace_config(config, entry_spot=default_entry_spot, symbol="GLD")
asset_quantity_from_workspace_config(config, entry_spot=default_entry_spot, symbol=default_symbol)
if config is not None and default_entry_spot > 0
else 1000.0
)
@@ -466,30 +493,31 @@ def _render_backtests_page(workspace_id: str | None = None) -> None:
dataset_select = (
ui.select(DATABENTO_DATASETS, value=default_dataset, label="Dataset")
.classes("w-full")
.props("data-testid=dataset-select")
.props("data-testid=dataset-select disable")
)
schema_select = (
ui.select(DATABENTO_SCHEMAS, value=default_schema, label="Resolution")
.classes("w-full")
.props("data-testid=schema-select")
)
ui.label(
"Dataset follows the selected symbol. Backtests currently use daily Databento bars only."
).classes("text-xs text-slate-500 dark:text-slate-400")
# Cost estimate display
with ui.row().classes("items-center gap-2"):
preview_spinner = ui.spinner(size="sm", color="primary")
preview_status_label = ui.label("").classes("text-sm text-slate-500 dark:text-slate-400")
load_preview_button = (
ui.button("Load scenario preview")
.props("outline color=primary data-testid=load-backtest-preview-button")
.classes("mt-2")
)
cost_estimate_label = ui.label("").classes("text-sm text-slate-500 dark:text-slate-400")
cost_estimate_label.set_visibility(False)
# Cache status display
cache_status_label = ui.label("").classes("text-sm")
# Show/hide Databento options based on data source
# Placeholder for cost estimate callback - will be defined later
_update_cost_estimate_callback: list[Any] = [None]
def update_databento_visibility() -> None:
is_databento = str(data_source_select.value) == "databento"
databento_options_card.set_visibility(is_databento)
if is_databento and _update_cost_estimate_callback[0]:
_update_cost_estimate_callback[0]()
data_source_select.on_value_change(lambda e: update_databento_visibility())
@@ -529,8 +557,7 @@ def _render_backtests_page(workspace_id: str | None = None) -> None:
f"GLD data available from {SYMBOL_MIN_DATES['GLD'].strftime('%Y-%m-%d')} (ETF launch)"
).classes("text-xs text-slate-500 dark:text-slate-400")
# Note: date_range_hint will be updated when symbol changes via on_value_change
# The get_symbol_from_dataset function is defined later and referenced in the callback
# date_range_hint is kept in sync with the selected symbol and data source.
start_price_input = (
ui.number("Start price (0 = auto-derive)", value=default_start_price, min=0, step=0.01)
@@ -578,6 +605,9 @@ def _render_backtests_page(workspace_id: str | None = None) -> None:
entry_spot_hint = ui.label("").classes("text-sm text-slate-500 dark:text-slate-400")
validation_label = ui.label("").classes("text-sm text-rose-600 dark:text-rose-300")
progress_label = ui.label("").classes("text-sm text-sky-600 dark:text-sky-300")
ui.label(
"Backtest page metadata is lazy-loaded. Use Load scenario preview when you want entry spot, Databento cost, and cache details without running the full backtest."
).classes("text-xs text-slate-500 dark:text-slate-400")
run_button = (
ui.button("Run backtest").props("color=primary data-testid=run-backtest-button").classes("mt-2")
)
@@ -595,100 +625,54 @@ def _render_backtests_page(workspace_id: str | None = None) -> None:
except ValueError as exc:
raise ValueError(f"{field_name} must be in YYYY-MM-DD format") from exc
def get_symbol_from_dataset() -> str:
"""Map dataset selection to underlying symbol."""
dataset = str(dataset_select.value)
if dataset == "GLBX.MDP3":
return "GC"
return "GLD" # Default for XNAS.BASIC
preview_state: dict[str, Any] = {
"entry_spot": None,
"error": None,
"pending": "Preview not loaded yet. Heavy Databento lookups stay lazy until you request them.",
}
def get_selected_symbol() -> str:
return str(symbol_select.value or default_symbol).strip().upper()
def sync_symbol_bound_databento_dataset() -> None:
dataset_select.value = recommended_databento_dataset(get_selected_symbol())
dataset_select.update()
def sync_databento_config() -> None:
service.databento_config = DatabentoSourceConfig(
dataset=str(dataset_select.value),
schema=str(schema_select.value),
)
service._databento_provider = None
def update_date_range_hint() -> None:
"""Update the date range hint based on selected symbol and data source."""
symbol = get_symbol_from_dataset()
symbol = get_selected_symbol()
data_source = str(data_source_select.value)
# Use dataset-specific minimum for Databento
if data_source == "databento":
try:
service.validate_data_source_support(symbol, data_source)
except ValueError as exc:
date_range_hint.set_text(str(exc))
return
dataset = str(dataset_select.value)
min_date = DATABENTO_DATASET_MIN_DATES.get(dataset)
if min_date:
date_range_hint.set_text(f"{dataset} data available from {min_date.strftime('%Y-%m-%d')}")
return
# Fall back to symbol minimum
min_date = SYMBOL_MIN_DATES.get(symbol)
if min_date:
date_range_hint.set_text(f"{symbol} data available from {min_date.strftime('%Y-%m-%d')}")
else:
date_range_hint.set_text(f"{symbol} data availability unknown")
def update_cost_estimate() -> None:
"""Update cost estimate display based on current settings."""
current_data_source = str(data_source_select.value)
if current_data_source != "databento":
cost_estimate_label.set_visibility(False)
cache_status_label.set_visibility(False)
return
# Set the callback for databento visibility updates
_update_cost_estimate_callback[0] = update_cost_estimate
try:
start = parse_iso_date(start_input.value, "Start date")
end = parse_iso_date(end_input.value, "End date")
symbol = get_symbol_from_dataset()
cost, error = _get_databento_cost_estimate(symbol, start, end)
if error:
cost_estimate_label.set_text(f"⚠️ Cost estimate unavailable: {error[:50]}")
cost_estimate_label.classes("text-sm text-amber-600 dark:text-amber-400", remove="")
elif cost > 0:
cost_estimate_label.set_text(f"💰 Estimated cost: ${cost:.4f}")
cost_estimate_label.classes("text-sm text-slate-600 dark:text-slate-400", remove="")
else:
cost_estimate_label.set_text("💰 Cost: Free (within usage allowance)")
cost_estimate_label.classes("text-sm text-emerald-600 dark:text-emerald-400", remove="")
cost_estimate_label.set_visibility(True)
# Update cache status
status_text, status_class = _get_databento_cache_status(symbol, start, end)
cache_status_label.set_text(f"📦 {status_text}")
cache_status_label.classes(
status_class, remove="text-slate-500 text-emerald-600 text-amber-600 text-rose-600"
)
cache_status_label.set_visibility(True)
except ValueError:
cost_estimate_label.set_text("⚠️ Enter valid dates for cost estimate")
cost_estimate_label.classes("text-sm text-amber-600 dark:text-amber-400", remove="")
cost_estimate_label.set_visibility(True)
cache_status_label.set_visibility(False)
def derive_entry_spot() -> tuple[float | None, str | None]:
"""Derive entry spot from historical data or use configured start price."""
configured_start_price = float(start_price_input.value or 0.0)
if configured_start_price > 0:
return configured_start_price, None
try:
# Use the symbol from the dataset selection
symbol = get_symbol_from_dataset()
resolved_entry_spot = service.derive_entry_spot(
symbol,
parse_iso_date(start_input.value, "Start date"),
parse_iso_date(end_input.value, "End date"),
data_source=str(data_source_select.value),
)
except (ValueError, KeyError, RuntimeError) as exc:
return None, str(exc)
return resolved_entry_spot, None
def render_seeded_summary(*, entry_spot: float | None = None, entry_spot_error: str | None = None) -> None:
seeded_summary.clear()
resolved_entry_spot = entry_spot
resolved_error = entry_spot_error
if resolved_entry_spot is None and resolved_error is None:
resolved_entry_spot, resolved_error = derive_entry_spot()
resolved_entry_spot = entry_spot if entry_spot is not None else preview_state["entry_spot"]
resolved_error = entry_spot_error if entry_spot_error is not None else preview_state["error"]
pending_text = str(preview_state.get("pending") or "")
with seeded_summary:
ui.label("Scenario Summary").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
with ui.grid(columns=1).classes("w-full gap-4 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2"):
@@ -708,7 +692,7 @@ def _render_backtests_page(workspace_id: str | None = None) -> None:
),
(
"Entry spot",
f"${resolved_entry_spot:,.2f}" if resolved_entry_spot is not None else "Unavailable",
f"${resolved_entry_spot:,.2f}" if resolved_entry_spot is not None else "Lazy load pending",
),
]
for label, value in cards:
@@ -729,9 +713,105 @@ def _render_backtests_page(workspace_id: str | None = None) -> None:
ui.label(f"Dataset: {dataset_label} • Resolution: {schema_label}").classes(
"text-sm text-slate-600 dark:text-slate-400"
)
if pending_text:
ui.label(pending_text).classes("text-sm text-sky-700 dark:text-sky-300")
if resolved_error:
ui.label(resolved_error).classes("text-sm text-amber-700 dark:text-amber-300")
def reset_lazy_preview(message: str | None = None) -> None:
preview_state["entry_spot"] = None
preview_state["error"] = None
preview_state["pending"] = message or (
"Preview not loaded yet. Heavy Databento lookups stay lazy until you request them."
)
preview_spinner.set_visibility(False)
preview_status_label.set_text(str(preview_state["pending"]))
load_preview_button.props(remove="loading")
cost_estimate_label.set_text("")
cost_estimate_label.set_visibility(False)
cache_status_label.set_text("")
cache_status_label.set_visibility(False)
render_seeded_summary()
def derive_entry_spot() -> tuple[float | None, str | None]:
"""Derive entry spot from historical data or use configured start price."""
configured_start_price = float(start_price_input.value or 0.0)
if configured_start_price > 0:
return configured_start_price, None
try:
symbol = get_selected_symbol()
sync_databento_config()
resolved_entry_spot = service.derive_entry_spot(
symbol,
parse_iso_date(start_input.value, "Start date"),
parse_iso_date(end_input.value, "End date"),
data_source=str(data_source_select.value),
)
except (ValueError, KeyError, RuntimeError) as exc:
return None, str(exc)
return resolved_entry_spot, None
def load_scenario_preview() -> None:
validation_label.set_text("")
preview_spinner.set_visibility(True)
preview_status_label.set_text("Loading scenario preview…")
load_preview_button.props("loading")
preview_state["pending"] = ""
cost_estimate_label.set_visibility(False)
cache_status_label.set_visibility(False)
try:
start = parse_iso_date(start_input.value, "Start date")
end = parse_iso_date(end_input.value, "End date")
symbol = get_selected_symbol()
data_source = str(data_source_select.value)
sync_databento_config()
date_range_error = validate_date_range_for_symbol(start, end, symbol)
if date_range_error:
raise ValueError(date_range_error)
service.validate_data_source_support(symbol, data_source)
entry_spot, entry_spot_error = derive_entry_spot()
preview_state["entry_spot"] = entry_spot
preview_state["error"] = entry_spot_error
preview_state["pending"] = ""
if data_source == "databento":
cost, error = _get_databento_cost_estimate(symbol, start, end)
if error:
cost_estimate_label.set_text(f"⚠️ Cost estimate unavailable: {error[:80]}")
cost_estimate_label.classes("text-sm text-amber-600 dark:text-amber-400", remove="")
elif cost > 0:
cost_estimate_label.set_text(f"💰 Estimated cost: ${cost:.4f}")
cost_estimate_label.classes("text-sm text-slate-600 dark:text-slate-400", remove="")
else:
cost_estimate_label.set_text("💰 Cost: Free (within usage allowance)")
cost_estimate_label.classes("text-sm text-emerald-600 dark:text-emerald-400", remove="")
cost_estimate_label.set_visibility(True)
status_text, status_class = _get_databento_cache_status(symbol, start, end)
cache_status_label.set_text(f"📦 {status_text}")
cache_status_label.classes(
status_class, remove="text-slate-500 text-emerald-600 text-amber-600 text-rose-600"
)
cache_status_label.set_visibility(True)
if entry_spot_error:
preview_status_label.set_text("Scenario preview loaded with warnings.")
else:
preview_status_label.set_text("Scenario preview loaded.")
except ValueError as exc:
preview_state["entry_spot"] = None
preview_state["error"] = str(exc)
preview_state["pending"] = ""
preview_status_label.set_text("Scenario preview unavailable.")
finally:
preview_spinner.set_visibility(False)
load_preview_button.props(remove="loading")
render_seeded_summary()
def render_result_state(title: str, message: str, *, tone: str = "info") -> None:
tone_classes = {
"info": "border-sky-200 bg-sky-50 dark:border-sky-900/60 dark:bg-sky-950/30",
@@ -879,7 +959,7 @@ def _render_backtests_page(workspace_id: str | None = None) -> None:
# Validate date range against symbol data availability
start_date = parse_iso_date(start_input.value, "Start date")
end_date = parse_iso_date(end_input.value, "End date")
symbol = get_symbol_from_dataset()
symbol = get_selected_symbol()
date_range_error = validate_date_range_for_symbol(start_date, end_date, symbol)
if date_range_error:
return date_range_error
@@ -922,6 +1002,7 @@ def _render_backtests_page(workspace_id: str | None = None) -> None:
import uuid
# Create or update settings
sync_databento_config()
entry_spot, _ = derive_entry_spot()
if entry_spot is None:
entry_spot = 0.0
@@ -955,12 +1036,11 @@ def _render_backtests_page(workspace_id: str | None = None) -> None:
return None
def on_form_change() -> None:
"""Handle form changes with minimal API calls."""
"""Keep page interactions fast by deferring preview I/O until requested."""
validation_label.set_text("")
# Only update cost estimate, don't derive entry spot on every change
# Entry spot derivation is expensive (Databento API call)
update_cost_estimate()
# Keep existing entry spot, don't re-derive
reset_lazy_preview(
"Scenario preview is stale. Load it again when you need fresh entry spot and Databento metadata."
)
mark_results_stale()
def start_backtest() -> None:
@@ -973,10 +1053,11 @@ def _render_backtests_page(workspace_id: str | None = None) -> None:
# Validate date range for symbol
start_date = parse_iso_date(start_input.value, "Start date")
end_date = parse_iso_date(end_input.value, "End date")
symbol = get_symbol_from_dataset()
symbol = get_selected_symbol()
# Validate dataset-specific minimum dates for Databento
data_source = str(data_source_select.value)
sync_databento_config()
if data_source == "databento":
dataset = str(dataset_select.value)
dataset_min = DATABENTO_DATASET_MIN_DATES.get(dataset)
@@ -998,6 +1079,8 @@ def _render_backtests_page(workspace_id: str | None = None) -> None:
progress_label.set_text("")
return
service.validate_data_source_support(symbol, data_source)
# Validate numeric inputs
units = float(units_input.value or 0.0)
loan = float(loan_input.value or 0.0)
@@ -1218,27 +1301,37 @@ def _render_backtests_page(workspace_id: str | None = None) -> None:
row_key="date",
).classes("w-full")
# Update cost estimate for Databento
if str(data_source_select.value) == "databento":
update_cost_estimate()
preview_state["entry_spot"] = result.get("entry_spot")
preview_state["error"] = None
preview_state["pending"] = ""
preview_status_label.set_text("Scenario preview loaded from the latest backtest run.")
preview_spinner.set_visibility(False)
# Wire up event handlers
# Date changes should NOT trigger expensive derive_entry_spot API call
# Entry spot derivation happens when user clicks Run
data_source_select.on_value_change(lambda e: on_form_change())
# Date changes should NOT trigger expensive Databento calls.
data_source_select.on_value_change(lambda e: (update_date_range_hint(), on_form_change()))
dataset_select.on_value_change(lambda e: (update_date_range_hint(), on_form_change()))
schema_select.on_value_change(lambda e: on_form_change())
symbol_select.on_value_change(lambda e: update_date_range_hint())
symbol_select.on_value_change(
lambda e: (sync_symbol_bound_databento_dataset(), update_date_range_hint(), on_form_change())
)
start_input.on_value_change(lambda e: on_form_change())
end_input.on_value_change(lambda e: on_form_change())
# Don't trigger API calls on these changes
start_price_input.on_value_change(lambda e: mark_results_stale())
template_select.on_value_change(lambda e: mark_results_stale())
units_input.on_value_change(lambda e: mark_results_stale())
loan_input.on_value_change(lambda e: mark_results_stale())
ltv_input.on_value_change(lambda e: mark_results_stale())
start_price_input.on_value_change(lambda e: on_form_change())
template_select.on_value_change(lambda e: on_form_change())
units_input.on_value_change(lambda e: on_form_change())
loan_input.on_value_change(lambda e: on_form_change())
ltv_input.on_value_change(lambda e: on_form_change())
load_preview_button.on_click(lambda: load_scenario_preview())
run_button.on_click(lambda: start_backtest())
# Initial render
render_seeded_summary(entry_spot=float(default_start_price) if default_start_price > 0 else None)
sync_symbol_bound_databento_dataset()
update_date_range_hint()
reset_lazy_preview("Preview not loaded yet. Heavy Databento lookups stay lazy until you request them.")
render_result_state(
"Run a backtest",
"Configure a scenario and click Run backtest to populate charts and daily results.",
tone="info",
)
# Don't auto-run backtest on page load - let user configure and click Run