OKX · Quant Hands-On
How to Backtest a Strategy: Pulling Historical OHLCV With ccxt
A backtest takes market data that has already happened and runs a strategy's buy and sell rules across it from start to finish, to see what equity curve those rules would have left behind in that stretch of history. Below is a dual-moving-average backtest in ccxt and pandas that runs as written: pull OKX candles, paginate for older data, compute the signal, deduct fees. After that come five common reasons a backtest curve looks good and the live account loses money, and a short checklist to go through once a backtest finishes.
The moving-average parameters and the fee rate in the code are illustrative values that show the workflow. They are not recommendations.
Backtest vs paper trading: which comes first
Start by keeping the two apart. A backtest runs on historical data: you drop your strategy logic in and let it play from one end of the record to the other. The upside is speed, since years of price action finish in seconds to minutes. The downside is that it is hindsight by nature, running inside a world whose ending is already known. Paper trading (demo trading) runs on live data, and the strategy walks forward day by day at the market's own pace. The upside is that it sits much closer to real execution, because the order placement, the fills, and the network latency are all genuine. The downside is that it is slow, and validating one idea can take weeks.
The order is backtest first to throw out ideas that clearly go nowhere, then the demo account, and only then a small amount of real money. What each step is for is covered under What to do after a backtest passes.
Pulling OKX candles with ccxt: 300 per call, paginate for more
Two tools are enough: ccxt to request candles from the exchange, and pandas to shape the data into a table and do the arithmetic. Candles are public market data, so no API Key is needed.
Behind ccxt's fetch_ohlcv sit two OKX endpoints, and whether you pass since decides which one is called (the logic is in ccxt's okx.py):
- Without
since: it requests Get candlesticks (/api/v5/market/candles) and gets the most recent stretch. This endpoint returns at most 300 candles per call. - With
since, when the start is older than roughly the most recent 1,440 candles: ccxt switches to Get candlesticks history (/api/v5/market/history-candles) on its own. Like the first endpoint, it returns at most 300 candles per call, and 100 whenlimitis not passed. The pagination below asks for 100 per page, which is that default; raisepageto 300 if you want fewer requests. It pages forward from the last candle actually returned, so the page size does not change the result.
Start with the most recent 300 daily candles:
import ccxt
import pandas as pd
# Candles are public market data, no API Key needed; enableRateLimit turns on ccxt's built-in request throttle
okx = ccxt.okx({'enableRateLimit': True})
def to_df(rows):
df = pd.DataFrame(rows, columns=['ts', 'open', 'high', 'low', 'close', 'volume'])
df['ts'] = pd.to_datetime(df['ts'], unit='ms')
return df.drop_duplicates('ts').set_index('ts').sort_index()
# No since: goes to the Get candlesticks endpoint, at most 300 per call
ohlcv = okx.fetch_ohlcv('BTC/USDT', timeframe='1d', limit=300)
df = to_df(ohlcv).iloc[:-1] # the last row is today's unfinished candle, leave it out of the backtest
print(df.tail())
300 daily candles is less than a year. For a longer sample, pass since and page forward from your start point, setting each page's start just after the last candle of the previous page:
def fetch_since(symbol, timeframe, since_ms, page=100):
"""Page forward from since_ms until no new candles come back"""
rows = []
while True:
batch = okx.fetch_ohlcv(symbol, timeframe=timeframe, since=since_ms, limit=page)
if not batch:
break
rows += batch
next_ms = batch[-1][0] + 1 # next page starts right after this page's last candle
if next_ms <= since_ms: # stop if we did not move forward, to avoid an endless loop
break
since_ms = next_ms
return rows
start = okx.parse8601('2023-01-01T00:00:00Z') # illustrative start, use the date you need
df = to_df(fetch_since('BTC/USDT', '1d', start)).iloc[:-1]
print(len(df), df.index[0], df.index[-1])
If the start is earlier than the pair's listing date, the first page comes back empty and the loop ends immediately; move the start later. It is worth saving what you pulled to a local file (df.to_csv('btc_1d.csv')) and reading that file while you work on the strategy, instead of requesting the data again each time.
A dual-MA backtest: signal and fees
The rule is the simplest one in the textbook: hold while the short moving average is above the long one, otherwise stay flat. The snippet below continues from the df above; 10 and 30 are illustrative values:
SHORT, LONG = 10, 30
FEE = 0.001 # assumed value: 0.1% of traded value on every buy or sell; replace with the rate that applies to your account
df['ma_short'] = df['close'].rolling(SHORT).mean()
df['ma_long'] = df['close'].rolling(LONG).mean()
# The MA relationship only known once this candle has closed: 1 = want to hold, 0 = want to be flat
df['want'] = (df['ma_short'] > df['ma_long']).astype(int)
# The actual position lags by one candle: a signal set at this close takes effect on the next candle (no look-ahead)
df['pos'] = df['want'].shift(1).fillna(0)
df['ret'] = df['close'].pct_change().fillna(0)
df['gross'] = df['ret'] * df['pos'] # daily return before fees
# A position change from 0 to 1 or from 1 to 0 counts as one trade, and each trade pays FEE once
df['trade'] = df['pos'].diff().abs().fillna(0)
df['net'] = df['gross'] - df['trade'] * FEE # daily return after fees
print('Number of trades:', int(df['trade'].sum()))
print('Buy-and-hold equity:', (1 + df['ret']).cumprod().iloc[-1])
print('Strategy equity (before fees):', (1 + df['gross']).cumprod().iloc[-1])
print('Strategy equity (after fees):', (1 + df['net']).cumprod().iloc[-1])
All three equity figures start from 1. Replace FEE with the rate that actually applies to your account on OKX's fee page; maker and taker rates differ, so if in doubt use the higher one. The code still leaves out slippage and assumes every trade fills at the closing price, so the after-fee figure remains on the optimistic side. Buy-and-hold is printed alongside because a strategy that cannot beat simply holding the coin is not worth the remaining steps.
If you have not used ccxt before, the connection and order basics are in the API quant starter.
Five reasons a great backtest loses money live
A backtest is run in a world whose ending is already known. The history is finished, and every time you try another parameter, change a rule or add a condition on top of it, you are fitting toward the answer; the closer the fit, the prettier the curve. So the better a backtest looks, the more it deserves suspicion first. Of the five reasons below, the first two mean the backtest itself is written wrong, and the last three mean the data or the costs were chosen wrong.
Overfitting: mistaking the accidents of one period for a pattern
Overfitting usually creeps in one step at a time. The first version is mediocre, so you adjust the period and it looks a bit better; you add a filter and it looks better again; one drawdown is ugly, so you add a rule not to trade in that situation. Each step is defensible, and together they tailor the strategy to this one stretch of history. Three checks:
- Out-of-sample test. Split the data in two, tune parameters on the first part only, then run the second part once and only once. If you look at the second part's result and go back to change parameters, it is no longer out of sample.
- Parameter sensitivity. Nudge each parameter up and down a little and rerun. Performance that changes gradually is normal; a return that collapses at the slightest move means you found one isolated point.
- Count the parameters. On the same data, a strategy with a dozen switches, thresholds and exception rules produces a pretty curve far more easily than one with two or three parameters. The more parameters, the longer and more varied the sample has to be to support them.
Look-ahead bias: using data you could not have had yet
Look-ahead bias means the strategy, at the moment it decides, uses information that did not exist yet. The typical version uses a candle's closing price to enter on that same candle: the close is only known once the candle has finished, and by then there is no going back to a price in the middle of it. Related mistakes include computing a mean and standard deviation over the whole dataset and then normalizing earlier rows with them, or shifting a column the wrong way so that later rows fill the current one. None of these raise an error; the backtest runs to the end and the result looks especially good. The shift(1) in the example above guards against exactly this: a signal set at this close only takes effect on the next candle. To check, ask the same question of every order decision: at that moment, did I really have this number?
Survivorship bias: delisted and dead coins never entered the sample
The convenient way to pick backtest symbols is to open today's market list and choose a few familiar pairs. Everything still on that list is a survivor; pairs that were delisted or whose liquidity dried up are not in your sample. A strategy run on symbols that all turned out to survive naturally looks robust. Any strategy of the "screen the whole market for candidates" kind carries this bias. If you cannot get historical data that includes delisted symbols, do at least two things: fix the backtest to a small number of long-lived, liquid pairs rather than treating a screened "best basket" as the conclusion, and state in your notes that the backtest did not include delisted symbols.
Fees, slippage and funding left out
There are at least three layers of cost: trading fees, slippage (the gap between the price you wanted and the price you got), and funding if you are testing futures. The more often a strategy trades, the more times costs are multiplied. At the 0.1% per trade assumed in the code above, 100 trades multiply equity by 0.999 to the power of 100, about 0.905, so fees alone remove roughly 9.5%. The approach is to deduct fees at your real rate first, then give slippage a clearly conservative assumption (how much depends on the pair's liquidity and your order size), and see what is left of the curve. A strategy whose return is gone after costs never worked. For a rough view of what fees add up to, use the fee calculator on the tools page.
The sample covers only one kind of market
If the test window happens to sit inside a sustained rally, almost any buy-and-hold strategy looks good; a high win rate earned in a long sideways stretch can turn into a string of stop-outs in a one-way move. When judging a sample, look first at whether it has covered markets of different character: a rise, a fall, a sideways grind, and ideally one or two violent moves. How many months it spans matters less.
Five questions to ask before trusting a backtest
When a backtest finishes, go through this table before looking at the return. Once you have seen the curve, you tend to look for reasons to believe it.
| Ask yourself | Sign it has not passed |
|---|---|
| Which stretch of data were the parameters tuned on? Was the result then run on data it had never seen? | There was only one stretch of data, used for both tuning and validation |
| For every order decision, did I really have that number at the time? | Entering on a candle using that candle's close; indicators computed over the whole dataset |
| How were the test symbols chosen? | Picked from today's market list for having done well |
| On what basis were fees and slippage deducted? | Not deducted, or deducted at a figure with no clear origin |
| Did the sample include a market completely unlike the current one? | It covered a single one-way move |
If any one of the five has no answer, do not let this backtest support a real-money decision yet.
Reading a backtest report: equity curve, max drawdown, win rate and payoff ratio
In a backtest report, leave the total return for last. Two strategies can both post "up 30 percent" while one climbs steadily the whole way and the other loses half its value before clawing back. Whether you could actually have held either one is a completely different question. At a minimum, a backtest report deserves these three passes:
- The shape of the equity curve. Do not just look at the endpoint, look at the whole path: smoothly rising, or wild swings with a couple of rollercoaster rides before it scrapes back to even. The shape says far more than the final figure about what holding this strategy would actually feel like.
- Maximum drawdown. How far it fell from the curve's peak to the trough that followed. This is the line in the report that deserves the most respect, because it roughly answers "how much unrealized loss you would have to sit through at the worst moment." If the drawdown is too deep, you probably could not have held on no matter how good the ending looks. It ties directly into writing position limits and stops into your code, so the two are worth reading together.
- Win rate paired with the payoff ratio. A high win rate does not mean profit. You also need to know how much an average winner makes and how much an average loser costs, which is the payoff ratio. A 70 percent win rate that wins small and loses big still bleeds money over time, while a 40 percent win rate that wins big and loses small can be a perfectly good ledger. Read the two numbers together, because either one on its own will mislead you.
What to do after a backtest passes
A backtest has no order queue, no network latency and no dropped connection. What it can answer is how these rules would have done in that stretch of history, and its use is to throw out clearly unworkable ideas early. After it passes, take the same logic to the demo account and run it at real-world pace to confirm that order placement, reconnection and error handling all hold up; then go live with a small amount you can accept losing, and watch the gap between live fill prices and what the backtest assumed. Before going live, position caps and stops should already be written into the code.
FAQ
What is the difference between a backtest and paper trading, and which one comes first?
A backtest fast-forwards your strategy logic through historical market data, so a few minutes of compute can cover several years of price action and quickly weed out ideas that clearly do not work. Paper trading follows the live order book at real speed with virtual money, so it reflects real queuing and latency far better. The normal order is backtest first to filter ideas, then paper trading to run the surviving idea at real-world pace, and only after that a small amount of real money.
How do I tell whether my strategy is overfitted?
Three checks. Split the data in two, tune parameters on the first part only, then run the second part once and only once. Nudge each parameter up and down a little and rerun, watching whether performance changes gradually or collapses. Count how many parameters, thresholds and exception rules the strategy really has; the more there are, the more suspect it is. If two of the three look wrong, treat it as overfitted.
What does look-ahead bias mean in a backtest?
Look-ahead bias means the strategy quietly uses data it could not possibly have had at the moment it made the decision. The classic coding mistake is using a candle's closing price to decide whether to buy or sell at that same candle's open. In reality the close is only known once the candle has finished, but the backtest code reaches for it early, so the returns come out clearly overstated. The way to avoid it is to let every decision use only candles that have already closed.
How should fees and slippage be handled in a backtest?
Plug in the fee rate that actually applies to your own account, remembering that maker and taker rates differ. Slippage has no universal value: it depends on the pair's liquidity and your order size, so make a clearly conservative assumption and don't borrow someone else's number. The more often a strategy trades, the more times both costs are multiplied. A strategy whose return disappears once costs are deducted never worked in the first place.
How long does a backtest sample need to be?
Length alone says little. What matters is whether the sample contains markets of different character: a rise, a fall, a sideways grind, and ideally one or two violent moves. A backtest that covers a single one-way rally proves nothing about the strategy; it only shows the market was kind in that period.
How many OKX candles can ccxt pull in one call, and how do I get older data?
When fetch_ohlcv is called without since, ccxt requests OKX's Get candlesticks endpoint, which returns at most 300 per call. For older data, pass since and page forward from your start point; when the start is older than roughly the most recent 1,440 candles, ccxt switches to the Get candlesticks history endpoint automatically, which also returns at most 300 per call and 100 when limit is not passed. Both are public market data and need no API Key.