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
« 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
7from finbot.backtest.experiment import ExperimentConfig, ExperimentMetadata, ExperimentRun
8from finbot.backtest.models import BacktestResult, EquityPoint, SignalRejection
9from finbot.execution.fill import Fill
12class ExperimentStore:
13 def __init__(self, root: Path):
14 self.root = root
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 )
23 run = ExperimentRun(metadata=metadata, result=result)
25 self.save(run)
26 return run
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)
32 payload = self._serialize(run)
34 (run_dir / "metadata.json").write_text(
35 json.dumps(payload, indent=2, sort_keys=True),
36 encoding="utf-8",
37 )
39 def load(self, run_id: UUID | str) -> ExperimentRun:
40 path = self.root / str(run_id) / "metadata.json"
42 if not path.exists():
43 raise FileNotFoundError(f"Experiment run not found: {run_id}")
45 payload = json.loads(path.read_text(encoding="utf-8"))
46 return self._deserialize(payload)
48 def load_many(self, run_ids: Iterable[UUID | str]) -> list[ExperimentRun]:
49 return [self.load(run_id) for run_id in run_ids]
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 }
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}")
108 config_payload = payload["config"]
109 result_payload = payload["result"]
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 )
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 )
134 metadata = ExperimentMetadata(
135 run_id=UUID(payload["run_id"]),
136 created_at=datetime.fromisoformat(payload["created_at"]),
137 config=config,
138 )
140 return ExperimentRun(metadata=metadata, result=result)