Skip to main content

Moving Average Crossover

TL;DR

The MA crossover strategy goes long when a fast moving average crosses above a slow one, and short when it crosses below. It's the most intuitive trend-following idea in trading. In its raw form, it loses money in most conditions because of whipsaws in choppy markets. Add a regime filter (ADX > 20, or higher-timeframe trend alignment) and it becomes viable.

A fast and a slow moving average on one chart, with every crossover marked. Two crossovers catch real trends and carry most of the profit. The cluster in the middle fires four times inside a sideways stretch and loses a little each time. That distribution, a few large winners paying for many small losers, is what every crossover system produces, and it is why skipping signals breaks it.

Eight signals, two worth taking. Skipping the bad ones means skipping the good ones.

The base strategy​

Classic 9/21 EMA crossover:

  • Long: 9 EMA crosses above 21 EMA
  • Short: 9 EMA crosses below 21 EMA
  • Exit: opposite crossover (always in the market)
  • Stop: 1× ATR beyond the opposite EMA

Simple. Easy to code. Fails in practice on its own.

Why the raw version fails​

In ranging markets — which is most markets, most of the time — the two EMAs dance around each other. Each crossover looks like a breakout signal. Each whips back within a few bars. You lose the stop-to-entry distance on each whipsaw.

Over a typical quarter, a raw 9/21 EMA crossover strategy on ES fires ~30–50 trades. 60–65% are losers. Winners are larger than losers on average, but not by enough to overcome the whipsaw frequency.

Result: ~1.0 profit factor in most market regimes. Breakeven, minus commissions. Not profitable.

The fixes that make it work​

Fix 1: Higher-timeframe trend filter. Only take longs if the daily close > 20-day SMA. Only take shorts if daily close < 20-day SMA. Cuts signal count by half. Removes most counter-trend failures.

Fix 2: ADX regime filter. Require ADX > 20 on the trade timeframe. Skips chop. Cuts another 30% of signals.

Fix 3: Price confirmation. Require the crossover bar to close beyond a recent swing high/low — confirming breakout alongside the crossover. Filters out "EMAs crossing while price is flat."

Fix 4: Partial exits and trailing stops. Don't always hold to opposite crossover. Scale out half at +2R, trail the rest. Captures trend without giving back all profit when it rolls.

With these fixes, a 9/21 strategy on ES daily can reach:

  • 40–50% win rate
  • Avg winner 2–3× avg loser
  • Profit factor 1.3–1.8
  • 5–15 trades per month

Still a long way from a perfect strategy. But tradeable.

Full filtered Pine Script (v6)​

//@version=6
strategy("9/21 EMA with Filters", overlay=true, initial_capital=50000, default_qty_type=strategy.fixed, default_qty_value=1)

// ---------- Inputs ----------
fastLen = input.int(9, "Fast EMA")
slowLen = input.int(21, "Slow EMA")
htfLen = input.int(20, "Higher-TF Trend Filter SMA (daily)")
adxLen = input.int(14, "ADX Length")
adxMin = input.float(20, "Min ADX for Entry")
atrLen = input.int(14, "ATR Length")
stopAtrMult = input.float(1.5, "Stop = N × ATR")

// ---------- Indicators ----------
fastEma = ta.ema(close, fastLen)
slowEma = ta.ema(close, slowLen)

// Higher-timeframe trend: daily close vs. daily 20-SMA
htfTrend = request.security(syminfo.tickerid, "D", ta.sma(close, htfLen))
htfClose = request.security(syminfo.tickerid, "D", close)
trendUp = htfClose > htfTrend
trendDown = htfClose < htfTrend

// ADX
[_diPlus, _diMinus, adxVal] = ta.dmi(adxLen, adxLen)
strongTrend = adxVal > adxMin

// ATR
atrVal = ta.atr(atrLen)

// ---------- Signals ----------
bullCross = ta.crossover(fastEma, slowEma)
bearCross = ta.crossunder(fastEma, slowEma)

longSignal = bullCross and trendUp and strongTrend
shortSignal = bearCross and trendDown and strongTrend

if longSignal
stopPrice = slowEma - stopAtrMult * atrVal
strategy.entry("MA Long", strategy.long)
strategy.exit("Long Exit", "MA Long", stop=stopPrice)

