An event-driven backtesting engine for systematic trading strategies.
Nothing calls anything else directly. Every component publishes to a shared FIFO queue and reacts to what it pops off: the data feed emits market bars, strategies turn bars into signals, the portfolio sizes signals into orders, and the execution handler fills orders at the next bar's open.
That last detail is the point. A strategy decides on the close of bar t, but the fill happens at the open of bar t+1 — the price it could actually have traded at. Filling on the same bar that triggered the signal is the most common way a backtest quietly lies to you, and the queue ordering here makes it structurally impossible.
Python 3.10+, plus:
pip install -r requirements.txtThe repository ships with a runnable example. From the parent directory of the repo:
python -m backtest_engine.exampleIt downloads hourly GBPUSD data, runs three strategies over it, prints their metrics and writes a PNG report for each.
The example is run as a module (
python -m backtest_engine.example), not as a script. The package uses relative imports, sopython example.pywill fail withImportError: attempted relative import with no known parent package.
Each run prints a summary and saves a three-panel chart:
=== swing ===
SwingBreakout(lookback=20, trend=50, stop=1.5atr, target=3.0atr, max_hold=14)
Portfolio(cash=9873.46, equity=9873.46, open_positions=0)
PerformanceAnalyzer(observations=12229, trades=608)
sharpe: -1.45
max dd: 1.33%
cagr: -0.18%
trades: 608
win rate: 42.8%
total PnL: -17.85
exits: {'stop': 274, 'target': 128, 'timeout': 206}
saved report_swing.png
Top panel is equity, middle is drawdown from the running high-water mark, bottom is a rolling 63-bar Sharpe. The chart above is the breakout strategy on GBPUSD — a slow bleed from commission on 608 trades, which is exactly the kind of result that looks profitable when you fill on the signal bar instead of the next open.
Subclass Strategy and implement on_market. Call self._emit_signal(...)
when you want a position; the portfolio handles sizing and the execution
handler handles filling.
from backtest_engine.strategy.base import Strategy
class BuyTheDip(Strategy):
def on_market(self, event):
bars = self.feed.get_latest_bars(2)
if len(bars) < 2:
return
close = bars["Close"]
# yfinance returns (field, ticker) columns for a single ticker.
if isinstance(close, pd.DataFrame):
close = close.iloc[:, 0]
if close.iloc[-1] < close.iloc[-2]:
self._emit_signal(event.ticker, direction=1, strength=0.5)direction is 1 for long and -1 for short. strength belongs in [0, 1]
— the portfolio's Kelly sizing assumes that range and treats 0 as a coin
flip.
If your strategy knows the trade's actual win probability rather than a
relative confidence, pass win_probability= as well; see
Position sizing for why that matters.
To close a position, emit a signal in the opposite direction. The portfolio reads an opposing signal against an open position as "flatten", not "reverse", so it will not accidentally flip you short when you meant to exit a long.
If you want your own loop rather than the one in example.py:
events = EventQueue()
feed = DataFeed("SPY", "2020-01-01", "2024-01-01", "1d")
strategy = SMACrossover(feed, events, fast=20, slow=50)
portfolio = Portfolio(events, initial_capital=10_000.0)
execution = ExecutionHandler(events, commission=0.0002)
feed.load()
while feed.has_more():
bars = feed.get_latest_bars(1)
row = bars.iloc[-1]
if isinstance(bars.columns, pd.MultiIndex):
row.index = bars.columns.get_level_values(0)
events.put(MarketEvent(bars.index[-1], "SPY", row))
while not events.empty():
event = events.get()
if isinstance(event, MarketEvent):
# Execution first: orders from the previous bar fill at this
# bar's open, before the strategy can emit anything new.
execution.on_market(event)
portfolio.last_prices[event.ticker] = float(event.bar["Close"])
portfolio.mark_to_market(event.timestamp)
strategy.on_market(event)
elif isinstance(event, SignalEvent):
portfolio.on_signal(event)
elif isinstance(event, OrderEvent):
execution.on_order(event)
elif isinstance(event, FillEvent):
portfolio.on_fill(event)
analyzer = PerformanceAnalyzer(portfolio.equity_curve(), portfolio.trade_log())
print(analyzer.summary())
ReportGenerator(analyzer, portfolio.trade_log()).plot("report.png")The order inside the MarketEvent branch matters: execution before strategy,
so that pending orders fill at this bar's open before any new signal is
generated from it.
backtest_engine/
├── data/
│ └── loader.py DataFeed — downloads OHLCV bars, streams them by cursor
├── events/
│ └── types.py MarketEvent, SignalEvent, OrderEvent, FillEvent, EventQueue
├── strategy/
│ └── base.py Strategy base class + SMACrossover
├── portfolio/
│ └── portfolio.py Position, Portfolio — cash, sizing, PnL, equity curve
├── execution/
│ └── handler.py ExecutionHandler — next-bar fills with commission
├── performance/
│ └── metrics.py PerformanceAnalyzer — Sharpe, drawdown, CAGR, Calmar
├── report/
│ └── generator.py ReportGenerator — equity/drawdown/Sharpe chart
├── docs/images/ Sample report output
└── example.py Runnable example: three strategies, metrics, charts
| Component | Role |
|---|---|
DataFeed |
Downloads OHLCV bars via yfinance. has_more() advances the cursor, get_latest_bars(n) returns only what's visible so far. |
EventQueue |
FIFO queue carrying all four event types. |
Strategy |
Abstract base; subclass and implement on_market. |
SMACrossover |
Built-in fast/slow moving-average crossover. |
Portfolio |
Tracks cash, positions and equity. Sizes signals with fractional Kelly, capped by max_position_fraction. |
ExecutionHandler |
Queues orders and fills them at the next bar's open, charging commission on notional. |
PerformanceAnalyzer |
Sharpe, max drawdown, CAGR, Calmar, trade count, win rate. |
ReportGenerator |
Renders equity, drawdown and rolling-Sharpe panels to PNG. |
Portfolio sizes new positions with a fractional Kelly rule. Signal strength
maps linearly onto an assumed win probability in [0.50, 0.65], full Kelly for
an even-money payoff reduces to 2p - 1, and the result is scaled by
kelly_multiplier (default 0.25) then capped at max_position_fraction
(default 0.10 of equity).
Portfolio(events, initial_capital=10_000.0, max_position_fraction=0.10, kelly_multiplier=0.25)Mapping strength onto [0.50, 0.65] is an inference, because strength is an
arbitrary confidence scale that means something different in each strategy. A
strategy that already holds a real probability — a classifier's calibrated
output, say — should pass it instead:
self._emit_signal(ticker, direction=1, strength=0.3, win_probability=0.58)Kelly then sizes from that number directly. The difference is not cosmetic:
Kelly is linear in 2p - 1, so a model reporting 0.58 while the strength
mapping infers 0.51 is sized roughly 8x too small. Leaving
win_probability unset keeps the original behaviour exactly, so existing
strategies are unaffected.
For a short, the winning outcome is the price falling, so pass 1 - p_up
rather than p_up.
Metrics take a periods argument, defaulting to 252 for daily bars. Pass the
value matching your interval or the numbers will be badly scaled — hourly FX
bars are roughly 252 * 7:
analyzer.sharpe(periods=252 * 7)
analyzer.cagr(periods=252 * 7)- Single-ticker only; the portfolio holds a dict of positions but the loop drives one symbol at a time.
MIT — see LICENSE.
