"""Experiment manifest loading, config merging, and run-ID generation.""" from __future__ import annotations import json import re from pathlib import Path from typing import Any from libs.backtest.domain import BacktestConfig, ExperimentManifest from libs.common.ids import sha256_checksum_str from libs.common.logging import get_logger from libs.common.time_utils import utc_now logger = get_logger(__name__) def load_base_config(config_path: str | Path) -> dict[str, Any]: """Load a base config JSON file.""" p = Path(config_path) if not p.exists(): raise FileNotFoundError(f"Base config not found: {p}") return json.loads(p.read_text()) def deep_merge(base: dict[str, Any], overrides: dict[str, Any]) -> dict[str, Any]: """Recursively merge overrides into base (overrides win on conflict).""" result = dict(base) for key, value in overrides.items(): if key in result and isinstance(result[key], dict) and isinstance(value, dict): result[key] = deep_merge(result[key], value) else: result[key] = value return result def load_manifest(manifest_path: str | Path) -> ExperimentManifest: """Load and validate an experiment manifest JSON file.""" p = Path(manifest_path) if not p.exists(): raise FileNotFoundError(f"Manifest not found: {p}") raw = json.loads(p.read_text()) return ExperimentManifest.model_validate(raw) def resolve_config( manifest: ExperimentManifest, config_root: str | Path | None = None, snapshot_id_override: str | None = None, ) -> BacktestConfig: """Load base config, apply manifest overrides, validate into BacktestConfig. Args: manifest: The experiment manifest. config_root: Root directory for resolving relative config paths. snapshot_id_override: If provided, overrides the dataset_snapshot_id. """ base_config_path = manifest.base_config if config_root is not None: resolved_path = Path(config_root) / base_config_path if resolved_path.exists(): base_config_path = str(resolved_path) base = load_base_config(base_config_path) merged = deep_merge(base, manifest.overrides) # Inject snapshot_id sid = snapshot_id_override or manifest.dataset_snapshot_id merged["dataset_snapshot_id"] = sid if manifest.strategy_engines: merged["strategy_engines"] = [ engine.model_dump(mode="json") for engine in manifest.strategy_engines ] return BacktestConfig.model_validate(merged) def _safe_slug(text: str, max_len: int = 20) -> str: """Convert text to safe alphanumeric slug.""" slug = re.sub(r"[^a-zA-Z0-9_-]", "_", text.strip()) return slug[:max_len] def generate_run_id( config: BacktestConfig, strategy_override: str | None = None, ) -> str: """Generate a unique, deterministic run ID. Format: bt_{safe_strategy}_{safe_snapshot[:12]}_{timestamp_us}_{config_hash[:8]} """ strategy = _safe_slug(strategy_override or config.strategy_name) snapshot = _safe_slug(config.dataset_snapshot_id, max_len=12) timestamp = utc_now().strftime("%Y%m%d%H%M%S%f") config_json = config.model_dump_json(indent=None) config_hash = sha256_checksum_str(config_json)[:8] return f"bt_{strategy}_{snapshot}_{timestamp}_{config_hash}" def save_resolved_config( config: BacktestConfig, run_dir: Path, ) -> Path: """Write resolved_config.json to the run directory.""" out = run_dir / "resolved_config.json" out.write_text(config.model_dump_json(indent=2)) return out def save_manifest( manifest: ExperimentManifest, run_dir: Path, ) -> Path: """Write manifest.json (copy of experiment manifest) to the run directory.""" out = run_dir / "manifest.json" out.write_text(manifest.model_dump_json(indent=2)) return out