Coverage for src/finbot/backtest/models.py: 98%
195 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
1from dataclasses import dataclass
2from datetime import datetime, timedelta
3from math import sqrt
4from statistics import mean, median, stdev
5from typing import Any
7from finbot.backtest.trades import Trade, trades_from_fills
8from finbot.execution.fill import Fill
11@dataclass(frozen=True)
12class EquityPoint:
13 timestamp: datetime
14 value: float
15 invested_value: float = 0.0
17 def serialize(self) -> dict[str, Any]:
18 return {
19 "timestamp": self.timestamp.isoformat(),
20 "value": self.value,
21 "invested_value": self.invested_value,
22 }
24 @classmethod
25 def deserialize(cls, data: dict[str, Any]) -> "EquityPoint":
26 return cls(
27 timestamp=datetime.fromisoformat(data["timestamp"]),
28 value=data["value"],
29 invested_value=data.get("invested_value", 0.0),
30 )
33@dataclass(frozen=True)
34class SignalRejection:
35 timestamp: datetime
36 symbol: str
37 reason: str
38 stage: str # Will be either "risk" or "sizing" for now
40 def serialize(self) -> dict[str, str]: # pragma: no cover
41 return {
42 "timestamp": self.timestamp.isoformat(),
43 "symbol": self.symbol,
44 "reason": self.reason,
45 "stage": self.stage,
46 }
48 @classmethod
49 def deserialize(cls, data: dict[str, str]) -> "SignalRejection":
50 return cls(
51 timestamp=datetime.fromisoformat(data["timestamp"]),
52 symbol=data["symbol"],
53 reason=data["reason"],
54 stage=data["stage"],
55 )
58@dataclass(frozen=True)
59class BacktestResult:
60 starting_cash: float
61 ending_value: float
62 equity_curve: list[EquityPoint]
63 fills: list[Fill]
64 rejections: list[SignalRejection]
65 periods_per_year: int = 252
66 benchmark_return: float | None = None
68 @property
69 def pnl(self) -> float:
70 return self.ending_value - self.starting_cash
72 @property
73 def total_return(self) -> float:
74 if self.starting_cash == 0:
75 raise ValueError("Starting cash must be greater than 0")
77 return (self.ending_value - self.starting_cash) / self.starting_cash
79 @property
80 def max_drawdown(self) -> float:
81 if not self.equity_curve:
82 return 0.0
84 peak = self.starting_cash
85 max_drawdown = 0.0
87 for point in self.equity_curve:
88 peak = max(peak, point.value)
89 if peak <= 0: continue
90 drawdown = (peak - point.value) / peak
91 max_drawdown = max(max_drawdown, drawdown)
93 return max_drawdown
95 @property
96 def trades(self) -> list[Trade]:
97 return trades_from_fills(self.fills)
99 @property
100 def trade_count(self) -> int:
101 return len(self.trades)
103 @property
104 def winning_trades(self) -> int:
105 return sum(trade.net_pnl > 0 for trade in self.trades)
107 @property
108 def losing_trades(self) -> int:
109 return sum(trade.net_pnl < 0 for trade in self.trades)
111 @property
112 def win_rate(self) -> float:
113 if not self.trades:
114 return 0.0
115 return self.winning_trades / self.trade_count
117 @property
118 def profit_factor(self) -> float:
119 gross_profit = sum(trade.net_pnl for trade in self.trades if trade.net_pnl > 0)
120 gross_loss = abs(sum(trade.net_pnl for trade in self.trades if trade.net_pnl < 0))
122 return gross_profit / gross_loss if gross_loss > 0 else float("inf")
124 @property
125 def returns(self) -> list[float]:
126 if len(self.equity_curve) < 2:
127 return []
129 return [
130 current.value / previous.value - 1
131 for previous, current in zip(self.equity_curve, self.equity_curve[1:])
132 ]
134 @property
135 def average_trade(self) -> float:
136 if not self.trades: return 0.0
137 return mean(trade.return_pct for trade in self.trades)
139 @property
140 def median_trade(self) -> float:
141 if not self.trades: return 0.0
142 return median(trade.return_pct for trade in self.trades)
144 @property
145 def best_trade(self) -> float:
146 if not self.trades: return 0.0
147 return max(trade.return_pct for trade in self.trades)
149 @property
150 def worst_trade(self) -> float:
151 if not self.trades: return 0.0
152 return min(trade.return_pct for trade in self.trades)
154 @property
155 def average_trade_duration(self) -> timedelta:
156 if not self.trades: return timedelta(0)
157 total = sum((trade.duration for trade in self.trades), start=timedelta(0))
158 return total / self.trade_count
160 @property
161 def cagr(self) -> float:
162 if len(self.equity_curve) < 2:
163 return 0.0
165 if self.starting_cash <= 0:
166 raise ValueError("Starting cash must be greater than 0")
168 if self.ending_value <= 0:
169 return -1.0
171 duration = self.equity_curve[-1].timestamp - self.equity_curve[0].timestamp
172 if duration.total_seconds() <= 0:
173 return 0.0
175 years = duration.total_seconds() / (365.25 * 24 * 60 * 60)
176 return (self.ending_value / self.starting_cash) ** (1 / years) - 1
178 @property
179 def calmar_ratio(self) -> float:
180 max_drawdown = self.max_drawdown
181 cagr = self.cagr
183 if max_drawdown == 0:
184 if cagr > 0:
185 return float("inf")
186 if cagr < 0: 186 ↛ 187line 186 didn't jump to line 187 because the condition on line 186 was never true
187 return float("-inf")
188 return 0.0
190 return cagr / max_drawdown
192 @property
193 def total_fees(self) -> float:
194 return sum(fill.fee for fill in self.fills)
196 @property
197 def total_slippage(self) -> float:
198 return sum(fill.slippage_cost for fill in self.fills)
200 @property
201 def turnover(self) -> float:
202 traded_notional = sum(fill.price * fill.quantity for fill in self.fills)
204 if traded_notional == 0: return 0.0
206 if self.equity_curve:
207 average_equity = mean(point.value for point in self.equity_curve)
208 else:
209 average_equity = self.starting_cash
211 if average_equity <= 0: return 0.0 211 ↛ exitline 211 didn't return from function 'turnover' because the return on line 211 wasn't executed
213 return traded_notional / average_equity
215 @property
216 def exposure(self) -> float:
217 if not self.equity_curve: return 0.0
219 exposures = [
220 point.invested_value / point.value
221 if point.value > 0
222 else 0.0
223 for point in self.equity_curve
224 ]
226 return mean(exposures)
228 @property
229 def excess_return(self) -> float | None:
230 if self.benchmark_return is None:
231 return None
233 return self.total_return - self.benchmark_return
235 @staticmethod
236 def _risk_free_rate_per_period(risk_free_rate: float, periods_per_year: int) -> float:
237 if periods_per_year <= 0: 237 ↛ 238line 237 didn't jump to line 238 because the condition on line 237 was never true
238 raise ValueError("Periods per year must be greater than 0")
240 return (1 + risk_free_rate) ** (1 / periods_per_year) - 1
242 def annualized_volatility(self, periods_per_year: int | None = None) -> float:
243 periods = self.periods_per_year if periods_per_year is None else periods_per_year
245 if periods <= 0:
246 raise ValueError("Periods per year must be greater than 0")
248 returns = self.returns
250 if len(returns) < 2:
251 return 0.0
253 return stdev(returns) * sqrt(periods)
255 def sharpe_ratio(
256 self,
257 risk_free_rate: float = 0.0,
258 periods_per_year: int | None = None,
259 ) -> float:
260 periods = self.periods_per_year if periods_per_year is None else periods_per_year
262 if periods <= 0:
263 raise ValueError("Periods per year must be greater than 0")
265 returns = self.returns
267 if len(returns) < 2:
268 return 0.0
270 volatility = stdev(returns)
272 if volatility == 0:
273 return 0.0
275 risk_free_rate_per_period = self._risk_free_rate_per_period(
276 risk_free_rate,
277 periods,
278 )
279 excess_returns = [r - risk_free_rate_per_period for r in returns]
280 return mean(excess_returns) / volatility * sqrt(periods)
282 def sortino_ratio(
283 self,
284 risk_free_rate: float = 0.0,
285 periods_per_year: int | None = None,
286 ) -> float:
287 periods = self.periods_per_year if periods_per_year is None else periods_per_year
289 if periods <= 0:
290 raise ValueError("Periods per year must be greater than 0")
292 returns = self.returns
294 if not returns:
295 return 0.0
297 risk_free_rate_per_period = self._risk_free_rate_per_period(
298 risk_free_rate,
299 periods,
300 )
301 excess_returns = [r - risk_free_rate_per_period for r in returns]
303 downside_squared = [min(excess_return, 0.0) ** 2 for excess_return in excess_returns]
304 downside_deviation = sqrt(mean(downside_squared))
306 if downside_deviation == 0:
307 average_excess_return = mean(excess_returns)
309 if average_excess_return > 0:
310 return float("inf")
311 return 0.0
313 return mean(excess_returns) / downside_deviation * sqrt(periods)