Coverage for src/finbot/engine/backtest_engine.py: 92%
79 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 datetime import datetime
2from enum import StrEnum
3from itertools import groupby
5from finbot.backtest.models import BacktestResult, EquityPoint, SignalRejection
6from finbot.engine.orchestrator import NoAction, TradingOrchestrator, TradingRejection
7from finbot.execution.broker import BacktestBroker
8from finbot.execution.models import Fill, Order
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.core import Signal, Strategy
16class ExecutionPolicy(StrEnum):
17 NEXT_OPEN = "next_open"
18 SAME_CLOSE = "same_close"
21class BacktestEngine:
22 def __init__(
23 self,
24 broker: BacktestBroker,
25 strategy: Strategy,
26 execution_policy: ExecutionPolicy = ExecutionPolicy.NEXT_OPEN,
27 risk_manager: RiskManager | None = None,
28 position_sizer: PositionSizer | None = None,
29 periods_per_year: int = 252,
30 ):
31 if periods_per_year <= 0:
32 raise ValueError("Periods per year must be greater than 0")
34 self.broker = broker
35 self.execution_policy = execution_policy
36 self.orchestrator = TradingOrchestrator(
37 strategy=strategy,
38 risk_manager=risk_manager if risk_manager is not None else RiskManager(RiskLimits()),
39 position_sizer=position_sizer,
40 )
41 self.periods_per_year = periods_per_year
42 self._has_run = False
44 def run(self, bars_by_symbol: dict[str, list[Bar]]) -> BacktestResult:
45 if self._has_run:
46 raise RuntimeError("BacktestEngine instances are one-shot and can only be run once.")
48 if self.broker.portfolio.positions:
49 raise ValueError("Initial portfolio positions are not supported by BacktestEngine.")
51 self._has_run = True
53 bars = sorted(
54 (bar for symbol_bars in bars_by_symbol.values() for bar in symbol_bars),
55 key=lambda bar: (bar.timestamp, bar.symbol),
56 )
58 starting_cash = self.broker.portfolio.cash
59 fills: list[Fill] = []
60 equity_curve: list[EquityPoint] = []
61 rejections: list[SignalRejection] = []
63 current_prices: dict[str, float] = {}
64 pending_signals: list[Signal] = []
66 for timestamp, grouped_bars in groupby(bars, key=lambda bar: bar.timestamp):
67 timestamp_bars = list(grouped_bars)
68 bars_by_current_symbol = {bar.symbol: bar for bar in timestamp_bars}
69 open_prices = dict(current_prices)
70 open_prices.update({bar.symbol: bar.open for bar in timestamp_bars})
72 if self.execution_policy == ExecutionPolicy.NEXT_OPEN:
73 still_pending: list[Signal] = []
75 for signal in pending_signals:
76 if signal.symbol not in bars_by_current_symbol: 76 ↛ 77line 76 didn't jump to line 77 because the condition on line 76 was never true
77 still_pending.append(signal)
78 continue
80 order = self._signal_to_order(
81 signal=signal,
82 market_prices=open_prices,
83 timestamp=timestamp,
84 rejections=rejections,
85 )
86 if order is None: 86 ↛ 87line 86 didn't jump to line 87 because the condition on line 86 was never true
87 continue
89 fill = self.broker.execute(order, open_prices[order.symbol], timestamp)
90 fills.append(fill)
92 pending_signals = still_pending
94 current_prices.update({bar.symbol: bar.close for bar in timestamp_bars})
96 generated_signals: list[Signal] = []
97 for bar in timestamp_bars:
98 generated_signals.extend(self.orchestrator.generate_signals(bar))
100 if self.execution_policy == ExecutionPolicy.SAME_CLOSE:
101 for signal in generated_signals:
102 order = self._signal_to_order(
103 signal=signal,
104 market_prices=current_prices,
105 timestamp=timestamp,
106 rejections=rejections,
107 )
109 if order is None:
110 continue
112 fill = self.broker.execute(
113 order=order,
114 market_price=current_prices[order.symbol],
115 timestamp=timestamp,
116 )
118 fills.append(fill)
120 elif self.execution_policy == ExecutionPolicy.NEXT_OPEN: 120 ↛ 124line 120 didn't jump to line 124 because the condition on line 120 was always true
121 pending_signals.extend(generated_signals)
123 else:
124 raise ValueError(f"Unsupported execution policy: {self.execution_policy}")
126 equity_curve.append(
127 EquityPoint(
128 timestamp=timestamp,
129 value=self.broker.portfolio.total_value(current_prices),
130 invested_value=self.broker.portfolio.positions_value(current_prices),
131 ),
132 )
134 ending_value = (
135 self.broker.portfolio.total_value(current_prices)
136 if current_prices
137 else starting_cash
138 )
140 return BacktestResult(
141 starting_cash=starting_cash,
142 ending_value=ending_value,
143 fills=fills,
144 equity_curve=equity_curve,
145 rejections=rejections,
146 periods_per_year=self.periods_per_year,
147 )
149 def _signal_to_order(
150 self,
151 *,
152 signal: Signal,
153 market_prices: dict[str, float],
154 timestamp: datetime,
155 rejections: list[SignalRejection],
156 ) -> Order | None:
157 result = self.orchestrator.process_signal(
158 signal=signal,
159 portfolio=self.broker.portfolio,
160 market_prices=market_prices,
161 fee_rate=self.broker.fee_rate,
162 slippage_rate=self.broker.slippage_rate,
163 )
165 if isinstance(result, TradingRejection):
166 rejections.append(
167 SignalRejection(
168 timestamp=timestamp,
169 symbol=result.signal.symbol,
170 reason=result.reason,
171 stage=result.stage.value,
172 ),
173 )
174 return None
176 if isinstance(result, NoAction): 176 ↛ 177line 176 didn't jump to line 177 because the condition on line 176 was never true
177 return None
179 return result