Improve backtest lazy loading and test automation
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -29,6 +29,7 @@ from app.services.backtesting.service import BacktestService
|
||||
from app.services.strategy_templates import StrategyTemplateService
|
||||
|
||||
SUPPORTED_BACKTEST_PAGE_SYMBOLS = ("GLD", "GC", "XAU")
|
||||
SUPPORTED_DATABENTO_BACKTEST_PAGE_SYMBOLS = ("GLD", "XAU")
|
||||
|
||||
|
||||
def _validate_initial_collateral(underlying_units: float, entry_spot: float, loan_amount: float) -> None:
|
||||
@@ -134,6 +135,15 @@ class BacktestPageService:
|
||||
self._yfinance_provider = YFinanceHistoricalPriceSource()
|
||||
return self._yfinance_provider
|
||||
|
||||
@staticmethod
|
||||
def validate_data_source_support(symbol: str, data_source: str) -> None:
|
||||
normalized_symbol = symbol.strip().upper()
|
||||
if data_source == "databento" and normalized_symbol not in SUPPORTED_DATABENTO_BACKTEST_PAGE_SYMBOLS:
|
||||
raise ValueError(
|
||||
"Databento backtests currently support GLD and XAU only. "
|
||||
"GC futures remain unavailable on the backtest page until contract mapping is wired."
|
||||
)
|
||||
|
||||
def get_historical_prices(
|
||||
self, symbol: str, start_date: date, end_date: date, data_source: str
|
||||
) -> list[DailyClosePoint]:
|
||||
@@ -148,6 +158,7 @@ class BacktestPageService:
|
||||
Returns:
|
||||
List of daily close points sorted by date
|
||||
"""
|
||||
self.validate_data_source_support(symbol, data_source)
|
||||
if data_source == "databento":
|
||||
return self._get_databento_provider().load_daily_closes(symbol, start_date, end_date)
|
||||
elif data_source == "yfinance":
|
||||
@@ -171,6 +182,8 @@ class BacktestPageService:
|
||||
if data_source != "databento":
|
||||
return 0.0
|
||||
|
||||
self.validate_data_source_support(symbol, data_source)
|
||||
|
||||
try:
|
||||
provider = self._get_databento_provider()
|
||||
return provider.get_cost_estimate(symbol, start_date, end_date)
|
||||
@@ -213,6 +226,8 @@ class BacktestPageService:
|
||||
if data_source != "databento":
|
||||
return None, None
|
||||
|
||||
self.validate_data_source_support(symbol, data_source)
|
||||
|
||||
try:
|
||||
provider = self._get_databento_provider()
|
||||
return provider.get_available_range(symbol)
|
||||
@@ -258,6 +273,7 @@ class BacktestPageService:
|
||||
raise ValueError("Symbol is required")
|
||||
if normalized_symbol not in SUPPORTED_BACKTEST_PAGE_SYMBOLS:
|
||||
raise ValueError(f"Backtests support symbols: {', '.join(SUPPORTED_BACKTEST_PAGE_SYMBOLS)}")
|
||||
self.validate_data_source_support(normalized_symbol, data_source)
|
||||
if start_date > end_date:
|
||||
raise ValueError("Start date must be on or before end date")
|
||||
normalized_inputs = normalize_historical_scenario_inputs(
|
||||
|
||||
Reference in New Issue
Block a user