Coverage for src/finbot/cli/ml_experiment.py: 86%
52 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-04 12:05 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-04 12:05 +0000
1import argparse
2import json
3import subprocess
4from pathlib import Path
6from finbot.logger import get_logger
7from finbot.market.cache import DEFAULT_CACHE_DIR, HistoricalDataCache
8from finbot.ml import (
9 MLExperimentConfig,
10 MLExperimentRunner,
11 MLExperimentStore,
12 MLWalkForwardConfig,
13 MLWalkForwardRunner,
14 MLWalkForwardStore,
15)
17DEFAULT_OUTPUT_DIR = Path(".finbot/experiments")
18logger = get_logger(__name__)
21def add_arguments(parser: argparse.ArgumentParser) -> None:
22 parser.add_argument("--config", type=Path, required=True, help="Path to an ML experiment JSON configuration.")
23 parser.add_argument("--cache-dir", type=Path, default=DEFAULT_CACHE_DIR, help="Historical dataset cache directory.")
24 parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR, help="ML experiment artifact directory.")
27def add_walk_forward_arguments(parser: argparse.ArgumentParser) -> None:
28 parser.add_argument(
29 "--config",
30 type=Path,
31 required=True,
32 help="Path to an ML walk-forward JSON configuration.",
33 )
34 parser.add_argument(
35 "--cache-dir",
36 type=Path,
37 default=DEFAULT_CACHE_DIR,
38 help="Historical dataset cache directory.",
39 )
40 parser.add_argument(
41 "--output-dir",
42 type=Path,
43 default=DEFAULT_OUTPUT_DIR,
44 help="ML experiment artifact directory.",
45 )
48def run(args: argparse.Namespace) -> int:
49 config = _load_config(args.config)
50 store = MLExperimentStore(args.output_dir)
51 experiment = store.execute(
52 config,
53 MLExperimentRunner(HistoricalDataCache(args.cache_dir)),
54 git_commit=_git_revision(),
55 )
56 logger.info("ML experiment completed: %s", experiment.run_id)
57 logger.info("Artifacts: %s", args.output_dir / str(experiment.run_id))
58 return 0
61def run_walk_forward(args: argparse.Namespace) -> int:
62 config = _load_walk_forward_config(args.config)
63 store = MLWalkForwardStore(args.output_dir)
64 experiment = store.execute(
65 config.experiment,
66 config.splits,
67 MLWalkForwardRunner(HistoricalDataCache(args.cache_dir)),
68 git_commit=_git_revision(),
69 )
70 logger.info("ML walk-forward experiment completed: %s", experiment.run_id)
71 logger.info("Artifacts: %s", args.output_dir / str(experiment.run_id))
72 return 0
75def _load_config(path: Path) -> MLExperimentConfig:
76 return MLExperimentConfig.from_dict(_load_json_object(path))
79def _load_walk_forward_config(path: Path) -> MLWalkForwardConfig:
80 return MLWalkForwardConfig.from_dict(_load_json_object(path))
83def _load_json_object(path: Path) -> dict[str, object]:
84 try:
85 payload = json.loads(path.read_text(encoding="utf-8"))
86 except (OSError, json.JSONDecodeError) as exc:
87 raise ValueError(f"Unable to read ML experiment configuration: {exc}") from exc
88 if not isinstance(payload, dict): 88 ↛ 89line 88 didn't jump to line 89 because the condition on line 88 was never true
89 raise ValueError("ML experiment configuration must be a JSON object.")
90 return payload
93def _git_revision() -> str:
94 try:
95 completed = subprocess.run(
96 ["git", "rev-parse", "HEAD"],
97 check=True,
98 capture_output=True,
99 text=True,
100 )
101 except (OSError, subprocess.CalledProcessError) as exc:
102 raise RuntimeError("Unable to determine the current Git revision.") from exc
103 revision = completed.stdout.strip()
104 if not revision:
105 raise RuntimeError("Unable to determine the current Git revision.")
106 return revision