Coverage for src/finbot/ml/walk_forward.py: 96%

128 statements  

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

1from collections.abc import Sequence 

2from dataclasses import dataclass, replace 

3from datetime import datetime, timedelta 

4from itertools import chain 

5 

6from finbot.backtest.experiment import ExperimentPeriod, WalkForwardSplit 

7from finbot.backtest.models import BacktestResult 

8from finbot.backtest.walk_forward import TrainingWindowMode, generate_walk_forward_splits 

9from finbot.market.cache import HistoricalDataCache 

10from finbot.ml.experiment import ( 

11 MLExperimentConfig, 

12 MLExperimentResult, 

13 MLExperimentRunner, 

14 StrategyMetrics, 

15 bars_in_period, 

16 run_ml_backtest, 

17) 

18from finbot.models import Prediction 

19from finbot.strategy import PredictionStrategy, ProbabilityThresholdPolicy 

20from finbot.strategy.strategies import BuyOnceStrategy, SmaCrossoverStrategy 

21 

22 

23@dataclass(frozen=True) 

24class MLWalkForwardConfig: 

25 experiment: MLExperimentConfig 

26 evaluation_period: ExperimentPeriod 

27 training_duration: timedelta 

28 validation_duration: timedelta 

29 test_duration: timedelta 

30 training_window: TrainingWindowMode = TrainingWindowMode.ROLLING 

31 

32 def __post_init__(self) -> None: 

33 object.__setattr__(self, "training_window", TrainingWindowMode(self.training_window)) 

34 for duration in ( 

35 self.training_duration, 

36 self.validation_duration, 

37 self.test_duration, 

38 ): 

39 _whole_days(duration) 

40 first_split = self.splits[0] 

41 object.__setattr__( 

42 self, 

43 "experiment", 

44 replace( 

45 self.experiment, 

46 training_period=first_split.train, 

47 validation_period=first_split.validation, 

48 test_period=first_split.test, 

49 ), 

50 ) 

51 

52 @property 

53 def splits(self) -> tuple[WalkForwardSplit, ...]: 

54 return tuple( 

55 generate_walk_forward_splits( 

56 self.evaluation_period, 

57 train_duration=self.training_duration, 

58 validation_duration=self.validation_duration, 

59 test_duration=self.test_duration, 

60 training_window=self.training_window, 

61 ), 

62 ) 

63 

64 def to_dict(self) -> dict[str, object]: 

65 payload = self.experiment.to_dict() 

66 payload.pop("periods") 

67 payload["walk_forward"] = { 

68 "start": self.evaluation_period.start.isoformat(), 

69 "end": self.evaluation_period.end.isoformat(), 

70 "training_days": _whole_days(self.training_duration), 

71 "validation_days": _whole_days(self.validation_duration), 

72 "test_days": _whole_days(self.test_duration), 

73 "training_window": self.training_window.value, 

74 } 

75 return payload 

76 

77 @classmethod 

78 def from_dict(cls, payload: dict[str, object]) -> "MLWalkForwardConfig": 

79 try: 

80 raw_walk_forward = payload["walk_forward"] 

81 if not isinstance(raw_walk_forward, dict): 81 ↛ 82line 81 didn't jump to line 82 because the condition on line 81 was never true

82 raise ValueError("walk_forward must be an object") 

83 evaluation_period = ExperimentPeriod( 

84 _datetime_value(raw_walk_forward["start"], "walk_forward.start"), 

85 _datetime_value(raw_walk_forward["end"], "walk_forward.end"), 

86 ) 

87 training_duration = _days_value(raw_walk_forward, "training_days") 

88 validation_duration = _days_value(raw_walk_forward, "validation_days") 

89 test_duration = _days_value(raw_walk_forward, "test_days") 

90 raw_training_window = raw_walk_forward.get( 

91 "training_window", 

92 TrainingWindowMode.ROLLING, 

93 ) 

94 try: 

95 training_window = TrainingWindowMode(raw_training_window) 

96 except (TypeError, ValueError) as exc: 

97 raise ValueError( 

98 f"Unsupported training window mode: {raw_training_window!r}", 

99 ) from exc 

100 splits = generate_walk_forward_splits( 

101 evaluation_period, 

102 train_duration=training_duration, 

103 validation_duration=validation_duration, 

104 test_duration=test_duration, 

105 training_window=training_window, 

106 ) 

107 experiment_payload = dict(payload) 

108 experiment_payload.pop("walk_forward") 

109 experiment_payload["periods"] = { 

110 "training": _period_to_dict(splits[0].train), 

111 "validation": _period_to_dict(splits[0].validation), 

112 "test": _period_to_dict(splits[0].test), 

113 } 

114 return cls( 

115 experiment=MLExperimentConfig.from_dict(experiment_payload), 

116 evaluation_period=evaluation_period, 

117 training_duration=training_duration, 

118 validation_duration=validation_duration, 

119 test_duration=test_duration, 

120 training_window=training_window, 

121 ) 

122 except (KeyError, TypeError, ValueError) as exc: 

123 raise ValueError(f"Invalid ML walk-forward configuration: {exc}") from exc 

124 

125 

126@dataclass(frozen=True) 

127class MLWalkForwardFoldResult: 

128 index: int 

129 split: WalkForwardSplit 

130 config: MLExperimentConfig 

131 experiment: MLExperimentResult 

132 

133 

134@dataclass(frozen=True) 

