Coverage for src/finbot/backtest/artifacts.py: 98%

92 statements  

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

1import json 

2from collections.abc import Iterable 

3from datetime import datetime 

4from pathlib import Path 

5from typing import Any, TypeVar, cast 

6from uuid import UUID, uuid4 

7 

8from finbot.backtest.experiment import ( 

9 ExperimentConfig, 

10 ExperimentMetadata, 

11 ExperimentPeriod, 

12 ExperimentRun, 

13 WalkForwardExperimentRun, 

14 WalkForwardFoldResult, 

15 WalkForwardResult, 

16 WalkForwardSplit, 

17) 

18from finbot.backtest.models import BacktestResult, EquityPoint, SignalRejection 

19from finbot.execution.models import Fill 

20 

21Run = ExperimentRun | WalkForwardExperimentRun 

22RunT = TypeVar("RunT", ExperimentRun, WalkForwardExperimentRun) 

23 

24 

25class ExperimentStore: 

26 def __init__(self, root: Path): 

27 self.root = root 

28 

29 @staticmethod 

30 def _create_metadata(config: ExperimentConfig) -> ExperimentMetadata: 

31 return ExperimentMetadata( 

32 run_id=uuid4(), 

33 created_at=datetime.now().astimezone(), 

34 config=config, 

35 ) 

36 

37 def create_run(self, config: ExperimentConfig, result: BacktestResult) -> ExperimentRun: 

38 metadata = self._create_metadata(config) 

39 run = ExperimentRun(metadata=metadata, result=result) 

40 

41 self.save(run) 

42 return run 

43 

44 def create_walk_forward_run( 

45 self, 

46 config: ExperimentConfig, 

47 result: WalkForwardResult, 

48 ) -> WalkForwardExperimentRun: 

49 metadata = self._create_metadata(config) 

50 run = WalkForwardExperimentRun(metadata=metadata, result=result) 

51 self.save(run) 

52 return run 

53 

54 def save(self, run: Run) -> None: 

55 run_dir = self.root / str(run.metadata.run_id) 

56 run_dir.mkdir(parents=True, exist_ok=False) 

57 

58 if isinstance(run, WalkForwardExperimentRun): 

59 payload = self._serialize_walk_forward(run) 

60 else: 

61 payload = self._serialize_backtest(run) 

62 

63 (run_dir / "metadata.json").write_text( 

64 json.dumps(payload, indent=2, sort_keys=True), 

65 encoding="utf-8", 

66 ) 

67 

68 def load(self, run_id: UUID | str) -> Run: 

69 path = self.root / str(run_id) / "metadata.json" 

70 

71 if not path.exists(): 

72 raise FileNotFoundError(f"Experiment run not found: {run_id}") 

73 

74 payload = json.loads(path.read_text(encoding="utf-8")) 

75 return self._deserialize(payload) 

76 

77 def load_many( 

78 self, 

79 run_ids: Iterable[UUID | str], 

80 *, 

81 expected_type: type[RunT], 

82 ) -> list[RunT]: 

83 runs: list[RunT] = [] 

84 

85 for run_id in run_ids: 

86 run = self.load(run_id) 

87 

88 if not isinstance(run, expected_type): 88 ↛ 89line 88 didn't jump to line 89 because the condition on line 88 was never true

89 raise TypeError( 

90 f"Expected {expected_type.__name__} for run {run_id}, " 

91 f"got {type(run).__name__}.", 

92 ) 

93 

94 runs.append(cast(RunT, run)) 

95 

96 return runs 

97 

98 @staticmethod 

99 def _serialize_config(config: ExperimentConfig) -> dict[str, Any]: 

100 return { 

101 "strategy": config.strategy, 

102 "strategy_params": config.strategy_params, 

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

104 "start": config.start.isoformat(), 

105 "end": config.end.isoformat(), 

106 "dataset": config.dataset, 

107 "initial_cash": config.initial_cash, 

108 "allocation": config.allocation, 

109 "fee_rate": config.fee_rate, 

110 "slippage_rate": config.slippage_rate, 

111 } 

112 

113 @staticmethod 

114 def _deserialize_config(payload: dict[str, Any]) -> ExperimentConfig: 

115 return ExperimentConfig( 

116 strategy=payload["strategy"], 

117 strategy_params=dict(payload["strategy_params"]), # type: ignore[arg-type] 

118 symbols=tuple(payload["symbols"]), 

119 start=datetime.fromisoformat(payload["start"]), 

120 end=datetime.fromisoformat(payload["end"]), 

121 dataset=payload["dataset"], 

122 initial_cash=float(payload["initial_cash"]), 

123 allocation=float(payload["allocation"]), 

124 fee_rate=float(payload["fee_rate"]), 

125 slippage_rate=float(payload["slippage_rate"]), 

126 ) 

127 

128 @staticmethod 

129 def _serialize_backtest_result(result: BacktestResult) -> dict[str, Any]: 

