How to Backtest a Trading Strategy in Python
How to backtest a trading strategy in under 80 lines of plain Python: a 5/20 moving average crossover with next-open fills, fees, slippage and a split fix.
How to backtest a trading strategy comes down to one loop: read a price history one bar at a time, apply a rule that only sees the past, fill each order at a price that existed after the decision, subtract costs, and measure what is left. Everything below runs in plain Python 3 with nothing to install, on a small price file you create yourself. The strategy is a 5/20 moving-average crossover, and the script prints what a backtest report leads with: total return and maximum drawdown, plus a trade count.
How to backtest a trading strategy: the four parts
A backtest is a replay of a trading rule over historical prices. It needs four things: a price series, a rule that turns prices into decisions, an execution model that says at what price each decision fills, and a cost model. Leave one out and the result is wrong, usually in the flattering direction.
The data here is illustrative: sixty rows of daily OHLCV bars (open, high, low, close, volume) for a made-up stock, with one deliberately awkward feature. A 2-for-1 stock split lands on February 20, 2024, recorded the way an exchange tape would have printed it, with the price halving overnight.
Step 1: Create the sample price file
Paste this into a terminal. It writes the 60-row file illustrative_ohlcv.csv into the current folder.
cat > illustrative_ohlcv.csv <<'EOF'
date,open,high,low,close,volume
2024-01-02,99.90,100.45,99.61,100.12,912400
2024-01-03,100.20,100.74,99.88,100.32,874100
2024-01-04,100.26,101.30,100.05,101.02,1038600
2024-01-05,101.16,101.52,100.71,101.06,951300
2024-01-08,100.96,101.88,100.80,101.66,889700
2024-01-09,101.70,102.15,101.33,101.82,806200
2024-01-10,101.70,102.91,101.49,102.66,1104900
2024-01-11,102.84,103.20,102.38,102.72,967500
2024-01-12,102.64,103.47,102.41,103.24,923800
2024-01-16,103.30,103.90,102.96,103.38,845600
2024-01-17,103.24,104.39,103.02,104.16,1071200
2024-01-18,104.26,104.80,103.95,104.36,898400
2024-01-19,104.32,105.21,104.10,104.98,1012700
2024-01-22,105.14,105.60,104.72,105.08,934900
2024-01-23,104.96,105.95,104.77,105.68,987300
2024-01-24,105.76,106.28,105.41,105.80,861800
2024-01-25,105.60,106.02,105.19,105.64,903500
2024-01-26,105.70,105.93,104.68,104.94,1129400
2024-01-29,104.78,105.16,104.35,104.74,942100
2024-01-30,104.86,105.05,103.57,103.82,1187600
2024-01-31,103.60,104.02,103.11,103.54,1064300
2024-02-01,103.62,103.89,102.64,102.90,1098800
2024-02-02,102.80,103.14,102.27,102.58,957200
2024-02-05,102.62,102.95,101.52,101.76,1146500
2024-02-06,101.62,102.83,101.40,102.60,1023100
2024-02-07,102.80,103.26,102.49,102.88,878900
2024-02-08,102.82,103.79,102.66,103.56,996400
2024-02-09,103.68,104.07,103.30,103.64,912700
2024-02-12,103.56,104.58,103.41,104.32,1047800
2024-02-13,104.48,104.90,104.09,104.48,869300
2024-02-14,104.38,105.49,104.20,105.26,1082600
2024-02-15,105.32,105.97,105.08,105.52,928500
2024-02-16,105.38,106.51,105.22,106.24,1015900
2024-02-20,53.17,53.44,52.91,53.19,2213800
2024-02-21,53.16,53.68,53.02,53.50,1968400
2024-02-22,53.59,53.86,53.37,53.61,1854200
2024-02-23,53.57,54.19,53.45,54.03,2091700
2024-02-26,54.09,54.40,53.90,54.13,1786300
2024-02-27,54.05,54.61,53.92,54.44,1932600
2024-02-28,54.46,54.78,54.23,54.47,1698900
2024-02-29,54.42,55.05,54.31,54.88,2047500
2024-03-01,54.95,55.24,54.70,54.97,1811200
2024-03-04,54.94,55.58,54.82,55.40,2118400
2024-03-05,55.50,55.79,55.21,55.46,1876900
2024-03-06,55.40,55.93,55.27,55.74,1994800
2024-03-07,55.62,55.81,55.12,55.34,2166300
2024-03-08,55.38,55.64,55.03,55.22,1902700
2024-03-11,55.13,55.30,54.58,54.79,2254100
2024-03-12,54.82,55.01,54.36,54.59,1987600
2024-03-13,54.53,54.72,53.94,54.11,2301900
2024-03-14,54.16,54.41,53.78,53.97,2078200
2024-03-15,53.89,54.08,53.32,53.56,2412500
2024-03-18,53.63,54.15,53.47,53.96,1958700
2024-03-19,53.94,54.33,53.76,54.11,1813400
2024-03-20,54.20,54.70,54.06,54.48,1946100
2024-03-21,54.44,54.89,54.29,54.58,1772800
2024-03-22,54.64,55.12,54.50,54.91,1889500
2024-03-25,54.84,55.27,54.69,55.03,1703600
2024-03-26,55.06,55.66,54.93,55.45,1934200
2024-03-27,55.39,55.82,55.24,55.59,1857900
EOF
Prices climb from about $100 to $106 through January, dip into early February, then climb again. On February 20 the open prints at $53.17 after a $106.24 close the day before. That is what a 2-for-1 split looks like in raw data: the share count doubles, the price halves, and no holder lost a cent.
Step 2: The backtest script
Save the following as backtest.py in the same folder. It fits in 77 lines and imports only csv and statistics, so it runs on any Python 3.8 or newer.
import csv
import statistics
CSV_PATH = "illustrative_ohlcv.csv"
FAST, SLOW = 5, 20 # moving-average lengths, in bars
START_CASH = 10_000.0 # starting equity in dollars
FEE = 1.00 # flat fee charged on every fill (buy or sell)
SLIPPAGE = 0.0 # fraction of the open lost on every fill; 0.001 = 0.1%
ADJUST_SPLITS = False # True: back-adjust prices before SPLIT_DATE
SPLIT_DATE, SPLIT_RATIO = "2024-02-20", 2 # the 2-for-1 split in the sample file
def load_bars(path):
bars = []
with open(path, newline="") as f:
for row in csv.DictReader(f):
factor = 1 / SPLIT_RATIO if ADJUST_SPLITS and row["date"] < SPLIT_DATE else 1
bars.append({"date": row["date"],
"open": float(row["open"]) * factor,
"close": float(row["close"]) * factor})
return bars
def moving_average(closes, length, i):
"""Mean of the closes in the window ending at bar i, or None before enough history."""
if i + 1 < length:
return None
return statistics.fmean(closes[i + 1 - length:i + 1])
def run(bars):
closes = [b["close"] for b in bars]
cash, shares, round_trips = START_CASH, 0, 0
pending = None # order decided at today's close, filled at tomorrow's open
equity = []
for i, bar in enumerate(bars):
# 1. fill yesterday's decision at today's open
if pending == "buy":
price = bar["open"] * (1 + SLIPPAGE)
shares = int((cash - FEE) // price)
cash -= shares * price + FEE
elif pending == "sell":
price = bar["open"] * (1 - SLIPPAGE)
cash += shares * price - FEE
shares = 0
round_trips += 1
pending = None
# 2. mark the account to market at today's close
equity.append(cash + shares * bar["close"])
# 3. decide, using only closes up to and including today
fast = moving_average(closes, FAST, i)
slow = moving_average(closes, SLOW, i)
if fast is None or slow is None:
continue
if fast > slow and shares == 0:
pending = "buy"
elif fast < slow and shares > 0:
pending = "sell"
return equity, round_trips
def max_drawdown(equity):
"""Largest peak-to-trough fall in the equity curve, as a negative fraction."""
peak, worst = equity[0], 0.0
for value in equity:
peak = max(peak, value)
worst = min(worst, value / peak - 1)
return worst
bars = load_bars(CSV_PATH)
equity, round_trips = run(bars)
print(f"bars {len(bars)}")
print(f"final equity {equity[-1]:,.2f}")
print(f"total return {equity[-1] / START_CASH - 1:.2%}")
print(f"max drawdown {max_drawdown(equity):.2%}")
print(f"round trips {round_trips}")
The loop does three things per bar, in an order that matters. First it fills any order left over from the previous bar, at today's open. Then it marks the account to market at today's close. Only then does it compute the two moving averages from closes up to and including today and decide whether tomorrow's open carries a buy, a sell, or nothing.
Filling at the next bar's open is the execution model. A 5-day average crossing above the 20-day average is only knowable once today's close has printed, and by then that close is gone; the earliest real price available is tomorrow's open. A backtest that buys at the same close it used to make the decision is peeking at a price it could not have had, the most common form of look-ahead bias in backtesting.
The cost model is a flat $1.00 on every fill, plus a slippage setting that starts at zero. The metrics come from the equity curve, the list of account values at each close. Total return is the last value against the starting $10,000. Maximum drawdown is the largest fall from any earlier peak in that list, the statistic explained in what is maximum drawdown. Round trips count completed buy-and-sell pairs; a position still open at the last bar is marked at that close and not counted.
Step 3: Run it and read the output
python3 backtest.py
bars 60
final equity 5,015.88
total return -49.84%
max drawdown -50.01%
round trips 2
On its face the strategy lost half the account; the trade list says otherwise. The crossover bought at the January 31 open for $103.60, sold at the February 5 open for $102.62 after the dip, then bought again at the February 15 open for $105.32. Two sessions later the file records the split: the close falls from $106.24 to $53.19, the 5-day average collapses through the 20-day average, and the script sells at the February 21 open for $53.16 a share, booking a 50% loss that no shareholder experienced. The remaining sessions are spent flat while the 20-day average, still full of $105 closes, sits far above a $54 price; the rule only re-enters at the final bar.
The script did its job: it traded a series with a discontinuity in it, and every metric a backtest prints inherits whatever the data contains.
What changes with split-adjusted data
Split adjustment divides every price before the split by the split ratio (and multiplies the volumes), which is the arithmetic behind any split-adjusted price history and leaves the series a rule sees continuous. The script already has the switch. Change one line:
ADJUST_SPLITS = True # was False
Run python3 backtest.py again:
bars 60
final equity 10,119.19
total return 1.19%
max drawdown -3.91%
round trips 2
Same rule, same 60 sessions, same fees. Total return moves from -49.84% to 1.19% and maximum drawdown from -50.01% to -3.91%. The February 15 entry, now $52.66 in adjusted terms, is held straight through the split and exited at the March 18 open for $53.63, after the early-March pullback drags the fast average under the slow one. The round-trip count stays at two in both runs, a reminder that a trade count alone says little about what happened inside the trades. Real price feeds deliver adjustment as a factor column rather than a hard-coded date, but the mechanics are identical, and so is the failure mode when it is missing.
What changes when you add slippage
Slippage is the gap between the price a backtest assumes and the price a real order fills at. A buy fills at the offer and a sell at the bid; the last printed price sits between them. The script models it as a fraction of the open, paid on every fill. Set it to 0.1%, keeping the split adjustment on:
SLIPPAGE = 0.001 # was 0.0
bars 60
final equity 10,068.81
total return 0.69%
max drawdown -3.90%
round trips 2
Five fills at 0.1% each take total return from 1.19% to 0.69%, a haircut of half a percentage point. Drawdown barely moves; it even narrows by a hundredth, since the slippage run holds 187 shares instead of 188 and a little more cash. That asymmetry is typical: costs erode return in proportion to how often a rule trades, while drawdown is set by the size of the moves a rule sits through. At 0.1% a fill, a rule that traded twelve round trips over the same window would pay about 2.4% in slippage, twice what this one earned before costs.
The three runs side by side
The panel below stores the three result lines the script printed; every figure quoted above is checked against it rather than retyped. Same rule, same 60-row file, three sets of metrics.
| run | total_return_pct | max_drawdown_pct | round_trips |
|---|---|---|---|
| raw tape | -49.84 | -50.01 | 2 |
| split-adjusted | 1.19 | -3.91 | 2 |
| split-adjusted, 0.1% slippage | 0.69 | -3.9 | 2 |
The exact SQL behind every number
SELECT
run,
total_return_pct,
max_drawdown_pct,
round_trips
FROM
(
SELECT 1 AS step, 'raw tape' AS run, -49.84 AS total_return_pct, -50.01 AS max_drawdown_pct, 2 AS round_trips
UNION ALL
SELECT 2, 'split-adjusted', 1.19, -3.91, 2
UNION ALL
SELECT 3, 'split-adjusted, 0.1% slippage', 0.69, -3.90, 2
)
ORDER BY stepWhere to go next
With pandas the two averages become two rolling-mean calls; vectorbt evaluates thousands of parameter combinations over the same logic at once; and quantjourney-bt makes a reproducible backtest its organizing idea. For prices, a free stock market data API returns the same six columns you just typed by hand, and it is worth knowing how OHLCV bars are built before trusting the open you fill at. Two more data traps wait beyond one made-up stock: survivorship bias, where delisted tickers are silently missing, and reused ticker symbols that stitch two companies into one history.
FAQ
What is a backtest in trading?
A backtest is a replay of a trading rule over historical prices to see what it would have done: when it would have bought and sold, what those trades would have cost, and what the account would have been worth afterward. It measures how a rule behaved on past data, nothing more.
Why does a backtest execute at the next bar's open?
A rule computed from today's close cannot be traded at today's close; that price has already printed by the time the close is known. The next bar's open is the first price a real order could fill at, and filling any earlier is look-ahead bias.
Do I need pandas to backtest a strategy in Python?
No. A daily-bar backtest with moving averages fits in the standard library, as the script above shows. pandas and dedicated backtesting libraries earn their place once there are thousands of tickers or many parameter combinations to sweep.
What slippage assumption is typical in a backtest?
For a liquid large-cap stock traded in small market orders, a few basis points (hundredths of a percent) per fill is a common starting point; for thin names or large orders the figure can be many times higher. The useful habit is to rerun the backtest at several values and watch how quickly the return decays.
What does maximum drawdown measure?
Maximum drawdown is the largest percentage fall in account value from any earlier peak to a later trough over the whole test. In the adjusted run above it is -3.91%, set between the March 6 peak and the March 15 close. It describes the worst stretch a strategy put its holder through; how often losses occurred is a separate statistic.
The script is yours to change: swap the averages or raise the fee and run it again. When the same crossover is ready for real history, the Strasmore terminal returns split-adjusted daily bars for any ticker from a plain-English question.