Backtesting
How to Backtest a Trading Strategy in Python, End to End
One complete, runnable backtest — data, signals, costs and an out-of-sample split — on the stack QuantIDE runs on
Educational content, not investment advice. Trading involves risk of loss.
In brief
- A trustworthy backtest does four things in order: a fixed data window, one rule stated before results, realistic fees and slippage from the first run, and a final judgement made only on out-of-sample data.
- The complete script in this guide runs on vectorbt with a QuantStats report — the stack QuantIDE uses — and judges a 20/100 moving-average crossover only on data from 2021 onward that it never tuned against.
- If a result collapses when you add costs or move to the held-out period, the backtest did its job: it ruled the strategy out cheaply.
On this page
The short answer
A correct backtest is an order of operations, not a number. Pull a fixed window of data. Split it, and lock the second half away. State one rule, in writing, before you run anything. Charge yourself fees and slippage from the first run, not as an afterthought. Then judge the strategy only on the data it has never seen.
Everything below is that process as one complete Python script. It uses vectorbt for the backtest and QuantStats for the report — the same stack QuantIDE runs on — with Yahoo Finance data, the data source QuantIDE bundles. Copy it, run it, then change the rule and run it again. The script is the lesson.
What you need before you run anything
Three things, and none of them costs money:
- Python with two libraries.
pip install vectorbt quantstats. vectorbt runs the backtest; QuantStats produces the risk report afterwards. - Data with a fixed window. The script downloads daily closes for SPY from Yahoo Finance between two fixed dates. Fixed dates matter: a backtest you cannot reproduce is a story, not a test.
- One rule, stated in advance. Ours is a 20/100 moving-average crossover: buy when the 20-day average crosses above the 100-day average, sell when it crosses back below. It is deliberately ordinary. The process is what you are learning; an exotic rule would hide it.
One honesty note about the data. Yahoo Finance gives you the history of instruments that exist today. For a single index ETF like SPY, that is a reasonable teaching dataset. Test a rule on a basket of individual stocks, though, and you have quietly deleted every company that went bankrupt along the way — one of the classic ways a backtest fools you.
Step 1: split the data before you test anything
Most backtests go wrong before the first trade. The trader tests on all the data, tweaks until it looks good, and then reads the result as evidence. By that point the data has been used twice — once to design the strategy and once to judge it — and the judgement is worthless.
So the script splits first. Everything before 2021 is the laboratory: experiment there, break things there. Everything from 2021 onward is locked away until the end, and the strategy gets exactly one judgement on it.
split = "2021-01-01"
train = price[:split]
test = price[split:]
This is the cheapest rigour in backtesting. It costs one line, and it is the difference between "this fitted history" and "this survived contact with data it had never seen."
Step 2: state one rule and run it, costs included
Here is the whole backtest:
"""Complete backtest from the QuantIDE pillar article.
Data -> split -> one rule -> costs -> out-of-sample judgement -> report.
Runs on vectorbt + quantstats, the stack QuantIDE uses for backtesting.
"""
import quantstats as qs
import vectorbt as vbt
# 1. Data: daily closes from Yahoo Finance, a fixed window so the
# result is reproducible every time the script runs.
data = vbt.YFData.download("SPY", start="2015-01-01", end="2025-01-01")
price = data.get("Close")
# 2. Split BEFORE testing: experiment on the first part,
# judge the strategy only on the part it has never seen.
split = "2021-01-01"
train = price[:split]
test = price[split:]
# 3. One rule, stated in advance: 20/100 moving-average crossover,
# charged realistic costs from the very first run.
def run_backtest(prices, fast_window=20, slow_window=100):
fast = vbt.MA.run(prices, fast_window, short_name="fast")
slow = vbt.MA.run(prices, slow_window, short_name="slow")
entries = fast.ma_crossed_above(slow)
exits = fast.ma_crossed_below(slow)
return vbt.Portfolio.from_signals(
prices,
entries,
exits,
init_cash=100_000,
fees=0.0005, # 0.05% charged on every trade
slippage=0.0005, # fills assumed 0.05% worse than the print
freq="1D",
)
train_pf = run_backtest(train)
test_pf = run_backtest(test)
print("In-sample (experiment here):")
print(train_pf.stats())
print("\nOut-of-sample (judge here):")
print(test_pf.stats())
# 4. A second opinion on the held-out returns, via QuantStats.
returns = test_pf.returns()
print("\nOut-of-sample Sharpe:", qs.stats.sharpe(returns))
print("Out-of-sample max drawdown:", qs.stats.max_drawdown(returns))
Three details do the heavy lifting:
fees=0.0005andslippage=0.0005. Every trade is charged 0.05% in fees and assumed to fill 0.05% worse than the printed price. These are placeholders, not universal constants — replace them with your broker's real numbers. What matters is that they are in the first run. A strategy that only works at zero cost does not work.- Signals come from closing prices only. The moving averages and the cross are computed from closes, so nothing in the script uses information from after the moment the decision is made. That sounds obvious; violating it accidentally is the most common backtesting bug there is.
init_cashandfreq="1D"keep the output honest and readable: a $100,000 starting account and daily bars, so the statistics annualise the way you expect.
Step 3: read the output like a sceptic
stats() prints a wall of numbers. Read them in this order:
- Max drawdown, out-of-sample first. The worst peak-to-trough loss is the number that decides whether you would actually have kept going. If you could not have sat through it, the rest of the table is fiction.
- Trade count. A daily crossover system trades rarely. A handful of trades is an anecdote with a chart attached; statistics need samples.
- Total return — last, and against the benchmark. vectorbt reports buy-and-hold alongside the strategy. A system that trails simply owning SPY while still taking drawdowns has some explaining to do.
- Sharpe ratio. Useful for comparing two runs of your own process. Treat any single Sharpe as a rough sketch, not a grade.
Then get a second opinion. The script feeds the out-of-sample returns to QuantStats — qs.stats.sharpe and qs.stats.max_drawdown — because a report produced by a different library than the backtester catches a surprising number of quiet mistakes. In QuantIDE this is the standard flow: vectorbt runs the test, QuantStats writes the report.
And when the numbers look wonderful, slow down. Extraordinary backtests are usually bugs wearing a suit. The four ways a backtest lies — overfitting, look-ahead bias, survivorship bias and ignored costs — are covered in depth in how to backtest a trading strategy without fooling yourself. The other half of surviving your own strategy, how much to put on each trade, is in the position sizing guide.
Where this process still fails you
Honesty about the limits is part of running it correctly:
- Daily bars hide the trading day. Fills, gaps and intraday stops are all smoothed away. A strategy whose behaviour depends on what happens inside the day needs intraday data and a harder test.
- One instrument is one biography. SPY from 2015 to 2025 contains a long bull market, a pandemic crash and a rate-shock bear market. That is better than a single regime — and it is still one instrument's history.
- The split protects you once. Peek at the out-of-sample result, adjust the rule, and peek again, and the locked room is open: the judgement is spent. When the held-out set is burned, move the split forward and use newer data.
- Costs are modelled, not known. The fees and slippage in the script are assumptions, and real fills in fast markets will be worse. If the strategy only survives at the assumed costs, treat it as not surviving.
The checklist
Pin this above the keyboard:
- Fixed data window, written down before the run
- Out-of-sample period locked away before any tuning
- One rule, stated in one sentence, before seeing results
- Fees and slippage in from the first run
- Drawdown read before return
- Trade count checked before believing any ratio
- Second-opinion report from a separate library
- An extraordinary result assumed to be a bug until proven otherwise
Frequently asked questions
How much historical data do I need to backtest a strategy?
Enough to cover different kinds of market, which matters more than raw length. Ten years containing a crash, a recovery and a sideways stretch teach more than twenty years of one bull market. For daily strategies, count trades as well as years: if the whole test produces a dozen trades, more data or more instruments will teach you more than more indicators.
How do I add fees and slippage to a backtest?
Charge them inside the backtest engine, on every trade, from the first run — in the script above, that is the fees and slippage arguments to Portfolio.from_signals. Use your broker's actual commission schedule and a slippage assumption you would be embarrassed to call optimistic. Then run it again with double the costs: a strategy that dies was renting its edge from your assumptions.
Which Python library should I use to backtest a trading strategy?
For vectorised strategy research, vectorbt is the working standard: it represents strategies as arrays, so a single script can test one rule or thousands of variations in seconds, and it pulls Yahoo Finance data natively. QuantIDE is built on it for exactly that reason, and pairs it with QuantStats for reporting. Event-driven engines exist for modelling intraday fills and order books; reach for one when daily-bar assumptions stop being honest.
What to do next
Run the script. Then break it on purpose: move the split, double the costs, swap the windows — and watch which conclusions survive. That instinct, not any single result, is the skill.
When you want the same process without wiring up the environment yourself, QuantIDE ships with this stack already configured: describe the rule, and the data, the backtest and the QuantStats report are set up for you. And when a rule survives its out-of-sample judgement, building your first trading bot takes it the next step, toward automation.
Sources
Related reading
- ComparisonQuantIDE vs QuantConnect: Which Systematic-Trading Setup Fits You?QuantConnect gives you a mature cloud platform with a free research tier and live execution built in. QuantIDE gives you the same work on your own machine, in your own repository, under your own keys.6 min read
- GuideTransaction Costs in Backtesting: The Fee and Slippage Line That Changes the VerdictThe same crossover loses 0.68% at zero cost and 13.18% at 50 basis points a trade. Costs are a per-trade tax, so turnover is the multiplier — here is the full runnable code and what it actually found.7 min read
- GuideWalk-Forward Validation: The Test That Keeps a Backtest HonestA backtest that only ever sees one stretch of history will flatter whatever parameters you tuned on it. Walk-forward validation re-tunes on each window and judges only on data the strategy has never seen — here is the full runnable code and what it actually found.6 min read