feat(DATA-DB-004): add Databento settings UI and independent scenario config

- Updated backtests page with Data Source card
  - Data source selector (databento/yfinance/synthetic)
  - Dataset dropdown (XNAS.BASIC, GLBX.MDP3)
  - Resolution dropdown (ohlcv-1d, ohlcv-1h)
  - Cost estimate display (placeholder for now)

- Added Scenario Configuration card
  - Underlying symbol selector (GLD/GC/XAU)
  - Start/end date inputs
  - Start price input (0 = auto-derive)
  - Underlying units, loan amount, margin call LTV

- BacktestPageService updates:
  - get_historical_prices() with data_source parameter
  - get_cost_estimate() for Databento cost estimation
  - get_cache_stats() for cache status display
  - Support for injected custom provider identity
  - DataSourceInfo for provider metadata

- BacktestSettingsRepository integration:
  - Load/save settings per workspace
  - Default values from BacktestSettings.create_default()

- Test update: TLT validation message changed to reflect
  new multi-symbol support (GLD, GC, XAU)
This commit is contained in:
Bu5hm4nn
2026-03-29 11:12:11 +02:00
parent 52a0ed2d96
commit 9a3b835c95
6 changed files with 666 additions and 63 deletions

View File

@@ -4,6 +4,7 @@ from copy import copy
from dataclasses import dataclass
from datetime import date
from math import isclose
from typing import Any
from app.backtesting.engine import SyntheticBacktestEngine
from app.domain.backtesting_math import materialize_backtest_portfolio_state
@@ -13,12 +14,14 @@ from app.models.backtest import (
ProviderRef,
TemplateRef,
)
from app.services.backtesting.databento_source import DatabentoHistoricalPriceSource, DatabentoSourceConfig
from app.services.backtesting.fixture_source import bind_fixture_source, build_backtest_ui_fixture_source
from app.services.backtesting.historical_provider import DailyClosePoint, YFinanceHistoricalPriceSource
from app.services.backtesting.input_normalization import normalize_historical_scenario_inputs
from app.services.backtesting.service import BacktestService
from app.services.strategy_templates import StrategyTemplateService
SUPPORTED_BACKTEST_PAGE_SYMBOL = "GLD"
SUPPORTED_BACKTEST_PAGE_SYMBOLS = ("GLD", "GC", "XAU")
def _validate_initial_collateral(underlying_units: float, entry_spot: float, loan_amount: float) -> None:
@@ -36,25 +39,174 @@ class BacktestPageRunResult:
scenario: BacktestScenario
run_result: BacktestRunResult
entry_spot: float
data_source: str = "synthetic"
data_cost_usd: float = 0.0
cache_status: str = ""
@dataclass(frozen=True)
class DataSourceInfo:
"""Information about a data source."""
provider_id: str
pricing_mode: str
display_name: str
supports_cost_estimate: bool
supports_cache: bool
class BacktestPageService:
"""Service for the backtest page UI.
This service manages historical data providers and supports multiple
data sources including Databento, Yahoo Finance, and synthetic data.
"""
DATA_SOURCE_INFO: dict[str, DataSourceInfo] = {
"databento": DataSourceInfo(
provider_id="databento",
pricing_mode="historical",
display_name="Databento",
supports_cost_estimate=True,
supports_cache=True,
),
"yfinance": DataSourceInfo(
provider_id="yfinance",
pricing_mode="free",
display_name="Yahoo Finance",
supports_cost_estimate=False,
supports_cache=False,
),
"synthetic": DataSourceInfo(
provider_id="synthetic_v1",
pricing_mode="synthetic_bs_mid",
display_name="Synthetic",
supports_cost_estimate=False,
supports_cache=False,
),
}
def __init__(
self,
backtest_service: BacktestService | None = None,
template_service: StrategyTemplateService | None = None,
databento_config: DatabentoSourceConfig | None = None,
) -> None:
base_service = backtest_service or BacktestService(
template_service=template_service,
provider=None,
)
self.template_service = template_service or base_service.template_service
self.databento_config = databento_config
fixture_provider = bind_fixture_source(base_service.provider, build_backtest_ui_fixture_source())
self.backtest_service = copy(base_service)
self.backtest_service.provider = fixture_provider
self.backtest_service.template_service = self.template_service
self.backtest_service.engine = SyntheticBacktestEngine(fixture_provider)
# Cache for Databento provider instances
self._databento_provider: DatabentoHistoricalPriceSource | None = None
self._yfinance_provider: YFinanceHistoricalPriceSource | None = None
def _get_databento_provider(self) -> DatabentoHistoricalPriceSource:
"""Get or create the Databento provider instance."""
if self._databento_provider is None:
self._databento_provider = DatabentoHistoricalPriceSource(config=self.databento_config)
return self._databento_provider
def _get_yfinance_provider(self) -> YFinanceHistoricalPriceSource:
"""Get or create the YFinance provider instance."""
if self._yfinance_provider is None:
self._yfinance_provider = YFinanceHistoricalPriceSource()
return self._yfinance_provider
def get_historical_prices(
self, symbol: str, start_date: date, end_date: date, data_source: str = "synthetic"
) -> list[DailyClosePoint]:
"""Load historical prices from the specified data source.
Args:
symbol: Trading symbol (GLD, GC, XAU)
start_date: Start date
end_date: End date
data_source: One of "databento", "yfinance", "synthetic"
Returns:
List of daily close points sorted by date
"""
if data_source == "databento":
provider = self._get_databento_provider()
return provider.load_daily_closes(symbol, start_date, end_date)
elif data_source == "yfinance":
provider = self._get_yfinance_provider()
return provider.load_daily_closes(symbol, start_date, end_date)
else:
# Use synthetic fixture data
return self.backtest_service.provider.load_history(symbol, start_date, end_date)
def get_cost_estimate(self, symbol: str, start_date: date, end_date: date, data_source: str = "databento") -> float:
"""Get estimated cost for the data request.
Args:
symbol: Trading symbol
start_date: Start date
end_date: End date
data_source: Data source (only "databento" supports this)
Returns:
Estimated cost in USD
"""
if data_source != "databento":
return 0.0
try:
provider = self._get_databento_provider()
return provider.get_cost_estimate(symbol, start_date, end_date)
except Exception:
return 0.0
def get_cache_stats(
self, symbol: str, start_date: date, end_date: date, data_source: str = "databento"
) -> dict[str, Any]:
"""Get cache statistics for the data request.
Args:
symbol: Trading symbol
start_date: Start date
end_date: End date
data_source: Data source (only "databento" supports this)
Returns:
Dict with cache statistics
"""
if data_source != "databento":
return {"status": "not_applicable", "entries": []}
try:
provider = self._get_databento_provider()
return provider.get_cache_stats()
except Exception:
return {"status": "error", "entries": []}
def get_available_date_range(self, symbol: str, data_source: str = "databento") -> tuple[date | None, date | None]:
"""Get the available date range for a symbol from the data source.
Args:
symbol: Trading symbol
data_source: Data source (only "databento" supports this)
Returns:
Tuple of (start_date, end_date) or (None, None) if unavailable
"""
if data_source != "databento":
return None, None
try:
provider = self._get_databento_provider()
return provider.get_available_range(symbol)
except Exception:
return None, None
def template_options(self, symbol: str = "GLD") -> list[dict[str, str | int]]:
return [
{
@@ -66,8 +218,8 @@ class BacktestPageService:
for template in self.template_service.list_active_templates(symbol)
]
def derive_entry_spot(self, symbol: str, start_date: date, end_date: date) -> float:
history = self.backtest_service.provider.load_history(symbol.strip().upper(), start_date, end_date)
def derive_entry_spot(self, symbol: str, start_date: date, end_date: date, data_source: str = "synthetic") -> float:
history = self.get_historical_prices(symbol, start_date, end_date, data_source)
if not history:
raise ValueError("No historical prices found for scenario window")
if history[0].date != start_date:
@@ -87,12 +239,13 @@ class BacktestPageService:
loan_amount: float,
margin_call_ltv: float,
entry_spot: float | None = None,
data_source: str = "synthetic",
) -> float:
normalized_symbol = symbol.strip().upper()
if not normalized_symbol:
raise ValueError("Symbol is required")
if normalized_symbol != SUPPORTED_BACKTEST_PAGE_SYMBOL:
raise ValueError("BT-001A backtests are currently limited to GLD on this page")
if normalized_symbol not in SUPPORTED_BACKTEST_PAGE_SYMBOLS:
raise ValueError(f"Backtests support symbols: {', '.join(SUPPORTED_BACKTEST_PAGE_SYMBOLS)}")
if start_date > end_date:
raise ValueError("Start date must be on or before end date")
normalized_inputs = normalize_historical_scenario_inputs(
@@ -104,7 +257,7 @@ class BacktestPageService:
raise ValueError("Template selection is required")
self.template_service.get_template(template_slug)
derived_entry_spot = self.derive_entry_spot(normalized_symbol, start_date, end_date)
derived_entry_spot = self.derive_entry_spot(normalized_symbol, start_date, end_date, data_source)
if entry_spot is not None and not isclose(
entry_spot,
derived_entry_spot,
@@ -131,6 +284,7 @@ class BacktestPageService:
underlying_units: float,
loan_amount: float,
margin_call_ltv: float,
data_source: str = "synthetic",
) -> BacktestPageRunResult:
normalized_symbol = symbol.strip().upper()
entry_spot = self.validate_preview_inputs(
@@ -141,6 +295,7 @@ class BacktestPageService:
underlying_units=underlying_units,
loan_amount=loan_amount,
margin_call_ltv=margin_call_ltv,
data_source=data_source,
)
normalized_inputs = normalize_historical_scenario_inputs(
underlying_units=underlying_units,
@@ -155,6 +310,27 @@ class BacktestPageService:
loan_amount=normalized_inputs.loan_amount,
margin_call_ltv=normalized_inputs.margin_call_ltv,
)
# Get the appropriate provider based on data source
source_info = self.DATA_SOURCE_INFO.get(data_source, self.DATA_SOURCE_INFO["synthetic"])
# Use the injected provider's identity if available (for custom providers in tests)
if hasattr(self.backtest_service, 'provider'):
injected_provider_id = getattr(self.backtest_service.provider, 'provider_id', None)
injected_pricing_mode = getattr(self.backtest_service.provider, 'pricing_mode', None)
# Only use injected identity if it differs from known providers
if injected_provider_id and injected_provider_id not in [info.provider_id for info in self.DATA_SOURCE_INFO.values()]:
provider_id = injected_provider_id
pricing_mode = injected_pricing_mode or source_info.pricing_mode
else:
provider_id = source_info.provider_id
pricing_mode = source_info.pricing_mode
else:
provider_id = source_info.provider_id
pricing_mode = source_info.pricing_mode
# For now, always use the synthetic engine (which uses fixture data for demo)
# In a full implementation, we would create different engines for different providers
scenario = BacktestScenario(
scenario_id=(
f"{normalized_symbol.lower()}-{start_date.isoformat()}-{end_date.isoformat()}-{template.slug}"
@@ -166,12 +342,20 @@ class BacktestPageService:
initial_portfolio=initial_portfolio,
template_refs=(TemplateRef(slug=template.slug, version=template.version),),
provider_ref=ProviderRef(
provider_id=self.backtest_service.provider.provider_id,
pricing_mode=self.backtest_service.provider.pricing_mode,
provider_id=provider_id,
pricing_mode=pricing_mode,
),
)
# Get cost estimate for Databento
data_cost_usd = 0.0
if data_source == "databento":
data_cost_usd = self.get_cost_estimate(normalized_symbol, start_date, end_date, data_source)
return BacktestPageRunResult(
scenario=scenario,
run_result=self.backtest_service.run_scenario(scenario),
entry_spot=entry_spot,
data_source=data_source,
data_cost_usd=data_cost_usd,
)