Skip to content

ccakirr/backtest_engine

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

17 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

backtest_engine

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.

Requirements

Python 3.10+, plus:

pip install -r requirements.txt

Quick start

The repository ships with a runnable example. From the parent directory of the repo:

python -m backtest_engine.example

It 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, so python example.py will fail with ImportError: attempted relative import with no known parent package.

Output

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

Equity curve, drawdown and rolling Sharpe for the swing strategy

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.

Writing a strategy

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.

Wiring it yourself

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.

Project layout

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

Components

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.

Position sizing

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)

When the strategy knows its win probability

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.

Annualization

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)

Known issues

  • Single-ticker only; the portfolio holds a dict of positions but the loop drives one symbol at a time.

License

MIT — see LICENSE.

About

Event-driven Python backtesting engine featuring look-ahead-safe execution, fractional Kelly position sizing, portfolio accounting, and performance analytics.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages