Coverage for src/finbot/engine/backtest_engine.py: 82%
98 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 datetime import datetime
2from itertools import groupby
4from finbot.backtest.models import BacktestResult, EquityPoint, SignalRejection
5from finbot.execution.broker import SimulatedBroker
6from finbot.execution.fill import Fill
7from finbot.execution.order import Order, OrderSide
8from finbot.execution.policy import ExecutionPolicy
9from finbot.market.models import Bar
10from finbot.risk.models import RiskLimits
11from finbot.risk.position_sizer import PositionSizer
12from finbot.risk.risk_manager import RiskManager
13from finbot.strategy.base import Strategy
14from finbot.strategy.signal import Signal, SignalDirection
17class BacktestEngine:
18 def __init__(
19 self,
20 broker: SimulatedBroker,
21 strategy: Strategy,
22 execution_policy: ExecutionPolicy = ExecutionPolicy.NEXT_OPEN,
23 risk_manager: RiskManager | None = None,
24 position_sizer: PositionSizer | None = None,
25 periods_per_year: int = 252,
26 ):
27 if periods_per_year <= 0:
28 raise ValueError("Periods per year must be greater than 0")
30 self.broker = broker
31 self.strategy = strategy
32 self.execution_policy = execution_policy
33 self.risk_manager = risk_manager or RiskManager(RiskLimits())
34 self.position_sizer = position_sizer or PositionSizer()
35 self.periods_per_year = periods_per_year
36 self._has_run = False
38 def run(self, bars_by_symbol: dict[str, list[Bar]]) -> BacktestResult:
39 if self._has_run:
40 raise RuntimeError("BacktestEngine instances are one-shot and can only be run once.")
42 if self.broker.portfolio.positions:
43 raise ValueError("Initial portfolio positions are not supported by BacktestEngine.")
45 self._has_run = True
47 bars = sorted(
48 (bar for symbol_bars in bars_by_symbol.values() for bar in symbol_bars),
49 key=lambda bar: (bar.timestamp, bar.symbol),
50 )
52 starting_cash = self.broker.portfolio.cash
53 fills: list[Fill] = []
54 equity_curve: list[EquityPoint] = []
55 rejections: list[SignalRejection] = []
57 current_prices: dict[str, float] = {}
58 pending_signals: list[Signal] = []
60 for timestamp, grouped_bars in groupby(bars, key=lambda bar: bar.timestamp):
61 timestamp_bars = list(grouped_bars)
62 bars_by_current_symbol = {bar.symbol: bar for bar in timestamp_bars}
63 open_prices = dict(current_prices)
64 open_prices.update({bar.symbol: bar.open for bar in timestamp_bars})
66 if self.execution_policy == ExecutionPolicy.NEXT_OPEN:
67 still_pending: list[Signal] = []
69 for signal in pending_signals:
70 if signal.symbol not in bars_by_current_symbol: 70 ↛ 71line 70 didn't jump to line 71 because the condition on line 70 was never true
71 still_pending.append(signal)
72 continue
74 order = self._signal_to_order(
75 signal=signal,
76 market_prices=open_prices,
77 timestamp=timestamp,
78 rejections=rejections,
79 )
80 if order is None: continue 80 ↛ 69line 80 didn't jump to line 69 because the continue on line 80 wasn't executed
82 fill = self.broker.execute(
83 order=order,
84 market_price=open_prices[order.symbol],
85 timestamp=timestamp,
86 )
87 fills.append(fill)
89 pending_signals = still_pending
91 current_prices.update({bar.symbol: bar.close for bar in timestamp_bars})
93 generated_signals: list[Signal] = []
95 for bar in timestamp_bars:
96 result = self.strategy.on_bar(bar=bar)
97 if result is None: continue
98 if isinstance(result, Signal):
99 generated_signals.append(result)
100 else:
101 generated_signals.extend(result)
103 if self.execution_policy == ExecutionPolicy.SAME_CLOSE:
104 for signal in generated_signals:
105 order = self._signal_to_order(
106 signal=signal,
107 market_prices=current_prices,
108 timestamp=timestamp,
109 rejections=rejections,
110 )
112 if order is None:
113 continue
115 fill = self.broker.execute(
116 order=order,
117 market_price=current_prices[order.symbol],
118 timestamp=timestamp,
119 )
121 fills.append(fill)
123 elif self.execution_policy == ExecutionPolicy.NEXT_OPEN: 123 ↛ 127line 123 didn't jump to line 127 because the condition on line 123 was always true
124 pending_signals.extend(generated_signals)
126 else:
127 raise ValueError(f"Unsupported execution policy: {self.execution_policy}")
129 equity_curve.append(
130 EquityPoint(
131 timestamp=timestamp,
132 value=self.broker.portfolio.total_value(current_prices),
133 invested_value=self.broker.portfolio.positions_value(current_prices),
134 ),
135 )
137 ending_value = (
138 self.broker.portfolio.total_value(current_prices)
139 if current_prices
140 else starting_cash
141 )
143 return BacktestResult(
144 starting_cash=starting_cash,
145 ending_value=ending_value,
146 fills=fills,
147 equity_curve=equity_curve,
148 rejections=rejections,
149 periods_per_year=self.periods_per_year,
150 )
152 def _signal_to_order(
153 self,
154 *,
155 signal: Signal,
156 market_prices: dict[str, float],
157 timestamp: datetime,
158 rejections: list[SignalRejection],
159 ) -> Order | None:
160 risk_decision = self.risk_manager.assess(
161 signal=signal,
162 portfolio=self.broker.portfolio,
163 market_prices=market_prices,
164 )
166 if not risk_decision.accepted:
167 rejections.append(
168 SignalRejection(
169 timestamp=timestamp,
170 symbol=signal.symbol,
171 reason=risk_decision.reason or "Risk manager rejected signal.",
172 stage="risk",
173 ),
174 )
175 return None
177 if risk_decision.order_value_budget is None: 177 ↛ 178line 177 didn't jump to line 178 because the condition on line 177 was never true
178 raise RuntimeError("Accepted risk decision is missing an order value budget.")
180 if signal.direction == SignalDirection.LONG: 180 ↛ 184line 180 didn't jump to line 184 because the condition on line 180 was always true
181 order_side = OrderSide.BUY
182 max_quantity = None
184 elif signal.direction == SignalDirection.SHORT:
185 order_side = OrderSide.SELL
186 position = self.broker.portfolio.positions.get(signal.symbol)
187 max_quantity = None if position is None else position.quantity
189 elif signal.direction == SignalDirection.FLAT:
190 return None
192 else:
193 raise ValueError(f"Unsupported signal direction: {signal.direction}")
195 sizing_decision = self.position_sizer.size(
196 order_side=order_side,
197 market_price=market_prices[signal.symbol],
198 order_value_budget=risk_decision.order_value_budget,
199 fee_rate=self.broker.fee_rate,
200 slippage_rate=self.broker.slippage_rate,
201 max_quantity=max_quantity,
202 )
204 if not sizing_decision.accepted: 204 ↛ 205line 204 didn't jump to line 205 because the condition on line 204 was never true
205 rejections.append(
206 SignalRejection(
207 timestamp=timestamp, symbol=signal.symbol,
208 reason=sizing_decision.reason or "Position sizer rejected signal.",
209 stage="sizing",
210 ),
211 )
212 return None
214 if sizing_decision.quantity is None: 214 ↛ 215line 214 didn't jump to line 215 because the condition on line 214 was never true
215 raise RuntimeError("Accepted sizing decision is missing a quantity.")
217 return Order(symbol=signal.symbol, side=order_side, quantity=sizing_decision.quantity)