How to backtest a moving average strategy with Backtrader Python

September 7, 2026
Facebook logo.
Twitter logo.
LinkedIn logo.
Newsletter issue count indicatorNewsletter total issues indicator
How to backtest a moving average strategy with Backtrader Python

How to backtest a moving average strategy with Backtrader Python

Backtrader Python is an open-source framework for testing trading strategies on historical price data before you risk real money. If you've wondered whether a rule like "buy when the short-term average crosses above the long-term average" actually makes money, backtrader lets you answer that question with code instead of guesswork. This step-by-step backtrader walkthrough covers the foundations.

The official backtrader site shows a feature overview and a short code snippet, but it doesn't walk you through a complete working example. That's what this article does. You'll install the library, load daily stock prices from a CSV, write a moving average crossover strategy, and read the output, all in about 60 lines of Python.

One thing is worth knowing up front. Backtrader is mature software. The core library is stable and widely used in tutorials, but development has slowed and some built-in integrations, such as the Yahoo Finance data feed, are outdated. Community examples on Stack Overflow are often the quickest help when you hit unusual cases.

What Backtrader Python Does

Backtesting means running a trading strategy on past price data to see how it would have performed. You feed the system historical prices, define your buy and sell rules, and the framework simulates trades day by day. At the end you get a final portfolio value and can inspect every trade.

Backtrader takes care of the mechanics. It keeps track of your simulated cash and holdings, figures out when orders fill, and can calculate common indicators like moving averages for you. You focus on the strategy logic.

This matters because testing ideas manually is slow and error-prone. Scrolling through charts and imagining "I would have bought here" doesn't account for transaction timing or position sizing. Backtrader forces you to spell out your rules, then applies them consistently across every day of data.

How Backtrader Organizes a Test

Backtrader uses a class-based structure. You write a strategy class that inherits from bt.Strategy and define two key methods. The __init__ method sets up any indicators you need. The next method runs once per bar of data (typically once per trading day) and contains your buy and sell logic.

A separate engine called Cerebro (Spanish for "brain") ties everything together. You create a Cerebro instance, add your data and strategy, set your starting cash, and call run(). Cerebro steps through the data one bar at a time and calls your strategy's next method at each step.

One detail trips up many beginners. When you place an order inside next(), it usually doesn't execute on that same bar. By default, the order fills on the next bar's open price. Keep that in mind when you compare your results to what you see on a chart.

Build a Backtrader Python Strategy

A moving average crossover is one of the simplest rule-based trading strategies. You compute two averages of the closing price over different time periods. When the shorter average crosses above the longer one, you buy. When it crosses below, you sell. The idea is that the short average reacts faster to price changes, so a crossover suggests that the overall direction of prices is shifting.

For other strategy patterns, the guide on creating and backtesting strategies with backtrader covers several variations.

Install Backtrader

pip install backtrader

Backtrader works with Python 3.6 and above. If you want the built-in plotting feature, install matplotlib too.

Prepare Your Data File

The code below expects a CSV file called daily_prices.csv with no header row and columns in this order: date, open, high, low, close, volume. You can download free daily data from Yahoo Finance for any stock. Yahoo's files usually include a header row, so delete that first line or add header=True to the feed configuration.

Here's what the first few rows should look like:

2023-01-03,130.28,131.04,129.04,130.15,89134500
2023-01-04,130.06,131.63,129.60,131.02,80379300
2023-01-05,131.25,131.26,128.12,128.38,87754700

The Complete Code

import backtrader as bt


class SmaCross(bt.Strategy):
    """Buys when a fast moving average crosses above a slow
    moving average, and sells when it crosses below."""

    params = (
        ("fast_period", 10),
        ("slow_period", 30),
    )

    def __init__(self):
        fast_ma = bt.indicators.SimpleMovingAverage(
            self.data.close, period=self.params.fast_period
        )
        slow_ma = bt.indicators.SimpleMovingAverage(
            self.data.close, period=self.params.slow_period
        )
        # CrossOver returns +1 when fast crosses above slow,
        # -1 when it crosses below
        self.crossover = bt.indicators.CrossOver(fast_ma, slow_ma)

    def next(self):
        if not self.position:
            # No current holding. Buy if fast crosses above slow.
            if self.crossover > 0:
                self.buy()
        else:
            # Currently holding. Sell if fast crosses below slow.
            if self.crossover < 0:
                self.sell()

    def notify_trade(self, trade):
        if trade.isclosed:
            print(
                f"Trade profit or loss: gross={trade.pnl:.2f}, "
                f"net={trade.pnlcomm:.2f}"
            )


# Set up the test engine
cerebro = bt.Cerebro()
cerebro.addstrategy(SmaCross)

