Coverage for src/finbot/ml/walk_forward_artifacts.py: 92%

165 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-04 12:05 +0000

1import csv 

2import io 

3from collections.abc import Sequence 

4from dataclasses import dataclass 

5from datetime import UTC, datetime 

6from pathlib import Path 

7from typing import Protocol 

8from uuid import UUID, uuid4 

9 

10from finbot.backtest.experiment import ExperimentPeriod, WalkForwardSplit 

11from finbot.ml.artifacts import ( 

12 MLArtifactError, 

13 MLExperimentStore, 

14 MLRunStatus, 

15 _sha256, 

16 _write_text_atomic, 

17 write_backtest_trades, 

18) 

19from finbot.ml.experiment import MLExperimentConfig 

20from finbot.ml.walk_forward import ( 

21 MLWalkForwardFoldResult, 

22 MLWalkForwardResult, 

23) 

24from finbot.models import Model, load_model 

25 

26ML_WALK_FORWARD_SCHEMA_VERSION = 1 

27ROOT_ARTIFACTS = ( 

28 "predictions.csv", 

29 "trades.csv", 

30 "metrics.json", 

31 "comparison.json", 

32) 

33FOLD_ARTIFACTS = ( 

34 "config.json", 

35 "model.json", 

36 "feature_schema.json", 

37 "predictions.csv", 

38 "metrics.json", 

39) 

40 

41 

42@dataclass(frozen=True) 

43class StoredMLWalkForwardExperiment: 

44 run_id: UUID 

45 metadata: dict[str, object] 

46 models: tuple[Model, ...] 

47 predictions: tuple[dict[str, str], ...] 

48 metrics: dict[str, object] 

49 comparison: dict[str, object] 

50 

51 

52class WalkForwardRunner(Protocol): 

53 def run( 

54 self, 

55 config: MLExperimentConfig, 

56 splits: Sequence[WalkForwardSplit], 

57 ) -> MLWalkForwardResult: 

58 ... 

59 

60 

61class MLWalkForwardStore(MLExperimentStore): 

62 

63 def execute( 

64 self, 

65 config: MLExperimentConfig, 

66 splits: Sequence[WalkForwardSplit], 

67 runner: WalkForwardRunner, 

68 *, 

69 git_commit: str, 

70 run_id: UUID | None = None, 

71 ) -> StoredMLWalkForwardExperiment: 

72 resolved_run_id = run_id or uuid4() 

73 resolved_splits = tuple(splits) 

74 run_dir = self._begin(resolved_run_id, config, resolved_splits, git_commit) 

75 try: 

76 result = runner.run(config, resolved_splits) 

77 self._complete(run_dir, resolved_run_id, config, git_commit, result) 

78 except Exception as exc: 

79 self._write_metadata( 

80 run_dir, 

81 self._metadata( 

82 resolved_run_id, 

83 config, 

84 resolved_splits, 

85 git_commit, 

86 MLRunStatus.FAILED, 

87 error={"type": type(exc).__name__, "message": str(exc)}, 

88 ), 

89 ) 

90 raise 

91 return self.load(resolved_run_id) 

92 

93 def load(self, run_id: UUID | str) -> StoredMLWalkForwardExperiment: 

94 run_dir = self.root / str(run_id) 

95 metadata = self._read_object(run_dir / "metadata.json") 

96 if metadata.get("status") != MLRunStatus.COMPLETED: 

97 raise MLArtifactError( 

98 f"Walk-forward ML experiment {run_id} is " 

99 f"{metadata.get('status') or 'incomplete'}.", 

100 ) 

101 if ( 101 ↛ 106line 101 didn't jump to line 106 because the condition on line 101 was never true

102 metadata.get("schema_version") != ML_WALK_FORWARD_SCHEMA_VERSION 

103 or metadata.get("run_type") != "ml_walk_forward" 

104 or metadata.get("run_id") != str(run_id) 

105 ): 

106 raise MLArtifactError("Walk-forward ML experiment metadata is inconsistent.") 

107 

108 folds = metadata.get("folds") 

109 if not isinstance(folds, list) or not folds: 109 ↛ 110line 109 didn't jump to line 110 because the condition on line 109 was never true

110 raise MLArtifactError("Walk-forward ML experiment has invalid fold metadata.") 

111 expected_files = _required_artifacts(len(folds)) 

112 manifest = self._read_object(run_dir / "manifest.json") 

113 files = manifest.get("files") 

