Two contrasting design philosophies in Python backtesting

Choosing between Backtrader and backtesting.py represents a fundamental architectural decision in quantitative research. While both libraries simulate strategy performance on historical price bars, they approach quantitative modeling from polar opposite software philosophies:

  • Backtrader represents the comprehensive, object-oriented event-driven engine. Designed in 2015, Backtrader models a full trading ecosystem. It features an orchestration brain (Cerebro), simulated broker accounts (Broker), line-based circular memory buffers (Lines), strategy lifecycle hooks, and extensible analyzers. It mirrors institutional event systems where orders, portfolio margins, and data feeds operate as discrete stateful entities.
  • backtesting.py represents the minimalist, pandas-first prototyping framework. Introduced around 2018, it intentionally strips away boilerplate. Instead of managing complex feed classes and custom line abstractions, backtesting.py consumes standard pandas.DataFrame tables directly. It provides a lean init() and next() strategy interface, relying on vectorized precomputation and rendering interactive HTML visual reports via Bokeh.
Code
flowchart TD
    subgraph Backtrader ["Backtrader Event Architecture"]
        B_Data["Custom Data Feed (bt.feeds.PandasData)"] --> B_Cerebro["Cerebro Orchestrator"]
        B_Cerebro --> B_Lines["LineSeries Buffers (Relative Indexing: [0], [-1])"]
        B_Lines --> B_Strat["Strategy Instance (prenext -> nextstart -> next)"]
        B_Strat --> B_Broker["Broker Simulation (Cash, Margin, Commission)"]
        B_Broker --> B_Analyzers["Analyzers & Observers"]
    end

    subgraph BacktestingPy ["backtesting.py Procedural Architecture"]
        P_DF["Standard pandas DataFrame (Open, High, Low, Close)"] --> P_Engine["Backtest Runner"]
        P_Engine --> P_Init["Strategy.init() Vectorized Precomputation via self.I()"]
        P_Init --> P_Next["Strategy.next() Bar-by-Bar Procedural Loop"]
        P_Next --> P_Exec["Internal Order Matcher (Next-bar Open Fills)"]
        P_Exec --> P_Report["pandas.Series Metrics + Interactive Bokeh Chart"]
    end

This structural divide impacts developer ergonomics. A quant looking to rapidly validate a single-asset breakout heuristic on daily equities can configure and run a backtest in backtesting.py with fewer than 30 lines of code. Conversely, a developer requiring complex multi-data feeds, custom order execution listeners, or custom position sizing across several instruments may find backtesting.py's constraints limiting and lean toward Backtrader's modular object hierarchy.


Ingesting Data: PandasData feeds vs. DataFrame columns

How market data enters a simulation determines memory efficiency, schema flexibility, and ease of data pipeline integration.

Backtrader: bt.feeds.PandasData and LineSeries buffers

Backtrader does not execute directly on pandas DataFrames during its bar loop. Instead, it ingests a DataFrame through bt.feeds.PandasData and converts each column into an internal LineSeries object—a circular array buffer indexed relative to the current simulation time:

  • self.data.close[0] represents the closing price at the current bar t.
  • self.data.close[-1] represents the closing price at the prior bar t-1.
  • self.data.close[1] represents the closing price at the next bar t+1 (lookahead, restricted in backtesting).
Ingesting data in Backtrader
import backtrader as bt
import pandas as pd

# Load raw OHLCV DataFrame
df = pd.read_csv("BTC_USD_daily.csv", index_col="timestamp", parse_dates=True)

# Feed must be wrapped in a Backtrader feed adapter
data_feed = bt.feeds.PandasData(
    dataname=df,
    datetime=None,   # Uses DataFrame index
    open="open",
    high="high",
    low="low",
    close="close",
    volume="volume",
    openinterest=-1  # Column not present in crypto/equity data
)

cerebro = bt.Cerebro()
cerebro.adddata(data_feed, name="BTC")

Because Backtrader feeds are modular objects registered with Cerebro, Backtrader natively supports multi-asset feeds. You can pass multiple PandasData instances (cerebro.adddata(data1), cerebro.adddata(data2)) and access them within strategy code via self.datas[0] and self.datas[1].

backtesting.py: Native DataFrame consumption and self.data

