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.
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
- Python 3 on a computer.
- Two free libraries: pandas (tables of data) and numpy (fast math). Install them with pip install pandas numpy.
- About an hour.
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.
- Buy when the 20-candle average crosses above the 50-candle average.
- Put the Stop loss 2 "average candle sizes" below the entry.
- Take profit at 2 times the risk.
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:
- Stop first. If one candle touches both the stop and the target, we can't know which came first. We count it as a loss. That's the cautious choice.
- Fees in R. "R" means one unit of risk: the distance from entry to stop. A fee of 0.05 on a trade risking 1.00 costs 0.05R. Small trades pay more of their risk in fees.
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")
- Average R per trade is the Expectancy: what one trade added or took away, on average.
- Worst dip is the Drawdown: the biggest drop from a high point in units of risk.
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
- No shift. Buying on the same candle that made the signal. Always shift by one.
- No fees. Small, frequent trades can look fine before costs and bad after.
- Tuning on all the data. Keep a part you never touch until the end.
- Trusting one run. Change the data, the dates or the seed. A real pattern survives that. Luck doesn't.
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 anytimeOr 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: