A quantitative trading strategy is only as credible as the backtest that evaluates it. When a backtest produces an alluring Sharpe ratio or exponential equity curve, experienced quants do not celebrate—they audit. More often than not, inflated simulated performance stems from subtle methodological flaws: lookahead bias in indicator computation, unrealistic execution assumptions, ignored transaction friction, or warm-up artifacts.

OpenBacktesting is an open-source, deterministic Python framework designed specifically for single-asset historical OHLCV research. Rather than treating simulation as a black box, it emphasizes inspectability: preserving strict bar-by-bar causality, capturing every order and fill in explicit ledgers, and allowing you to verify every trade before trusting any summary metric.

In this tutorial, you will build and audit an end-to-end quantitative trading strategy in Python using OpenBacktesting v0.2.1. We will cover environment setup, input data validation, strategy lifecycle modeling, transaction friction configuration, and granular trade auditing.

Anatomy of a reliable backtest

A scientifically defensible backtest rests on three independent pillars:

PillarCore requirementCommon failure mode
Data IntegrityMonotonic timestamps, positive prices, clean volume, and explicit timezone context.Unhandled duplicates, survivorship bias, forward-adjusted anomalies, and timezone mismatches.
Causal Strategy LifecycleClear boundary between vectorized precomputation and bar-by-bar evaluation.Lookahead leakage, peeking at future bars, or executing signals on incomplete indicator warm-up periods.
Frictional AccountingRealistic fill timing, bid-ask spread, commissions, and non-zero slippage.Zero-commission assumptions, instantaneous close-to-close fills, and ignoring market liquidity constraints.

In many popular backtesting libraries, order execution defaults to the closing price of the current bar—the very bar that generated the signal. In real trading, however, a signal generated at 16:00:00 cannot execute at 16:00:00; by definition, the closing print has already printed. OpenBacktesting models reality by defaulting to next_open execution: a signal triggered on bar t stages an order that executes on the opening print of bar t+1.

Code
Bar t:      [ Open ─── High ─── Low ─── Close ]  ──>  Strategy evaluates next(), stages Buy Order
                                                                  │
                                                                  ▼
Bar t+1:    [ Open ─── High ─── Low ─── Close ]  <──  Order matches at Open (plus slippage & fee)

[!IMPORTANT] A backtest is an experiment designed to falsify a hypothesis, not to validate an optimistic scenario. Modeling realistic delays and cost friction is the first defense against catastrophic live deployment.

Setting up the environment

OpenBacktesting is built on modern Python standards. It targets Python >=3.12, <3.15, taking advantage of enhanced type annotations, deterministic dictionary ordering, and standard library optimizations.

Core dependencies are lean:

  • NumPy: Low-level array manipulation and numeric primitives.
  • pandas: Tabular market data structures and time series alignment.
  • pandas-ta-classic: Technical analysis indicator kernels without external C-compiler toolchain dependencies.

To set up an isolated environment, initialize a clean Python virtual environment and install OpenBacktesting from source or your package repository:

Terminal: Create and activate virtual environment
# Create a dedicated virtual environment with Python 3.12+
python3 -m venv .venv
source .venv/bin/activate

# Install OpenBacktesting
pip install openbacktesting

Verify your installation and confirm the active engine version:

Verify installation
import openbacktesting as ob

print(f"OpenBacktesting version: {ob.__version__}")
# Output: OpenBacktesting version: 0.2.1

Step 1: Ingesting and validating OHLC data

Every backtest begins with market data. OpenBacktesting enforces strict validation rules on input data to prevent corrupted calculations:

  1. Format: The dataset must be a pandas.DataFrame.
  2. Column Names: Required columns are Open, High, Low, and Close (case-sensitive). Volume is optional; if omitted, it defaults safely to NaN.
  3. Index: The index must be an ordered, monotonic increasing pd.DatetimeIndex or pd.RangeIndex. Duplicate timestamps are rejected.
  4. Values: Prices must be strictly positive, finite numbers (price > 0). Missing values (NaN or None) within OHLC columns must be cleaned before simulation.

Here is how to ingest and validate a raw CSV dataset or prepare standard market data:

data_ingestion.py
import pandas as pd
import numpy as np

def load_and_validate_ohlc(csv_path: str) -> pd.DataFrame:
    """Load, clean, and validate OHLC data for OpenBacktesting."""
    df = pd.read_csv(csv_path, parse_dates=["Date"], index_col="Date")
    
    # Ensure correct column naming
    rename_map = {
        "open": "Open", "high": "High", "low": "Low", 
        "close": "Close", "volume": "Volume"
    }
    df = df.rename(columns=rename_map)
    
    # Sort index chronologically to guarantee monotonic ordering
    df = df.sort_index()
    
    # Drop rows with missing price entries
    df = df.dropna(subset=["Open", "High", "Low", "Close"])
    
    # Validate strictly positive prices
    assert (df[["Open", "High", "Low", "Close"]] > 0).all().all(), "Prices must be positive"
    assert df.index.is_monotonic_increasing, "Index must be monotonically increasing"
    assert df.index.is_unique, "Duplicate timestamps are not allowed"
    
    return df