backtesting.py bypasses feed wrappers entirely. The Backtest runner takes a raw pandas.DataFrame as its primary input. However, it enforces a strict naming schema:

  • Price column headers must be capitalized Title Case: Open, High, Low, Close, and optionally Volume.
  • Lowercase column headers (open, close) cause an immediate runtime ValueError.
  • The index must be a monotonically increasing pd.DatetimeIndex.
Ingesting data in backtesting.py
import pandas as pd
from backtesting import Backtest

df = pd.read_csv("BTC_USD_daily.csv", index_col="timestamp", parse_dates=True)

# Strict column formatting required
df = df.rename(columns={
    "open": "Open",
    "high": "High",
    "low": "Low",
    "close": "Close",
    "volume": "Volume"
})

# Passed directly into the Backtest constructor
# bt = Backtest(df, StrategyClass, cash=10000, commission=0.001)

Inside a strategy's next() method, data is accessed through a dynamic proxy array self.data. Unlike Backtrader's relative zero-offset, backtesting.py adopts Python array slicing conventions:

  • self.data.Close[-1] references the current bar (the most recent bar evaluated).
  • self.data.Close[-2] references the previous bar.
  • self.data.Close.s provides access to the underlying pandas.Series of prices up to the current bar.
Data DimensionBacktrader (bt.feeds.PandasData)backtesting.py (pandas.DataFrame)
Input TypeSubclassed Feed wrapping a DataFrame or CSVPure pandas.DataFrame directly
Column SchemaFlexible; configurable column mappings in PandasDataStrict Title Case (Open, High, Low, Close)
Current Bar Indexself.data.close[0]self.data.Close[-1]
Prior Bar Indexself.data.close[-1]self.data.Close[-2]
Multi-Asset SupportNative (multiple feeds added to Cerebro)Single-asset only (one DataFrame per backtest)

Defining Indicators: bt.indicators vs. self.I()

Technical indicators drive algorithmic signals. The mechanisms by which Backtrader and backtesting.py calculate indicators highlight their architectural differences.

Backtrader: Declarative indicator graphs and the lifecycle

In Backtrader, indicators are declared inside the strategy's __init__() method. They are not static NumPy arrays; they are computational nodes within Cerebro's execution graph.

Declaring indicators in Backtrader
class DualSMA(bt.Strategy):
    params = (("fast", 10), ("slow", 30),)

    def __init__(self):
        # Declared declaratively; creates LineSeries nodes
        self.fast_sma = bt.indicators.SMA(self.data.close, period=self.params.fast)
        self.slow_sma = bt.indicators.SMA(self.data.close, period=self.params.slow)
        self.crossover = bt.indicators.CrossOver(self.fast_sma, self.slow_sma)

Backtrader automatically manages indicator warmup periods. Because a 30-period SMA requires 30 bars of data before producing a valid value, Cerebro delays invoking next() until all indicators have sufficient data:

  1. prenext() is called for bars 1 through 29.
  2. nextstart() is called once on bar 30 when warmup finishes.
  3. next() is called for all subsequent bars.

Furthermore, Backtrader supports Line arithmetic: operators like +, -, >, and < create derived lines automatically (e.g., spread = self.fast_sma - self.slow_sma). However, writing custom indicators requires subclassing bt.Indicator and mastering Backtrader's internal meta-class system.

backtesting.py: Vectorized wrapping with self.I()

backtesting.py delegates indicator calculations to standard Python functions (NumPy, pandas, or libraries like TA-Lib) wrapped inside self.I() during Strategy.init().

Wrapping indicators in backtesting.py
def calculate_sma(series: pd.Series, period: int) -> pd.Series:
    return pd.Series(series).rolling(period).mean()

class DualSMA(Strategy):
    fast = 10
    slow = 30

    def init(self):
        # self.I runs calculate_sma once across the full array
        self.fast_sma = self.I(calculate_sma, self.data.Close.s, self.fast)
        self.slow_sma = self.I(calculate_sma, self.data.Close.s, self.slow)

self.I() precomputes the indicator across the full price vector once before the bar-by-bar loop begins. In next(), self.fast_sma exposes a sliding NumPy array view truncated at the current bar index.

[!NOTE] Unlike Backtrader's prenext(), backtesting.py executes next() across the full bar range. Indicators return np.nan during their warmup period. Functions like backtesting.lib.crossover() handle NaN values gracefully, but custom logical conditions must guard against np.isnan(self.fast_sma[-1]) to prevent unexpected evaluation failures.


