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"