Coverage for src/finbot/backtest/experiment.py: 96%
76 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
1from dataclasses import dataclass, field
2from datetime import datetime, timedelta
3from typing import Any
4from uuid import UUID
6from finbot.backtest.models import BacktestResult
9@dataclass(frozen=True)
10class ExperimentConfig:
11 strategy: str
12 symbols: tuple[str, ...]
13 start: datetime
14 end: datetime
15 dataset: str
16 initial_cash: float
17 allocation: float = 0.999
18 fee_rate: float = 0.0
19 slippage_rate: float = 0.0
20 strategy_params: dict[str, Any] = field(default_factory=dict)
22 def __post_init__(self) -> None:
23 if not self.strategy: 23 ↛ 24line 23 didn't jump to line 24 because the condition on line 23 was never true
24 raise ValueError("strategy must not be empty")
25 if not self.symbols:
26 raise ValueError("symbols must not be empty")
27 if self.start >= self.end:
28 raise ValueError("start must be before end")
29 if self.initial_cash <= 0:
30 raise ValueError("initial_cash must be greater than 0")
31 if not 0 < self.allocation <= 1:
32 raise ValueError("allocation must be greater than 0 and at most 1")
33 if self.fee_rate < 0:
34 raise ValueError("fee_rate must not be negative")
35 if self.slippage_rate < 0:
36 raise ValueError("slippage_rate must not be negative")
39@dataclass(frozen=True)
40class ExperimentMetadata:
41 run_id: UUID
42 created_at: datetime
43 config: ExperimentConfig
46@dataclass(frozen=True)
47class ExperimentRun:
48 metadata: ExperimentMetadata
49 result: BacktestResult
52@dataclass(frozen=True)
53class ExperimentPeriod:
54 """Half-open time interval: [start, end)"""
55 start: datetime
56 end: datetime
58 @property
59 def duration(self) -> timedelta:
60 return self.end - self.start
62 def __post_init__(self) -> None:
63 if self.start >= self.end:
64 raise ValueError("Start must be before end.")
67@dataclass(frozen=True)
68class WalkForwardSplit:
69 train: ExperimentPeriod
70 validation: ExperimentPeriod
71 test: ExperimentPeriod
73 def __post_init__(self) -> None:
74 if self.train.end > self.validation.start:
75 raise ValueError("Train period must not overlap validation period.")
76 if self.validation.end > self.test.start:
77 raise ValueError("Validation period must not overlap test period.")
80@dataclass(frozen=True)
81class WalkForwardFoldResult:
82 split: WalkForwardSplit
83 selected_params: dict[str, Any]
84 test_result: BacktestResult
87@dataclass(frozen=True)
88class WalkForwardResult:
89 folds: tuple[WalkForwardFoldResult, ...]
91 def __post_init__(self):
92 if not self.folds: 92 ↛ 93line 92 didn't jump to line 93 because the condition on line 92 was never true
93 raise ValueError("Walk-forward result must contain at least one fold.")
96@dataclass(frozen=True)
97class WalkForwardExperimentRun:
98 metadata: ExperimentMetadata
99 result: WalkForwardResult