StructureX

Home › Guides › Trading bots & code

How to backtest in Python: test one rule on past data, step by step

A backtest replays a rule on past prices to see how it would have behaved. In Python it takes about 40 lines of pandas, and you can read every one of them.

Beginner guide · 11-minute read · Updated 2026-10-21

What a backtest is (and isn't)

A backtest asks one question: "If I had followed this exact rule on these past prices, what would have happened?"

It's practice on past data, done by a computer. That makes it fast. It also makes it easy to fool yourself, because a computer will happily test a rule that cheats.

So this page does two jobs. It shows you how to build a backtest. And it shows you the three traps that make backtests look better than they are.

If you'd rather practice by hand first, start with how to backtest a trading strategy on TradingView's replay. The ideas are the same.

What you need

New to Python? Read Python for trading beginners first.

Example code for learning — test it on paper first; it is not a ready-made trading system.

Step 1: Make practice data

We start with made-up prices, not a real market. That way nothing on this page depends on a data feed, a key or an account.

import numpy as np
import pandas as pd
# 2,000 fake candles from a random walk. Made-up data, not a real market.
rng = np.random.default_rng(seed=7)
close = 100 + rng.normal(0, 0.5, 2000).cumsum()
open_ = np.r_[close[0], close[:-1]]           # each candle opens where the last one closed
wiggle = np.abs(rng.normal(0, 0.3, 2000))     # random tails above and below
df = pd.DataFrame({
    "open": open_,
    "high": np.maximum(open_, close) + wiggle,
    "low": np.minimum(open_, close) - wiggle,
    "close": close,
})
print(df.tail(3))

In plain words: each new price is the last price plus a small random step. The result looks a bit like a chart. But nobody can predict it, by design. Keep that in mind for later.

Step 2: Write the rule as code

Our example rule is a moving-average crossover. A Moving average is the average close of the last few candles.

fast = df["close"].rolling(20).mean()
slow = df["close"].rolling(50).mean()
cross_up = (fast > slow) & (fast.shift(1) <= slow.shift(1))
candle_size = (df["high"] - df["low"]).rolling(14).mean()   # a simple ATR-style measure
stop_distance = 2 * candle_size

rolling(20).mean() is the 20-candle average. cross_up is True only on the candle where the fast line moves from below to above. candle_size is a plain cousin of the ATR.

Step 3: Stop the look-ahead with one shift

Here's the first trap. A crossover is only known when the candle closes. You can't buy at that same candle's open — that price is already gone.

A backtest that does this is "looking ahead". It uses information it didn't have yet. It's the most common reason a backtest looks too good.

The fix is one line. Move every signal forward by one candle:

df["enter"] = cross_up.shift(1, fill_value=False)       # signal from the candle that just closed
df["risk"] = stop_distance.shift(1)                     # stop size known at that close too

Now, on any row where enter is True, the signal came from the candle before. We buy at this candle's open. That's a price you could really have got.

Step 4: Simulate the trades

This loop walks through the candles one at a time, like replay. It holds one trade at a time.

def backtest(df, rr=2.0, fee=0.05):
    """Long only. fee = round-trip cost in price units (spread + commission)."""
    results = []
    i, n = 0, len(df)
    while i < n:
        if not df["enter"].iloc[i] or not df["risk"].iloc[i] > 0:
            i += 1
            continue
        entry = df["open"].iloc[i]
        risk = df["risk"].iloc[i]
        stop, target = entry - risk, entry + rr * risk
        result = None
        for j in range(i, n):
            if df["low"].iloc[j] <= stop:      # check the stop first: the cautious choice
                result = -1.0
                break
            if df["high"].iloc[j] >= target:
                result = rr
                break
        if result is None:                     # still open when the data ends
            j = n - 1
            result = (df["close"].iloc[j] - entry) / risk
        results.append(result - fee / risk)    # fees, turned into units of risk
        i = j + 1                              # next trade starts after this one ends
    return pd.Series(results)

Two honest choices hide in here:

One thing this loop can't show: in real markets, price can jump past a stop. Then a loss is bigger than 1R. See Slippage and Gap.

Step 5: Read the results in units of risk

We report in R, not money. R lets you compare any rule on any account size.

r = backtest(df)
curve = r.cumsum()
worst_dip = (curve - curve.cummax().clip(lower=0)).min()
print(f"Trades: {len(r)}")
print(f"Win rate: {(r > 0).mean():.0%}")
print(f"Average: {r.mean():+.2f}R per trade")
print(f"Total: {r.sum():+.1f}R")
print(f"Worst dip from a high: {worst_dip:.1f}R")

Want to turn R into a trade size? R times your risk per trade is the money at stake. The position size calculator does that math.

What the numbers mean here: nothing

Run it. On this data you'll get about 20 trades, some wins, some losses and a total in R. (20 is far too few to judge anything — more on that below.)

Now change seed=7 to seed=8 and run it again. The total changes, maybe a lot.

That's the lesson. This data is random. No rule can have a real edge on it. Any good or bad total you see is luck. Results on synthetic data mean nothing about real markets — and results on real past data don't promise the future either.

So why use fake data? Because it's the perfect lie detector. If your rule looks great on random prices, your backtest has a bug — usually look-ahead.

The overfitting trap

Here's the second trap. Try 50 pairs of averages and keep the best one, and you'll find a "winner" — even on random data. It just fits the noise.

The fix: split your data in two.

first_half = df.iloc[:1000]
second_half = df.iloc[1000:].reset_index(drop=True)
print("Tuning half:", round(backtest(first_half).sum(), 1), "R")
print("Untouched half:", round(backtest(second_half).sum(), 1), "R")

Pick your settings on the first half only. Then run them once on the half you never touched. If the result falls apart, the settings were fitted to noise. More on this in common trading bot mistakes.

The third trap: too few trades

Ten trades tell you almost nothing. A coin can land heads 7 times in 10.

Aim for at least 100 trades before you read anything into the totals. Then keep testing forward — see paper trading a bot.

Common mistakes

Try it today. Run the code, then change one thing: the stop from 2 to 3 candle sizes. Write down the trade count, average R and worst dip for both. That's a real backtest log.

Questions

Is Python good for backtesting?

Yes. pandas makes it easy to load prices, build signals and count results, and you can read exactly what the test does. It takes more setup than TradingView's built-in tester.

What is look-ahead bias in backtesting?

It's when a backtest uses information that wasn't known yet, like buying at a candle's open because of that same candle's close. Shifting signals forward one candle fixes the usual case.

How many trades do I need in a backtest?

More is better. Around 100 is a sensible minimum before reading anything into the results, and even then past results don't promise future ones.

Should a backtest include fees?

Always. Spreads and commissions are a real cost on every trade. Counting them in units of risk shows how much of each trade they eat.

Want the next step? We'll walk you through it.

Four free lessons by email, one every few days — each with a 10-minute exercise on a real chart.

Send Me the Free Lessons 4 short emails · No spam · Unsubscribe anytime

Or see the whole method, step by step:

See What’s Inside the Course Try everything free first. Decide when you're ready. · Open the free chart tool (works in the TradingView phone app)

One chart idea a day, in plain English. Follow along:

Follow on XFollow on TradingView