For quick experimentation and testing, OpenBacktesting provides bundled historical datasets, such as daily GOOG data:

Load bundled sample dataset
from openbacktesting.test import GOOG

print(GOOG.head(3))

Step 2: Defining the strategy lifecycle (init and next)

Trading strategies in OpenBacktesting inherit from the base Strategy class. The framework separates computation into two distinct lifecycle phases:

  1. init(self): Executed once before the backtest begins. This is where you precompute indicators in bulk across the historical series. Precomputation is vectorized and highly efficient.
  2. next(self): Executed sequentially on every single bar (t = 0, 1, \dots, N-1). This is where you inspect current and past indicator values, evaluate entry/exit rules, and submit order instructions.

Registering indicators and indicator handles

Inside init(), indicators are registered via self.ta (which exposes built-in formulas like self.ta.sma(), self.ta.ema(), self.ta.macd()) or custom formulas via self.ind(func, *args). These methods return IndicatorHandle objects.

An IndicatorHandle is not a static scalar; it is a time-aware handle synchronized with the backtest cursor. Inside next():

  • self.close[-1] refers to the close of the current bar.
  • self.close[-2] refers to the close of the prior bar.
  • Attempting to slice forward (e.g. looking ahead) is blocked by the engine cursor, ensuring historical fidelity.

Preventing warm-up trading with .valid checks

A moving average with length 30 cannot produce valid mathematical output on bars 0 through 28. In those bars, the indicator yields NaN. If a strategy attempts to compare numbers against NaN, logic gates fail silently or trigger erroneous trades.

OpenBacktesting handles this via the .valid property on every indicator handle:

Handling indicator warm-up periods
def next(self):
    # Ensure we have sufficient history and valid indicators
    if len(self.close) < 2 or not self.slow_ma.valid[-2:].all():
        return
    
    # Now safe to evaluate crossovers
    if self.ta.is_crossover(self.fast_ma, self.slow_ma):
        self.buy(percent=95)

By verifying self.slow_ma.valid[-2:].all(), you guarantee that both the current bar and the preceding bar contain non-null, fully settled indicator values before evaluating crossover signals.

Step 3: Configuring execution, commissions, and slippage

A profitable theoretical signal often fails in production due to friction. OpenBacktesting allows you to configure exact execution and cost models directly on the Backtest instance.

Sizing and execution parameters

  • cash: Starting capital balance (e.g. $10,000).
  • commission: Transaction fee model. It can be defined as a proportional rate (0.001 = 0.1% of order value), a fixed fee per transaction, or a composite tuple (fixed, rate).
  • slippage: Execution price degradation. Configured via the Slippage class:
    • Slippage("bps", 5.0): 5 basis points (0.05%) slippage per fill.
    • Slippage("fixed", 0.02): $0.02 per share/unit.
  • execution_timing: Set to "next_open" by default. Orders placed during next() at bar t fill at the opening price of bar t+1.
  • end_policy: Controls liquidation at the end of the simulation. Set to "mark_to_market" (open positions evaluated at final market price) or "close_at_last_close" (all open lots are liquidated on the final close, charging commission).
Configuring Backtest execution parameters
from openbacktesting import Backtest
from openbacktesting.broker.execution import Slippage

bt = Backtest(
    data=GOOG,
    strategy=MyStrategy,
    cash=10_000.0,
    commission=0.001,               # 0.1% per fill (10 bps)
    slippage=Slippage("bps", 5.0),  # 5 bps execution slippage
    execution_timing="next_open",   # Realistic next-bar open fills
    end_policy="close_at_last_close",
)

[!NOTE] Slippage is modeled deterministically: buy orders fill slightly higher than the benchmark price, while sell orders fill slightly lower. Slippage will never violate an explicit limit order price.

Step 4: Running the backtest and inspecting results

Calling bt.run() executes the event-driven simulation loop and returns a comprehensive pandas.Series containing over 30 headline performance and risk metrics:

Run simulation and examine metrics
stats = bt.run()
print(stats)

Key output statistics include:

  • Capital & Returns: Start, End, Duration, Return [%], CAGR [%], Buy & Hold Return [%].
  • Risk Metrics: Sharpe Ratio, Sortino Ratio, Calmar Ratio, Volatility (Ann.) [%].
  • Drawdown Profile: Max. Drawdown [%], Avg. Drawdown [%], Max. Drawdown Duration.
  • Trade Statistics: # Trades, Win Rate [%], Profit Factor, Avg. Trade [%], Commissions [$].

Beyond summary statistics, the returned stats object embeds underlying execution ledgers accessible via private attributes:

  • stats._trades: A DataFrame documenting every closed trade (entry time, exit time, entry price, exit price, PnL, duration, and fees).
  • stats._equity_curve: A bar-by-bar record of portfolio equity and drawdown.
  • stats._fills: An immutable log of individual order fills and matching prices.

Complete, reproducible code example

