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:
12
AGENTS.md
12
AGENTS.md
@@ -1,4 +1,16 @@
|
|||||||
policy:
|
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:
|
test_loop:
|
||||||
required: true
|
required: true
|
||||||
rules:
|
rules:
|
||||||
|
|||||||
88
app/models/backtest_settings.py
Normal file
88
app/models/backtest_settings.py
Normal 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
|
||||||
130
app/models/backtest_settings_repository.py
Normal file
130
app/models/backtest_settings_repository.py
Normal 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"],
|
||||||
|
),
|
||||||
|
)
|
||||||
662
tests/test_backtest_settings.py
Normal file
662
tests/test_backtest_settings.py
Normal file
@@ -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"
|
||||||
Reference in New Issue
Block a user