You wrote a crossover rule, it compiled, and the chart is covered in arrows. Or it has no arrows at all, even though the two lines clearly cross twice on the screen. Or the arrow shows up in the backtest and the alert never fires. Almost every one of these problems traces back to a misunderstanding of what ta.crossover and ta.crossunder actually return on each bar, so it is worth slowing down and learning the two functions properly.
This guide covers the exact definition of a cross in Pine Script v6, how to write the same test by hand, how to cross a fixed level instead of a second line, the mistakes that make a cross silently disappear, and how to use the result in a strategy and an alert without it repainting. Every code block is complete, so you can paste it into the Pine Editor and read the result on a chart.
What ta.crossover and ta.crossunder return
Both functions take two series and return a plain bool. They answer one narrow question about the current bar: did the relationship between the two series flip on this bar?
ta.crossover(source1, source2) is true when source1 is above source2 on the current bar and was at or below it on the previous bar. ta.crossunder(source1, source2) is the mirror image: true when source1 is below source2 now and was at or above it one bar ago. On every other bar, including the many bars where the lines are far apart, both return false.
That makes a cross an event on a single bar, not a state. The fast line being above the slow line is a state, and it stays true for as long as the trend lasts. The cross is the one bar where the state changed. This distinction drives most of the design decisions later in the guide, so keep it in mind.
//@version=6
indicator("Crossover basics", overlay = true)
fast = ta.sma(close, 10)
slow = ta.sma(close, 30)
up = ta.crossover(fast, slow)
down = ta.crossunder(fast, slow)
plot(fast, "Fast", color.teal)
plot(slow, "Slow", color.orange)
plotshape(up, "Cross up", shape.triangleup, location.belowbar, color.teal, size = size.small)
plotshape(down, "Cross down", shape.triangledown, location.abovebar, color.orange, size = size.small)The two shapes are drawn only on bars where the cross happened. If you scroll through a long trend you will see no marks at all between them, which is the correct behavior. If you want a mark on every bar while a trend lasts, you want the state fast > slow, not the event.
If you only care that the two lines crossed and not in which direction, there is also ta.cross(source1, source2), which is true on either kind of cross. It is handy for a color flash or a label, but a strategy almost always needs to know the direction, so you will use the other two far more often.
The same test written by hand
It helps to see that there is no magic in the built-in. A crossover is a comparison on the current bar combined with the opposite comparison on the previous bar, using the history operator [1].
//@version=6
indicator("Manual crossover", overlay = true)
a = ta.sma(close, 10)
b = ta.sma(close, 30)
builtInUp = ta.crossover(a, b)
manualUp = a > b and a[1] <= b[1]
plotchar(builtInUp, "Built-in", "^", location.belowbar, color.teal)
plotchar(manualUp and not builtInUp, "Mismatch", "!", location.abovebar, color.red)The red exclamation mark should never appear. The two forms agree on every bar where both values exist, and the second plotchar is a cheap way to prove it to yourself. The practical value of the manual form is that you can tweak it. If you want to treat a touch as a cross, change the strict comparison. If you want the lines to be separated by a minimum distance on the cross bar, add that to the condition. The built-in cannot do either.
Look closely at the <= on the previous bar. It is deliberate. Imagine the fast line equals the slow line exactly on bar 10, then moves above on bar 11. If the rule used a strict < on the previous bar, bar 10 would not count as the "before" state and the cross would never register. Using <= for the previous bar and > for the current bar means a cross is counted exactly once: on the first bar where the line is strictly above after having been at or below. Exact equality is rare with floating point averages, but it is common with integer-like data such as a level you set at a round number, which brings us to the next section.
Crossing a level instead of a second line
The second argument does not have to be a moving line. It can be a number, an input, or a plot from another indicator. Crossing a fixed level is how most oscillator rules are written: RSI back above 30, MACD histogram through zero, a stochastic leaving the overbought zone.
//@version=6
indicator("RSI level cross", overlay = false)
len = input.int(14, "Length", minval = 2)
level = input.float(30.0, "Level")
r = ta.rsi(close, len)
backAbove = ta.crossover(r, level)
plot(r, "RSI")
hline(level, "Level")
plotshape(backAbove, "Back above", shape.circle, location.bottom, color.teal, size = size.tiny)Here the cross fires on the bar where RSI closes above 30 after being at or below it. It does not fire on the bar where RSI first dips below 30, and it does not fire repeatedly while RSI stays above. Compare that with the lazy way of writing the rule, r > level, which would be true on every bar of the recovery and give you a wall of marks.
This is also where the <= matters in practice. A value that lands exactly on 30.0 counts as "not yet above", so the cross is recorded on the next bar that closes higher. A rule that used strict inequality in both directions would miss that case entirely.
For a deeper look at the oscillators themselves, the RSI strategy guide builds a complete rule around this exact kind of level cross.
Why a cross sometimes disappears
If a crossover you can see with your own eyes never fires, the cause is almost always one of four things.
The first is calling the function inside a conditional. ta.crossover needs the previous bar's values of its inputs, and Pine Script only keeps a full history for a series when the function that uses it runs on every bar. If you call it inside an if block, a ternary, or a function that is not called every bar, it runs only on some bars and sees gaps in its history. The compiler warns about this, and the warning is worth taking seriously.
//@version=6
indicator("Where to call it", overlay = true)
fast = ta.sma(close, 10)
slow = ta.sma(close, 30)
// Risky: evaluated only on bars where close > open
gated = close > open ? ta.crossover(fast, slow) : false
// Safe: evaluated on every bar, then combined with the filter
crossed = ta.crossover(fast, slow)
safe = crossed and close > open
plotshape(safe, "Safe", shape.circle, location.belowbar, color.teal, size = size.tiny)
plotshape(gated and not safe, "Differs", shape.xcross, location.abovebar, color.red)The rule of thumb is to compute every ta.* call once, at the top level of the script, into a named variable, and only then combine those variables with filters. That habit also protects you from the short-circuit behavior of and and or in v6, which can skip the right-hand side entirely. The lazy evaluation post shows how that bites when a ta call hides on the right of an and.
The second cause is na. If either input is na on the current or the previous bar, the comparison is na, and the cross does not register. A moving average with a 200 bar length has no value for the first 199 bars. That is expected, but a source that goes na in the middle of the chart, such as data from another symbol with gaps, can swallow a real cross. The na handling guide covers how to detect and patch those holes.
The third is a mismatch between what you see and what is compared. The plot you are looking at might be offset, smoothed after the fact, or drawn from a different timeframe than the series you passed in. The function compares the values you gave it, not the pixels. When in doubt, plot the exact variables you pass to ta.crossover and nothing else.
The fourth is two lines that move together. If both averages are built from the same data with similar lengths, they can touch and separate repeatedly over a few bars, and you will get a cluster of crosses instead of one. That is correct behavior, not a bug, and it is the reason the crossover rule is paired with a filter in the next section.
Using the cross in a strategy
An entry rule built from a cross is short, but it deserves a few guard rails. The version below enters long on a fast over slow cross only when price is above a longer trend average, and closes the position on the opposite cross.
//@version=6
strategy("Crossover with filter", overlay = true, default_qty_type = strategy.percent_of_equity, default_qty_value = 10)
fastLen = input.int(10, "Fast length", minval = 1)
slowLen = input.int(30, "Slow length", minval = 2)
trendLen = input.int(200, "Trend filter length", minval = 2)
fast = ta.ema(close, fastLen)
slow = ta.ema(close, slowLen)
trend = ta.sma(close, trendLen)
crossUp = ta.crossover(fast, slow)
crossDown = ta.crossunder(fast, slow)
longCond = crossUp and close > trend
if longCond
strategy.entry("Long", strategy.long)
if crossDown
strategy.close("Long")
plot(fast, "Fast", color.teal)
plot(slow, "Slow", color.orange)
plot(trend, "Trend", color.gray)Notice the structure. All three ta calls happen at the top level. The filter is a plain comparison on the right. The exit does not repeat the filter, because the reason to leave is the opposite event, regardless of where price sits relative to the long average.
A few details are worth knowing. A strategy evaluates this script once per bar and, by default, fills the order at the open of the next bar, because the cross is only known when the bar closes. That is the honest way to model it. Looking for a fill at the close of the cross bar would assume you could act on information you did not yet have. And because the exit is a different event from the entry, the strategy can sit out a stretch where the fast line is above the slow one but price is below the trend average. That is a design choice, not an error, and you should know which one you made.
The Strategy Tester in TradingView will report the results of this rule on whatever symbol and timeframe you apply it to. Treat those results as a description of the past on that one dataset, not as a statement about what the rule will do next. Two or three parameter sets that all behave sensibly are more informative than one that happens to look good.
Alerts that fire on the right bar
A cross is computed on every tick of the live bar, and while the bar is open the fast line can be above the slow line at one moment and below it a moment later. An alert built directly on ta.crossover can therefore fire, and then the cross can vanish by the time the bar closes. If you want one firing per confirmed cross, tie the condition to bar confirmation.
//@version=6
indicator("Crossover alert", overlay = true)
fast = ta.ema(close, 9)
slow = ta.ema(close, 21)
up = ta.crossover(fast, slow) and barstate.isconfirmed
down = ta.crossunder(fast, slow) and barstate.isconfirmed
alertcondition(up, "Fast above slow", "Fast EMA crossed above slow EMA")
alertcondition(down, "Fast below slow", "Fast EMA crossed below slow EMA")
plot(fast, "Fast", color.teal)
plot(slow, "Slow", color.orange)The ta.crossover call is on the left of the and, so it still runs on every bar and keeps its history intact. barstate.isconfirmed is true only on the last tick of a bar, which means the alert fires once, at the close, after the cross is final. The trade-off is timing: you hear about the cross at the end of the bar rather than in the middle of it. For most rules built on closing prices that is the right trade. The barstate.isconfirmed post and the repainting guide explain why, and the alert system guide shows how to build the whole message and delivery path.
Common mistakes, in one place
Using source1 > source2 where you meant a cross is the most frequent error. It gives you a state and fires on every bar of the trend. If your strategy is entering again and again, check this first.
Swapping the arguments is the second. ta.crossover(slow, fast) fires when the slow line rises above the fast one, which is the opposite of what most people intend. When a rule behaves like its mirror image, read the argument order out loud.
Mixing timeframes without care is the third. If one input comes from request.security on a higher timeframe, it only changes when that higher bar updates, so crosses can land at odd times. The multi-timeframe guide covers how to fetch it so it does not repaint.
Treating every cross as equally meaningful is the last. The function only reports that two numbers changed order. Whether that is useful depends on the data, the market, and the filter around it. For a wider look at the most common pairing, see the moving average crossover guide.
Turning a plain-English rule into this code
Most people do not start with ta.crossover. They start with a sentence like "alert me when the 9 EMA closes above the 21 EMA, but only if the 200 average is rising." Turning that into code means choosing the right function, putting the ta calls at the top level, deciding between a state and an event, and wiring in confirmation.
That is the kind of request PineScripter is built for. You describe the rule in the AI editor, it writes Pine Script v6 and checks the code for compile errors the way TradingView does, and a follow-up such as "also require RSI above 50" edits only the lines that change. You can then run the script on the chart inside PineScripter, on the crypto markets it supports, and see where the arrows land before you paste it into TradingView. Reading the code yourself is still the point, which is why the sections above are worth knowing: you will be able to tell whether what it wrote is an event or a state, and whether the call sits where it should.
A note on scope
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.
Frequently asked questions
What is the difference between ta.crossover and ta.cross?
ta.crossover is true only when the first series moves from at or below the second to above it. ta.crossunder is the opposite direction. ta.cross is true for either direction, so use it when you only need to know that the two lines swapped order.
Does ta.crossover fire when two values are exactly equal?
Not on that bar. The first series must be strictly above the second on the current bar and at or below it on the previous bar. If the lines touch and then separate, the cross is recorded on the first bar where the first series is strictly above.
Can I cross a fixed number such as 30 or 0?
Yes. The second argument can be any number or series, so ta.crossover(rsi, 30) and ta.crossunder(histogram, 0) are normal. The result is true only on the bar where the value moves through the level.
Why does my crossover never fire on some bars I can see?
Common causes are calling the function inside an if block or ternary so it skips bars, na values in either input, plotting a different series than the one you passed in, or swapped arguments. Compute the cross once at the top level and plot the exact inputs.
How do I stop a crossover alert from repainting?
Combine the cross with barstate.isconfirmed, keeping the ta call on the left so it still runs on every bar. The alert then fires at the bar close, once the cross is final, at the cost of hearing about it a little later.
Describe your indicator in plain English and get Pine Script that compiles →
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.