114 if ( 

115 manifest.get("schema_version") != ML_WALK_FORWARD_SCHEMA_VERSION 

116 or not isinstance(files, dict) 

117 or set(files) != expected_files 

118 ): 

119 raise MLArtifactError("Walk-forward ML experiment manifest is inconsistent.") 

120 for name, expected_hash in files.items(): 

121 path = run_dir / name 

122 if not path.is_file() or not isinstance(expected_hash, str): 122 ↛ 123line 122 didn't jump to line 123 because the condition on line 122 was never true

123 raise MLArtifactError(f"Walk-forward ML experiment is missing artifact {name}.") 

124 if _sha256(path.read_bytes()) != expected_hash: 

125 raise MLArtifactError(f"Walk-forward ML artifact {name} failed integrity validation.") 

126 

127 try: 

128 models = tuple( 

129 load_model(run_dir / _fold_path(index) / "model.json") 

130 for index in range(len(folds)) 

131 ) 

132 predictions = _read_csv(run_dir / "predictions.csv") 

133 metrics = self._read_object(run_dir / "metrics.json") 

134 comparison = self._read_object(run_dir / "comparison.json") 

135 self._validate_folds(run_dir, folds, models) 

136 self._validate_predictions(predictions, models) 

137 except (OSError, TypeError, ValueError) as exc: 

138 raise MLArtifactError(f"Walk-forward ML artifacts are invalid: {exc}") from exc 

139 

140 return StoredMLWalkForwardExperiment( 

141 run_id=UUID(str(run_id)), 

142 metadata=metadata, 

143 models=models, 

144 predictions=predictions, 

145 metrics=metrics, 

146 comparison=comparison, 

147 ) 

148 

149 def _begin( 

150 self, 

151 run_id: UUID, 

152 config: MLExperimentConfig, 

153 splits: tuple[WalkForwardSplit, ...], 

154 git_commit: str, 

155 ) -> Path: 

156 if not git_commit.strip(): 

157 raise ValueError("git_commit must not be empty") 

158 if not splits: 

159 raise ValueError("Walk-forward ML evaluation requires at least one fold.") 

160 self.root.mkdir(parents=True, exist_ok=True) 

161 run_dir = self.root / str(run_id) 

162 run_dir.mkdir(exist_ok=False) 

163 self._write_metadata( 

164 run_dir, 

165 self._metadata(run_id, config, splits, git_commit, MLRunStatus.RUNNING), 

166 ) 

167 return run_dir 

168 

169 def _complete( 

170 self, 

171 run_dir: Path, 

172 run_id: UUID, 

173 config: MLExperimentConfig, 

174 git_commit: str, 

175 result: MLWalkForwardResult, 

176 ) -> None: 

177 for fold in result.folds: 

178 fold_dir = run_dir / _fold_path(fold.index) 

179 fold_dir.mkdir(parents=True) 

180 fold.experiment.model.save(fold_dir / "model.json") 

181 self._write_json(fold_dir / "config.json", fold.config.to_dict()) 

182 self._write_json( 

183 fold_dir / "feature_schema.json", 

184 { 

185 "feature_dataset_id": fold.experiment.feature_dataset.dataset_id, 

186 "feature_names": list(fold.experiment.feature_dataset.feature_names), 

187 "schema": [ 

188 entry.to_dict() 

189 for entry in fold.experiment.feature_dataset.metadata.feature_schema 

190 ], 

191 }, 

192 ) 

193 _write_fold_predictions(fold_dir / "predictions.csv", fold) 

194 self._write_json( 

195 fold_dir / "metrics.json", 

196 { 

197 "validation": fold.experiment.validation_metrics.to_dict(), 

198 "test": fold.experiment.test_metrics.to_dict(), 

199 }, 

200 ) 

201 

202 _write_oos_predictions(run_dir / "predictions.csv", result) 

203 write_backtest_trades(run_dir / "trades.csv", result.backtests) 

204 self._write_json( 

205 run_dir / "metrics.json", 

206 { 

207 "fold_count": len(result.folds), 

208 "aggregate": result.comparison["logistic_regression"].to_dict(), 

209 }, 

210 ) 

