The engine
src/engine/ contains two orchestrators. They wire the other layers together and
own the per-bar loop — but contain no indicator math, no metric formulas, and no
vendor specifics.
BacktestEngine
run(symbols, start, end, initial_capital) -> BacktestResult:
- Fetch bars for all symbols via the
MarketDataClient. - For each symbol:
process_data→generate_signals→_simulate_symbol. - Aggregate trades, build the equity curve, compute metrics
(
analytics.performance), and return aBacktestResult.
_simulate_symbol
A bar-by-bar replay holding one position at a time:
- On an entry signal with no open position, size via
Strategy.calculate_position_sizeand open (if affordable) with stop/take levels from the strategy config. - On each subsequent bar, check stop-loss, take-profit, then a signal-based exit, in that order. Stop/take fill at their level; a signal exit fills at the next open.
- Any position still open at the final bar is force-closed (
END_OF_PERIOD).
Realised cash (_available) carries across symbols so the run can't spend the
same dollar twice. P&L is (exit − entry) × size × direction.
BacktestResult carries metrics, the trades DataFrame, the equity_curve,
capital, dates, and the strategy config.
LiveEngine
start(symbols):
_warm_up— fetch a lookback window, runprocess_data, and seed each symbol's rolling buffer viaStrategy.warm_up, so indicators are valid on the very first live bar.- Subscribe to the live stream through the
MarketDataClient. _on_bar— feed each full streamed bar toprocess_bar; forward any actionable signal to theLiveTrader.- If the broker supports it, run the trade-update stream concurrently
(
asyncio.gather) so fills/cancels/rejects are logged alongside trading.
Both live streams (market data and trade updates) auto-reconnect with capped
backoff via a shared run_with_reconnect helper, and shut down cleanly on
cancellation.
The engine never calls the broker directly — it delegates to execution. That boundary is exactly why the same strategy object backtests and trades live unchanged.