if shortSignal
stopPrice = slowEma + stopAtrMult * atrVal
strategy.entry("MA Short", strategy.short)
strategy.exit("Short Exit", "MA Short", stop=stopPrice)

// Exit on opposite crossover
if strategy.position_size > 0 and bearCross
strategy.close("MA Long", comment="Bear cross exit")
if strategy.position_size < 0 and bullCross
strategy.close("MA Short", comment="Bull cross exit")

// ---------- Plots ----------
plot(fastEma, color=color.yellow, linewidth=2, title="9 EMA")
plot(slowEma, color=color.blue, linewidth=2, title="21 EMA")
plotshape(longSignal, style=shape.triangleup, location=location.belowbar, color=color.green, size=size.small)
plotshape(shortSignal, style=shape.triangledown, location=location.abovebar, color=color.red, size=size.small)

Automating with CrossTrade​

Build the CrossTrade payload inside Pine Script and pass it to strategy.entry() / strategy.exit() via alert_message=:

if longSignal
stopPrice = slowEma - stopAtrMult * atrVal
msg = "key=YOUR-SECRET-KEY;"
msg := msg + "command=PLACE;"
msg := msg + "account=Sim101;"
msg := msg + "instrument={{ticker}};"
msg := msg + "action=BUY;"
msg := msg + "qty=1;"
msg := msg + "order_type=MARKET;"
msg := msg + "tif=DAY;"
msg := msg + "sync_strategy=true;"
msg := msg + "stop_loss=" + str.tostring(stopPrice) + ";"
strategy.entry("MA Long", strategy.long, alert_message=msg)
strategy.exit("Long Exit", "MA Long", stop=stopPrice)

if shortSignal
stopPrice = slowEma + stopAtrMult * atrVal
msg = "key=YOUR-SECRET-KEY;"
msg := msg + "command=PLACE;"
msg := msg + "account=Sim101;"
msg := msg + "instrument={{ticker}};"
msg := msg + "action=SELL;"
msg := msg + "qty=1;"
msg := msg + "order_type=MARKET;"
msg := msg + "tif=DAY;"
msg := msg + "sync_strategy=true;"
msg := msg + "stop_loss=" + str.tostring(stopPrice) + ";"
strategy.entry("MA Short", strategy.short, alert_message=msg)
strategy.exit("Short Exit", "MA Short", stop=stopPrice)

In the TradingView alert dialog: Condition = Order fills only; Message = {{strategy.order.alert_message}}.

Because this strategy fires relatively infrequently on daily, alerts can use alert.freq_once_per_bar_close. On intraday timeframes (15-min, 1-hour), monitor carefully — strategy.entry() can fire multiple times before the position opens on NT8.

See Pine Script webhook alerts for the full pattern.

Common mistakes​

  • Trading the raw 9/21 on intraday without filters. It loses money. Don't bother.
  • Optimizing the EMA periods endlessly. The specific numbers matter very little. The filters matter enormously.
  • Holding too long. Trailing after profitable moves captures more edge than waiting for opposite crossovers.
  • Using it on mean-reverting products. MA crossovers are trend strategies. On sideways currency pairs or range-bound commodities, they whipsaw badly even with filters.

Frequently Asked Questions

Does the 9/21 EMA crossover strategy work?

Raw, no — it breaks even to slightly negative across most market regimes due to whipsaws in choppy conditions. With a higher-timeframe trend filter and an ADX minimum, it becomes profitable. The filters are the strategy; the crossover is just the trigger.

What are the best moving average crossover settings?

The classic 9/21 works as well as any other pair (5/20, 8/21, 13/34). Obsessing over the specific numbers is wasted time. Adding regime filters produces dramatically more improvement than tuning the periods.

What timeframe is best for MA crossovers?

Higher timeframes (daily, 4-hour) produce more reliable signals because each bar carries more information. Intraday 5-minute crossovers fire too frequently and whipsaw more — possible to trade, but much harder to profit from.

Should I exit on an opposite crossover or trail the stop?

Trailing stops typically outperform strict opposite-crossover exits because they capture trend continuation after the initial edge is won. A common hybrid: trail below the slow EMA, exit if price closes below it.