211 self._write_json( 

212 run_dir / "comparison.json", 

213 { 

214 "assumptions": { 

215 "dataset_id": config.dataset_id, 

216 "symbols": list(config.symbols), 

217 "evaluation_period": { 

218 "start": result.folds[0].split.test.start.isoformat(), 

219 "end": result.folds[-1].split.test.end.isoformat(), 

220 }, 

221 "initial_cash": config.initial_cash, 

222 "fee_rate": config.fee_rate, 

223 "slippage_rate": config.slippage_rate, 

224 "execution_policy": config.execution_policy.value, 

225 }, 

226 "parameters": { 

227 "logistic_regression": { 

228 "models": [ 

229 { 

230 "fold_index": fold.index, 

231 "model_id": fold.experiment.model.metadata.model_id, 

232 "hyperparameters": dict( 

233 fold.experiment.model.metadata.hyperparameters, 

234 ), 

235 } 

236 for fold in result.folds 

237 ], 

238 "signal_policy": config.to_dict()["signal_policy"], 

239 }, 

240 "sma_crossover": config.to_dict()["baselines"]["sma_crossover"], 

241 "buy_and_hold": {}, 

242 }, 

243 "results": { 

244 name: value.to_dict() for name, value in result.comparison.items() 

245 }, 

246 }, 

247 ) 

248 expected_files = _required_artifacts(len(result.folds)) 

249 self._write_json( 

250 run_dir / "manifest.json", 

251 { 

252 "schema_version": ML_WALK_FORWARD_SCHEMA_VERSION, 

253 "files": { 

254 name: _sha256((run_dir / name).read_bytes()) 

255 for name in sorted(expected_files) 

256 }, 

257 }, 

258 ) 

259 self._write_metadata( 

260 run_dir, 

261 self._metadata( 

262 run_id, 

263 config, 

264 tuple(fold.split for fold in result.folds), 

265 git_commit, 

266 MLRunStatus.COMPLETED, 

267 result=result, 

268 ), 

269 ) 

270 

271 @staticmethod 

272 def _metadata( 

273 run_id: UUID, 

274 config: MLExperimentConfig, 

275 splits: tuple[WalkForwardSplit, ...], 

276 git_commit: str, 

277 status: MLRunStatus, 

278 *, 

279 result: MLWalkForwardResult | None = None, 

280 error: dict[str, str] | None = None, 

281 ) -> dict[str, object]: 

282 fold_results = result.folds if result is not None else () 

283 metadata: dict[str, object] = { 

284 "schema_version": ML_WALK_FORWARD_SCHEMA_VERSION, 

285 "run_type": "ml_walk_forward", 

286 "run_id": str(run_id), 

287 "status": status.value, 

288 "updated_at": datetime.now(UTC).isoformat(), 

289 "git_commit": git_commit, 

290 "config": config.to_dict(), 

291 "folds": [ 

292 { 

293 "index": index, 

294 "periods": _split_to_dict(split), 

295 **( 

296 { 

297 "model_id": fold_results[index].experiment.model.metadata.model_id, 

298 "model_type": fold_results[index].experiment.model.metadata.model_type, 

299 "training_dataset_id": fold_results[index].experiment.model.metadata.training_configuration.dataset_id, 

300 "feature_dataset_id": fold_results[index].experiment.feature_dataset.dataset_id, 

301 "supervised_dataset_id": fold_results[index].experiment.supervised_dataset.dataset_id, 

302 "artifact_directory": _fold_path(index), 

303 } 

304 if result is not None 

305 else {} 

306 ), 

307 } 

308 for index, split in enumerate(splits) 

309 ], 

310 } 

311 if error is not None: 

312 metadata["error"] = error 

313 return metadata 

314 

315 @staticmethod 

316 def _validate_folds( 

317 run_dir: Path, 

318 folds: list[object], 

319 models: tuple[Model, ...], 

320 ) -> None: 

321 for index, (raw_fold, model) in enumerate(zip(folds, models, strict=True)): 

322 if not isinstance(raw_fold, dict) or raw_fold.get("index") != index: 322 ↛ 323line 322 didn't jump to line 323 because the condition on line 322 was never true

323 raise ValueError("Fold metadata indexes are inconsistent.") 

324 if raw_fold.get("model_id") != model.metadata.model_id: 

325 raise ValueError("Fold model identity does not match metadata.") 

326 fold_dir = run_dir / _fold_path(index) 

327 feature_schema = MLWalkForwardStore._read_object( 

328 fold_dir / "feature_schema.json", 

329 ) 

330 if feature_schema.get("feature_names") != list(model.metadata.feature_schema): 

331 raise ValueError("Fold feature schema does not match its model.") 

332 fold_predictions = _read_csv(fold_dir / "predictions.csv") 

333 if any(row.get("model_id") != model.metadata.model_id for row in fold_predictions): 333 ↛ 334line 333 didn't jump to line 334 because the condition on line 333 was never true

