Backtrader and vectorbt agree to the cent on the same strategy, and Backtesting.py lands within $45, with every dollar of the gap explained.

But only after you align a few rules that each library handles differently out of the box. Run each one with its out-of-the-box settings and the same moving-average crossover on the same data gives you three different answers, with a 5.5% gap between them.

If you're:

  • choosing a Python backtesting library for your first serious project,
  • moving a strategy from one engine to another,
  • or wondering why your backtest doesn't match someone else's,

this matters.

I ran one strategy through all three on 10 years of daily SPY data, then ran an 80-pair parameter sweep in each one. Here's what matched, what didn't, and why.

The setup

Everything below is reproducible:

  • Data: SPY.US daily bars from EODHD, October 28, 2016 to October 28, 2026 (2,513 bars), adjusted for splits and dividends
  • Strategy: SMA 50/200 crossover, long only. Buy when the 50-day average crosses above the 200-day, sell when it crosses below
  • Account: $10,000, all in, whole shares, 0.1% commission per trade
  • Libraries: backtrader 1.9.78.123, vectorbt 1.2.0, backtesting 0.6.6, on Python 3.12 and pandas 3.1
  • Run: October 29, 2026

I'm not looking for a good strategy here. I want to give three engines the same job and see whether they agree.

Step 1: one clean data source for all three

Engines can only agree if they read the same numbers. So the data comes from one place, adjusted once, and saved to a CSV that every script reads.

import os import pandas as pd import requests r = requests.get( "https://eodhd.com/api/eod/SPY.US", params={"api_token": os.environ["EODHD_API_KEY"], "fmt": "json", "from": "2016-10-28", "to": "2026-10-28"}, timeout=30, ) raw = pd.DataFrame(r.json()).set_index("date") raw.index = pd.to_datetime(raw.index) # One adjustment factor per day, applied to every price f = raw["adjusted_close"] / raw["close"] df = pd.DataFrame({ "Open": raw["open"] * f, "High": raw["high"] * f, "Low": raw["low"] * f, "Close": raw["adjusted_close"], "Volume": raw["volume"], }) df.to_csv("spy.csv") 

The adjustment step matters more than it looks. EODHD returns the raw close and a split- and dividend-adjusted adjusted_close. If you feed raw prices to a backtest, every split looks like a crash and every dividend like a small loss. Applying the same daily factor to open, high and low keeps the whole bar consistent, which matters here because all three engines fill orders at the open.

That's one API call for ten years of data.

Step 2: the same crossover in three libraries

Here are the three versions with the rules aligned. The next section explains each choice.

Backtrader

import math import backtrader as bt import pandas as pd FEE = 0.001 df = pd.read_csv("spy.csv", index_col=0, parse_dates=True) class SmaCross(bt.Strategy): params = dict(fast=50, slow=200) def __init__(self): self.cross = bt.ind.CrossOver(bt.ind.SMA(period=self.p.fast), bt.ind.SMA(period=self.p.slow)) def next_open(self): # Runs before today's bar is processed: cross[0] is yesterday's signal  if not self.position and self.cross[0] > 0: size = math.floor(self.broker.getcash() / (self.data.open[0] * (1 + FEE))) self.buy(size=size) elif self.position and self.cross[0] < 0: self.close() cerebro = bt.Cerebro(cheat_on_open=True) cerebro.adddata(bt.feeds.PandasData(dataname=df)) cerebro.addstrategy(SmaCross) cerebro.broker.setcash(10_000) cerebro.broker.setcommission(commission=FEE) cerebro.run() print(f"Backtrader final value: {cerebro.broker.getvalue():,.2f}") 

vectorbt

import numpy as np import pandas as pd import vectorbt as vbt df = pd.read_csv("spy.csv", index_col=0, parse_dates=True) fast = vbt.MA.run(df["Close"], 50) slow = vbt.MA.run(df["Close"], 200) entries = fast.ma_crossed_above(slow).shift(1, fill_value=False) # act on the next bar exits = fast.ma_crossed_below(slow).shift(1, fill_value=False) pf = vbt.Portfolio.from_signals( df["Close"], entries, exits, price=df["Open"], # fill at the next open, not at the signal close  size=np.inf, size_granularity=1, # all in, whole shares  init_cash=10_000, fees=0.001, freq="1D", ) print(f"vectorbt final value: {pf.final_value():,.2f}") 

Backtesting.py

import pandas as pd from backtesting import Backtest, Strategy from backtesting.lib import crossover from backtesting.test import SMA df = pd.read_csv("spy.csv", index_col=0, parse_dates=True) class SmaCross(Strategy): def init(self): self.fast = self.I(SMA, self.data.Close, 50) self.slow = self.I(SMA, self.data.Close, 200) def next(self): if crossover(self.fast, self.slow): self.buy() elif crossover(self.slow, self.fast): self.position.close() stats = Backtest(df, SmaCross, cash=10_000, commission=0.001, finalize_trades=True).run() print(f"Backtesting.py final value: {stats['Equity Final [$]']:,.2f}") 

The three scripts print the same final value on the same EODHD data

