Tutorial

Moving Average Crossover in Pine Script: A Complete Guide

The hello world of trading indicators. Build one from scratch in Pine Script v6, first as an indicator, then a backtestable strategy, with the pitfalls explained.

9 min read

The moving average crossover is the "hello world" of trading indicators. It is simple enough to understand in a sentence, common enough that every charting platform supports it, and a genuinely useful first project for learning Pine Script on TradingView. This guide walks through building one from scratch in Pine Script v6, first as an indicator that marks the crossovers, then as a strategy you can backtest, with the common variations and pitfalls explained along the way.

By the end you will have working code you can paste into the Pine Editor, and more importantly you will understand each line well enough to change it. This is a coding tutorial, not trading advice: a crossover is a tool for expressing logic on a chart, not a promise about outcomes.

What a moving average crossover is

A moving average smooths price into a single line by averaging the last N bars. A crossover strategy uses two of them: a fast one over a short lookback that reacts quickly, and a slow one over a longer lookback that reacts, well, slowly. When the fast average crosses above the slow average, it signals that recent prices are pulling up relative to the longer trend. When it crosses below, the opposite. That is the entire idea.

The appeal is that it turns a fuzzy notion, "the trend seems to be turning," into a precise, testable rule. The catch, which we will come back to, is that the same simplicity that makes it easy to code also makes it lag and produce false signals in choppy markets. Understanding both sides is the point of building it yourself.

Building the indicator

Start with an indicator that just draws the two averages and marks where they cross. Here is the complete v6 code.

pine
//@version=6
indicator("MA Crossover", overlay = true)

fastLen = input.int(9, "Fast MA Length")
slowLen = input.int(21, "Slow MA Length")

fastMA = ta.sma(close, fastLen)
slowMA = ta.sma(close, slowLen)

plot(fastMA, "Fast MA", color = color.aqua, linewidth = 2)
plot(slowMA, "Slow MA", color = color.orange, linewidth = 2)

bullish = ta.crossover(fastMA, slowMA)
bearish = ta.crossunder(fastMA, slowMA)

plotshape(bullish, "Bullish cross", shape.triangleup, location.belowbar, color.green, size = size.small)
plotshape(bearish, "Bearish cross", shape.triangledown, location.abovebar, color.red, size = size.small)

Walking through it: the //@version=6 annotation tells TradingView which language version to compile against, and it has to be the first line. The indicator() declaration names the script and sets overlay = true so the averages draw on top of the price chart rather than in a separate pane. The two input.int() calls create the length settings, which appear as editable fields in the script's settings, so you can tune the lookbacks without touching the code.

The two ta.sma() calls compute the simple moving averages of close over your chosen lengths. Then the two built-ins that do the real work: ta.crossover(a, b) returns true on the exact bar where a crosses from below b to above it, and ta.crossunder() is the mirror image. Finally, plotshape() draws a small triangle at each signal. That is a complete, working indicator.

Turning it into a strategy you can backtest

An indicator marks signals; a strategy acts on them so you can run it through the Strategy Tester. The conversion is small: swap the declaration, and replace the shape plots with order calls.

pine
//@version=6
strategy("MA Crossover Strategy", overlay = true, margin_long = 100, margin_short = 100)

fastLen = input.int(9, "Fast MA Length")
slowLen = input.int(21, "Slow MA Length")

fastMA = ta.ema(close, fastLen)
slowMA = ta.ema(close, slowLen)

plot(fastMA, "Fast MA", color = color.aqua, linewidth = 2)
plot(slowMA, "Slow MA", color = color.orange, linewidth = 2)

if ta.crossover(fastMA, slowMA)
    strategy.entry("Long", strategy.long)

if ta.crossunder(fastMA, slowMA)
    strategy.close("Long")

The strategy() declaration replaces indicator(). Note the two order functions live inside if blocks: this is required in v6, where the when parameter that older scripts used on strategy.entry() has been removed. If you are porting an older crossover from v5, that is one of the changes to watch for, and we cover the rest in our guide to migrating from Pine Script v5 to v6. Here, strategy.entry() opens a long position on the bullish cross, and strategy.close() exits it on the bearish cross. Load it and open the Strategy Tester tab to see the trade list and equity curve on historical data.

SMA or EMA, and choosing lengths

You may have noticed the indicator used ta.sma() and the strategy used ta.ema(). That was deliberate, to show both. The simple moving average weights every bar in its window equally. The exponential moving average weights recent bars more heavily, so it turns faster and hugs price more closely. Neither is universally better; the EMA reacts sooner but whipsaws more, and the SMA is smoother but lags more.

Simple MA (SMA)Exponential MA (EMA)
WeightingEvery bar equalRecent bars heavier
Reaction speedSlowerFaster
WhipsawsFewerMore
Pine functionta.sma()ta.ema()