334 raise ValueError("Fold predictions do not match their model.") 

335 

336 @staticmethod 

337 def _validate_predictions( 

338 predictions: tuple[dict[str, str], ...], 

339 models: tuple[Model, ...], 

340 ) -> None: 

341 keys: set[tuple[str, str]] = set() 

342 previous_timestamp: datetime | None = None 

343 for row in predictions: 

344 index = int(row.get("fold_index", "")) 

345 if index < 0 or index >= len(models): 345 ↛ 346line 345 didn't jump to line 346 because the condition on line 345 was never true

346 raise ValueError("Aggregate prediction has an invalid fold index.") 

347 if row.get("model_id") != models[index].metadata.model_id: 

348 raise ValueError("Aggregate prediction does not match its fold model.") 

349 symbol = row.get("symbol") 

350 raw_timestamp = row.get("timestamp") 

351 if not symbol or not raw_timestamp: 351 ↛ 352line 351 didn't jump to line 352 because the condition on line 351 was never true

352 raise ValueError("Aggregate prediction identity is incomplete.") 

353 timestamp = datetime.fromisoformat(raw_timestamp) 

354 key = (symbol, raw_timestamp) 

355 if key in keys: 355 ↛ 356line 355 didn't jump to line 356 because the condition on line 355 was never true

356 raise ValueError("Aggregate predictions contain duplicate rows.") 

357 if previous_timestamp is not None and timestamp < previous_timestamp: 357 ↛ 358line 357 didn't jump to line 358 because the condition on line 357 was never true

358 raise ValueError("Aggregate predictions are not ordered.") 

359 keys.add(key) 

360 previous_timestamp = timestamp 

361 

362 

363def _required_artifacts(fold_count: int) -> set[str]: 

364 return set(ROOT_ARTIFACTS) | { 

365 f"{_fold_path(index)}/{name}" 

366 for index in range(fold_count) 

367 for name in FOLD_ARTIFACTS 

368 } 

369 

370 

371def _fold_path(index: int) -> str: 

372 return f"folds/{index:04d}" 

373 

374 

375def _split_to_dict(split: WalkForwardSplit) -> dict[str, object]: 

376 return { 

377 "training": _period_to_dict(split.train), 

378 "validation": _period_to_dict(split.validation), 

379 "test": _period_to_dict(split.test), 

380 } 

381 

382 

383def _period_to_dict(period: ExperimentPeriod) -> dict[str, str]: 

384 return {"start": period.start.isoformat(), "end": period.end.isoformat()} 

385 

386 

387def _write_fold_predictions(path: Path, fold: MLWalkForwardFoldResult) -> None: 

388 output = io.StringIO(newline="") 

389 fields = ["split", "symbol", "timestamp", "probability_positive", "model_id", "model_type"] 

390 writer = csv.DictWriter(output, fieldnames=fields, lineterminator="\n") 

391 writer.writeheader() 

392 for split, predictions in ( 

393 ("validation", fold.experiment.validation_predictions), 

394 ("test", fold.experiment.test_predictions), 

395 ): 

396 for prediction in predictions: 

397 writer.writerow({ 

398 "split": split, 

399 "symbol": prediction.symbol, 

400 "timestamp": prediction.timestamp.isoformat(), 

401 "probability_positive": repr(prediction.probability_positive), 

402 "model_id": prediction.model_id, 

403 "model_type": prediction.model_type, 

404 }) 

405 _write_text_atomic(path, output.getvalue()) 

406 

407 

408def _write_oos_predictions(path: Path, result: MLWalkForwardResult) -> None: 

409 output = io.StringIO(newline="") 

410 fields = ["fold_index", "symbol", "timestamp", "probability_positive", "model_id", "model_type"] 

411 writer = csv.DictWriter(output, fieldnames=fields, lineterminator="\n") 

412 writer.writeheader() 

413 for fold in result.folds: 

414 for prediction in fold.experiment.test_predictions: 

415 writer.writerow({ 

416 "fold_index": fold.index, 

417 "symbol": prediction.symbol, 

418 "timestamp": prediction.timestamp.isoformat(), 

419 "probability_positive": repr(prediction.probability_positive), 

420 "model_id": prediction.model_id, 

421 "model_type": prediction.model_type, 

422 }) 

423 _write_text_atomic(path, output.getvalue()) 

424 

425 

426def _read_csv(path: Path) -> tuple[dict[str, str], ...]: 

427 return tuple(csv.DictReader(io.StringIO(path.read_text(encoding="utf-8"))))