A free moving average pullback backtester for ThinkorSwim. Test pullbacks to 5 stacked moving averages, blind or confirmed, with ATR targets and stops.
Download BacktesterIn this tutorial I build a moving average pullback backtester for ThinkorSwim from scratch. It buys pullbacks into one of 5 moving averages while all 5 are stacked in an uptrend, or shorts rallies into them in a downtrend, and it exits at a target or a stop measured in ATR. The Floating P/L study under the chart shows how each version has done.
The backtester answers three questions: do moving average pullbacks work on a given market, which moving average works best there, and should you wait for a confirmation candle or buy the pullback blindly? The tutorial is beginner-friendly, and every choice is an input, so you can run a new test from the settings window without touching the code.
A moving average pullback is a dip back to a moving average inside a trend, which traders use as a place to join that trend. In an uptrend, price pulls back down to an average such as the 21 EMA and then moves higher. In a downtrend, price rallies up to the average and turns lower.
The moving average pullback backtester puts that idea to the test with fixed rules. I built it to answer three questions:
The moving average pullback backtester is built on 5 requirements I set before writing any code, so the result is a repeatable process:
Our free Stacked Moving Averages indicator plots the same 5 averages if you want to see the structure on your chart.
The moving average pullback backtester has 2 entry types, picked from the entryType input. Both need the stack in place. They differ in what price has to do at the average and in the price the backtest pays.
| Rule | Pullback only | Confirmation |
|---|---|---|
| Long entry | Bullish stack and the bar’s low at or below the chosen average | Bullish stack, the lowest low of the last 2 bars at or below the average, and a close above the previous bar’s high |
| Short entry | Bearish stack and the bar’s high at or above the average | Bearish stack, the highest high of the last 2 bars at or above the average, and a close below the previous bar’s low |
| Entry price | The moving average’s value | The close of the signal bar |
Confirmation waits for price to show that buyers have taken control by closing above the previous candle’s high. It checks 2 bars because the low that tags the average often comes one bar before the close that confirms it.
The moving average pullback comparison at the start of the video ran on SPY, the S&P 500 ETF, over 5 years of daily bars. It tested long trades only, with a target of 3 times the ATR from the entry price and a stop of 2 times the ATR.
These are historical results from the video. It shows P/L only: no trade counts, no win rates, and no order size for this test. The strategy’s default is 100 shares per order.
| Moving average (SPY, daily, 5 years, longs only, 3x ATR target, 2x ATR stop) | P/L, pullback only | P/L, with confirmation | Trades |
|---|---|---|---|
| 8 EMA | $1,732.10 | $6,023.84 | Not shown |
| 21 EMA | $4,291.46 | $1,551.53 | Not shown |
| 34 EMA | $5,934.91 | $4,556.75 | Not shown |
| 50 SMA | $5,046.69 | $3,034.42 | Not shown |
The moving average pullback backtester is a ThinkorSwim strategy, so you create it on the Strategies tab, which is what gives you P/L reporting.
To import the downloaded file instead of pasting, follow our guide on how to import a thinkScript file into ThinkorSwim.
The moving average pullback backtester has 7 inputs in its settings window. The SPY comparison used a 3x target and a 2x stop, so change the two multipliers to repeat it.
| Input | Default | What it controls |
|---|---|---|
longEntries |
yes | Buys pullbacks in a bullish stack |
shortEntries |
no | Shorts pullbacks in a bearish stack |
entryMA |
EMA8 | The average for the pullback: EMA8, EMA21, EMA34, SMA50 or SMA200 |
entryType |
pullbackOnly | Enter blindly at the average, or wait for Confirmation |
targetMultiplier |
2 | Target distance from the entry price, in ATRs |
stopMultiplier |
1 | Stop distance from the entry price, in ATRs |
quantity |
100 | Shares per order |
The moving average pullback backtester code has 6 parts: the inputs, the stack, two switches, the stored ATR and the orders. The snippets come from the downloaded file.
input entryMA = {default EMA8, EMA21, EMA34, SMA50, SMA200};
input entryType = {default pullbackOnly, Confirmation};
Curly brackets turn an input into a dropdown of text choices. I could have used a number, but picking EMA21 from a list is more intuitive.
Five def lines build the averages with ExpAverage(close, 8), 21 and 34, and SimpleMovingAvg(close, 50) and 200. Then the bullish stack needs each one above the next slower one:
def bullishStackedMAs = EMA8 > EMA21 and EMA21 > EMA34 and EMA34 > SMA50 and SMA50 > SMA200;
The bearish stack is the same line with every sign flipped to less than.
def entryPullbackMA;
switch (entryMA){
case EMA8:
entryPullbackMA = EMA8;
case EMA21:
entryPullbackMA = EMA21;
case EMA34:
entryPullbackMA = EMA34;
case SMA50:
entryPullbackMA = SMA50;
case SMA200:
entryPullbackMA = SMA200;
}
Each text choice becomes a case that sets entryPullbackMA, the one variable the rest of the code uses.
The strategy wouldn’t compile at this point, so in the video I added a temporary AddOrder line to check the dropdown, then replaced it later. I also note that SMA200 triggers very few times: by the time price pulls back that far, it has usually broken the stack.
A second switch sets 4 variables for each entry type: a true or false entry signal for each side, and the entry price the P/L is measured from.
case pullbackOnly:
bullEntrySignal = bullishStackedMAs and low <= entryPullbackMA;
bullEntryPrice = entryPullbackMA;
bearEntrySignal = bearishStackedMAs and high >= entryPullbackMA;
bearEntryPrice = entryPullbackMA;
Pullback only buys the average itself, at its value.
case Confirmation:
bullEntrySignal = bullishStackedMAs and Lowest(low,2) <= entryPullbackMA and close > high[1];
bullEntryPrice = close;
bearEntrySignal = bearishStackedMAs and Highest(high,2) >= entryPullbackMA and close < low[1];
bearEntryPrice = close;
Confirmation enters at the close of the bar that confirms the move.
Average true range (ATR) measures how far a market typically moves in one bar, averaged over a set number of bars. The backtester sizes its target and stop with it.
def ATRvalue = if (bullEntrySignal and !bullEntrySignal[1]) or (bearEntrySignal and !bearEntrySignal[1]) then ATR(14) else ATRvalue[1];
Put ATR() straight into the exits and the target and stop would move with every candle. This line saves the 14-period ATR on the first bar of a new signal and carries it forward until the next new signal.
The code passes AddOrder 4 parameters: the order type, the condition that has to be true, the price, and the quantity. ThinkorSwim offers 6 order types: BUY_AUTO, BUY_TO_CLOSE, BUY_TO_OPEN, SELL_AUTO, SELL_TO_CLOSE and SELL_TO_OPEN.
#Long-Side Trades
AddOrder(OrderType.BUY_TO_OPEN, longEntries and bullEntrySignal, bullEntryPrice, quantity);
AddOrder(OrderType.SELL_TO_CLOSE, high >= entryPrice() + targetMultiplier * ATRValue, entryPrice() + targetMultiplier * ATRValue, quantity);
AddOrder(OrderType.SELL_TO_CLOSE, low <= entryPrice() - stopMultiplier * ATRValue, entryPrice() - stopMultiplier * ATRValue, quantity);
The first order buys when longEntries is yes and the bullish signal is true. EntryPrice() returns the price of the open position, so the target and stop exits fill a fixed number of ATRs above and below it.
#Short-Side Trades
AddOrder(OrderType.SELL_TO_OPEN, shortEntries and bearEntrySignal, bearEntryPrice, quantity);
AddOrder(OrderType.BUY_TO_CLOSE, low <= entryPrice() - targetMultiplier * ATRValue, entryPrice() - targetMultiplier * ATRValue, quantity);
AddOrder(OrderType.BUY_TO_CLOSE, high >= entryPrice() + stopMultiplier * ATRValue, entryPrice() + stopMultiplier * ATRValue, quantity);
Every piece is reversed: the target below the entry, the stop above.
After building the moving average pullback backtester, I changed one setting at a time on SPY, Verizon (VZ) and Home Depot (HD). These are historical results from the video, which never shows a trade count. At the end I describe the tests as going back 5 years, but the chart isn’t restated for each run.
| Symbol and side | Average and entry type | Target / stop | P/L shown | Trades | Chart and period |
|---|---|---|---|---|---|
| SPY, longs | 8 EMA, pullback only | 2x / 1x ATR | $3,646 | Not shown | Same SPY chart, not restated |
| SPY, longs | 8 EMA, pullback only | 5x / 3x ATR | $6,579 | Not shown | Same SPY chart, not restated |
| SPY, longs | 34 EMA, pullback only | 5x / 3x ATR | $6,104 | Not shown | Same SPY chart, not restated |
| SPY, shorts | 34 EMA, confirmation, then pullback only | 5x / 3x ATR | Confirmation: no trades met all the criteria. Pullback only: poor, no figure read out. | Confirmation: none. Pullback only: not shown. | Same SPY chart, not restated |
| VZ, longs | 8 EMA, both entry types | 3x / 2x, then 2x / 1x and 2x / 2x ATR | Poor. Confirmation and smaller multipliers didn’t help. No figures read out. | Not shown | Not restated |
| HD, longs | 8 EMA, confirmation | 2x / 2x ATR | $919 | Not shown | Not restated |
| HD, longs | 8 EMA, pullback only | 2x / 2x ATR | $5,214 | Not shown | Not restated |
| HD, longs | 21 EMA | 2x / 2x ATR | Much lower than $5,214. No figure read out. | Not shown | Not restated |
Widening SPY’s 8 EMA target and stop from 2x and 1x to 5x and 3x raised the P/L from $3,646 to $6,579. The P/L chart also showed much less red at the start, which suggests the wider stop kept trades open longer.
Verizon is the counterexample: the pullbacks that tested well on SPY didn’t work there. On Home Depot, the confirmation that helped SPY’s 8 EMA cut the P/L from $5,214 to $919. The best pullback depends on the market, which is why you test each one.
The moving average pullback backtester answers questions before any capital goes in. I prefer testing longs only: I find the data better, and most traders are long-focused.
entryMA and entryType one at a time. On SPY the answer changed with the depth of the pullback.A result here shows how a pullback rule behaved on one chart in the past. Decide your stop before the trade, which the stop multiplier gives you, and size the position so a stop-out costs a small, fixed share of your account.
The moving average pullback backtester has a few behaviors that trip people up.
longEntries or shortEntries is yes, and that the stack is in place. In the video, SPY shorts on the 34 EMA with confirmation took no trades.The moving average pullback backtester is a starting point. At the end of the video I suggest adding your own moving averages as extra entryMA choices, adding labels (see our AddLabel tutorial), and plotting the ATR value, so you know the exact target and stop prices when a trade sets up.
To test crossovers instead of pullbacks, use our moving average crossover backtester. To find stocks that are already stacked, run our stacked moving average scans.
Our research asks which moving average pullback is best for trend days and which is best on a 5-minute QQQ chart. More strategies are on our ThinkorSwim backtesters page.
#TOS Indicators
#Home of the Volatility Box
#Indicator Name: Moving Average Pullback Backtester
#Full tutorial here: https://tosindicators.com/backtesters/moving-average-pullback
input longEntries = yes;
input shortEntries = no;
input entryMA = {default EMA8, EMA21, EMA34, SMA50, SMA200};
input entryType = {default pullbackOnly, Confirmation};
input targetMultiplier = 2;
input stopMultiplier = 1;
input quantity = 100;
def EMA8 = ExpAverage(close, 8);
def EMA21 = ExpAverage(close, 21);
def EMA34 = ExpAverage(close, 34);
def SMA50 = SimpleMovingAvg(close, 50);
def SMA200 = SimpleMovingAvg(close, 200);
def entryPullbackMA;
switch (entryMA){
case EMA8:
entryPullbackMA = EMA8;
case EMA21:
entryPullbackMA = EMA21;
case EMA34:
entryPullbackMA = EMA34;
case SMA50:
entryPullbackMA = SMA50;
case SMA200:
entryPullbackMA = SMA200;
}
def bullishStackedMAs = EMA8 > EMA21 and EMA21 > EMA34 and EMA34 > SMA50 and SMA50 > SMA200;
def bearishStackedMAs = EMA8 < EMA21 and EMA21 < EMA34 and EMA34 < SMA50 and SMA50 < SMA200;
def bullEntrySignal;
def bullEntryPrice;
def bearEntrySignal;
def bearEntryPrice;
switch(entryType){
case pullbackOnly:
bullEntrySignal = bullishStackedMAs and low <= entryPullbackMA;
bullEntryPrice = entryPullbackMA;
bearEntrySignal = bearishStackedMAs and high >= entryPullbackMA;
bearEntryPrice = entryPullbackMA;
case Confirmation:
bullEntrySignal = bullishStackedMAs and Lowest(low,2) <= entryPullbackMA and close > high[1];
bullEntryPrice = close;
bearEntrySignal = bearishStackedMAs and Highest(high,2) >= entryPullbackMA and close < low[1];
bearEntryPrice = close;
}
def ATRvalue = if (bullEntrySignal and !bullEntrySignal[1]) or (bearEntrySignal and !bearEntrySignal[1]) then ATR(14) else ATRvalue[1];
#Long-Side Trades
AddOrder(OrderType.BUY_TO_OPEN, longEntries and bullEntrySignal, bullEntryPrice, quantity);
AddOrder(OrderType.SELL_TO_CLOSE, high >= entryPrice() + targetMultiplier * ATRValue, entryPrice() + targetMultiplier * ATRValue, quantity);
AddOrder(OrderType.SELL_TO_CLOSE, low <= entryPrice() - stopMultiplier * ATRValue, entryPrice() - stopMultiplier * ATRValue, quantity);
#Short-Side Trades
AddOrder(OrderType.SELL_TO_OPEN, shortEntries and bearEntrySignal, bearEntryPrice, quantity);
AddOrder(OrderType.BUY_TO_CLOSE, low <= entryPrice() - targetMultiplier * ATRValue, entryPrice() - targetMultiplier * ATRValue, quantity);
AddOrder(OrderType.BUY_TO_CLOSE, high >= entryPrice() + stopMultiplier * ATRValue, entryPrice() + stopMultiplier * ATRValue, quantity);Want the .ts file and the one-click import? Both come with a free account.
Here are some resources that you may find useful:
Join 40,000+ traders using institutional-grade tools for ThinkOrSwim.
Get the Bundle