From 43067bb2754d12ac3ba52af1eb5a9afb39efb18d Mon Sep 17 00:00:00 2001 From: Bu5hm4nn Date: Sun, 29 Mar 2026 10:46:25 +0200 Subject: [PATCH] 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 --- AGENTS.md | 12 + app/models/backtest_settings.py | 88 +++ app/models/backtest_settings_repository.py | 130 ++++ tests/test_backtest_settings.py | 662 +++++++++++++++++++++ 4 files changed, 892 insertions(+) create mode 100644 app/models/backtest_settings.py create mode 100644 app/models/backtest_settings_repository.py create mode 100644 tests/test_backtest_settings.py diff --git a/AGENTS.md b/AGENTS.md index e8311b3..745c51e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,16 @@ policy: + subagent_usage: + required: true + rules: + - prefer sub-agents for parallelizable implementation work + - use sub-agents when implementing independent roadmap items + - create worktrees for sub-agents working on the same codebase + - review sub-agent output before merging to main + - use `agent: implementation-reviewer` for code quality checks + - use `agent: qa-validator` for end-to-end validation + - chain sub-agents for multi-step workflows (plan → implement → review) + - always use sub-agents unless the task is trivial or requires direct interaction + test_loop: required: true rules: diff --git a/app/models/backtest_settings.py b/app/models/backtest_settings.py new file mode 100644 index 0000000..bcc6787 --- /dev/null +++ b/app/models/backtest_settings.py @@ -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 diff --git a/app/models/backtest_settings_repository.py b/app/models/backtest_settings_repository.py new file mode 100644 index 0000000..e74a54d --- /dev/null +++ b/app/models/backtest_settings_repository.py @@ -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"], + ), + ) diff --git a/tests/test_backtest_settings.py b/tests/test_backtest_settings.py new file mode 100644 index 0000000..936e6ba --- /dev/null +++ b/tests/test_backtest_settings.py @@ -0,0 +1,662 @@ +from __future__ import annotations + +from datetime import date +from uuid import uuid4 + +import pytest + +from app.models.backtest import ProviderRef +from app.models.backtest_settings import BacktestSettings +from app.models.backtest_settings_repository import BacktestSettingsRepository + + +class TestBacktestSettings: + """Tests for the BacktestSettings model.""" + + def test_default_settings_creation(self) -> None: + """Verify create_default() works correctly.""" + settings = BacktestSettings.create_default() + + # Verify basic properties + assert isinstance(settings.settings_id, type(uuid4())) + assert settings.name == "Default Backtest Settings" + assert settings.data_source == "databento" + assert settings.dataset == "XNAS.BASIC" + assert settings.schema == "ohlcv-1d" + assert settings.start_date == date(2020, 1, 1) + assert settings.end_date == date(2023, 12, 31) + assert settings.underlying_symbol == "GLD" + assert settings.start_price == 0.0 + assert settings.underlying_units == 1000.0 + assert settings.loan_amount == 0.0 + assert settings.margin_call_ltv == 0.75 + assert settings.template_slugs == ("default-template",) + assert settings.cache_key == "" + assert settings.data_cost_usd == 0.0 + assert settings.provider_ref.provider_id == "default" + assert settings.provider_ref.pricing_mode == "standard" + + def test_settings_serialization_to_dict(self) -> None: + """Verify to_dict() serialization works correctly.""" + settings_id = uuid4() + provider_ref = ProviderRef(provider_id="test_provider", pricing_mode="test_mode") + settings = BacktestSettings( + settings_id=settings_id, + name="Test Settings", + data_source="yfinance", + dataset="TEST.DATASET", + schema="test-schema", + start_date=date(2021, 1, 1), + end_date=date(2021, 12, 31), + underlying_symbol="GC", + start_price=50.0, + underlying_units=500.0, + loan_amount=10000.0, + margin_call_ltv=0.80, + template_slugs=("template1", "template2"), + cache_key="test-cache-key", + data_cost_usd=500.0, + provider_ref=provider_ref, + ) + + # Call the _to_dict method through the repository since it's private + repo = BacktestSettingsRepository() + serialized = repo._to_dict(settings) + + # Verify all fields are properly serialized + assert serialized["settings_id"] == str(settings_id) + assert serialized["name"] == "Test Settings" + assert serialized["data_source"] == "yfinance" + assert serialized["dataset"] == "TEST.DATASET" + assert serialized["schema"] == "test-schema" + assert serialized["start_date"] == "2021-01-01" + assert serialized["end_date"] == "2021-12-31" + assert serialized["underlying_symbol"] == "GC" + assert serialized["start_price"] == 50.0 + assert serialized["underlying_units"] == 500.0 + assert serialized["loan_amount"] == 10000.0 + assert serialized["margin_call_ltv"] == 0.80 + assert serialized["template_slugs"] == ["template1", "template2"] + assert serialized["cache_key"] == "test-cache-key" + assert serialized["data_cost_usd"] == 500.0 + assert serialized["provider_ref"]["provider_id"] == "test_provider" + assert serialized["provider_ref"]["pricing_mode"] == "test_mode" + + def test_settings_deserialization_from_dict(self) -> None: + """Verify _settings_from_dict() works correctly.""" + data = { + "settings_id": str(uuid4()), + "name": "Deserialized Settings", + "data_source": "synthetic", + "dataset": "SYNTHETIC.DATA", + "schema": "synthetic-schema", + "start_date": "2022-01-01", + "end_date": "2022-12-31", + "underlying_symbol": "XAU", + "start_price": 75.0, + "underlying_units": 750.0, + "loan_amount": 15000.0, + "margin_call_ltv": 0.70, + "template_slugs": ["template3", "template4"], + "cache_key": "deserialized-cache", + "data_cost_usd": 750.0, + "provider_ref": { + "provider_id": "deserialized_provider", + "pricing_mode": "deserialized_mode", + }, + } + + repo = BacktestSettingsRepository() + settings = repo._settings_from_dict(data) + + # Verify all fields are properly deserialized + assert str(settings.settings_id) == data["settings_id"] + assert settings.name == "Deserialized Settings" + assert settings.data_source == "synthetic" + assert settings.dataset == "SYNTHETIC.DATA" + assert settings.schema == "synthetic-schema" + assert settings.start_date == date(2022, 1, 1) + assert settings.end_date == date(2022, 12, 31) + assert settings.underlying_symbol == "XAU" + assert settings.start_price == 75.0 + assert settings.underlying_units == 750.0 + assert settings.loan_amount == 15000.0 + assert settings.margin_call_ltv == 0.70 + assert settings.template_slugs == ("template3", "template4") + assert settings.cache_key == "deserialized-cache" + assert settings.data_cost_usd == 750.0 + assert settings.provider_ref.provider_id == "deserialized_provider" + assert settings.provider_ref.pricing_mode == "deserialized_mode" + + def test_date_range_validation(self) -> None: + """Start_date must be <= end_date.""" + with pytest.raises(ValueError, match="start_date must be on or before end_date"): + BacktestSettings( + settings_id=uuid4(), + name="Bad Date Range", + data_source="databento", + dataset="TEST.DATASET", + schema="test-schema", + start_date=date(2022, 1, 2), # After end_date + end_date=date(2022, 1, 1), + underlying_symbol="GLD", + start_price=0.0, + underlying_units=1000.0, + loan_amount=0.0, + margin_call_ltv=0.75, + template_slugs=("template1",), + cache_key="", + data_cost_usd=0.0, + provider_ref=ProviderRef(provider_id="default", pricing_mode="standard"), + ) + + def test_symbol_validation(self) -> None: + """Only allow "GLD", "GC", "XAU", "GC=F".""" + # Valid symbols should work + valid_symbols = ["GLD", "GC", "XAU"] + for symbol in valid_symbols: + settings = BacktestSettings( + settings_id=uuid4(), + name=f"Valid Symbol {symbol}", + data_source="databento", + dataset="TEST.DATASET", + schema="test-schema", + start_date=date(2022, 1, 1), + end_date=date(2022, 12, 31), + underlying_symbol=symbol, + start_price=0.0, + underlying_units=1000.0, + loan_amount=0.0, + margin_call_ltv=0.75, + template_slugs=("template1",), + cache_key="", + data_cost_usd=0.0, + provider_ref=ProviderRef(provider_id="default", pricing_mode="standard"), + ) + assert settings.underlying_symbol == symbol + + # Invalid symbol should raise ValueError + with pytest.raises(ValueError, match="underlying_symbol must be 'GLD', 'GC', or 'XAU'"): + BacktestSettings( + settings_id=uuid4(), + name="Invalid Symbol", + data_source="databento", + dataset="TEST.DATASET", + schema="test-schema", + start_date=date(2022, 1, 1), + end_date=date(2022, 12, 31), + underlying_symbol="INVALID", # Not in allowed list + start_price=0.0, + underlying_units=1000.0, + loan_amount=0.0, + margin_call_ltv=0.75, + template_slugs=("template1",), + cache_key="", + data_cost_usd=0.0, + provider_ref=ProviderRef(provider_id="default", pricing_mode="standard"), + ) + + def test_data_source_validation(self) -> None: + """Only allow "databento", "yfinance", "synthetic".""" + # Valid sources should work + valid_sources = ["databento", "yfinance", "synthetic"] + for source in valid_sources: + settings = BacktestSettings( + settings_id=uuid4(), + name=f"Valid Source {source}", + data_source=source, + dataset="TEST.DATASET", + schema="test-schema", + start_date=date(2022, 1, 1), + end_date=date(2022, 12, 31), + underlying_symbol="GLD", + start_price=0.0, + underlying_units=1000.0, + loan_amount=0.0, + margin_call_ltv=0.75, + template_slugs=("template1",), + cache_key="", + data_cost_usd=0.0, + provider_ref=ProviderRef(provider_id="default", pricing_mode="standard"), + ) + assert settings.data_source == source + + # Invalid source should raise ValueError + with pytest.raises(ValueError, match="data_source must be 'databento', 'yfinance', or 'synthetic'"): + BacktestSettings( + settings_id=uuid4(), + name="Invalid Source", + data_source="invalid_source", # Not in allowed list + dataset="TEST.DATASET", + schema="test-schema", + start_date=date(2022, 1, 1), + end_date=date(2022, 12, 31), + underlying_symbol="GLD", + start_price=0.0, + underlying_units=1000.0, + loan_amount=0.0, + margin_call_ltv=0.75, + template_slugs=("template1",), + cache_key="", + data_cost_usd=0.0, + provider_ref=ProviderRef(provider_id="default", pricing_mode="standard"), + ) + + def test_margin_call_ltv_validation(self) -> None: + """Must be between 0 and 1.""" + # Valid values should work + valid_ltvs = [0.01, 0.5, 0.99] + for ltv in valid_ltvs: + settings = BacktestSettings( + settings_id=uuid4(), + name=f"Valid LTV {ltv}", + data_source="databento", + dataset="TEST.DATASET", + schema="test-schema", + start_date=date(2022, 1, 1), + end_date=date(2022, 12, 31), + underlying_symbol="GLD", + start_price=0.0, + underlying_units=1000.0, + loan_amount=0.0, + margin_call_ltv=ltv, + template_slugs=("template1",), + cache_key="", + data_cost_usd=0.0, + provider_ref=ProviderRef(provider_id="default", pricing_mode="standard"), + ) + assert settings.margin_call_ltv == ltv + + # Invalid values should raise ValueError + invalid_ltvs = [-0.1, 0.0, 1.0, 1.1] + for ltv in invalid_ltvs: + with pytest.raises(ValueError, match="margin_call_ltv must be between 0 and 1"): + BacktestSettings( + settings_id=uuid4(), + name=f"Invalid LTV {ltv}", + data_source="databento", + dataset="TEST.DATASET", + schema="test-schema", + start_date=date(2022, 1, 1), + end_date=date(2022, 12, 31), + underlying_symbol="GLD", + start_price=0.0, + underlying_units=1000.0, + loan_amount=0.0, + margin_call_ltv=ltv, + template_slugs=("template1",), + cache_key="", + data_cost_usd=0.0, + provider_ref=ProviderRef(provider_id="default", pricing_mode="standard"), + ) + + def test_loan_amount_validation(self) -> None: + """Must be non-negative.""" + # Valid values should work + valid_amounts = [0.0, 1000.0, 100000.0] + for amount in valid_amounts: + settings = BacktestSettings( + settings_id=uuid4(), + name=f"Valid Loan Amount {amount}", + data_source="databento", + dataset="TEST.DATASET", + schema="test-schema", + start_date=date(2022, 1, 1), + end_date=date(2022, 12, 31), + underlying_symbol="GLD", + start_price=0.0, + underlying_units=1000.0, + loan_amount=amount, + margin_call_ltv=0.75, + template_slugs=("template1",), + cache_key="", + data_cost_usd=0.0, + provider_ref=ProviderRef(provider_id="default", pricing_mode="standard"), + ) + assert settings.loan_amount == amount + + # Invalid negative value should raise ValueError + with pytest.raises(ValueError, match="loan_amount must be non-negative"): + BacktestSettings( + settings_id=uuid4(), + name="Invalid Negative Loan Amount", + data_source="databento", + dataset="TEST.DATASET", + schema="test-schema", + start_date=date(2022, 1, 1), + end_date=date(2022, 12, 31), + underlying_symbol="GLD", + start_price=0.0, + underlying_units=1000.0, + loan_amount=-1000.0, # Negative value + margin_call_ltv=0.75, + template_slugs=("template1",), + cache_key="", + data_cost_usd=0.0, + provider_ref=ProviderRef(provider_id="default", pricing_mode="standard"), + ) + + def test_underlying_units_validation(self) -> None: + """Must be positive.""" + # Valid values should work + valid_units = [0.1, 1.0, 1000.0] + for units in valid_units: + settings = BacktestSettings( + settings_id=uuid4(), + name=f"Valid Units {units}", + data_source="databento", + dataset="TEST.DATASET", + schema="test-schema", + start_date=date(2022, 1, 1), + end_date=date(2022, 12, 31), + underlying_symbol="GLD", + start_price=0.0, + underlying_units=units, + loan_amount=0.0, + margin_call_ltv=0.75, + template_slugs=("template1",), + cache_key="", + data_cost_usd=0.0, + provider_ref=ProviderRef(provider_id="default", pricing_mode="standard"), + ) + assert settings.underlying_units == units + + # Invalid zero or negative values should raise ValueError + invalid_units = [0.0, -1.0, -1000.0] + for units in invalid_units: + with pytest.raises(ValueError, match="underlying_units must be positive"): + BacktestSettings( + settings_id=uuid4(), + name=f"Invalid Units {units}", + data_source="databento", + dataset="TEST.DATASET", + schema="test-schema", + start_date=date(2022, 1, 1), + end_date=date(2022, 12, 31), + underlying_symbol="GLD", + start_price=0.0, + underlying_units=units, + loan_amount=0.0, + margin_call_ltv=0.75, + template_slugs=("template1",), + cache_key="", + data_cost_usd=0.0, + provider_ref=ProviderRef(provider_id="default", pricing_mode="standard"), + ) + + def test_template_slugs_serialization(self) -> None: + """Tuple to list conversion during serialization.""" + template_slugs_tuple = ("slug1", "slug2", "slug3") + settings = BacktestSettings( + settings_id=uuid4(), + name="Template Slugs Test", + data_source="databento", + dataset="TEST.DATASET", + schema="test-schema", + start_date=date(2022, 1, 1), + end_date=date(2022, 12, 31), + underlying_symbol="GLD", + start_price=0.0, + underlying_units=1000.0, + loan_amount=0.0, + margin_call_ltv=0.75, + template_slugs=template_slugs_tuple, + cache_key="", + data_cost_usd=0.0, + provider_ref=ProviderRef(provider_id="default", pricing_mode="standard"), + ) + + # Serialize and check that tuple becomes list + repo = BacktestSettingsRepository() + serialized = repo._to_dict(settings) + assert serialized["template_slugs"] == ["slug1", "slug2", "slug3"] + + # Deserialize and check that list becomes tuple + deserialized = repo._settings_from_dict(serialized) + assert deserialized.template_slugs == template_slugs_tuple + + def test_start_price_validation(self) -> None: + """Start price must be non-negative.""" + # Valid values should work + valid_prices = [0.0, 50.0, 1000.0] + for price in valid_prices: + settings = BacktestSettings( + settings_id=uuid4(), + name=f"Valid Start Price {price}", + data_source="databento", + dataset="TEST.DATASET", + schema="test-schema", + start_date=date(2022, 1, 1), + end_date=date(2022, 12, 31), + underlying_symbol="GLD", + start_price=price, + underlying_units=1000.0, + loan_amount=0.0, + margin_call_ltv=0.75, + template_slugs=("template1",), + cache_key="", + data_cost_usd=0.0, + provider_ref=ProviderRef(provider_id="default", pricing_mode="standard"), + ) + assert settings.start_price == price + + # Invalid negative value should raise ValueError + with pytest.raises(ValueError, match="start_price must be non-negative"): + BacktestSettings( + settings_id=uuid4(), + name="Invalid Negative Start Price", + data_source="databento", + dataset="TEST.DATASET", + schema="test-schema", + start_date=date(2022, 1, 1), + end_date=date(2022, 12, 31), + underlying_symbol="GLD", + start_price=-50.0, # Negative value + underlying_units=1000.0, + loan_amount=0.0, + margin_call_ltv=0.75, + template_slugs=("template1",), + cache_key="", + data_cost_usd=0.0, + provider_ref=ProviderRef(provider_id="default", pricing_mode="standard"), + ) + + def test_data_cost_validation(self) -> None: + """Data cost must be non-negative.""" + # Valid values should work + valid_costs = [0.0, 100.0, 10000.0] + for cost in valid_costs: + settings = BacktestSettings( + settings_id=uuid4(), + name=f"Valid Data Cost {cost}", + data_source="databento", + dataset="TEST.DATASET", + schema="test-schema", + start_date=date(2022, 1, 1), + end_date=date(2022, 12, 31), + underlying_symbol="GLD", + start_price=0.0, + underlying_units=1000.0, + loan_amount=0.0, + margin_call_ltv=0.75, + template_slugs=("template1",), + cache_key="", + data_cost_usd=cost, + provider_ref=ProviderRef(provider_id="default", pricing_mode="standard"), + ) + assert settings.data_cost_usd == cost + + # Invalid negative value should raise ValueError + with pytest.raises(ValueError, match="data_cost_usd must be non-negative"): + BacktestSettings( + settings_id=uuid4(), + name="Invalid Negative Data Cost", + data_source="databento", + dataset="TEST.DATASET", + schema="test-schema", + start_date=date(2022, 1, 1), + end_date=date(2022, 12, 31), + underlying_symbol="GLD", + start_price=0.0, + underlying_units=1000.0, + loan_amount=0.0, + margin_call_ltv=0.75, + template_slugs=("template1",), + cache_key="", + data_cost_usd=-100.0, # Negative value + provider_ref=ProviderRef(provider_id="default", pricing_mode="standard"), + ) + + +class TestBacktestSettingsRepository: + """Tests for the BacktestSettingsRepository.""" + + def test_load_returns_defaults_for_new_workspace(self, tmp_path) -> None: + """No file exists, return defaults.""" + repo = BacktestSettingsRepository(base_path=tmp_path / ".workspaces") + workspace_id = "new-workspace-id" + + # Should return default settings when no file exists + settings = repo.load(workspace_id) + assert settings.name == "Default Backtest Settings" + assert settings.data_source == "databento" + assert settings.dataset == "XNAS.BASIC" + assert settings.schema == "ohlcv-1d" + + def test_save_and_load_round_trips(self, tmp_path) -> None: + """Save then load returns same values.""" + repo = BacktestSettingsRepository(base_path=tmp_path / ".workspaces") + workspace_id = "test-workspace" + + # Create test settings + original_settings = BacktestSettings.create_default() + original_settings = BacktestSettings( + settings_id=uuid4(), + name="Round Trip Test", + data_source="yfinance", + dataset="ROUNDTRIP.TEST", + schema="roundtrip-schema", + start_date=date(2023, 1, 1), + end_date=date(2023, 12, 31), + underlying_symbol="GC", + start_price=100.0, + underlying_units=2000.0, + loan_amount=50000.0, + margin_call_ltv=0.85, + template_slugs=("roundtrip1", "roundtrip2"), + cache_key="roundtrip-cache", + data_cost_usd=1000.0, + provider_ref=ProviderRef(provider_id="roundtrip_provider", pricing_mode="roundtrip_mode"), + ) + + # Save and then load + repo.save(workspace_id, original_settings) + loaded_settings = repo.load(workspace_id) + + # Verify all fields match + assert loaded_settings.settings_id == original_settings.settings_id + assert loaded_settings.name == original_settings.name + assert loaded_settings.data_source == original_settings.data_source + assert loaded_settings.dataset == original_settings.dataset + assert loaded_settings.schema == original_settings.schema + assert loaded_settings.start_date == original_settings.start_date + assert loaded_settings.end_date == original_settings.end_date + assert loaded_settings.underlying_symbol == original_settings.underlying_symbol + assert loaded_settings.start_price == original_settings.start_price + assert loaded_settings.underlying_units == original_settings.underlying_units + assert loaded_settings.loan_amount == original_settings.loan_amount + assert loaded_settings.margin_call_ltv == original_settings.margin_call_ltv + assert loaded_settings.template_slugs == original_settings.template_slugs + assert loaded_settings.cache_key == original_settings.cache_key + assert loaded_settings.data_cost_usd == original_settings.data_cost_usd + assert loaded_settings.provider_ref.provider_id == original_settings.provider_ref.provider_id + assert loaded_settings.provider_ref.pricing_mode == original_settings.provider_ref.pricing_mode + + def test_overwrites_existing_settings(self, tmp_path) -> None: + """Save twice, second wins.""" + repo = BacktestSettingsRepository(base_path=tmp_path / ".workspaces") + workspace_id = "overwrite-test" + + # First settings + first_settings = BacktestSettings( + settings_id=uuid4(), + name="First Settings", + data_source="databento", + dataset="FIRST.DATASET", + schema="first-schema", + start_date=date(2020, 1, 1), + end_date=date(2020, 12, 31), + underlying_symbol="GLD", + start_price=50.0, + underlying_units=1000.0, + loan_amount=25000.0, + margin_call_ltv=0.70, + template_slugs=("first",), + cache_key="first-cache", + data_cost_usd=500.0, + provider_ref=ProviderRef(provider_id="first_provider", pricing_mode="first_mode"), + ) + + # Second settings + second_settings = BacktestSettings( + settings_id=uuid4(), + name="Second Settings", + data_source="yfinance", + dataset="SECOND.DATASET", + schema="second-schema", + start_date=date(2021, 1, 1), + end_date=date(2021, 12, 31), + underlying_symbol="GC", + start_price=75.0, + underlying_units=1500.0, + loan_amount=30000.0, + margin_call_ltv=0.75, + template_slugs=("second",), + cache_key="second-cache", + data_cost_usd=750.0, + provider_ref=ProviderRef(provider_id="second_provider", pricing_mode="second_mode"), + ) + + # Save first, then second + repo.save(workspace_id, first_settings) + repo.save(workspace_id, second_settings) + + # Load and verify second settings were saved + loaded_settings = repo.load(workspace_id) + assert loaded_settings.name == "Second Settings" + assert loaded_settings.data_source == "yfinance" + assert loaded_settings.dataset == "SECOND.DATASET" + assert loaded_settings.schema == "second-schema" + assert loaded_settings.start_date == date(2021, 1, 1) + assert loaded_settings.end_date == date(2021, 12, 31) + assert loaded_settings.underlying_symbol == "GC" + assert loaded_settings.start_price == 75.0 + assert loaded_settings.underlying_units == 1500.0 + assert loaded_settings.loan_amount == 30000.0 + assert loaded_settings.margin_call_ltv == 0.75 + assert loaded_settings.template_slugs == ("second",) + assert loaded_settings.cache_key == "second-cache" + assert loaded_settings.data_cost_usd == 750.0 + assert loaded_settings.provider_ref.provider_id == "second_provider" + assert loaded_settings.provider_ref.pricing_mode == "second_mode" + + def test_handles_missing_directory(self, tmp_path) -> None: + """Creates .workspaces/{id} if needed.""" + repo = BacktestSettingsRepository(base_path=tmp_path / ".workspaces") + workspace_id = "missing-dir-test" + + # Directory shouldn't exist yet + settings_path = repo._settings_path(workspace_id) + assert not settings_path.parent.exists() + + # Save settings - should create directory + settings = BacktestSettings.create_default() + repo.save(workspace_id, settings) + + # Directory should now exist + assert settings_path.parent.exists() + assert settings_path.exists() + + # Load should work + loaded_settings = repo.load(workspace_id) + assert loaded_settings.name == "Default Backtest Settings"