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

43 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-21 17:12 +0000

1import json 

2from datetime import datetime 

3from pathlib import Path 

4from typing import Any, Iterable 

5from uuid import UUID, uuid4 

6 

7from finbot.backtest.experiment import ExperimentConfig, ExperimentMetadata, ExperimentRun 

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

9from finbot.execution.fill import Fill 

10 

11 

12class ExperimentStore: 

13 def __init__(self, root: Path): 

14 self.root = root 

15 

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

17 metadata = ExperimentMetadata( 

18 run_id=uuid4(), 

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

20 config=config, 

21 ) 

22 

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

24 

25 self.save(run) 

26 return run 

27 

28 def save(self, run: ExperimentRun) -> None: 

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

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

31 

32 payload = self._serialize(run) 

33 

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

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

36 encoding="utf-8", 

37 ) 

38 

39 def load(self, run_id: UUID | str) -> ExperimentRun: 

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

41 

42 if not path.exists(): 

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

44 

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

46 return self._deserialize(payload) 

47 

48 def load_many(self, run_ids: Iterable[UUID | str]) -> list[ExperimentRun]: 

49 return [self.load(run_id) for run_id in run_ids] 

50 

51 @staticmethod 

52 def _serialize(run: ExperimentRun) -> dict[str, Any]: 

53 return { 

54 "schema_version": 1, 

55 "run_id": str(run.metadata.run_id), 

56 "created_at": run.metadata.created_at.isoformat(), 

57 "config": { 

58 "strategy": run.metadata.config.strategy, 

59 "strategy_params": run.metadata.config.strategy_params, 

60 "symbols": list(run.metadata.config.symbols), 

61 "start": run.metadata.config.start.isoformat(), 

62 "end": run.metadata.config.end.isoformat(), 

63 "dataset": run.metadata.config.dataset, 

64 "initial_cash": run.metadata.config.initial_cash, 

65 "allocation": run.metadata.config.allocation, 

66 "fee_rate": run.metadata.config.fee_rate, 

67 "slippage_rate": run.metadata.config.slippage_rate, 

68 }, 

69 "result": { 

70 "starting_cash": run.result.starting_cash, 

71 "ending_value": run.result.ending_value, 

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

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

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

75 "periods_per_year": run.result.periods_per_year, 

76 "benchmark_return": run.result.benchmark_return, 

77 "pnl": run.result.pnl, 

78 "total_return": run.result.total_return, 

79 "max_drawdown": run.result.max_drawdown, 

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

81 "trade_count": run.result.trade_count, 

82 "winning_trades": run.result.winning_trades, 

83 "losing_trades": run.result.losing_trades, 

84 "win_rate": run.result.win_rate, 

85 "profit_factor": run.result.profit_factor, 

86 "returns": run.result.returns, 

87 "average_trade": run.result.average_trade, 

88 "median_trade": run.result.median_trade, 

89 "best_trade": run.result.best_trade, 

90 "worst_trade": run.result.worst_trade, 

91 "average_trade_duration": str(run.result.average_trade_duration), 

92 "cagr": run.result.cagr, 

93 "calmar_ratio": run.result.calmar_ratio, 

94 "total_fees": run.result.total_fees, 

95 "total_slippage": run.result.total_slippage, 

96 "turnover": run.result.turnover, 

97 "exposure": run.result.exposure, 

98 "excess_return": run.result.excess_return, 

99 }, 

100 } 

101 

102 @staticmethod 

103 def _deserialize(payload: dict[str, Any]) -> ExperimentRun: 

104 schema_version = payload.get("schema_version") 

105 if schema_version != 1: 

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

107 

108 config_payload = payload["config"] 

109 result_payload = payload["result"] 

110 

111 config = ExperimentConfig( 

112 strategy=config_payload["strategy"], 

113 strategy_params=dict(config_payload["strategy_params"]), # type: ignore[arg-type] 

114 symbols=tuple(config_payload["symbols"]), 

115 start=datetime.fromisoformat(config_payload["start"]), 

116 end=datetime.fromisoformat(config_payload["end"]), 

117 dataset=config_payload["dataset"], 

118 initial_cash=float(config_payload["initial_cash"]), 

119 allocation=float(config_payload["allocation"]), 

120 fee_rate=float(config_payload["fee_rate"]), 

121 slippage_rate=float(config_payload["slippage_rate"]), 

122 ) 

123 

124 result = BacktestResult( 

125 starting_cash=result_payload["starting_cash"], 

126 ending_value=result_payload["ending_value"], 

127 equity_curve=[EquityPoint.deserialize(p) for p in result_payload["equity_curve"]], 

128 fills=[Fill.deserialize(f) for f in result_payload["fills"]], 

129 rejections=[SignalRejection.deserialize(r) for r in result_payload["rejections"]], 

130 periods_per_year=result_payload["periods_per_year"], 

131 benchmark_return=result_payload.get("benchmark_return"), 

132 ) 

133 

134 metadata = ExperimentMetadata( 

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

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

137 config=config, 

138 ) 

139 

140 return ExperimentRun(metadata=metadata, result=result)