135class MLWalkForwardResult: 

136 folds: tuple[MLWalkForwardFoldResult, ...] 

137 backtests: dict[str, BacktestResult] 

138 comparison: dict[str, StrategyMetrics] 

139 

140 def __post_init__(self) -> None: 

141 if not self.folds: 

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

143 if tuple(fold.index for fold in self.folds) != tuple(range(len(self.folds))): 

144 raise ValueError("Walk-forward ML fold indexes must be consecutive.") 

145 

146 keys = [ 

147 (prediction.symbol, prediction.timestamp) 

148 for prediction in self.oos_predictions 

149 ] 

150 if len(keys) != len(set(keys)): 

151 raise ValueError("Walk-forward ML predictions must not overlap or appear twice.") 

152 

153 @property 

154 def oos_predictions(self) -> tuple[Prediction, ...]: 

155 return tuple(chain.from_iterable( 

156 fold.experiment.test_predictions for fold in self.folds 

157 )) 

158 

159 

160class MLWalkForwardRunner: 

161 def __init__(self, datasets: HistoricalDataCache) -> None: 

162 self._datasets = datasets 

163 self._runner = MLExperimentRunner(datasets) 

164 

165 def run( 

166 self, 

167 config: MLExperimentConfig, 

168 splits: Sequence[WalkForwardSplit], 

169 ) -> MLWalkForwardResult: 

170 resolved_splits = tuple(splits) 

171 _validate_test_periods(resolved_splits) 

172 

173 folds: list[MLWalkForwardFoldResult] = [] 

174 for index, split in enumerate(resolved_splits): 

175 fold_config = replace( 

176 config, 

177 training_period=split.train, 

178 validation_period=split.validation, 

179 test_period=split.test, 

180 ) 

181 try: 

182 experiment = self._runner.run(fold_config) 

183 except ValueError as exc: 

184 raise ValueError(f"Walk-forward ML fold {index} failed: {exc}") from exc 

185 folds.append( 

186 MLWalkForwardFoldResult( 

187 index=index, 

188 split=split, 

189 config=fold_config, 

190 experiment=experiment, 

191 ), 

192 ) 

193 resolved_folds = tuple(folds) 

194 predictions = tuple(chain.from_iterable( 

195 fold.experiment.test_predictions for fold in resolved_folds 

196 )) 

197 evaluation_period = ExperimentPeriod( 

198 resolved_splits[0].test.start, 

199 resolved_splits[-1].test.end, 

200 ) 

201 _, bars_by_symbol = self._datasets.load(config.dataset_id) 

202 test_bars = bars_in_period( 

203 bars_by_symbol, 

204 config.symbols, 

205 evaluation_period, 

206 ) 

207 warmup_bars = sorted( 

208 ( 

209 bar 

210 for symbol in config.symbols 

211 for bar in bars_by_symbol[symbol] 

212 if bar.timestamp < evaluation_period.start 

213 ), 

214 key=lambda bar: (bar.timestamp, bar.symbol), 

215 ) 

216 

217 prediction_strategy = PredictionStrategy( 

218 predictions, 

219 ProbabilityThresholdPolicy(config.long_threshold, config.short_threshold), 

220 ) 

221 sma = SmaCrossoverStrategy(config.sma_short_window, config.sma_long_window) 

222 sma.prime(warmup_bars) 

223 backtests = { 

224 "logistic_regression": run_ml_backtest(config, prediction_strategy, test_bars), 

225 "sma_crossover": run_ml_backtest(config, sma, test_bars), 

226 "buy_and_hold": run_ml_backtest(config, BuyOnceStrategy(), test_bars), 

227 } 

228 return MLWalkForwardResult( 

229 folds=resolved_folds, 

230 backtests=backtests, 

231 comparison={ 

232 name: StrategyMetrics.from_result(result) 

233 for name, result in backtests.items() 

234 }, 

235 ) 

236 

237 

238def _validate_test_periods(splits: tuple[WalkForwardSplit, ...]) -> None: 

239 if not splits: 

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

241 for previous, current in zip(splits[:-1], splits[1:], strict=True): 

242 if previous.test.end != current.test.start: 

243 raise ValueError( 

244 "Walk-forward ML test periods must be ordered and contiguous; " 

245 "gaps and overlaps are not supported.", 

246 ) 

247 

248 

249def _days_value(payload: dict[object, object], name: str) -> timedelta: 

250 value = payload[name] 

251 if isinstance(value, bool) or not isinstance(value, int) or value <= 0: 

252 raise ValueError(f"walk_forward.{name} must be a positive integer") 

253 return timedelta(days=value) 

254 

255 

256def _datetime_value(value: object, name: str) -> datetime: 

257 if not isinstance(value, str): 257 ↛ 258line 257 didn't jump to line 258 because the condition on line 257 was never true

258 raise ValueError(f"{name} must be an ISO-8601 string") 

259 parsed = datetime.fromisoformat(value) 

260 if parsed.tzinfo is None: 

261 raise ValueError(f"{name} must include a timezone") 

262 return parsed 

263 

264 

265def _whole_days(value: timedelta) -> int: 

266 if value.total_seconds() % timedelta(days=1).total_seconds(): 266 ↛ 267line 266 didn't jump to line 267 because the condition on line 266 was never true

267 raise ValueError("Walk-forward durations must contain whole days.") 

268 return value.days 

269 

270 

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

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