Side-by-Side Code Comparison: A Dual Moving Average Strategy

To evaluate the operational differences in syntax, broker setup, and execution dispatch, let us implement the identical strategy across both engines:

  • Strategy: 10-period SMA crossing over a 30-period SMA.
  • Rules: Enter long 100 shares when Fast SMA crosses above Slow SMA; exit the position when Fast SMA crosses below Slow SMA.
  • Initial Capital: $10,000.
  • Commission: 0.1% (0.001) per trade.

Implementation 1: Backtrader

backtrader_dual_sma.py
import backtrader as bt
import pandas as pd

class BacktraderDualSMA(bt.Strategy):
    params = (
        ("fast_period", 10),
        ("slow_period", 30),
        ("size", 100),
    )

    def __init__(self):
        self.fast_sma = bt.indicators.SMA(self.data.close, period=self.params.fast_period)
        self.slow_sma = bt.indicators.SMA(self.data.close, period=self.params.slow_period)
        self.crossover = bt.indicators.CrossOver(self.fast_sma, self.slow_sma)

    def next(self):
        # In Backtrader: self.position evaluates truthy if holding an open position
        if not self.position:
            if self.crossover > 0:
                self.buy(size=self.params.size)
        elif self.crossover < 0:
            self.close()

if __name__ == "__main__":
    df = pd.read_csv("historical_bars.csv", index_col=0, parse_dates=True)
    
    cerebro = bt.Cerebro()
    feed = bt.feeds.PandasData(dataname=df)
    cerebro.adddata(feed)
    cerebro.addstrategy(BacktraderDualSMA)
    
    cerebro.broker.setcash(10000.0)
    cerebro.broker.setcommission(commission=0.001)  # 0.1%
    
    cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name="sharpe")
    cerebro.addanalyzer(bt.analyzers.DrawDown, _name="drawdown")
    
    results = cerebro.run()
    strat = results[0]
    print(f"Final Value: {cerebro.broker.getvalue():.2f}")
    print(f"Sharpe Ratio: {strat.analyzers.sharpe.get_analysis().get('sharperatio')}")

Implementation 2: backtesting.py

backtesting_py_dual_sma.py
import pandas as pd
from backtesting import Backtest, Strategy
from backtesting.lib import crossover

def SMA(series: pd.Series, period: int) -> pd.Series:
    return pd.Series(series).rolling(period).mean()

class BacktestingPyDualSMA(Strategy):
    fast_period = 10
    slow_period = 30
    trade_size = 100

    def init(self):
        self.fast_sma = self.I(SMA, self.data.Close.s, self.fast_period)
        self.slow_sma = self.I(SMA, self.data.Close.s, self.slow_period)

    def next(self):
        # In backtesting.py: self.position evaluates boolean truthy if active
        if not self.position:
            if crossover(self.fast_sma, self.slow_sma):
                self.buy(size=self.trade_size)
        elif crossover(self.slow_sma, self.fast_sma):
            self.position.close()

if __name__ == "__main__":
    df = pd.read_csv("historical_bars.csv", index_col=0, parse_dates=True)
    # Ensure TitleCase schema
    df = df.rename(columns={"open": "Open", "high": "High", "low": "Low", "close": "Close"})
    
    bt = Backtest(
        df, 
        BacktestingPyDualSMA, 
        cash=10000.0, 
        commission=0.001  # 0.1%
    )
    
    stats = bt.run()
    print(stats[["Start", "End", "Equity Final [$]", "Sharpe Ratio", "Max. Drawdown [%]"]])

Reconciling Execution and Metric Discrepancies

Researchers implementing the exact same strategy on identical data in both frameworks frequently observe differing returns, Sharpe ratios, and drawdowns. These discrepancies stem from subtle divergences in execution models, fee accounting, and statistical formulas.

1. Order fill timing: Next-bar open mechanics

Both frameworks model realistic market order execution by default: an order placed during the decision evaluation of bar t fills at the Open price of bar t+1.

However, discrepancies emerge during edge conditions:

  • Cash and Margin Verification: In Backtrader, when an order is submitted at bar t, Cerebro checks cash sufficiency immediately. At bar t+1, before executing the fill at Open, the broker validates margin again. If the market gapped open higher, exhausting purchasing power, the order may be rejected. In backtesting.py, order sizing checks occur at fill time against remaining cash.
  • Price Slippage Application: Backtrader supports granular slippage configuration via cerebro.broker.set_slippage_perc() or fixed ticks, applying slippage against the open price. In backtesting.py, slippage is modeled via a custom spread or modified execution price.

2. Commission calculation structures

Commission differences alter compounding equity over long backtests:

  • In backtesting.py, passing commission=0.001 strictly deducts 0.1% of the total transaction value (Price \times Quantity \times 0.001) directly from cash balance immediately upon fill.
  • In Backtrader, cerebro.broker.setcommission() defaults to percentage commissions for stocks, but can also represent per-share currency fees or futures margin schedules depending on the commtype, margin, and mult parameters. If parameters are misconfigured, Backtrader may treat 0.001 as $0.001 per unit rather than 0.1%.

3. Indicator warmup and start bar offsets

As demonstrated earlier, Backtrader suppresses strategy execution during the warmup window via prenext(). If the slow SMA has a period of 30, Backtrader executes its first next() call on bar 30.

In backtesting.py, next() runs starting from bar 0. While crossover() will evaluate to False while either array contains NaN, any non-indicator conditions (such as calendar filters, bar counter logic, or unhedged entries) could trigger on bars 1 through 29 in backtesting.py, generating trades that Backtrader would never take.

4. Divergent performance metric formulas

Even when identical trades occur at identical prices, the final reported summary metrics rarely match:

Code
┌────────────────────────┬──────────────────────────────────┬─────────────────────────────────┐
│ Metric Dimension       │ Backtrader (Sharpe Analyzer)     │ backtesting.py (stats output)   │
├────────────────────────┼──────────────────────────────────┼─────────────────────────────────┤
│ Return Basis           │ Periodic returns on bar equity   │ Compounded daily equity returns │
│ Annualization Factor   │ √252 (Days), √52 (Weeks), etc.   │ √252 based on DatetimeIndex     │
│ Risk-Free Rate Subtr.  │ Per-period rate deduction        │ Fixed annualized rate default   │
│ Drawdown Tracking      │ Closed-bar or tick equity peak   │ Peak-to-trough on closed bars   │
└────────────────────────┴──────────────────────────────────┴─────────────────────────────────┘
  • Sharpe Ratio: Backtrader's SharpeRatio analyzer calculates excess periodic returns using either standard or logarithmic returns, scaled by \sqrt&#123;252&#125; for daily data. backtesting.py computes an annualized Sharpe ratio from the daily equity curve using simple returns. A difference in risk-free rate defaults or return frequency creates varying Sharpe values.
  • Drawdown Calculation: Backtrader records DrawDown using high-water mark equity across bars, reporting both maximum drawdown percentage and duration. backtesting.py reports Max. Drawdown [%] as the deepest single peak-to-trough decline across historical closes.

Licensing and Maintenance: AGPL-3.0 vs. GPLv3

Beyond technical mechanics, licensing and software maintenance are critical considerations for quantitative funds, corporate trading desks, and proprietary systems.

Code
┌──────────────────────────────────┐        ┌──────────────────────────────────┐
│          backtesting.py          │        │            Backtrader            │
│             AGPL-3.0             │        │              GPLv3               │
├──────────────────────────────────┤        ├──────────────────────────────────┤
│ • Strong copyleft                │        │ • Traditional copyleft           │
│ • Network use clause (Sect. 13)  │        │ • No network use clause          │
│ • Cloud/API hosting triggers     │        │ • Dormant upstream (2019/2020)   │
│   source disclosure obligations  │        │ • Deprecations on Python 3.12+   │
└──────────────────────────────────┘        └──────────────────────────────────┘

The AGPL-3.0 constraint in backtesting.py

backtesting.py is licensed under the GNU Affero General Public License v3.0 (AGPL-3.0).

  • AGPL-3.0 is a strong copyleft license with a specific network interaction clause (Section 13).
  • If you run backtesting.py within an internal web service, microservice API, or software-as-a-service (SaaS) platform where users or remote systems interact with the service over a network, AGPL-3.0 can trigger legal obligations to make the complete source code of the backend service available to all network users.
  • For proprietary trading desks, quant hedge funds, and fintech startups, AGPL-3.0 often disqualifies backtesting.py from production infrastructure.

The maintenance status of Backtrader and GPLv3

