The design philosophy of backtesting.py: Clean, pandas-native research

In quantitative trading research, heavy event-driven backtesting frameworks often impose substantial operational friction. Configuring custom feed classes, managing line buffers, and writing hundreds of lines of boilerplate can slow exploratory research on single instruments.

backtesting.py was designed to streamline this process. It prioritizes three practical mechanics:

  1. Pandas-native inputs: Backtests consume standard pandas.DataFrame objects directly without bespoke data wrappers or feed adapters.
  2. Minimalist Strategy interface: Strategy logic requires only two lifecycle methods: init() for one-time indicator precomputation and next() for bar-by-bar decision logic.
  3. Interactive Bokeh visualizations: Simulation runs produce self-contained HTML charts with zoomable candlesticks, indicator overlays, volume bars, and individual trade markers.
Code
┌────────────────────────────────────────────────────────┐
│               Standard pandas DataFrame                │
│             [Open, High, Low, Close, Volume]           │
└───────────────────────────┬────────────────────────────┘
                            │
                            ▼
┌────────────────────────────────────────────────────────┐
│                   Strategy Lifecycle                   │
│   ├── init(): Vectorized indicator wrapping via I()    │
│   └── next(): Bar-by-bar procedural order dispatch     │
└───────────────────────────┬────────────────────────────┘
                            │
                            ▼
┌────────────────────────────────────────────────────────┐
│                   Backtest Execution                   │
│   ├── Next-bar open fill matching                      │
│   ├── Cash, position, and commission tracking          │
│   └── Output: pandas.Series statistics + Bokeh HTML    │
└────────────────────────────────────────────────────────┘

The engine focuses exclusively on single-asset historical OHLCV simulation. It does not attempt multi-asset portfolio accounting or high-frequency order matching. Instead, it offers an approachable environment for testing technical strategies on daily or intraday bars.


Data requirements and the I() indicator wrapper

Working effectively with backtesting.py requires adhering to its input schema and understanding how indicator calculations are vectorized through self.I().

Strict column formatting requirements

backtesting.py expects historical price bars structured as a pandas DataFrame with specific conventions:

  • The index must be a sorted, monotonic DatetimeIndex.
  • Price columns must use exact Title Case: Open, High, Low, Close, and optionally Volume.
  • Values must be numeric floats without unhandled NaN entries in price fields.

Passing lowercase column names (e.g., open, close) triggers a runtime ValueError. You can normalize raw data before testing:

Validate and format OHLCV data
import pandas as pd

def prepare_ohlcv(df: pd.DataFrame) -> pd.DataFrame:
    """Normalize column names and enforce a monotonic DatetimeIndex."""
    data = df.copy()
    mapping = {c: c.capitalize() for c in data.columns if c.lower() in ["open", "high", "low", "close", "volume"]}
    data = data.rename(columns=mapping)

    required = ["Open", "High", "Low", "Close"]
    for col in required:
        if col not in data.columns:
            raise KeyError(f"Required column '{col}' missing from DataFrame.")

    if not isinstance(data.index, pd.DatetimeIndex):
        data.index = pd.to_datetime(data.index)
    
    return data.sort_index().dropna(subset=required)

The self.I() indicator wrapper

Recomputing technical indicators inside the iterative next() loop is computationally inefficient. Conversely, computing indicators outside the strategy risks lookahead leakage if series slicing is mismanaged.

backtesting.py solves this via self.I(). Declared inside Strategy.init(), self.I() precomputes indicators across the full historical series once using vectorized operations, then manages causal, lookahead-free indexing during next().

Wrapping indicators with self.I()
def SMA(series: pd.Series, period: int) -> pd.Series:
    """Simple moving average helper."""
    return pd.Series(series).rolling(period).mean()

# Inside Strategy.init():
# self.fast_ma = self.I(SMA, self.data.Close.s, 10)
# self.slow_ma = self.I(SMA, self.data.Close.s, 30)

[!NOTE] Within Strategy.next(), indicators registered through self.I() expose sliding-array indexing where [-1] returns the current bar's indicator value and [-2] retrieves the prior bar. This preserves historical causality while maintaining vectorized execution speeds.


Building a complete strategy: Moving Average Crossover

Here is an end-to-end moving average crossover strategy with ATR-based stop-loss placement and fractional position sizing.

Strategy lifecycle and execution mechanics

By default, backtesting.py implements causal execution timing:

  • Signals are evaluated on bar t closing prices during next().
  • Orders staged during next() execute on the open of bar t+1 (trade_on_close=False).
  • Setting trade_on_close=True fills orders immediately at bar t close, which can introduce lookahead bias unless specifically simulating market-on-close orders.