Backtrader and vectorbt end at exactly $21,263.19. Backtesting.py ends at $21,218.60, and the next section shows where that $44.59 comes from.

Not counting blank lines and comments, that's 23 lines for Backtrader, 15 for vectorbt and 16 for Backtesting.py.

Why the defaults don't match

Now the version most people actually run: each library with its out-of-the-box settings. For vectorbt that's a plain from_signals(close, entries, exits). For Backtrader I used its AllInSizerInt sizer, which sizes the order on the signal bar's close. Backtesting.py ran with a plain buy().

Default settings versus aligned rules in all three engines

Run (no fees) Backtrader vectorbt Backtesting.py
Defaults $21,231.58 $22,395.33 $21,325.07
Aligned rules $21,348.83 $21,348.83 $21,325.07

With defaults, vectorbt comes out 5.5% ahead of Backtrader. Same strategy, same data, same four trades. To see which settings cause the gap, I changed them one at a time in vectorbt:

vectorbt run (no fees) Final value
Defaults: fill at the signal close, fractional shares $22,395.33
Whole shares only $22,223.85
Next-open fill only $21,613.44
Next-open fill and whole shares $21,348.83

The last row is exactly Backtrader's aligned result. Here's what each change does.

1. When the order fills. With defaults, vectorbt's first entry came on March 26, 2019 at $251.97, the close of the signal day. Backtrader and Backtesting.py entered on March 27 at the open, $251.96. Filling at the signal close means buying at the price you used to decide, which you can't do in real life. Shifting the signals one bar and passing price=df["Open"] puts vectorbt on the next-open rule.

2. Fractional shares. With defaults, vectorbt bought 39.6881 shares on that first entry. The other two bought 39. size_granularity=1 makes vectorbt trade whole shares.

Backtrader's own default result is lower than its aligned one for a related reason: AllInSizerInt sizes the order on the signal bar's close, but the order fills at the next open. On the second entry that meant 30 shares instead of 31.

3. How the last open trade is handled. This one remains after aligning, and it explains every dollar Backtesting.py is away from the other two. All three engines take the same four trades at the same prices. The fourth trade is still open at the end of the data. vectorbt and Backtrader value it at the final close, $772.31, without selling. Backtesting.py, with the finalize_trades=True setting I passed, actually closes it at the final bar's open, $771.43:

  • Without fees, that's 27 shares times $0.88, or $23.76.
  • With 0.1% fees, Backtesting.py also pays the commission on that closing sale: 27 shares times $771.43 times 0.1%, or $20.83. Add the $23.76 and you get the full $44.59.

So once fills and share rounding match, Backtrader and vectorbt agree to the cent, and Backtesting.py differs only by how it settles the final open trade.

I checked that with a more active strategy too. On a 10/30 crossover, with 36 trades in ten years, all three ended at exactly $20,254.43.

Two Backtrader traps that cost me a trade

Backtrader took the most code and both of my mistakes.

Trap 1: an all-in order that can't pay its commission

My first sizing rule was floor(cash / open). Without fees, that works. With a 0.1% commission, the order costs slightly more than the cash on hand, and Backtrader's broker rejects it with a Margin status.

Nothing crashes. The order just doesn't happen.

On the 70/250 crossover that skipped the third entry, on March 23, 2023. Backtrader finished with 3 trades and a 65.8% return, while vectorbt and Backtesting.py took 4 trades and returned 149%. Sizing with floor(cash / (open * (1 + fee))) fixed it, and the three engines lined up again. If you size all in, count rejected orders in notify_order, or you won't notice.

Trap 2: next_open reads yesterday's indicators

To trade at the open with whole shares sized on that open, Backtrader offers cheat_on_open=True and a next_open() method. Inside next_open, the indicators haven't been updated with the new bar yet. So self.cross[0] is still the previous bar's signal.

I first wrote cross[-1], assuming [0] would be today's value. Every trade came one day late: the first entry landed on March 28, 2019 instead of March 27, and the result drifted away from the other two engines. Printing both values around that crossover date showed the problem.

Speed: where vectorbt is in a different league

One backtest takes a fraction of a second in any of the three. The difference shows up when you test many parameter sets.

I ran the same sweep in each engine: fast averages from 10 to 100 and slow averages from 50 to 250, 80 valid pairs, 0.1% fees.

80-pair parameter sweep: time and results in each engine

Engine 80 backtests
vectorbt, first call 5.09 s
vectorbt, already warmed up 0.08 s
Backtesting.py 3.23 s
Backtrader 19.27 s

vectorbt's first call includes compiling its numba functions, so a one-off sweep isn't faster than Backtesting.py. Once it's warm, it runs all 80 portfolios as one vectorized computation, about 40 times faster than Backtesting.py and about 250 times faster than Backtrader.

All three found the same best pair, 80/250. Across the 80 pairs, vectorbt and Backtrader returned identical results, and Backtesting.py was never more than 0.545 percentage points away, consistent with how it settles the final open trade.

Where this breaks, and what I'd actually trust

The engines agree. The strategy is a different story.

SMA 50/200 and SMA 10/30 against buy and hold, same data and fees

Over the same ten years, buying SPY and holding it turned $10,000 into $42,429.98, a 324.30% return. Even buying only on August 16, 2017, the first day a 200-day average exists, returned 260.49%. The 50/200 crossover returned 112.63%. Even the best of the 80 pairs, 80/250, returned 158.40%. None of them came close.

SMA 10/30 results and buy and hold

The crossovers did one thing better: the 10/30 version cut the maximum drawdown to 17.44%, against 33.72% for buy and hold. Whether that's worth giving up two thirds of the return is a question for your own risk tolerance, not for a backtest.

Three more caveats:

  • Picking the best of 80 is overfitting. 80/250 won on this decade of SPY. Choosing it because it won, then reporting its return, tells you about the past, not the next ten years. Test the chosen pair on data it hasn't seen.
  • 0.1% commission isn't the whole cost. There's no slippage or spread in these runs. With four trades in ten years that barely matters. With a strategy that trades daily, it does.
  • Agreement between engines isn't proof of correctness. All three can agree on a backtest that uses the wrong prices. That's why the data step comes first. I went deeper into this in Backtesting in Python: Why Most Backtests Lie.

Which one should you use?

Backtrader vectorbt Backtesting.py
Style Event-driven, broker simulation Vectorized arrays Event-driven, compact API
Lines for this strategy 23 15 16
80-run sweep 19.27 s 0.08 s warm, 5.09 s cold 3.23 s
Fills in my default run Next open Signal-bar close Next open
Fractional shares in my default run No Yes No
Latest PyPI release April 19, 2023 October 19, 2026 July 22, 2026
Gotcha to watch Commission can reject all-in orders; next_open indexing Shift signals and set the fill price With finalize_trades=True, the final trade closes at the last open

Backtrader

Pros

  • A full broker model: order types, commissions, margin checks, multiple data feeds
  • The event loop reads like live trading code, so moving to a broker is easier

Cons

  • The slowest of the three on parameter sweeps
  • No release on PyPI since April 2023. It still ran fine on Python 3.12 and pandas 3.1 here

Best for: strategies with realistic order handling, multiple instruments, or a path to live trading.

vectorbt

Pros

  • 80 backtests in 0.08 seconds once compiled
  • The shortest code in this test, by one line

Cons

  • Defaults that differ from the other two (signal-bar fills, fractional shares)
  • Path-dependent logic, like a trailing stop that depends on every previous bar, is harder to express than in an event loop

Best for: research, parameter sweeps and comparing many signals quickly.

Backtesting.py

Pros

  • A small, readable API and a built-in stats table
  • 80 backtests in about 3 seconds

Cons

  • One instrument per backtest
  • With finalize_trades=True, it closes the final open trade at the last bar's open and charges the commission, which shifts the reported result slightly

Best for: single-asset strategies where you want clear code and quick results.

Getting the data

The ten years of SPY in this test came from one call to EODHD's end-of-day API, with adjusted_close doing the adjustment work.

The free plan covers one year of end-of-day history and 20 API calls a day, so it's enough to try the code on a short range. Ten years of history needs the EOD Historical All-World plan or higher.

👉 Get your EODHD API key here. Use the code KEVIN10 at checkout for 10% off.

Key takeaways

  • With the same fill timing and share rounding, Backtrader and vectorbt agree to the cent on the same data, and Backtesting.py differs only by how it settles the final open trade.
  • With defaults, the same crossover differs by 5.5%, mostly because vectorbt fills at the signal-bar close.
  • None of 80 SMA crossovers beat buy and hold on SPY over the last ten years. The engine you pick matters less than the question you test.

FAQs

❓ Is Backtrader still maintained?
✅ Its latest release on PyPI is 1.9.78.123, from April 19, 2023, with none since. It still installed and ran correctly on Python 3.12 with pandas 3.1 in this test, and matched vectorbt to the cent once the rules were aligned.

❓ Is vectorbt faster than Backtrader?
✅ For parameter sweeps, by a wide margin. In this test, 80 backtests took 0.08 seconds in vectorbt once warmed up, against 19.27 seconds in Backtrader. The first vectorbt call took 5.09 seconds because it compiles its functions.

❓ Why do my backtest results differ between libraries?
✅ Usually because of fill timing, share rounding or how the last open trade is handled. In this test, vectorbt's defaults filled at the signal-bar close with fractional shares, while Backtrader and Backtesting.py filled at the next open with whole shares.

❓ Which Python backtesting library is best for beginners?
✅ Backtesting.py has the smallest API and a readable stats report, and it ran this strategy in 16 lines. vectorbt is shorter still, but its defaults need care to match realistic fills.

❓ Should I use adjusted prices for backtesting?
✅ Yes. Raw prices turn splits into fake crashes and leave dividends out of the return. Apply the adjustment factor to open, high and low as well as close, so orders filled at the open use consistent prices.

The three engines aren't the hard part. Once the rules are aligned they agree, and every remaining dollar has a reason.

The hard part is the question you ask them, and whether the answer beats doing nothing.

Looking for technical content for your company? I can help — LinkedIn · kevinmenesesgonzalez@gmail.com