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:
| Pillar | Core requirement | Common failure mode |
|---|---|---|
| Data Integrity | Monotonic timestamps, positive prices, clean volume, and explicit timezone context. | Unhandled duplicates, survivorship bias, forward-adjusted anomalies, and timezone mismatches. |
| Causal Strategy Lifecycle | Clear 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 Accounting | Realistic 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.
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:
# Create a dedicated virtual environment with Python 3.12+
python3 -m venv .venv
source .venv/bin/activate
# Install OpenBacktesting
pip install openbacktestingVerify your installation and confirm the active engine version:
import openbacktesting as ob
print(f"OpenBacktesting version: {ob.__version__}")
# Output: OpenBacktesting version: 0.2.1Step 1: Ingesting and validating OHLC data
Every backtest begins with market data. OpenBacktesting enforces strict validation rules on input data to prevent corrupted calculations:
- Format: The dataset must be a
pandas.DataFrame. - Column Names: Required columns are
Open,High,Low, andClose(case-sensitive).Volumeis optional; if omitted, it defaults safely toNaN. - Index: The index must be an ordered, monotonic increasing
pd.DatetimeIndexorpd.RangeIndex. Duplicate timestamps are rejected. - Values: Prices must be strictly positive, finite numbers (
price > 0). Missing values (NaNorNone) within OHLC columns must be cleaned before simulation.
Here is how to ingest and validate a raw CSV dataset or prepare standard market data:
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 dfFor quick experimentation and testing, OpenBacktesting provides bundled historical datasets, such as daily GOOG data:
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:
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.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:
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 theSlippageclass: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 duringnext()at bartfill at the opening price of bart+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).
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:
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.
"""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:
python sma_crossover_backtest.pyAuditing 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:
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.
Next steps and documentation links
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:
- Quickstart Guide: Get familiar with CLI commands and workspace structures in the OpenBacktesting Quickstart Documentation.
- Mitigating Bias: Learn how subtle data leakages ruin algorithmic strategies in our deep-dive on Avoiding Lookahead Bias in Backtesting.
- Research Principles: Explore how inspectable evidence separates signal from noise in Inspectable Strategy Research.
