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:
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user