Visual inspection: The role of plotting in strategy verification
Quantitative research often emphasizes aggregated summary statistics—Sharpe ratio, maximum drawdown, Calmar ratio, and profit factor. However, evaluating a trading strategy solely through scalar metrics introduces acute blind spots. A strategy can display an attractive 1.8 Sharpe ratio while concealing severe structural flaws: orders executing on unintended bars, stop-losses filling at unrealistic prices during market gaps, indicators lagging behind sharp price regimes, or portfolio cash dropping into negative territory due to uncontrolled sizing.
Visual inspection via price charting bridges the gap between tabular trade logs and raw market mechanics. A comprehensive strategy plot renders four critical simulation layers simultaneously on a synchronized time axis:
- Trade Execution Markers: Direct visual verification of where buy and sell orders entered and exited the market (rendered as green upward triangles and red downward triangles), along with connection lines indicating holding periods and realized profit/loss.
- Indicator Trajectories: Overlay indicators (such as moving averages and Bollinger Bands) plotted directly against price action, and oscillator subplots (such as RSI, MACD, or ATR) showing exact threshold crossings.
- Portfolio Equity and Cash Curves: Continuous monitoring of total portfolio value alongside unallocated cash reserves, revealing leverage spikes, idle capital drag, and drawdown duration.
- Volume and Liquidity Dynamics: Trading volume bars aligned under price candles to verify whether fills coincided with realistic market turnover.
flowchart TD
subgraph Engine ["Backtrader Engine (Cerebro)"]
Data["OHLCV Price Feed"]
Strat["Strategy Logic (Lines & Indicators)"]
Broker["Simulated Broker (Orders, Trades, Cash)"]
end
subgraph PlottingLayer ["Plotting Subsystem (cerebro.plot)"]
Canvas["Matplotlib Figure Generator"]
PricePane["Main Price Pane (Candles + Overlays + Trade Markers)"]
VolPane["Volume Pane (Volume Bars)"]
IndPane["Indicator Subplots (RSI, MACD)"]
EquityPane["Portfolio Equity & Cash Observer"]
end
subgraph OutputTargets ["Rendering Targets"]
GUI["Interactive GUI Window (TkAgg / Qt5Agg)"]
Disk["Headless Image Export (PNG / SVG via Agg)"]
Notebook["Jupyter Notebook (Inline Display)"]
end
Data --> Canvas
Strat --> Canvas
Broker --> Canvas
Canvas --> EquityPane
Canvas --> PricePane
Canvas --> VolPane
Canvas --> IndPane
PricePane & VolPane & IndPane & EquityPane --> GUI
PricePane & VolPane & IndPane & EquityPane --> Disk
PricePane & VolPane & IndPane & EquityPane --> NotebookIn Backtrader, the primary entry point for visual verification is cerebro.plot(). While convenient for local prototyping, plotting in Backtrader frequently frustrates quantitative engineers due to legacy Matplotlib dependencies, headless server crashes, and cluttered multi-pane layouts. Understanding the mechanics of cerebro.plot() allows you to diagnose missing figures and tailor charts for systematic audits.
How cerebro.plot() works
When you call cerebro.plot() after cerebro.run(), Backtrader iterates through its internal hierarchy of line series—data feeds, indicators, and observers—and constructs one or more Matplotlib Figure objects.
Core parameters of cerebro.plot()
cerebro.plot(
plotter=None, # Custom Plotter instance; defaults to bt.plot.Plot()
numfigs=1, # Number of figures to partition the time series into
iplot=True, # Enable Jupyter Notebook inline plotting if detected
start=None, # Slice start date/index
end=None, # Slice end date/index
width=16, # Width of the generated figure in inches
height=9, # Height of the generated figure in inches
dpi=300, # Dots per inch resolution
tight=True, # Apply tight layout to prevent label clipping
use=None, # Matplotlib backend override
style='candlestick', # Price bar style: 'candlestick', 'bar', 'line', 'ohlc'
barup='green', # Bullish candle color
bardown='red', # Bearish candle color
barupfill=True, # Whether bullish candle bodies are filled
bardownfill=True, # Whether bearish candle bodies are filled
volume=True, # Plot volume bars
voloverlay=True, # Overlay volume on the price chart or give it a subplot
volup='green', # Bullish volume bar color
voldown='red', # Bearish volume bar color
subtxt=True # Show indicator parameter subtitles in legends
)Visual layer assignment: Main pane vs. subplots
Backtrader organizes lines onto Matplotlib subplots based on declarations embedded in each object's plotinfo configuration:
| Layer / Object | Default Position | Configuration Override | Description |
|---|---|---|---|
Broker Observers (CashValue, Trades) | Top Subplots | subplot=True | Tracks continuous cash, portfolio valuation, and win/loss trade markers. |
| Price Data Feeds | Primary Chart | Main Axis | Candlestick or OHLC bars representing historical market quotes. |
| Overlay Indicators (SMA, EMA, Bollinger) | Primary Chart | subplot=False | Rendered directly on top of price candles sharing the same Y-axis scale. |
| Oscillators (RSI, MACD, Stochastic) | Bottom Subplots | subplot=True | Rendered in dedicated axes below the price chart with independent scales. |
| Volume | Overlay or Subplot | voloverlay=True/False | Rendered at the bottom of the price pane or separated into an isolated subplot. |
Return value of cerebro.plot()
A common misconception is that cerebro.plot() merely opens an OS window. In reality, cerebro.plot() returns a nested Python list:
figs = cerebro.plot()
# figs is a list of lists of Matplotlib Figure objects:
# [[fig_data0_chunk0, fig_data0_chunk1, ...], [fig_data1_chunk0, ...]]When single-asset backtests run with numfigs=1, the return value is [[<matplotlib.figure.Figure>]]. Capturing this object allows direct manipulation, such as saving high-resolution vector images directly to disk without invoking GUI display servers.
Common plotting failures and troubleshooting
Deploying Backtrader scripts in automated pipelines, cloud virtual machines, Docker containers, or modern Python environments frequently triggers plotting errors. Here are the four most frequent failure modes and their exact technical solutions.
1. Headless server and SSH crashes (Agg backend error)
The Symptom
When executing a script on a remote server via SSH, an AWS EC2 instance, or inside a Docker container without an active X11 display server, cerebro.plot() fails with one of two errors:
UserWarning: Matplotlib is currently using agg, which is a non-GUI backend, and cannot show the figure.or
_tkinter.TclError: no display name and no $DISPLAY environment variableThe Root Cause
By default, Backtrader invokes plt.show() internally, which requests an interactive GUI backend (such as TkAgg, Qt5Agg, or MacOSX). In a headless environment, no display manager exists, causing the GUI window initialization to crash or silently skip rendering.
The Fix
Force Matplotlib to use the headless Agg (Anti-Grain Geometry) backend before importing pyplot or running Backtrader, and pass iplot=False to cerebro.plot(). Then, save the resulting figure objects directly to image files on disk:
import matplotlib
matplotlib.use('Agg') # Must be set before importing matplotlib.pyplot or running cerebro
import backtrader as bt
import matplotlib.pyplot as plt
# ... configure and run cerebro ...
results = cerebro.run()
# Generate figure objects without attempting GUI display
figs = cerebro.plot(iplot=False, style='candlestick', numfigs=1)
# Iterate through returned figures and save to disk
for data_idx, fig_list in enumerate(figs):
for chunk_idx, fig in enumerate(fig_list):
output_filename = f"backtest_plot_data{data_idx}_part{chunk_idx}.png"
fig.savefig(output_filename, dpi=300, bbox_inches='tight')
plt.close(fig) # Release Matplotlib memory[!IMPORTANT] Always call
plt.close(fig)after saving figures in automated loops. If you execute multiple backtests or parameter evaluations without closing figures, Matplotlib retains all canvas buffers in memory, triggering memory leaks.
2. Jupyter Notebook display issues
The Symptom
In Jupyter Notebook or JupyterLab, calling cerebro.plot() produces one of the following issues:
- Blank output with no error message.
- A tiny, squished image embedded in the notebook cell.
- Two identical figures rendered consecutively.
The Root Cause
Backtrader contains an internal check (iplot=True) that attempts to automatically detect IPython environments and invoke JavaScript rendering. However, modern JupyterLab installations deprecate legacy inline hooks or conflict with the %matplotlib inline and %matplotlib widget magic commands.
The Fix
Disable Backtrader's automatic inline mechanism by setting iplot=False, and explicitly render the returned Matplotlib figure using IPython's display system:
%matplotlib inline
import matplotlib
import matplotlib.pyplot as plt
import IPython.display as display
import backtrader as bt
# Run strategy
cerebro.run()
# Generate figure with iplot disabled
figs = cerebro.plot(iplot=False, style='candlestick', width=14, height=7, dpi=150)
# Explicitly display the figure in the notebook cell
for fig_list in figs:
for fig in fig_list:
display.display(fig)
plt.close(fig)3. Matplotlib version incompatibilities with legacy Backtrader code
The Symptom
Running cerebro.plot() on Python environments with modern Matplotlib (\ge 3.5) frequently throws internal framework errors:
AttributeError: module 'matplotlib.dates' has no attribute 'warnings'or
ValueError: Date locator has no intervalor
TypeError: float() argument must be a string or a real number, not 'datetime.date'The Root Cause
Backtrader's plotting engine was architected around Matplotlib 1.4–2.2 and legacy matplotlib.finance structures. When Matplotlib deprecated older date locators (YearLocator, DayLocator), date formatters, and private submodules, Backtrader's bundled internal plotting code (backtrader.plot.locator, backtrader.plot.finance) broke.
The Fix
Depending on your project constraints, choose one of three mitigation strategies:
- Virtual Environment Pinning: If you maintain legacy Backtrader codebases, pin Matplotlib to a compatible release:
Code pip install "matplotlib>=3.2.2,<3.5.0" - Community Fork Patches: If running Python 3.10+, use actively maintained community forks that patch locator internals (such as
backtrader2or specific community patched builds). - Decoupled Metric Export: In modern quant stacks, avoid using framework-level Matplotlib wrappers altogether. Instead, export the strategy's trades, equity curve, and OHLCV bars into standard pandas DataFrames and visualize them using dedicated charting engines.
4. Overlapping subplots and squished volume bars
The Symptom
When backtesting over multi-year periods (e.g., 5 years of daily bars or 6 months of 5-minute bars), cerebro.plot() condenses thousands of bars onto a single canvas. Individual candlesticks turn into illegible black vertical lines, indicator labels collide, and the volume panel shrinks into an unreadable band at the bottom.
The Fix: Time series chunking with numfigs
Use the numfigs parameter to partition the backtest across multiple sequential figures. Backtrader splits the chronological data into evenly distributed windows:
# Split the backtest across 3 consecutive figures (~500 bars each)
figs = cerebro.plot(
numfigs=3,
style='candlestick',
width=16,
height=9,
dpi=200,
voloverlay=False # Place volume in its own subplot to avoid price overlap
)
for idx, fig in enumerate(figs[0]):
fig.savefig(f"strategy_segment_{idx + 1}.png", bbox_inches='tight')Customizing indicator appearance with plotinfo and plotlines
Every indicator and observer in Backtrader inherits from bt.LineSeries and contains two configuration dictionaries: plotinfo (controlling the container and axis behavior) and plotlines (controlling the visual styling of individual lines).
Container styling with plotinfo
plotinfo controls whether an indicator appears in the main price axis or a secondary subplot:
class CustomMovingAverage(bt.Indicator):
lines = ('sma',)
params = (('period', 20),)
plotinfo = dict(
plot=True, # If False, hides this indicator from plotting
subplot=False, # False = overlay on price chart; True = dedicated subplot
plotname='SMA 20', # Legend label
plotabove=False, # If subplot=True, whether to position above price chart
plotlinelabels=True, # Print numerical value in axis margin
plotlinevalues=True, # Print current value in legend
plotdist=0.05, # Relative distance to adjacent subplot
plotymargin=0.1 # Top and bottom margin padding inside the subplot
)Visual styling with plotlines
plotlines defines line colors, widths, styles, and rendering methods for each line defined in the indicator:
class CustomBollingerBands(bt.Indicator):
lines = ('top', 'mid', 'bot')
params = (('period', 20), ('dev', 2.0))
plotinfo = dict(subplot=False)
plotlines = dict(
top=dict(
color='#2962FF', # Hex color code
ls='--', # Line style: '-', '--', '-.', ':'
lw=1.2, # Line width in points
alpha=0.8 # Opacity
),
mid=dict(
color='#FF6D00',
ls='-',
lw=1.5
),
bot=dict(
color='#2962FF',
ls='--',
lw=1.2,
alpha=0.8
)
)Dynamically modifying parameters on existing indicators
You do not need to create custom classes just to restyle built-in indicators. You can modify plotinfo and plotlines on instantiated objects directly inside your strategy's __init__ method:
class TrendStrategy(bt.Strategy):
def __init__(self):
# 1. Overlay fast EMA with custom blue dashed line
self.fast_ema = bt.indicators.EMA(self.data.close, period=12)
self.fast_ema.plotinfo.plotname = "Fast EMA (12)"
self.fast_ema.plotlines.ema.color = "#0052FF"
self.fast_ema.plotlines.ema.ls = "--"
self.fast_ema.plotlines.ema.lw = 1.5
# 2. Add RSI in a subplot, but omit overbought/oversold line rendering
self.rsi = bt.indicators.RSI(self.data.close, period=14)
self.rsi.plotinfo.subplot = True
self.rsi.plotinfo.plotname = "Relative Strength (14)"
self.rsi.plotlines.rsi.color = "#9C27B0"
self.rsi.plotlines.rsi.lw = 1.8Complete runnable code example: A robust headless plotting script
The following production-ready Python script demonstrates how to run a complete Backtrader backtest and safely export publication-quality strategy figures to disk in a headless environment. It uses reproducible synthetic OHLCV data to eliminate external network and file dependencies.
import os
import datetime
import numpy as np
import pandas as pd
# Step 1: Force Matplotlib to use the non-interactive Agg backend
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import backtrader as bt
class DualMaRsiStrategy(bt.Strategy):
"""
Moving Average crossover strategy with RSI filter,
configured with custom plot styling.
"""
params = (
('fast_period', 10),
('slow_period', 30),
('rsi_period', 14),
)
def __init__(self):
# Fast Moving Average (Overlay on Price)
self.fast_ma = bt.indicators.SMA(
self.data.close,
period=self.params.fast_period
)
self.fast_ma.plotinfo.plotname = f"Fast SMA ({self.params.fast_period})"
self.fast_ma.plotlines.sma.color = "#2962FF"
self.fast_ma.plotlines.sma.lw = 1.5
# Slow Moving Average (Overlay on Price)
self.slow_ma = bt.indicators.SMA(
self.data.close,
period=self.params.slow_period
)
self.slow_ma.plotinfo.plotname = f"Slow SMA ({self.params.slow_period})"
self.slow_ma.plotlines.sma.color = "#FF6D00"
self.slow_ma.plotlines.sma.lw = 1.8
# RSI Oscillator (Standalone Subplot)
self.rsi = bt.indicators.RSI(
self.data.close,
period=self.params.rsi_period
)
self.rsi.plotinfo.subplot = True
self.rsi.plotinfo.plotname = f"RSI ({self.params.rsi_period})"
self.rsi.plotlines.rsi.color = "#8E24AA"
self.rsi.plotlines.rsi.lw = 1.2
# Crossover signal
self.crossover = bt.indicators.CrossOver(self.fast_ma, self.slow_ma)
self.crossover.plotinfo.plot = False # Suppress plotting helper line
def next(self):
if not self.position:
# Enter Long when fast MA crosses above slow MA and RSI is not overbought
if self.crossover > 0 and self.rsi[0] < 70:
self.buy(size=100)
else:
# Exit Long when fast MA crosses below slow MA
if self.crossover < 0:
self.close()
def generate_synthetic_ohlcv(bars: int = 250) -> pd.DataFrame:
"""Generate realistic synthetic daily OHLCV data."""
np.random.seed(42)
start_date = datetime.date(2025, 1, 1)
date_index = [start_date + datetime.timedelta(days=i) for i in range(bars)]
# Geometric random walk
drift = 0.0004
volatility = 0.015
returns = np.random.normal(drift, volatility, bars)
price_series = 100.0 * np.exp(np.cumsum(returns))
high = price_series * (1.0 + np.abs(np.random.normal(0, 0.006, bars)))
low = price_series * (1.0 - np.abs(np.random.normal(0, 0.006, bars)))
open_p = low + (high - low) * np.random.uniform(0.2, 0.8, bars)
close_p = low + (high - low) * np.random.uniform(0.2, 0.8, bars)
volume = np.random.randint(100_000, 500_000, bars).astype(float)
df = pd.DataFrame({
'open': open_p,
'high': high,
'low': low,
'close': close_p,
'volume': volume,
'openinterest': 0.0
}, index=pd.to_datetime(date_index))
df.index.name = 'datetime'
return df
def run_headless_simulation(output_dir: str = "./reports"):
"""Run backtest and export strategy plots safely to disk."""
os.makedirs(output_dir, exist_ok=True)
# 1. Initialize Cerebro
cerebro = bt.Cerebro()
cerebro.broker.setcash(100_000.0)
cerebro.broker.setcommission(commission=0.001) # 10 bps commission
# 2. Add Strategy
cerebro.addstrategy(DualMaRsiStrategy)
# 3. Feed Data
df = generate_synthetic_ohlcv(bars=200)
data_feed = bt.feeds.PandasData(dataname=df)
cerebro.adddata(data_feed, name="SYNTH_EQUITY")
# 4. Execute Backtest
print("Executing simulation...")
results = cerebro.run()
final_value = cerebro.broker.getvalue()
print(f"Simulation completed. Final Portfolio Value: ${final_value:,.2f}")
# 5. Generate and Export Plots
print("Generating Matplotlib strategy figures (headless)...")
try:
# iplot=False prevents Jupyter inline hooks
# numfigs=1 renders the entire timeline in one coherent canvas
figs = cerebro.plot(
iplot=False,
style='candlestick',
numfigs=1,
width=16,
height=10,
dpi=200,
barup='#26A69A', # Clean Teal
bardown='#EF5350', # Clean Coral
volume=True,
voloverlay=False # Independent volume axis
)
# 6. Save figures safely to disk
saved_files = []
for d_idx, fig_list in enumerate(figs):
for p_idx, fig in enumerate(fig_list):
chart_path = os.path.join(
output_dir, f"strategy_chart_d{d_idx}_p{p_idx}.png"
)
fig.savefig(chart_path, dpi=200, bbox_inches='tight')
plt.close(fig) # Crucial: Prevent memory accumulation
saved_files.append(chart_path)
print(f"Successfully exported {len(saved_files)} chart(s):")
for f in saved_files:
print(f" -> {os.path.abspath(f)}")
except Exception as exc:
print(f"Plotting failed: {exc}")
raise
if __name__ == '__main__':
run_headless_simulation()Modern visualization architectures: Static plots vs. Interactive dashboards
The evolution of algorithmic trading frameworks over the past decade highlights three distinct generations of strategy visualization:
flowchart LR
subgraph Gen1 ["1st Generation: Static Matplotlib"]
G1_Engine["Backtrader (cerebro.plot)"]
G1_Out["Raster PNG / PDF / OS Window"]
G1_Lim["Fixed resolution, no zooming, GUI backend crashes"]
end
subgraph Gen2 ["2nd Generation: Canvas / Bokeh"]
G2_Engine["backtesting.py (plot)"]
G2_Out["Interactive Standalone HTML"]
G2_Lim["Browser memory limits on >50k bars, single-file"]
end
subgraph Gen3 ["3rd Generation: Decoupled Web Applications"]
G3_Engine["OpenBacktesting (v0.2.1) Engine"]
G3_Ledger["Immutable Ledger & SQLite Trials"]
G3_Studio["Local OpenBacktesting Studio"]
end
G1_Engine --> G1_Out --> G1_Lim
G2_Engine --> G2_Out --> G2_Lim
G3_Engine --> G3_Ledger --> G3_StudioArchitectural comparison
| Feature | Backtrader (cerebro.plot) | backtesting.py (plot()) | OpenBacktesting Studio |
|---|---|---|---|
| Rendering Engine | Matplotlib (raster canvas) | Bokeh (HTML5 Canvas) | Local browser-based Web GUI |
| Interactivity | Basic Matplotlib pan/zoom toolbar | Smooth interactive pan, zoom, hover tooltips | High-performance interactive candlestick & fill charts |
| Execution Coupling | Hardcoded into Cerebro line iterators | Tightly bound to backtest result object | Completely decoupled via immutable data schemas |
| Server / Cloud Readiness | Requires manual Agg backend setup | Generates standalone static HTML file | Native local browser server, no X11/GUI dependencies |
| Optimization Sweeps | Not supported (single-run only) | Parameter surface heatmap HTML | Native SQLite trial persistence (Grid & SAMBO Bayesian search) |
| Core Dependencies | Python 2/3 legacy, Matplotlib | NumPy, pandas, Bokeh | Python \ge 3.12, < 3.15, NumPy, pandas, pandas-ta-classic |
Why decoupled architectures are standard in modern quants
While static Matplotlib charts remain valuable for generating fixed PDF fact-sheets and compliance logs, modern quantitative research relies on decoupled visualization architectures:
- Separation of Computation and Presentation: When running large parameter optimizations or systematic backtests, coupling the simulation engine to a desktop plotting library introduces fragility. A rendering glitch should never crash a five-hour backtest run.
- Tabular Audit Ledgers: Rather than storing visualization state inside opaque line objects, modern frameworks generate immutable pandas DataFrames (
trades_df,orders_df,equity_series). Downstream tools can render these ledgers in browser dashboards, export them to Parquet, or index them in databases. - Parameter Surface Inspection: In Backtrader, evaluating multi-parameter sweeps visually requires writing custom external scripts. In OpenBacktesting (v0.2.1), parameter optimization trials (via grid search or SAMBO Bayesian optimization) commit directly to an SQLite trial database. Researchers then inspect parameter landscapes, metric correlations, and trade distributions locally inside OpenBacktesting Studio without configuring display drivers or managing Matplotlib GUI threads.
Summary checklist for strategy visualization
Before relying on backtest charts for capital allocation or research verification, run through this practical diagnostic checklist:
- Backend configuration: If running on a remote cloud server, Docker container, or CI/CD runner, verify that
matplotlib.use('Agg')is invoked before importingmatplotlib.pyplot. - Resource hygiene: Confirm that
plt.close(fig)is called immediately afterfig.savefig()to prevent memory leaks during automated sweeps. - Time series bar density: For datasets exceeding 1,000 bars, set
numfigs > 1(e.g., ~300–500 bars per figure) to prevent candlesticks from collapsing into illegible solid blocks. - Fill execution verification: Visually confirm that trade markers align with bar boundaries. In realistic bar-by-bar backtesting, market orders submitted on bar
tfill on the open of bart+1, rather than retroactively on bart's close. - Volume alignment: If indicators depend on liquidity or turnover, verify that volume bars are not obscured by overlaying them onto the price chart; configure
voloverlay=Falseto isolate volume into an independent axis. - Cash trajectory audit: Inspect the cash line in the top observer pane. Ensure cash never drops below zero unless leverage and margin borrowing have been explicitly modeled and financed.
- Indicator scaling sanity: Ensure overlay indicators (like moving averages) have
subplot=Falseset so they share the price scale, while unbounded oscillators (like RSI, ATR, MACD) havesubplot=Trueto avoid squishing the main price axis.
Further reading and related resources
- Backtrader Tutorial: Build and Inspect Your First Strategy — Comprehensive walkthrough of the Cerebro engine, data feeds, and order lifecycle.
- Quantitative Backtesting Dashboard: Architecture and Visual Analytics — Designing modern interactive analytics dashboards for quantitative strategies.
- Backtesting.py vs Backtrader: Architectural Comparison — In-depth evaluation of event-driven vs vector-based simulation frameworks and plotting paradigms.
- OpenBacktesting Documentation — Official documentation for single-asset bar-by-bar backtesting, realistic order execution modeling, and OpenBacktesting Studio.