sma_crossover.py: Complete backtesting.py strategy
import numpy as np
import pandas as pd
from backtesting import Backtest, Strategy
from backtesting.lib import crossover
from backtesting.test import GOOG

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

def calculate_atr(high: pd.Series, low: pd.Series, close: pd.Series, period: int = 14) -> pd.Series:
    tr1 = high - low
    tr2 = (high - close.shift(1)).abs()
    tr3 = (low - close.shift(1)).abs()
    tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1)
    return tr.rolling(period).mean()

class SmaCrossWithAtrStop(Strategy):
    n_fast = 10
    n_slow = 30
    atr_period = 14
    atr_mult = 2.0

    def init(self):
        # Register indicators with self.I()
        price = self.data.Close.s
        self.sma_fast = self.I(calculate_sma, price, self.n_fast)
        self.sma_slow = self.I(calculate_sma, price, self.n_slow)
        self.atr = self.I(calculate_atr, self.data.High.s, self.data.Low.s, price, self.atr_period)

    def next(self):
        # Skip bars until indicators finish warming up
        if np.isnan(self.sma_slow[-1]) or np.isnan(self.atr[-1]):
            return

        current_close = self.data.Close[-1]
        atr_val = self.atr[-1]

        # Entry logic: fast MA crosses above slow MA
        if crossover(self.sma_fast, self.sma_slow):
            if self.position.is_short:
                self.position.close()
            if not self.position:
                sl_price = current_close - (atr_val * self.atr_mult)
                # Fractional sizing: 0.95 allocates 95% of available portfolio equity
                self.buy(size=0.95, sl=sl_price)

        # Exit logic: fast MA crosses below slow MA
        elif crossover(self.slow_ma, self.fast_ma):
            if self.position.is_long:
                self.position.close()

Position sizing conventions

In self.buy() and self.sell():

  • Values between 0 and 1 (exclusive): Represents a fraction of total equity (e.g., size=0.95 uses 95% of equity).
  • Values \ge 1: Interpreted as an exact integer quantity of units or shares (e.g., size=100).

Running Backtest.run() and interpreting the output Series

To run the simulation, pass the formatted DataFrame and strategy class to Backtest, set starting cash and commissions, and call .run().

Run backtest and view diagnostics
bt = Backtest(
    GOOG,
    SmaCrossWithAtrStop,
    cash=10_000,
    commission=0.002,      # 0.2% commission per trade
    trade_on_close=False   # Next-bar Open fills
)

stats = bt.run()
print(stats)

Interpreting key performance metrics

bt.run() returns a pandas.Series containing standard portfolio attribution statistics:

MetricOutput FormatQuantitative Meaning
Return [%]PercentageCumulative total strategy return over the backtest duration.
Buy & Hold Return [%]PercentageBenchmark return from holding the asset from start to finish.
Max. Drawdown [%]PercentagePeak-to-trough decline in portfolio equity.
Sharpe RatioFloatAnnualized excess return divided by annualized return volatility.
Win Rate [%]PercentagePercentage of closed trades that produced positive net PnL.
Profit FactorFloatRatio of gross winning profits to gross losing losses (> 1.0 indicates profitability).
# TradesIntegerTotal count of closed round-trip trades.

Inspecting closed trades and generating charts

To audit individual trades, inspect the trade log stored in stats._trades:

Examine trade log and render Bokeh plot
# View individual trade fills
print(stats._trades[["EntryTime", "ExitTime", "EntryPrice", "ExitPrice", "PnL", "ReturnPct"]].head())

# Render standalone interactive chart
bt.plot(filename="crossover_report.html", open_browser=False)

The resulting HTML file provides zoomable candlestick series, volume overlays, entry/exit arrows, and equity curves.


Parameter optimization and heatmaps

backtesting.py includes a built-in grid search optimizer via Backtest.optimize(). It evaluates parameter combinations across multiple CPU cores using Python's standard multiprocessing.

Parameter optimization with constraint
stats_opt, heatmap = bt.optimize(
    n_fast=range(5, 25, 5),
    n_slow=range(20, 60, 10),
    atr_mult=[1.5, 2.0, 2.5],
    maximize="Sharpe Ratio",
    constraint=lambda p: p.n_fast < p.n_slow,
    max_tries=100,
    return_heatmap=True
)

print("Best Sharpe Ratio:", stats_opt["Sharpe Ratio"])
print("Optimal Configuration:\n", stats_opt._strategy)

Visualizing parameter sensitivity

When return_heatmap=True is enabled, the method returns a multi-index Series containing the objective metric across trials. Unstacking this Series highlights the stability of the parameter space:

Parameter heatmap extraction
heatmap_2d = heatmap.groupby(["n_fast", "n_slow"]).mean().unstack()
print(heatmap_2d)
Code
n_slow         20        30        40        50
n_fast                                         
5        0.421503  0.584102  0.612091  0.540122
10            NaN  0.710344  0.684511  0.631209
15            NaN       NaN  0.590123  0.562301
20            NaN       NaN       NaN  0.481230

[!WARNING]

Mitigating in-sample curve fitting

Maximizing returns across dense parameter grids frequently produces overfitting. An isolated performance peak surrounded by deteriorating metrics is typically a statistical artifact of market noise. Target broad parameter plateaus where adjacent settings produce consistent risk-adjusted returns, and always validate selections on holdout out-of-sample data.


License and architecture considerations: backtesting.py vs. OpenBacktesting

Selecting backtesting software requires considering legal terms and system architecture alongside API convenience. backtesting.py and OpenBacktesting are independent projects with distinct engineering objectives.

Code
┌─────────────────────────────────┐       ┌─────────────────────────────────┐
│         backtesting.py          │       │         OpenBacktesting         │
├─────────────────────────────────┤       ├─────────────────────────────────┤
│ • License: GNU AGPL-3.0         │       │ • License: Apache-2.0           │
│ • Focus: Single-script research │       │ • Focus: Inspectable audit      │
│ • Interactive Bokeh HTML plots  │       │ • Pluggable execution models    │
│ • Ephemeral in-memory trials    │       │ • SQLite trial persistence      │
│ • Single-asset OHLCV modeling   │       │ • Deterministic bar-by-bar      │
└─────────────────────────────────┘       └─────────────────────────────────┘

Licensing model: Copyleft vs. Permissive

  • backtesting.py (GNU AGPL-3.0): The Affero General Public License is a strong copyleft license. If you modify or embed backtesting.py within software made available over a network (such as a proprietary research server or web-based quant platform), the AGPL requires releasing the complete source code under the same license. For commercial hedge funds and proprietary desks, this often requires legal review.
  • OpenBacktesting (Apache-2.0): The Apache license is commercially permissive. Firms and individual quants can run proprietary strategies, integrate custom execution pipelines, or build closed-source applications without copyleft disclosure mandates.

Architecture and feature comparison

Architectural Dimensionbacktesting.py (v0.3.x)OpenBacktesting (v0.2.1)
Primary FocusRapid single-asset prototyping & Bokeh chartsDeterministic, inspectable strategy auditing
Open Source LicenseGNU AGPL-3.0 (Copyleft)Apache-2.0 (Permissive)
Python RuntimePython 3.7+Python >=3.12, <3.15
Core DependenciesNumPy, pandas, BokehNumPy, pandas, pandas-ta-classic
Supported Order TypesMarket, Limit, StopMarket, Limit, Stop, Stop-Limit, Trailing Stop, OCO
Indicator EcosystemUser-defined functions wrapped via self.I()Built-in pandas-ta-classic catalog + custom functions
Cost & SlippageProportional commission percentagePluggable percentage, fixed-fee, and explicit price slippage
Parameter SearchMultiprocessing Grid & skoptGrid Search and SAMBO (Bayesian Optimization)
Persistence & AuditIn-memory Series and static Bokeh HTMLDurable SQLite trial storage, JSON/CSV exports, local Studio
Live ExecutionNone (Historical simulation only)None (Historical simulation only)

Neither library provides live broker connectivity, low-latency execution, or real-time data feeds; both are strictly offline simulation engines. Furthermore, they are independent codebases without 1-to-1 code compatibility or direct migration tools.


Strategy audit checklist

Before relying on backtest metrics for any strategy, verify the following experimental conditions:

  • Execution timing: Ensure orders stage at bar t close and execute on bar t+1 open (trade_on_close=False).
  • Data monotonicity: Check that timestamps advance strictly forward without duplicate rows or missing price columns.
  • Indicator warm-up: Verify that trading logic suppresses execution while indicator lookbacks contain NaN values.
  • Friction modeling: Include realistic commission fees (e.g., 0.1%–0.2%) and slippage penalties.
  • Parameter robustness: Confirm that optimal settings sit on smooth parameter plateaus rather than isolated spikes.
  • Out-of-sample testing: Evaluate the strategy on unseen historical periods before drawing conclusions.
  • License alignment: Verify that the framework license fits your operational and commercial deployment requirements.

Conclusion and resources

backtesting.py offers a streamlined, pandas-native workflow for single-asset technical research. Its clean init() and next() lifecycle, vectorized indicator wrapper self.I(), and interactive Bokeh charts make it well suited for rapid strategy prototyping.

When your research requires deeper execution modeling—such as multi-order OCO brackets, trailing stops, persistent SQLite trial databases, and permissive Apache-2.0 licensing—modern frameworks like OpenBacktesting offer complementary, inspectable architectures.

Explore related backtesting guides and documentation: