You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
199 lines
6.3 KiB
Python
199 lines
6.3 KiB
Python
"""Unit tests for libs/backtest/manifests.py."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from libs.backtest.domain import BacktestConfig, ExperimentManifest
|
|
|
|
|
|
def _write_json(path: Path, data: dict) -> None:
|
|
path.write_text(json.dumps(data))
|
|
|
|
|
|
VALID_BASE_CONFIG = {
|
|
"strategy_name": "test_strategy",
|
|
"dataset_snapshot_id": "snap_001",
|
|
"universe": {
|
|
"min_price": 5.0,
|
|
"min_avg_dollar_volume": 1_000_000,
|
|
"exclude_asset_types": [],
|
|
},
|
|
"signal": {
|
|
"score_threshold": 0.5,
|
|
"max_candidates_per_day": 5,
|
|
"execution_timing": "next_open",
|
|
},
|
|
"risk": {
|
|
"per_trade_risk_pct": 0.01,
|
|
"max_daily_new_risk_pct": 0.03,
|
|
"max_positions": 10,
|
|
"max_positions_per_sector": 3,
|
|
},
|
|
"execution": {
|
|
"entry_fill_model": "next_open",
|
|
"exit_fill_model": "daily_bar_approximation",
|
|
"slippage_bps_base": 10.0,
|
|
"same_bar_priority": "stop_first_conservative",
|
|
},
|
|
"reporting": {
|
|
"write_trade_blotter": True,
|
|
"write_equity_curve": True,
|
|
"write_metrics_summary": True,
|
|
},
|
|
}
|
|
|
|
|
|
class TestLoadBaseConfig:
|
|
def test_load_valid_config(self, tmp_path):
|
|
from libs.backtest.manifests import load_base_config
|
|
|
|
cfg_file = tmp_path / "defaults.json"
|
|
_write_json(cfg_file, VALID_BASE_CONFIG)
|
|
loaded = load_base_config(cfg_file)
|
|
assert loaded["strategy_name"] == "test_strategy"
|
|
|
|
def test_missing_file_raises(self, tmp_path):
|
|
from libs.backtest.manifests import load_base_config
|
|
|
|
with pytest.raises(FileNotFoundError):
|
|
load_base_config(tmp_path / "nonexistent.json")
|
|
|
|
|
|
class TestDeepMerge:
|
|
def test_simple_override(self):
|
|
from libs.backtest.manifests import deep_merge
|
|
|
|
base = {"a": 1, "b": 2}
|
|
overrides = {"b": 99}
|
|
merged = deep_merge(base, overrides)
|
|
assert merged["a"] == 1
|
|
assert merged["b"] == 99
|
|
|
|
def test_nested_merge(self):
|
|
from libs.backtest.manifests import deep_merge
|
|
|
|
base = {"risk": {"max_positions": 10, "per_trade_risk_pct": 0.01}}
|
|
overrides = {"risk": {"max_positions": 5}}
|
|
merged = deep_merge(base, overrides)
|
|
assert merged["risk"]["max_positions"] == 5
|
|
assert merged["risk"]["per_trade_risk_pct"] == 0.01
|
|
|
|
def test_does_not_mutate_base(self):
|
|
from libs.backtest.manifests import deep_merge
|
|
|
|
base = {"a": {"b": 1}}
|
|
overrides = {"a": {"c": 2}}
|
|
deep_merge(base, overrides)
|
|
assert "c" not in base["a"]
|
|
|
|
def test_override_wins(self):
|
|
from libs.backtest.manifests import deep_merge
|
|
|
|
merged = deep_merge({"x": 1}, {"x": 2})
|
|
assert merged["x"] == 2
|
|
|
|
|
|
class TestLoadManifest:
|
|
def test_valid_manifest(self, tmp_path):
|
|
from libs.backtest.manifests import load_manifest
|
|
|
|
manifest_data = {
|
|
"experiment_name": "test_exp",
|
|
"dataset_snapshot_id": "snap_001",
|
|
"base_config": "configs/backtest/defaults.json",
|
|
"overrides": {},
|
|
}
|
|
f = tmp_path / "manifest.json"
|
|
_write_json(f, manifest_data)
|
|
m = load_manifest(f)
|
|
assert m.experiment_name == "test_exp"
|
|
|
|
def test_missing_file_raises(self, tmp_path):
|
|
from libs.backtest.manifests import load_manifest
|
|
|
|
with pytest.raises(FileNotFoundError):
|
|
load_manifest(tmp_path / "nope.json")
|
|
|
|
|
|
class TestResolveConfig:
|
|
def test_basic_resolve(self, tmp_path):
|
|
from libs.backtest.manifests import load_manifest, resolve_config
|
|
|
|
cfg_file = tmp_path / "defaults.json"
|
|
_write_json(cfg_file, VALID_BASE_CONFIG)
|
|
|
|
manifest_data = {
|
|
"experiment_name": "test",
|
|
"dataset_snapshot_id": "snap_001",
|
|
"base_config": str(cfg_file),
|
|
"overrides": {},
|
|
}
|
|
m_file = tmp_path / "manifest.json"
|
|
_write_json(m_file, manifest_data)
|
|
manifest = load_manifest(m_file)
|
|
config = resolve_config(manifest)
|
|
assert isinstance(config, BacktestConfig)
|
|
assert config.strategy_name == "test_strategy"
|
|
|
|
def test_overrides_applied(self, tmp_path):
|
|
from libs.backtest.manifests import load_manifest, resolve_config
|
|
|
|
cfg_file = tmp_path / "defaults.json"
|
|
_write_json(cfg_file, VALID_BASE_CONFIG)
|
|
|
|
manifest_data = {
|
|
"experiment_name": "test",
|
|
"dataset_snapshot_id": "snap_001",
|
|
"base_config": str(cfg_file),
|
|
"overrides": {"risk": {"max_positions": 3}},
|
|
}
|
|
m_file = tmp_path / "manifest.json"
|
|
_write_json(m_file, manifest_data)
|
|
manifest = load_manifest(m_file)
|
|
config = resolve_config(manifest)
|
|
assert config.risk.max_positions == 3
|
|
assert config.risk.per_trade_risk_pct == 0.01 # from base
|
|
|
|
def test_snapshot_id_override(self, tmp_path):
|
|
from libs.backtest.manifests import load_manifest, resolve_config
|
|
|
|
cfg_file = tmp_path / "defaults.json"
|
|
_write_json(cfg_file, VALID_BASE_CONFIG)
|
|
|
|
manifest_data = {
|
|
"experiment_name": "test",
|
|
"dataset_snapshot_id": "snap_001",
|
|
"base_config": str(cfg_file),
|
|
"overrides": {},
|
|
}
|
|
m_file = tmp_path / "manifest.json"
|
|
_write_json(m_file, manifest_data)
|
|
manifest = load_manifest(m_file)
|
|
config = resolve_config(manifest, snapshot_id_override="snap_override")
|
|
assert config.dataset_snapshot_id == "snap_override"
|
|
|
|
|
|
class TestGenerateRunId:
|
|
def test_format(self):
|
|
from libs.backtest.manifests import generate_run_id
|
|
|
|
cfg = BacktestConfig(strategy_name="my_strategy", dataset_snapshot_id="snap_2026_01_01")
|
|
run_id = generate_run_id(cfg)
|
|
assert run_id.startswith("bt_")
|
|
parts = run_id.split("_")
|
|
assert len(parts) >= 4
|
|
|
|
def test_deterministic_for_same_config(self):
|
|
"""Two calls with same config at same time should have same hash suffix."""
|
|
from libs.backtest.manifests import generate_run_id
|
|
|
|
cfg = BacktestConfig(strategy_name="test", dataset_snapshot_id="snap_001")
|
|
id1 = generate_run_id(cfg)
|
|
id2 = generate_run_id(cfg)
|
|
# Hash suffix should be identical
|
|
assert id1.split("_")[-1] == id2.split("_")[-1]
|