130 return { 

131 "starting_cash": result.starting_cash, 

132 "ending_value": result.ending_value, 

133 "equity_curve": [point.serialize() for point in result.equity_curve], 

134 "fills": [fill.serialize() for fill in result.fills], 

135 "rejections": [rejection.serialize() for rejection in result.rejections], 

136 "periods_per_year": result.periods_per_year, 

137 "benchmark_return": result.benchmark_return, 

138 "pnl": result.pnl, 

139 "total_return": result.total_return, 

140 "max_drawdown": result.max_drawdown, 

141 "trades": [trade.serialize() for trade in result.trades], 

142 "trade_count": result.trade_count, 

143 "winning_trades": result.winning_trades, 

144 "losing_trades": result.losing_trades, 

145 "win_rate": result.win_rate, 

146 "profit_factor": result.profit_factor, 

147 "returns": result.returns, 

148 "average_trade": result.average_trade, 

149 "median_trade": result.median_trade, 

150 "best_trade": result.best_trade, 

151 "worst_trade": result.worst_trade, 

152 "average_trade_duration": str(result.average_trade_duration), 

153 "cagr": result.cagr, 

154 "calmar_ratio": result.calmar_ratio, 

155 "total_fees": result.total_fees, 

156 "total_slippage": result.total_slippage, 

157 "turnover": result.turnover, 

158 "exposure": result.exposure, 

159 "excess_return": result.excess_return, 

160 } 

161 

162 @staticmethod 

163 def _deserialize_backtest_result(payload: dict[str, Any]) -> BacktestResult: 

164 return BacktestResult( 

165 starting_cash=payload["starting_cash"], 

166 ending_value=payload["ending_value"], 

167 equity_curve=[EquityPoint.deserialize(p) for p in payload["equity_curve"]], 

168 fills=[Fill.deserialize(f) for f in payload["fills"]], 

169 rejections=[SignalRejection.deserialize(r) for r in payload["rejections"]], 

170 periods_per_year=payload["periods_per_year"], 

171 benchmark_return=payload.get("benchmark_return"), 

172 ) 

173 

174 @staticmethod 

175 def _serialize_period(period: ExperimentPeriod) -> dict[str, Any]: 

176 return { 

177 "start": period.start.isoformat(), 

178 "end": period.end.isoformat(), 

179 } 

180 

181 @staticmethod 

182 def _deserialize_period(payload: dict[str, Any]) -> ExperimentPeriod: 

183 return ExperimentPeriod( 

184 start=datetime.fromisoformat(payload["start"]), 

185 end=datetime.fromisoformat(payload["end"]), 

186 ) 

187 

188 def _serialize_fold(self, fold: WalkForwardFoldResult) -> dict[str, Any]: 

189 return { 

190 "split": { 

191 "train": self._serialize_period(fold.split.train), 

192 "validation": self._serialize_period(fold.split.validation), 

193 "test": self._serialize_period(fold.split.test), 

194 }, 

195 "selected_params": fold.selected_params, 

196 "test_result": self._serialize_backtest_result(fold.test_result), 

197 } 

198 

199 def _deserialize_fold(self, payload: dict[str, Any]) -> WalkForwardFoldResult: 

200 return WalkForwardFoldResult( 

201 split=WalkForwardSplit( 

202 train=self._deserialize_period(payload["split"]["train"]), 

203 validation=self._deserialize_period(payload["split"]["validation"]), 

204 test=self._deserialize_period(payload["split"]["test"]), 

205 ), 

206 selected_params=dict(payload["selected_params"]), # type: ignore[arg-type] 

207 test_result=self._deserialize_backtest_result(payload["test_result"]), 

208 ) 

209 

210 def _serialize_metadata(self, metadata: ExperimentMetadata) -> dict[str, Any]: 

211 return { 

212 "run_id": str(metadata.run_id), 

213 "created_at": metadata.created_at.isoformat(), 

214 "config": self._serialize_config(metadata.config), 

215 } 

216 

217 def _deserialize_metadata(self, payload: dict[str, Any]) -> ExperimentMetadata: 

218 return ExperimentMetadata( 

219 run_id=UUID(payload["run_id"]), 

220 created_at=datetime.fromisoformat(payload["created_at"]), 

221 config=self._deserialize_config(payload["config"]), 

222 ) 

223 

224 def _serialize_backtest(self, run: ExperimentRun) -> dict[str, Any]: 

225 return { 

226 "schema_version": 2, 

227 "run_type": "backtest", 

228 **self._serialize_metadata(run.metadata), 

229 "result": self._serialize_backtest_result(run.result), 

230 } 

231 

232 def _deserialize_backtest(self, payload: dict[str, Any]) -> ExperimentRun: 

233 metadata = self._deserialize_metadata(payload) 

234 

235 return ExperimentRun( 

236 metadata=metadata, 

237 result=self._deserialize_backtest_result(payload["result"]), 

238 ) 

239 

240 def _serialize_walk_forward(self, run: WalkForwardExperimentRun) -> dict[str, Any]: 

241 return { 

242 "schema_version": 2, 

243 "run_type": "walk_forward", 

244 **self._serialize_metadata(run.metadata), 

245 "result": { 

246 "folds": [self._serialize_fold(fold) for fold in run.result.folds], 

247 }, 

248 } 

249 

250 def _deserialize_walk_forward(self, payload: dict[str, Any]) -> WalkForwardExperimentRun: 

251 metadata = self._deserialize_metadata(payload) 

252 

253 result = WalkForwardResult( 

254 folds=tuple( 

255 (self._deserialize_fold(fold) 

256 for fold in payload["result"]["folds"]), 

257 ), 

258 ) 

259 

260 return WalkForwardExperimentRun(metadata=metadata, result=result) 

261 

262 def _deserialize(self, payload: dict[str, Any]) -> Run: 

263 schema_version = payload.get("schema_version") 

264 if schema_version == 1 or (schema_version == 2 and payload["run_type"] == "backtest"): 

265 return self._deserialize_backtest(payload) 

266 

267 elif schema_version == 2 and payload["run_type"] == "walk_forward": 

268 return self._deserialize_walk_forward(payload) 

269 

270 else: 

271 raise ValueError(f"Unsupported experiment schema version: {schema_version!r}")