feat(DATA-DB-002): add BacktestSettings model and repository

- BacktestSettings dataclass with all configuration fields
- BacktestSettingsRepository for persistence per workspace
- Settings independent of portfolio configuration
- Full validation for dates, symbols, LTV, etc.
- 16 comprehensive tests

Fields:
- settings_id, name: identification
- data_source: databento|yfinance|synthetic
- dataset, schema: Databento configuration
- start_date, end_date: date range
- underlying_symbol, start_price, underlying_units: position config
- loan_amount, margin_call_ltv: LTV analysis
- template_slugs: strategies to test
- cache_key, data_cost_usd: caching metadata
- provider_ref: provider configuration
This commit is contained in:
Bu5hm4nn
2026-03-29 10:46:25 +02:00
parent f4c3cee91d
commit 43067bb275
4 changed files with 892 additions and 0 deletions

View File

@@ -0,0 +1,88 @@
"""Backtest settings model for configuring backtest scenarios independently of portfolio settings."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import date
from typing import Literal
from uuid import UUID, uuid4
# Self type annotation
from app.models.backtest import ProviderRef
@dataclass(frozen=True)
class BacktestSettings:
"""Configuration for running backtests independent of portfolio settings.
These settings determine what data to fetch and how to run backtests,
separate from the actual portfolio configurations being tested.
"""
settings_id: UUID
name: str
data_source: Literal["databento", "yfinance", "synthetic"]
dataset: str
schema: str
start_date: date
end_date: date
underlying_symbol: Literal["GLD", "GC", "XAU"]
start_price: float
underlying_units: float
loan_amount: float
margin_call_ltv: float
template_slugs: tuple[str, ...]
cache_key: str
data_cost_usd: float
provider_ref: ProviderRef
def __post_init__(self) -> None:
if not self.settings_id:
raise ValueError("settings_id is required")
if not self.name:
raise ValueError("name is required")
if self.start_date > self.end_date:
raise ValueError("start_date must be on or before end_date")
if self.data_source not in ("databento", "yfinance", "synthetic"):
raise ValueError("data_source must be 'databento', 'yfinance', or 'synthetic'")
if self.underlying_symbol not in ("GLD", "GC", "XAU"):
raise ValueError("underlying_symbol must be 'GLD', 'GC', or 'XAU'")
if self.start_price < 0:
raise ValueError("start_price must be non-negative")
if self.underlying_units <= 0:
raise ValueError("underlying_units must be positive")
if self.loan_amount < 0:
raise ValueError("loan_amount must be non-negative")
if not 0 < self.margin_call_ltv < 1:
raise ValueError("margin_call_ltv must be between 0 and 1")
if not self.template_slugs:
raise ValueError("at least one template slug is required")
if self.data_cost_usd < 0:
raise ValueError("data_cost_usd must be non-negative")
@classmethod
def create_default(cls, name: str = "Default Backtest Settings") -> BacktestSettings:
"""Create default backtest settings configuration."""
return cls(
settings_id=uuid4(),
name=name,
data_source="databento",
dataset="XNAS.BASIC",
schema="ohlcv-1d",
start_date=date(2020, 1, 1),
end_date=date(2023, 12, 31),
underlying_symbol="GLD",
start_price=0.0,
underlying_units=1000.0,
loan_amount=0.0,
margin_call_ltv=0.75,
template_slugs=("default-template",),
cache_key="",
data_cost_usd=0.0,
provider_ref=ProviderRef(provider_id="default", pricing_mode="standard"),
)
# For backward compatibility - alias to existing models
BacktestScenario = "app.models.backtest.BacktestScenario"
# TemplateRef and ProviderRef imported from app.models.backtest

View File

@@ -0,0 +1,130 @@
"""Repository for persisting backtest settings configuration."""
from __future__ import annotations
import json
from datetime import date
from pathlib import Path
from typing import Any
from uuid import UUID
from app.models.backtest_settings import BacktestSettings
class BacktestSettingsRepository:
"""Repository for persisting backtest settings configuration.
Persists to `.workspaces/{workspace_id}/backtest_settings.json`
"""
def __init__(self, base_path: Path | str = Path(".workspaces")) -> None:
self.base_path = Path(base_path)
self.base_path.mkdir(parents=True, exist_ok=True)
def load(self, workspace_id: str) -> BacktestSettings:
"""Load backtest settings for a workspace.
Args:
workspace_id: The workspace ID to load settings for
Returns:
BacktestSettings: The loaded settings
Raises:
FileNotFoundError: If settings file doesn't exist
ValueError: If settings file is invalid
"""
settings_path = self._settings_path(workspace_id)
if not settings_path.exists():
# Return default settings if none exist
return BacktestSettings.create_default()
try:
with open(settings_path) as f:
data = json.load(f)
return self._settings_from_dict(data)
except (json.JSONDecodeError, KeyError) as e:
raise ValueError(f"Invalid backtest settings file: {e}") from e
def save(self, workspace_id: str, settings: BacktestSettings) -> None:
"""Save backtest settings for a workspace.
Args:
workspace_id: The workspace ID to save settings for
settings: The settings to save
"""
settings_path = self._settings_path(workspace_id)
settings_path.parent.mkdir(parents=True, exist_ok=True)
payload = self._to_dict(settings)
tmp_path = settings_path.with_name(f"{settings_path.name}.tmp")
with open(tmp_path, "w") as f:
json.dump(payload, f, indent=2)
f.flush()
# Atomic replace
tmp_path.replace(settings_path)
def _settings_path(self, workspace_id: str) -> Path:
"""Get the path to the settings file for a workspace."""
return self.base_path / workspace_id / "backtest_settings.json"
def _to_dict(self, settings: BacktestSettings) -> dict[str, Any]:
"""Convert BacktestSettings to a dictionary for serialization."""
return {
"settings_id": str(settings.settings_id),
"name": settings.name,
"data_source": settings.data_source,
"dataset": settings.dataset,
"schema": settings.schema,
"start_date": settings.start_date.isoformat(),
"end_date": settings.end_date.isoformat(),
"underlying_symbol": settings.underlying_symbol,
"start_price": settings.start_price,
"underlying_units": settings.underlying_units,
"loan_amount": settings.loan_amount,
"margin_call_ltv": settings.margin_call_ltv,
"template_slugs": list(settings.template_slugs),
"cache_key": settings.cache_key,
"data_cost_usd": settings.data_cost_usd,
"provider_ref": {
"provider_id": settings.provider_ref.provider_id,
"pricing_mode": settings.provider_ref.pricing_mode,
},
}
def _settings_from_dict(self, data: dict[str, Any]) -> BacktestSettings:
"""Create BacktestSettings from a dictionary."""
# Handle potential string dates
start_date = data["start_date"]
if isinstance(start_date, str):
start_date = date.fromisoformat(start_date)
end_date = data["end_date"]
if isinstance(end_date, str):
end_date = date.fromisoformat(end_date)
# Import here to avoid circular import issues at module level
from app.models.backtest import ProviderRef
return BacktestSettings(
settings_id=UUID(data["settings_id"]),
name=data["name"],
data_source=data["data_source"],
dataset=data["dataset"],
schema=data["schema"],
start_date=start_date,
end_date=end_date,
underlying_symbol=data["underlying_symbol"],
start_price=data["start_price"],
underlying_units=data["underlying_units"],
loan_amount=data["loan_amount"],
margin_call_ltv=data["margin_call_ltv"],
template_slugs=tuple(data["template_slugs"]),
cache_key=data["cache_key"],
data_cost_usd=data["data_cost_usd"],
provider_ref=ProviderRef(
provider_id=data["provider_ref"]["provider_id"],
pricing_mode=data["provider_ref"]["pricing_mode"],
),
)