Backtrader is licensed under the GNU General Public License v3.0 (GPLv3). While GPLv3 imposes copyleft requirements upon distribution of software binaries, it does not contain the AGPL network-trigger clause.

However, Backtrader faces a significant maintenance challenge:

  • The official upstream repository (mementum/backtrader) has been largely dormant since 2019–2020.
  • Running vanilla Backtrader on modern Python runtimes (Python 3.12+) triggers deprecation warnings due to internal references to legacy modules (e.g., imp, outdated collections.abc constructs).
  • Built-in charting via cerebro.plot() frequently errors out on modern Matplotlib installations (\ge 3.8) due to deprecated axes and canvas attributes.
  • While community forks (such as backtrader2) address specific bug fixes, the fragmentation leaves quants without an authoritative, actively maintained distribution.

Further Reading: Modern Inspectable Alternatives

As quantitative research workflows adapt to Python 3.12+ environments, engineering teams increasingly prioritize reproducibility, execution determinism, and license compatibility. Modern research platforms seek to eliminate both the metaprogramming opacity of legacy event engines and the legal friction of network copyleft licenses.

For quants evaluating current alternatives, OpenBacktesting (version 0.2.1) provides an Apache-2.0 licensed, single-asset bar-by-bar backtesting framework designed for complete auditability:

  • Permissive Apache-2.0 License: Freely embeddable within internal research pipelines, microservices, and proprietary proprietary trading workflows without copyleft restrictions.
  • Python 3.12+ Native: Built strictly on NumPy, pandas, and pandas-ta-classic, avoiding legacy dependencies and meta-class overhead.
  • Realistic Order Lifecycle Modeling: Precise single-asset simulation supporting Market, Limit, Stop, Stop-Limit, Trailing Stop, and One-Cancels-Other (OCO) order brackets with deterministic fill mechanics.
  • Durable Trial Persistence: Built-in SQLite trial persistence for parameter optimization (grid search and Sequential Adaptive Model-Based Optimization, or SAMBO), ensuring every trial, parameter combination, and execution metric is stored for retrospective analysis.
  • Interactive Local Auditing: Inspect trade-by-trade logs, execution slippage, and portfolio equity paths locally via the browser-based OpenBacktesting Studio.

To explore building complete strategies with event-driven or pandas architectures, review our hands-on tutorials:


Conclusion and summary matrix

Both Backtrader and backtesting.py have made enduring contributions to Python quantitative research. Understanding their design trade-offs helps you select the appropriate tool for your specific research phase.

Head-to-head comparison matrix

Evaluation CriteriaBacktraderbacktesting.py
Primary ArchitectureObject-oriented event-driven engine (Cerebro)Procedural loop over precomputed arrays
Input Data Formatbt.feeds.PandasData / LineSeriesStandard pandas.DataFrame (TitleCase columns)
Data Indexing SyntaxRelative offset (close[0], close[-1])Python slicing (Close[-1], Close[-2])
Indicator Creationbt.indicators computation graphVectorized wrappers via self.I()
Execution TimingNext-bar Open fill (cheat_on_open=False)Next-bar Open fill
Multi-Asset SupportNative multi-feed synchronizationSingle-asset only
Optimization MethodMulti-core Grid Search in memoryExhaustive Grid / Skopt Random Search
Trial PersistenceEphemeral (results held in RAM)Ephemeral (results held in RAM)
VisualizationStatic Matplotlib chart (cerebro.plot())Standalone interactive Bokeh HTML
LicenseGPLv3 (Copyleft on distribution)AGPL-3.0 (Copyleft over network/API)
Modern Python SupportInactive upstream; warnings on Python 3.12+Stable, but pinned Bokeh dependencies

Practical selection guide

  1. Choose backtesting.py if you are conducting exploratory single-asset research, require fast parameter prototyping on daily or intraday OHLCV bars, value interactive Bokeh HTML charts, and operate in an environment where AGPL-3.0 compliance is acceptable.
  2. Choose Backtrader if you maintain existing event-driven strategies built around Cerebro, need native multi-asset data feeds within a single strategy instance, or require granular simulation of order states across custom brokerage observers.
  3. Choose modern inspectable engines like OpenBacktesting if you work in Python 3.12+, require a permissive Apache-2.0 license for proprietary research, need auditable bar-by-bar execution with SQLite optimization persistence, and demand deterministic order mechanics for production-grade strategy validation.