# Load your CSV (adjust the path to match your file)
data = bt.feeds.GenericCSVData(
    dataname="daily_prices.csv",
    dtformat="%Y-%m-%d",
    datetime=0,
    open=1,
    high=2,
    low=3,
    close=4,
    volume=5,
    openinterest=-1,
)

cerebro.adddata(data)
cerebro.broker.setcash(100000.0)
cerebro.broker.setcommission(commission=0.001)

print(f"Starting portfolio value: {cerebro.broker.getvalue():.2f}")
results = cerebro.run()
print(f"Final portfolio value: {cerebro.broker.getvalue():.2f}")

cerebro.plot()

Walking Through the Code

The SmaCross class defines two parameters at the top, fast_period and slow_period. These control how many past days each moving average uses. Putting them in params instead of hardcoding numbers makes it easy to test different values later.

In __init__, we create two SimpleMovingAverage indicators and a CrossOver indicator. Backtrader computes these automatically as it steps through the data. You never need to write your own loop to calculate them.

The next method is where decisions happen. On each bar, we check self.position, which tells you whether the strategy currently holds the asset. If it doesn't and the crossover value is positive (the fast average just crossed above the slow one), we buy. If it does hold the asset and the crossover goes negative, we sell. Because of the if not self.position check, the strategy won't keep buying on every bar where the fast average stays above the slow one. It buys once and waits.

The notify_trade method is optional. It prints the profit or loss each time a trade has both an entry and an exit. This helps you see each trade result without digging through logs.

Below the class, we set up Cerebro, load the CSV, set starting cash to $100,000, and add a commission of 0.1% per trade. The run() call executes the entire test. The plot() call generates a chart with the price series and the trades the strategy placed.

How to Read Backtrader Python Results

When you run the script, you'll see output like this:

Starting portfolio value: 100000.00
Trade profit or loss: gross=1250.40, net=1230.15
Trade profit or loss: gross=-320.80, net=-340.92
Trade profit or loss: gross=870.60, net=851.22
Final portfolio value: 103740.45

Each "Trade profit or loss" line shows one completed trade. The gross number is the raw profit or loss. The net number subtracts commission costs. The final portfolio value tells you where you ended up.

If the final value is higher than the starting value, the strategy made money on that dataset. But that alone doesn't mean the strategy is good. You judged it on the same historical data you used to inspect it. That's called in-sample testing, which means you test on the same data you used to examine the idea, and it can make results look better than they really are.

A strategy can look profitable on one stretch of old data because its rules matched quirks in that period. For example, a 10-day and 30-day setting might look best only because it happened to catch a few lucky price swings. When you try fresh data, those same rules may stop working. That problem is called overfitting, which means the rules fit this dataset too closely and don't hold up on new data.

Also consider that final portfolio value is just one number. A strategy that ended with a profit but only made three trades in two years, or lost 40% of the account before recovering, might not be worth using. Check the number of trades and the size of the worst losses too.

To get a more honest estimate, split your data into two parts. Run the strategy on the first part, then test it on the second part that the strategy never touched. Backtrader doesn't do this split for you, but you can load different CSV files or filter date ranges in the data feed. The comprehensive guide to backtrader explains how to configure date filtering.

Extending Your Backtrader Python Strategy

Once the basic crossover works, there are practical improvements you can make. For a deeper look at tuning, see the guide on effective backtesting with backtrader.

Adding a Position Sizer

By default, backtrader buys one share per trade. That's not realistic. You can tell Cerebro to invest a percentage of your portfolio on each buy signal.

cerebro.addsizer(bt.sizers.PercentSizer, percents=95)

This invests 95% of available cash when the strategy buys. The remaining 5% acts as a buffer for commission costs.

Optimizing Parameters

Backtrader has a built-in optimizer that tests many parameter combinations. Replace addstrategy with optstrategy.

cerebro.optstrategy(SmaCross, fast_period=range(5, 15), slow_period=range(20, 40))

This runs the strategy for every combination of fast periods from 5 to 14 and slow periods from 20 to 39. Be careful here. Testing hundreds of combinations on the same data increases the chance of finding a parameter set that worked by luck. Always validate optimized parameters on data the optimizer never touched.

Adding Analyzers

Analyzers calculate extra report numbers from your historical strategy test. One useful analyzer calculates the Sharpe ratio, which estimates how much return the strategy produced compared with how much the account value bounced up and down. A higher number means the strategy delivered smoother returns.

cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name="sharpe")

After running, access the result with results[0].analyzers.sharpe.get_analysis(). The build and backtest a strategy guide shows how to combine multiple analyzers into a full performance report.

Go Deeper With Free Python Guides

The free Algorithmic Trading With Python guides go deeper on this. They show more strategy examples and explain how to connect backtrader to live market data for simulated trading with no real money at stake.