Files
vault-dash/app/pages/backtests.py

1338 lines
62 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import logging
from datetime import date, datetime, timedelta
from typing import TYPE_CHECKING, Any
from fastapi.responses import RedirectResponse
from nicegui import ui
from app.domain.backtesting_math import asset_quantity_from_workspace_config
from app.models.backtest import ProviderRef
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, DatabentoSourceConfig
from app.services.backtesting.jobs import (
JobStatus,
job_store,
start_backtest_job,
)
from app.services.backtesting.ui_service import BacktestPageRunResult, BacktestPageService
if TYPE_CHECKING:
pass # for forward references
logger = logging.getLogger(__name__)
# Dataset and schema options for Databento
DATABENTO_DATASETS = {
"XNAS.BASIC": "XNAS.BASIC (GLD ETF)",
"GLBX.MDP3": "GLBX.MDP3 (GC=F Futures)",
}
DATABENTO_SCHEMAS = {
"ohlcv-1d": "Daily bars (ohlcv-1d)",
}
UNDERLYING_SYMBOLS = {
"GLD": "GLD (Gold ETF)",
"GC": "GC (Gold Futures)",
"XAU": "XAU (Gold Index)",
}
DATA_SOURCES = {
"databento": "Databento",
"yfinance": "Yahoo Finance",
"synthetic": "Synthetic",
}
# Minimum dates for common symbols (ETF/futures inception)
SYMBOL_MIN_DATES = {
"GLD": date(2004, 11, 18), # GLD ETF launched November 18, 2004
"GC": date(1974, 1, 1), # Gold futures have much longer history
"XAU": date(1970, 1, 1), # XAU index historical data
}
# Minimum dates for Databento datasets (when data became available)
DATABENTO_DATASET_MIN_DATES = {
"XNAS.BASIC": date(2024, 7, 1), # XNAS.BASIC data available from July 2024
"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 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
DEFAULT_BACKTEST_START = get_default_backtest_dates()[0].isoformat()
DEFAULT_BACKTEST_END = get_default_backtest_dates()[1].isoformat()
def validate_date_range_for_symbol(start_date: date, end_date: date, symbol: str) -> str | None:
"""Validate date range is within available data for symbol.
Returns error message if invalid, None if valid.
Validation order:
1. Logical order (start <= end)
2. End not in future
3. Symbol-specific data availability
"""
if start_date > end_date:
return "Start date must be before or equal to end date."
if end_date > date.today():
return "End date cannot be in the future."
min_date = SYMBOL_MIN_DATES.get(symbol)
if min_date and start_date < min_date:
return f"Start date must be on or after {min_date.strftime('%Y-%m-%d')} for {symbol} (data availability)."
return None
def validate_numeric_inputs(
units: float,
loan_amount: float,
margin_call_ltv: float,
) -> str | None:
"""Validate numeric inputs for backtest scenario.
Returns error message if invalid, None if valid.
"""
if units <= 0:
return "Underlying units must be positive."
if loan_amount < 0:
return "Loan amount cannot be negative."
if not (0 < margin_call_ltv < 1):
return "Margin call LTV must be between 0 and 1 (exclusive)."
return None
def _portfolio_chart_options(result: BacktestPageRunResult) -> dict:
"""Create ECharts options for stacked bar chart of portfolio value."""
template_result = result.run_result.template_results[0]
dates = [point.date.isoformat() for point in template_result.daily_path]
# Underlying value and option value for stacked bars
underlying_values = [round(point.underlying_value, 2) for point in template_result.daily_path]
option_values = [round(point.option_market_value, 2) for point in template_result.daily_path]
return {
"tooltip": {
"trigger": "axis",
"axisPointer": {"type": "shadow"},
},
"legend": {"data": ["Underlying", "Option Value"]},
"grid": {"left": "10%", "right": "10%", "top": "10%", "bottom": "15%"},
"xAxis": {
"type": "category",
"data": dates,
"axisLabel": {"show": False}, # Hide labels, will show on candle chart below
},
"yAxis": {
"type": "value",
"name": "Portfolio",
"position": "left",
"axisLabel": {"formatter": "${value}"},
},
"series": [
{
"name": "Underlying",
"type": "bar",
"stack": "total",
"data": underlying_values,
"itemStyle": {"color": "#64748b"}, # Slate gray
},
{
"name": "Option Value",
"type": "bar",
"stack": "total",
"data": option_values,
"itemStyle": {"color": "#0ea5e9"}, # Sky blue
},
],
}
def _candle_chart_options(result: BacktestPageRunResult) -> dict:
"""Create ECharts options for candlestick chart."""
template_result = result.run_result.template_results[0]
dates = [point.date.isoformat() for point in template_result.daily_path]
# Candlestick data: [open, close, low, high]
candlestick_data = [
[
point.spot_open or point.spot_close,
point.spot_close,
point.spot_low or point.spot_close,
point.spot_high or point.spot_close,
]
for point in template_result.daily_path
]
return {
"tooltip": {
"trigger": "axis",
"axisPointer": {"type": "cross"},
},
"legend": {"data": ["Price"], "show": False},
"grid": {"left": "10%", "right": "10%", "top": "5%", "bottom": "20%"},
"xAxis": {
"type": "category",
"data": dates,
"axisLabel": {"rotate": 45},
},
"yAxis": {
"type": "value",
"name": "Price",
"position": "left",
"scale": True,
},
"series": [
{
"name": "Price",
"type": "candlestick",
"data": candlestick_data,
"itemStyle": {
"color": "#22c55e", # Green for up
"color0": "#ef4444", # Red for down
"borderColor": "#22c55e",
"borderColor0": "#ef4444",
},
},
],
}
def _portfolio_chart_options_from_dict(result: dict) -> dict:
"""Create ECharts options from serialized job result dict."""
template_results = result.get("template_results", [])
first_template = template_results[0] if template_results else {}
daily_path = first_template.get("daily_path", [])
dates = [dp.get("date", "") for dp in daily_path]
underlying_values = [round(dp.get("underlying_value", 0), 2) for dp in daily_path]
option_values = [round(dp.get("option_market_value", 0), 2) for dp in daily_path]
return {
"tooltip": {
"trigger": "axis",
"axisPointer": {"type": "shadow"},
},
"legend": {"data": ["Underlying", "Option Value"]},
"grid": {"left": "10%", "right": "10%", "top": "10%", "bottom": "15%"},
"xAxis": {
"type": "category",
"data": dates,
"axisLabel": {"show": False},
},
"yAxis": {
"type": "value",
"name": "Portfolio",
"position": "left",
"axisLabel": {"formatter": "${value}"},
},
"series": [
{
"name": "Underlying",
"type": "bar",
"stack": "total",
"data": underlying_values,
"itemStyle": {"color": "#64748b"},
},
{
"name": "Option Value",
"type": "bar",
"stack": "total",
"data": option_values,
"itemStyle": {"color": "#0ea5e9"},
},
],
}
def _candle_chart_options_from_dict(result: dict) -> dict:
"""Create ECharts options from serialized job result dict."""
template_results = result.get("template_results", [])
first_template = template_results[0] if template_results else {}
daily_path = first_template.get("daily_path", [])
dates = [dp.get("date", "") for dp in daily_path]
# Candlestick data: [open, close, low, high]
candlestick_data = [
[
dp.get("spot_open", dp.get("spot_close", 0)),
dp.get("spot_close", 0),
dp.get("spot_low", dp.get("spot_close", 0)),
dp.get("spot_high", dp.get("spot_close", 0)),
]
for dp in daily_path
]
return {
"tooltip": {
"trigger": "axis",
"axisPointer": {"type": "cross"},
},
"legend": {"data": ["Price"], "show": False},
"grid": {"left": "10%", "right": "10%", "top": "5%", "bottom": "20%"},
"xAxis": {
"type": "category",
"data": dates,
"axisLabel": {"rotate": 45},
},
"yAxis": {
"type": "value",
"name": "Price",
"position": "left",
"scale": True,
},
"series": [
{
"name": "Price",
"type": "candlestick",
"data": candlestick_data,
"itemStyle": {
"color": "#22c55e",
"color0": "#ef4444",
"borderColor": "#22c55e",
"borderColor0": "#ef4444",
},
},
],
}
def _get_databento_cost_estimate(symbol: str, start_date: date, end_date: date) -> tuple[float, str]:
"""Get cost estimate from Databento API.
Returns:
Tuple of (cost_usd, error_message). If successful, error_message is empty.
"""
try:
source = DatabentoHistoricalPriceSource()
cost = source.get_cost_estimate(symbol, start_date, end_date)
return cost, ""
except Exception as e:
logger.warning(f"Failed to get Databento cost estimate: {e}")
return 0.0, str(e)
def _get_databento_cache_status(symbol: str, start_date: date, end_date: date) -> tuple[str, str]:
"""Get cache status for Databento data.
Returns:
Tuple of (status_text, status_class). status_class is one of:
'text-slate-500', 'text-emerald-600', 'text-amber-600', 'text-rose-600'
"""
try:
import hashlib
import json
from app.services.backtesting.databento_source import DatabentoSourceConfig
config = DatabentoSourceConfig()
dataset = DatabentoHistoricalPriceSource.__new__(DatabentoHistoricalPriceSource)._resolve_dataset(symbol)
databento_symbol = DatabentoHistoricalPriceSource.__new__(DatabentoHistoricalPriceSource)._resolve_symbol(
symbol
)
key_str = f"{dataset}_{databento_symbol}_ohlcv-1d_{start_date}_{end_date}"
key_hash = hashlib.sha256(key_str.encode()).hexdigest()[:16]
meta_file = config.cache_dir / f"dbn_{key_hash}_meta.json"
if not meta_file.exists():
return "No cached data", "text-slate-500"
with open(meta_file) as f:
meta = json.load(f)
download_date = date.fromisoformat(meta["download_date"])
age_days = (date.today() - download_date).days
if age_days == 0:
return f"Cached today ({meta.get('rows', 0)} rows)", "text-emerald-600"
elif age_days <= config.max_cache_age_days:
return f"Cached {age_days} days ago ({meta.get('rows', 0)} rows)", "text-emerald-600"
else:
return f"Cache stale ({age_days} days old)", "text-amber-600"
except Exception as e:
logger.warning(f"Failed to get cache status: {e}")
return f"Status unavailable: {str(e)[:30]}", "text-rose-600"
@ui.page("/{workspace_id}/backtests")
def workspace_backtests_page(workspace_id: str) -> None:
repo = get_workspace_repository()
if not repo.workspace_exists(workspace_id):
return RedirectResponse(url="/", status_code=307)
_render_backtests_page(workspace_id=workspace_id)
def _render_backtests_page(workspace_id: str | None = None) -> None:
service = BacktestPageService()
settings_repo = BacktestSettingsRepository()
# Load saved settings if available
saved_settings: BacktestSettings | None = None
if workspace_id:
try:
saved_settings = settings_repo.load(workspace_id)
except Exception as e:
logger.warning(f"Failed to load backtest settings for workspace {workspace_id}: {e}")
template_options = service.template_options("GLD")
select_options = {str(option["slug"]): str(option["label"]) for option in template_options}
default_template_slug = str(template_options[0]["slug"]) if template_options else None
repo = get_workspace_repository()
config = repo.load_portfolio_config(workspace_id) if workspace_id else None
# Initialize defaults from saved settings or workspace config
if saved_settings:
default_data_source = saved_settings.data_source
default_dataset = saved_settings.dataset
default_schema = saved_settings.schema
default_start_date = saved_settings.start_date.isoformat()
default_end_date = saved_settings.end_date.isoformat()
default_symbol = saved_settings.underlying_symbol
default_start_price = saved_settings.start_price
default_units = saved_settings.underlying_units
default_loan = saved_settings.loan_amount
default_margin_call_ltv = saved_settings.margin_call_ltv
else:
default_data_source = "databento"
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
# 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=default_symbol)
if config is not None and default_entry_spot > 0
else 1000.0
)
default_loan = float(config.loan_amount) if config else 68000.0
default_margin_call_ltv = float(config.margin_threshold) if config else 0.75
with dashboard_page(
"Backtests",
"Run historical backtests with configurable data sources and scenario parameters.",
"backtests",
workspace_id=workspace_id,
):
left_pane, right_pane = split_page_panes(
left_testid="backtests-left-pane",
right_testid="backtests-right-pane",
)
with left_pane:
# Data Source Configuration Card
with ui.card().classes(
"w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
):
ui.label("Data Source").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
ui.label(
"Select the data provider for historical price data. Databento provides institutional-grade data with usage-based pricing."
).classes("text-sm text-slate-500 dark:text-slate-400")
data_source_select = (
ui.select(DATA_SOURCES, value=default_data_source, label="Data source")
.classes("w-full")
.props("data-testid=data-source-select")
)
# Databento-specific options (shown conditionally)
databento_options_card = ui.card().classes(
"w-full rounded-xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-700 dark:bg-slate-800"
)
with databento_options_card:
dataset_select = (
ui.select(DATABENTO_DATASETS, value=default_dataset, label="Dataset")
.classes("w-full")
.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")
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")
cache_status_label = ui.label("").classes("text-sm")
def update_databento_visibility() -> None:
is_databento = str(data_source_select.value) == "databento"
databento_options_card.set_visibility(is_databento)
data_source_select.on_value_change(lambda e: update_databento_visibility())
# Initialize visibility
update_databento_visibility()
# Scenario Configuration Card (Independent of Portfolio)
with ui.card().classes(
"w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
):
ui.label("Scenario Configuration").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
ui.label(
"Configure backtest scenario parameters independently of Portfolio settings. "
"Start price = 0 auto-derives from first historical close."
).classes("text-sm text-slate-500 dark:text-slate-400")
symbol_select = (
ui.select(UNDERLYING_SYMBOLS, value=default_symbol, label="Underlying symbol")
.classes("w-full")
.props("data-testid=symbol-select")
)
with ui.row().classes("w-full gap-4"):
with ui.column().classes("flex-1"):
start_input = (
ui.input("Start date", value=default_start_date)
.classes("w-full")
.props("data-testid=start-date-input")
)
with ui.column().classes("flex-1"):
end_input = (
ui.input("End date", value=default_end_date)
.classes("w-full")
.props("data-testid=end-date-input")
)
date_range_hint = ui.label(
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")
# 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)
.classes("w-full")
.props("data-testid=start-price-input")
)
units_input = (
ui.number("Underlying units", value=default_units, min=0.0001, step=1)
.classes("w-full")
.props("data-testid=units-input")
)
loan_input = (
ui.number("Loan amount", value=default_loan, min=0, step=1000)
.classes("w-full")
.props("data-testid=loan-input")
)
ltv_input = (
ui.number("Margin call LTV", value=default_margin_call_ltv, min=0.01, max=0.99, step=0.01)
.classes("w-full")
.props("data-testid=ltv-input")
)
# Template Selection Card
with ui.card().classes(
"w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
):
ui.label("Template Selection").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
ui.label("Select the hedging strategy template to apply during the backtest.").classes(
"text-sm text-slate-500 dark:text-slate-400"
)
template_select = (
ui.select(select_options, value=default_template_slug, label="Template")
.classes("w-full")
.props("data-testid=template-select")
)
# Entry Spot and Validation Card
with ui.card().classes(
"w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
):
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")
)
seeded_summary = ui.card().classes(
"w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
)
with right_pane:
result_panel = ui.column().classes("w-full gap-6")
def parse_iso_date(raw: object, field_name: str) -> date:
try:
return datetime.strptime(str(raw), "%Y-%m-%d").date()
except ValueError as exc:
raise ValueError(f"{field_name} must be in YYYY-MM-DD format") from exc
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_selected_symbol()
data_source = str(data_source_select.value)
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
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 render_seeded_summary(*, entry_spot: float | None = None, entry_spot_error: str | None = None) -> None:
seeded_summary.clear()
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"):
cards = [
("Data source", DATA_SOURCES.get(str(data_source_select.value), str(data_source_select.value))),
("Template", select_options.get(str(template_select.value), str(template_select.value or ""))),
("Underlying units", f"{float(units_input.value or 0.0):,.0f}"),
("Loan amount", f"${float(loan_input.value or 0.0):,.0f}"),
("Margin call LTV", f"{float(ltv_input.value or 0.0):.1%}"),
(
"Underlying symbol",
UNDERLYING_SYMBOLS.get(str(symbol_select.value), str(symbol_select.value)),
),
(
"Date range",
f"{str(start_input.value or '')}{str(end_input.value or '')}",
),
(
"Entry spot",
f"${resolved_entry_spot:,.2f}" if resolved_entry_spot is not None else "Lazy load pending",
),
]
for label, value in cards:
with ui.card().classes(
"rounded-xl border border-slate-200 bg-slate-50 p-4 shadow-none dark:border-slate-800 dark:bg-slate-950"
):
ui.label(label).classes("text-sm text-slate-500 dark:text-slate-400")
ui.label(value).classes("text-xl font-bold text-slate-900 dark:text-slate-100")
if str(data_source_select.value) == "databento":
dataset_label = str(dataset_select.value)
schema_label = DATABENTO_SCHEMAS.get(str(schema_select.value), str(schema_select.value))
with ui.card().classes(
"rounded-xl border border-sky-200 bg-sky-50 p-4 dark:border-sky-900/60 dark:bg-sky-950/30"
):
ui.label("Databento Configuration").classes(
"text-sm font-semibold text-slate-900 dark:text-slate-100"
)
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",
"warning": "border-amber-200 bg-amber-50 dark:border-amber-900/60 dark:bg-amber-950/30",
"error": "border-rose-200 bg-rose-50 dark:border-rose-900/60 dark:bg-rose-950/30",
}
text_classes = {
"info": "text-sky-800 dark:text-sky-200",
"warning": "text-amber-800 dark:text-amber-200",
"error": "text-rose-800 dark:text-rose-200",
}
result_panel.clear()
with result_panel:
with ui.card().classes(
f"w-full rounded-2xl border shadow-sm {tone_classes.get(tone, tone_classes['info'])}"
):
ui.label(title).classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
ui.label(message).classes(f"text-sm {text_classes.get(tone, text_classes['info'])}")
def mark_results_stale() -> None:
render_result_state(
"Results out of date",
"Inputs changed. Run backtest again to refresh charts and daily results for the current scenario.",
tone="info",
)
def render_result(result: BacktestPageRunResult) -> None:
result_panel.clear()
template_result = result.run_result.template_results[0]
summary = template_result.summary_metrics
# Update entry spot hint based on what was used
configured_start_price = float(start_price_input.value or 0.0)
if configured_start_price > 0:
entry_spot_hint.set_text(f"Using configured start price: ${configured_start_price:,.2f}")
else:
entry_spot_hint.set_text(f"Auto-derived entry spot: ${result.entry_spot:,.2f}")
render_seeded_summary(entry_spot=result.entry_spot)
with result_panel:
with ui.card().classes(
"w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
):
ui.label("Scenario Results").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
ui.label(f"Template: {template_result.template_name}").classes(
"text-sm text-slate-500 dark:text-slate-400"
)
# Get option contracts from first day (constant throughout backtest)
option_contracts = (
template_result.daily_path[0].option_contracts if template_result.daily_path else 0.0
)
with ui.grid(columns=4).classes("w-full gap-4 max-lg:grid-cols-2 max-sm:grid-cols-1"):
cards = [
("Start value", f"${summary.start_value:,.0f}"),
("End value hedged", f"${summary.end_value_hedged_net:,.0f}"),
("Max LTV hedged", f"{summary.max_ltv_hedged:.1%}"),
("Hedge cost", f"${summary.total_hedge_cost:,.0f}"),
("Option contracts", f"{option_contracts:,.0f}"),
("Margin call days hedged", str(summary.margin_call_days_hedged)),
("Margin call days unhedged", str(summary.margin_call_days_unhedged)),
(
"Hedged survived",
"Yes" if not summary.margin_threshold_breached_hedged else "No",
),
(
"Unhedged breached",
"Yes" if summary.margin_threshold_breached_unhedged else "No",
),
]
for label, value in cards:
with ui.card().classes(
"rounded-xl border border-slate-200 bg-slate-50 p-4 shadow-none dark:border-slate-800 dark:bg-slate-950"
):
ui.label(label).classes("text-sm text-slate-500 dark:text-slate-400")
ui.label(value).classes("text-2xl font-bold text-slate-900 dark:text-slate-100")
# Portfolio value stacked bar chart (above candle chart)
ui.echart(_portfolio_chart_options(result)).classes(
"h-48 w-full rounded-t-2xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900"
)
# Candlestick chart (double height)
ui.echart(_candle_chart_options(result)).classes(
"h-[48rem] w-full rounded-b-2xl border border-t-0 border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900"
)
with ui.card().classes(
"w-full mt-4 rounded-xl border border-slate-200 bg-slate-50 p-4 shadow-none dark:border-slate-800 dark:bg-slate-950"
):
ui.label("Daily Results").classes("text-md font-semibold text-slate-900 dark:text-slate-100 mb-2")
ui.table(
columns=[
{"name": "date", "label": "Date", "field": "date", "align": "left"},
{"name": "low", "label": "Low", "field": "low", "align": "right"},
{"name": "high", "label": "High", "field": "high", "align": "right"},
{"name": "close", "label": "Close", "field": "close", "align": "right"},
{
"name": "portfolio_value",
"label": "Portfolio",
"field": "portfolio_value",
"align": "right",
},
{
"name": "option_value",
"label": "Option value",
"field": "option_value",
"align": "right",
},
{
"name": "ltv_unhedged",
"label": "LTV unhedged",
"field": "ltv_unhedged",
"align": "right",
},
{
"name": "ltv_hedged",
"label": "LTV hedged",
"field": "ltv_hedged",
"align": "right",
},
{
"name": "margin_call",
"label": "Margin call",
"field": "margin_call",
"align": "center",
},
],
rows=[
{
"date": point.date.isoformat(),
"low": f"${point.spot_low:,.2f}",
"high": f"${point.spot_high:,.2f}",
"close": f"${point.spot_close:,.2f}",
"portfolio_value": f"${point.net_portfolio_value:,.0f}",
"option_value": f"${point.option_market_value:,.0f}",
"ltv_unhedged": f"{point.ltv_unhedged:.1%}",
"ltv_hedged": f"{point.ltv_hedged:.1%}",
"margin_call": "Yes" if point.margin_call_hedged else "No",
}
for point in template_result.daily_path
],
row_key="date",
).classes("w-full")
def validate_current_scenario(*, entry_spot: float | None = None) -> str | 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_selected_symbol()
date_range_error = validate_date_range_for_symbol(start_date, end_date, symbol)
if date_range_error:
return date_range_error
try:
service.validate_preview_inputs(
symbol=symbol,
start_date=parse_iso_date(start_input.value, "Start date"),
end_date=parse_iso_date(end_input.value, "End date"),
template_slug=str(template_select.value or ""),
underlying_units=float(units_input.value or 0.0),
loan_amount=float(loan_input.value or 0.0),
margin_call_ltv=float(ltv_input.value or 0.0),
entry_spot=entry_spot,
data_source=str(data_source_select.value),
)
except (ValueError, KeyError) as exc:
return str(exc)
except Exception:
logger.exception(
"Backtest preview failed for workspace=%s symbol=%s start=%s end=%s template=%s units=%s loan=%s margin_call_ltv=%s",
workspace_id,
symbol_select.value,
start_input.value,
end_input.value,
template_select.value,
units_input.value,
loan_input.value,
ltv_input.value,
)
return "Backtest preview failed. Please verify the scenario inputs and try again."
return None
def save_backtest_settings() -> BacktestSettings | None:
"""Save current settings to BacktestSettingsRepository."""
if not workspace_id:
return None
try:
import uuid
# Create or update settings
sync_databento_config()
entry_spot, _ = derive_entry_spot()
if entry_spot is None:
entry_spot = 0.0
settings = BacktestSettings(
settings_id=saved_settings.settings_id if saved_settings else uuid.uuid4(),
name=f"Backtest {start_input.value} - {end_input.value}",
data_source=str(data_source_select.value), # type: ignore[arg-type]
dataset=str(dataset_select.value),
schema=str(schema_select.value),
start_date=parse_iso_date(start_input.value, "Start date"),
end_date=parse_iso_date(end_input.value, "End date"),
underlying_symbol=str(symbol_select.value), # type: ignore[arg-type]
start_price=float(start_price_input.value or 0.0),
underlying_units=float(units_input.value or 0.0),
loan_amount=float(loan_input.value or 0.0),
margin_call_ltv=float(ltv_input.value or 0.0),
template_slugs=(str(template_select.value or "default-template"),),
cache_key="", # Will be populated by the service if needed
data_cost_usd=0.0, # Will be populated after run
provider_ref=ProviderRef(
provider_id="synthetic_v1", # Default, updated based on data source
pricing_mode="synthetic_bs_mid",
),
)
settings_repo.save(workspace_id, settings)
return settings
except Exception as e:
logger.warning(f"Failed to save backtest settings for workspace {workspace_id}: {e}")
return None
def on_form_change() -> None:
"""Keep page interactions fast by deferring preview I/O until requested."""
validation_label.set_text("")
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:
"""Submit backtest job and start polling for results."""
validation_label.set_text("")
run_button.props("loading")
progress_label.set_text("Validating inputs...")
try:
# 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_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)
if dataset_min and start_date < dataset_min:
validation_label.set_text(
f"Start date must be on or after {dataset_min.strftime('%Y-%m-%d')} for {dataset} dataset. "
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")
progress_label.set_text("")
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")
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)
ltv = float(ltv_input.value or 0.0)
numeric_error = validate_numeric_inputs(units, loan, ltv)
if numeric_error:
validation_label.set_text(numeric_error)
render_result_state("Input validation failed", numeric_error, tone="warning")
run_button.props(remove="loading")
progress_label.set_text("")
return
# Save settings before running
save_backtest_settings()
# Start background job
job = start_backtest_job(
workspace_id=workspace_id,
service=service,
symbol=symbol,
start_date=start_date,
end_date=end_date,
template_slug=str(template_select.value or ""),
underlying_units=units,
loan_amount=loan,
margin_call_ltv=ltv,
data_source=str(data_source_select.value),
)
# Start polling for job status
ui.timer(1.0, lambda: poll_job_status(job.job_id), once=True)
except (ValueError, KeyError) as exc:
run_button.props(remove="loading")
progress_label.set_text("")
validation_label.set_text(str(exc))
render_result_state("Validation failed", str(exc), tone="warning")
except Exception:
run_button.props(remove="loading")
progress_label.set_text("")
logger.exception("Failed to start backtest job")
validation_label.set_text("Failed to start backtest. Please try again.")
render_result_state("Error", "Failed to start backtest. Please try again.", tone="error")
def poll_job_status(job_id: str) -> None:
"""Poll for job status and update UI."""
job = job_store.get_job(workspace_id)
if not job or job.job_id != job_id:
run_button.props(remove="loading")
progress_label.set_text("")
render_result_state("Error", "Job not found", tone="error")
return
# Update progress display
progress_label.set_text(job.stage.label)
if job.status == JobStatus.COMPLETE:
# Job complete, render results
run_button.props(remove="loading")
progress_label.set_text("")
if job.result:
render_job_result(job.result)
ui.notify("Backtest completed!", type="positive")
elif job.status == JobStatus.FAILED:
# Job failed, show error
run_button.props(remove="loading")
progress_label.set_text("")
error_msg = job.error or "Unknown error occurred"
validation_label.set_text(error_msg)
render_result_state("Backtest failed", error_msg, tone="error")
else:
# Still running, poll again in 1 second
ui.timer(1.0, lambda: poll_job_status(job_id), once=True)
def render_job_result(result: dict[str, Any]) -> None:
"""Render backtest result from job store."""
# Update entry spot hint
configured_start_price = float(start_price_input.value or 0.0)
if configured_start_price > 0:
entry_spot_hint.set_text(f"Using configured start price: ${configured_start_price:,.2f}")
else:
entry_spot_hint.set_text(f"Auto-derived entry spot: ${result.get('entry_spot', 0):,.2f}")
if result.get("entry_spot"):
render_seeded_summary(entry_spot=result["entry_spot"])
result_panel.clear()
with result_panel:
with ui.card().classes(
"w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
):
ui.label("Scenario Results").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
ui.label(f"Template: {result.get('scenario_name', 'Unknown')}").classes(
"text-sm text-slate-500 dark:text-slate-400"
)
# Get template results for summary
template_results = result.get("template_results", [])
first_template = template_results[0] if template_results else {}
summary = first_template.get("summary_metrics", {})
# Summary metrics using correct field names
start_value = summary.get("start_value", result.get("start_value", 0))
end_value_hedged = summary.get("end_value_hedged_net", 0)
hedge_cost = summary.get("total_hedge_cost", result.get("total_hedge_cost", 0))
max_ltv_hedged = summary.get("max_ltv_hedged", 0)
margin_days_hedged = summary.get("margin_call_days_hedged", 0)
margin_days_unhedged = summary.get("margin_call_days_unhedged", 0)
# Calculate hedge cost percentage
hedge_cost_pct = (hedge_cost / start_value * 100) if start_value > 0 else 0
summary_data = [
("Start value", f"${start_value:,.0f}"),
("End value hedged", f"${end_value_hedged:,.0f}"),
("Hedge cost", f"${hedge_cost:,.0f}"),
("Hedge cost %", f"{hedge_cost_pct:.2f}%"),
]
with ui.grid(columns=4).classes("w-full gap-4 max-lg:grid-cols-2 max-sm:grid-cols-1"):
for label, value in summary_data:
with ui.card().classes(
"rounded-xl border border-slate-200 bg-slate-50 p-4 shadow-none dark:border-slate-800 dark:bg-slate-950"
):
ui.label(label).classes("text-sm text-slate-500 dark:text-slate-400")
ui.label(value).classes("text-2xl font-bold text-slate-900 dark:text-slate-100")
# Get option contracts from first day (constant throughout backtest)
daily_path = first_template.get("daily_path", [])
first_day = daily_path[0] if daily_path else {}
option_contracts = first_day.get("option_contracts", 0)
# Additional metrics row
with ui.grid(columns=4).classes("w-full gap-4 max-lg:grid-cols-2 max-sm:grid-cols-1"):
extra_data = [
("Max LTV hedged", f"{max_ltv_hedged:.1%}"),
("Option contracts", f"{option_contracts:,.0f}"),
("Margin call days hedged", str(margin_days_hedged)),
("Margin call days unhedged", str(margin_days_unhedged)),
("Breached threshold", "No" if margin_days_hedged == 0 else "Yes"),
]
for label, value in extra_data:
with ui.card().classes(
"rounded-xl border border-slate-200 bg-slate-50 p-4 shadow-none dark:border-slate-800 dark:bg-slate-950"
):
ui.label(label).classes("text-sm text-slate-500 dark:text-slate-400")
ui.label(value).classes("text-2xl font-bold text-slate-900 dark:text-slate-100")
# Charts above table
if daily_path:
# Portfolio value stacked bar chart
ui.echart(_portfolio_chart_options_from_dict(result)).classes(
"h-48 w-full rounded-t-2xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900"
)
# Candlestick chart (double height)
ui.echart(_candle_chart_options_from_dict(result)).classes(
"h-[48rem] w-full rounded-b-2xl border border-t-0 border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900"
)
# Daily path table
if daily_path:
with ui.card().classes(
"w-full mt-4 rounded-xl border border-slate-200 bg-slate-50 p-4 shadow-none dark:border-slate-800 dark:bg-slate-950"
):
ui.label("Daily Results").classes(
"text-md font-semibold text-slate-900 dark:text-slate-100 mb-2"
)
ui.table(
columns=[
{"name": "date", "label": "Date", "field": "date", "align": "left"},
{"name": "low", "label": "Low", "field": "low", "align": "right"},
{"name": "high", "label": "High", "field": "high", "align": "right"},
{"name": "close", "label": "Close", "field": "close", "align": "right"},
{
"name": "portfolio_value",
"label": "Portfolio",
"field": "portfolio_value",
"align": "right",
},
{
"name": "option_value",
"label": "Option value",
"field": "option_value",
"align": "right",
},
{
"name": "ltv_unhedged",
"label": "LTV unhedged",
"field": "ltv_unhedged",
"align": "right",
},
{
"name": "ltv_hedged",
"label": "LTV hedged",
"field": "ltv_hedged",
"align": "right",
},
{
"name": "margin_call",
"label": "Margin call",
"field": "margin_call",
"align": "center",
},
],
rows=[
{
"date": dp.get("date", ""),
"low": f"${dp.get('spot_low', dp.get('spot_close', 0)):,.2f}",
"high": f"${dp.get('spot_high', dp.get('spot_close', 0)):,.2f}",
"close": f"${dp.get('spot_close', 0):,.2f}",
"portfolio_value": f"${dp.get('net_portfolio_value', 0):,.0f}",
"option_value": f"${dp.get('option_market_value', 0):,.0f}",
"ltv_unhedged": f"{dp.get('ltv_unhedged', 0):.1%}",
"ltv_hedged": f"{dp.get('ltv_hedged', 0):.1%}",
"margin_call": "Yes" if dp.get("margin_call_hedged") else "No",
}
for dp in daily_path
],
row_key="date",
).classes("w-full")
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 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: (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())
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
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