Coverage for src/finbot/backtest/walk_forward.py: 100%
45 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 math
2from dataclasses import dataclass
3from datetime import timedelta
4from enum import StrEnum
5from typing import Any
7from finbot.backtest.experiment import ExperimentPeriod, WalkForwardSplit
10class TrainingWindowMode(StrEnum):
11 ROLLING = "rolling"
12 EXPANDING = "expanding"
15def generate_walk_forward_splits(
16 period: ExperimentPeriod,
17 train_duration: timedelta,
18 validation_duration: timedelta,
19 test_duration: timedelta,
20 training_window: TrainingWindowMode | str = TrainingWindowMode.ROLLING,
21) -> list[WalkForwardSplit]:
22 if train_duration.total_seconds() <= 0:
23 raise ValueError("train_duration must be greater than zero.")
24 if validation_duration.total_seconds() <= 0:
25 raise ValueError("validation_duration must be greater than zero.")
26 if test_duration.total_seconds() <= 0:
27 raise ValueError("test_duration must be greater than zero.")
28 try:
29 resolved_training_window = TrainingWindowMode(training_window)
30 except (TypeError, ValueError) as exc:
31 raise ValueError(
32 f"Unsupported training window mode: {training_window!r}.",
33 ) from exc
35 fold_duration = train_duration + validation_duration + test_duration
37 if period.duration < fold_duration:
38 raise ValueError("Period is too short for one complete walk-forward split.")
40 splits: list[WalkForwardSplit] = []
41 current_train_end = period.start + train_duration
42 while current_train_end + validation_duration + test_duration <= period.end:
43 train_start = (
44 period.start
45 if resolved_training_window == TrainingWindowMode.EXPANDING
46 else current_train_end - train_duration
47 )
48 train_period = ExperimentPeriod(start=train_start, end=current_train_end)
49 validation_period = ExperimentPeriod(
50 start=train_period.end,
51 end=train_period.end + validation_duration,
52 )
53 test_period = ExperimentPeriod(
54 start=validation_period.end,
55 end=validation_period.end + test_duration,
56 )
58 splits.append(
59 WalkForwardSplit(train=train_period, validation=validation_period, test=test_period),
60 )
62 current_train_end += test_duration
64 return splits
67@dataclass(frozen=True)
68class ParameterEvaluation:
69 params: dict[str, Any]
70 validation_score: float
72 def __post_init__(self):
73 if not math.isfinite(self.validation_score):
74 raise ValueError("validation_score must be finite")
77def select_best_parameters(evaluations: list[ParameterEvaluation]) -> dict[str, Any]:
78 if not evaluations:
79 raise ValueError("At least one parameter evaluation is required.")
81 best_evaluation = max(evaluations, key=lambda e: e.validation_score)
82 return best_evaluation.params