Length choice is the same trade-off in another form. A 9-and-21 pairing reacts quickly and suits shorter timeframes; a 50-and-200 pairing (the classic "golden cross" and "death cross") reacts slowly and is used for long-term trend context. The gap between the two lengths matters as much as the lengths themselves: a 9-and-21 pair sits close together and flips often, while a 50-and-200 pair sits far apart and crosses rarely, sometimes only a handful of times a year on a daily chart. Because the lengths are inputs, you can change them in the settings and watch the effect immediately rather than editing code each time, which is exactly the kind of quick experimentation the input fields are there to encourage.

A word of caution on tuning them, though. It is easy to keep nudging the lengths until the backtest looks its best on one chart, but numbers hand-picked to fit past data on a single symbol rarely behave the same way on the next symbol or the next stretch of history. Treat the lengths as settings to reason about, not dials to optimize blindly.

The honest pitfalls

The crossover's weakness is the flip side of its simplicity. Because both averages are built from past prices, every signal arrives after the move has already begun; the indicator lags by design. A 50-period average, for instance, cannot turn until enough new bars have shifted its 50-bar window, so by the time the fast line crosses it, part of the move is already in the past. In a strong, sustained trend that lag is tolerable, because the trend continues long enough to make the late entry worthwhile. In a sideways, choppy market the two averages cross back and forth repeatedly, producing a string of false signals known as whipsaws, each of which would trigger a trade in the strategy version.

This is also why the trade list in the Strategy Tester matters more than any single number at the top of it. A handful of trades tells you almost nothing, because a couple of lucky or unlucky crosses can dominate the result. If you are going to judge a crossover, judge it across a meaningful sample, ideally dozens of trades over a few years of data and across more than one market, so you are looking at how the tool behaves rather than how one stretch of history happened to unfold.

This is why traders often add a filter, a longer-term trend condition, a volatility floor, or a confirmation requirement, so the crossover only acts in conditions where it tends to behave. Adding one is a small code change: compute the filter as its own variable and combine it with the crossover using and.

Adding a trend filter

A common addition is a long-term trend filter that only allows long entries while price is above a slow reference average, so the strategy sits out the choppy, range-bound stretches where crossovers whipsaw most. Here is the strategy from before with a 200-period trend filter added.

pine
//@version=6
strategy("MA Crossover With Trend Filter", overlay = true, margin_long = 100, margin_short = 100)

fastLen  = input.int(9, "Fast MA Length")
slowLen  = input.int(21, "Slow MA Length")
trendLen = input.int(200, "Trend Filter Length")

fastMA  = ta.ema(close, fastLen)
slowMA  = ta.ema(close, slowLen)
trendMA = ta.ema(close, trendLen)

plot(fastMA, "Fast MA", color = color.aqua, linewidth = 2)
plot(slowMA, "Slow MA", color = color.orange, linewidth = 2)
plot(trendMA, "Trend MA", color = color.gray, linewidth = 1)

crossUp    = ta.crossover(fastMA, slowMA)
aboveTrend = close > trendMA

if crossUp and aboveTrend
    strategy.entry("Long", strategy.long)

if ta.crossunder(fastMA, slowMA)
    strategy.close("Long")

The important detail is the line crossUp = ta.crossover(fastMA, slowMA). In v6, the and and or operators evaluate lazily, meaning the right side of an expression can be skipped when the left side already settles the result. If you wrote the ta.crossover() call directly inside the if crossUp and aboveTrend condition, it could be skipped on some bars, and functions like ta.crossover() need to run on every bar to track state correctly. Assigning the call to its own variable first guarantees it executes each bar, then the boolean is what gets combined with and. That hoisting habit prevents one of the subtle bugs we cover in why Pine Script won't compile.

Load this version in the Strategy Tester and compare its trade count against the unfiltered version on the same symbol and timeframe. The filtered one usually takes fewer trades, since it ignores crosses that happen below the trend average. Whether that trade-off suits what you are testing is a question the tester answers, not the code.

Building it faster

Typing this out by hand is a great way to learn, and if you are learning Pine Script that is exactly what you should do. If you just want a working crossover with a specific filter or a particular exit rule, describing it in plain English is faster than assembling it line by line. A tool like PineScripter generates the v6 code, marks the crossovers, and lets you ask for a change, like "only take longs when price is above the 200 EMA," without you rewriting the script. You can see how that workflow compares across tools in our roundup of the best AI Pine Script generators.

The takeaway

A moving average crossover is two averages and two built-in functions, ta.crossover() and ta.crossunder(), wrapped in an indicator or a strategy. It is the ideal first Pine Script project because you can build it in a dozen lines, understand every one of them, and then extend it with filters and different average types as you learn. Just go in clear-eyed about the lag and the whipsaws, because knowing a tool's weaknesses is as much a part of using it well as knowing how to code it.


Disclaimer: PineScripter is a coding tool for Pine Script development. It does not provide financial advice and does not guarantee trading profits. Always backtest strategies thoroughly and understand the risks before live trading.