The following self-contained script implements a Dual Moving Average Crossover strategy (10-bar SMA vs 30-bar SMA) on historical GOOG daily data. It sets up 0.1% commission, 5 bps slippage, validates indicator warm-up periods, and prints both headline statistics and the trade log.

sma_crossover_backtest.py
"""Reproducible Dual Moving Average Crossover backtest in OpenBacktesting."""

import pandas as pd
from openbacktesting import Backtest, Strategy
from openbacktesting.broker.execution import Slippage
from openbacktesting.test import GOOG


class DualSmaCross(Strategy):
    # Strategy hyperparameters
    fast_period = 10
    slow_period = 30

    def init(self):
        """Precompute technical indicators across the dataset."""
        self.fast_ma = self.ta.sma(self.close, length=self.fast_period)
        self.slow_ma = self.ta.sma(self.close, length=self.slow_period)

    def next(self):
        """Evaluate strategy logic bar-by-bar."""
        # 1. Indicator warm-up check: Ensure slow MA has at least 2 valid historical bars
        if len(self.close) < 2 or not self.slow_ma.valid[-2:].all():
            return

        # 2. Bullish Crossover: Fast MA crosses above Slow MA
        if self.ta.is_crossover(self.fast_ma, self.slow_ma):
            if self.position.is_short:
                self.position.close()
            if not self.position.is_long:
                # Allocate 95% of available portfolio equity
                self.buy(percent=95)

        # 3. Bearish Crossunder: Fast MA crosses below Slow MA
        elif self.ta.is_crossunder(self.fast_ma, self.slow_ma):
            if self.position.is_long:
                self.position.close()


if __name__ == "__main__":
    # Slice the first 500 daily bars for this demonstration
    data = GOOG.iloc[:500]

    # Initialize the backtest engine
    bt = Backtest(
        data=data,
        strategy=DualSmaCross,
        cash=10_000.0,
        commission=0.001,                # 0.1% commission rate
        slippage=Slippage("bps", 5.0),   # 5 basis points slippage
        execution_timing="next_open",    # Causal next-bar execution
        end_policy="close_at_last_close" # Liquidate at end of test
    )

    # Execute simulation
    results = bt.run()

    # Display headline performance
    print("=" * 50)
    print("STRATEGY PERFORMANCE SUMMARY")
    print("=" * 50)
    summary_metrics = [
        "Start", "End", "Duration", "Return [%]", "Buy & Hold Return [%]",
        "Sharpe Ratio", "Sortino Ratio", "Max. Drawdown [%]",
        "# Trades", "Win Rate [%]", "Profit Factor", "Commissions [$]"
    ]
    print(results[summary_metrics])

    # Inspect closed trades ledger
    trades_df = results._trades
    print("\n" + "=" * 50)
    print(f"CLOSED TRADES AUDIT (Total: {len(trades_df)})")
    print("=" * 50)
    if not trades_df.empty:
        display_cols = ["Size", "EntryBar", "ExitBar", "EntryPrice", "ExitPrice", "PnL", "ReturnPct"]
        # Print first few trades
        print(trades_df[display_cols].head())

Run this script directly in your Python environment:

Execute the backtest
python sma_crossover_backtest.py

Auditing your trades: Beyond the final return

Headline metrics such as final return and Sharpe ratio can easily mislead. A strategy with a +80% return might owe its entire success to a single outlier trade while suffering from poor execution efficiency or crippling fee drag across dozens of losing trades.

An authentic quant audit inspects the underlying trade distribution:

audit_trades.py
from openbacktesting.portfolio import export_result

# 1. Inspect trade-level duration and payoff ratio
trades = results._trades
if not trades.empty:
    winning_trades = trades[trades["PnL"] > 0]
    losing_trades = trades[trades["PnL"] < 0]
    
    avg_win = winning_trades["PnL"].mean() if not winning_trades.empty else 0.0
    avg_loss = abs(losing_trades["PnL"].mean()) if not losing_trades.empty else 0.0
    payoff_ratio = (avg_win / avg_loss) if avg_loss > 0 else float("inf")
    
    print(f"Average Win: ${avg_win:.2f}")
    print(f"Average Loss: ${avg_loss:.2f}")
    print(f"Payoff Ratio: {payoff_ratio:.2f}")
    print(f"Total Commission Paid: ${results['Commissions [$]']:.2f}")

# 2. Export an immutable, standalone Portfolio JSON report
report = export_result(results)
print(f"Exported Portfolio Report schema: {report['schemaVersion']}")

The immutable Portfolio report

The export_result() function generates a standalone, portable dictionary containing complete execution facts:

  • facts: Raw trade ledgers, fill sequences, and event timestamps.
  • summary: Partitioned performance breakdowns across all trades, long-only trades, and short-only trades.
  • series: Complete time series records of equity, cash, exposure, and drawdown.

Because this report contains no unpickled Python functions or active database handles, it serves as an immutable artifact for audit trails, peer review, and visualization in OpenBacktesting Studio.

Writing a reliable backtest is an iterative discipline of tightening assumptions and verifying causality. Now that you have built a working, inspectable backtest, continue your research with these resources: