Tutorial

Higher Highs and Lower Lows in Pine Script: Structure

Detect swing points, label HH, HL, LH and LL, and turn them into a trend state with alerts in Pine Script v6, handling pivot delay correctly.

•12 min read

You open a chart, squint at a run of swings, and say "that is an uptrend, it keeps making higher highs and higher lows." A moment later you try to turn that sentence into Pine Script and the vagueness shows up. Higher than what? Which swing counts? When do you know a swing has happened at all? The eye answers those questions without effort. Code needs every one of them spelled out, and the answers decide whether your indicator is useful or quietly misleading.

This guide builds a market structure indicator from the ground up. You will detect swing points with ta.pivothigh and ta.pivotlow, classify each one as a higher high (HH), lower high (LH), higher low (HL) or lower low (LL), and then turn that sequence into a trend state you can color, filter on, and raise alerts from. Along the way you will see the one timing detail that trips up most first attempts: a swing is only known several bars after it forms.

What market structure means in code

Market structure is a description of how successive swings relate to each other. A swing high is a bar whose high is the highest within some window on both sides. A swing low is the mirror image. Once you have two swing highs in a row, you can ask whether the newer one is above or below the older one. The same goes for swing lows.

That gives four labels. A higher high is a swing high above the previous swing high. A lower high is a swing high below it. A higher low is a swing low above the previous swing low, and a lower low is a swing low below it. A sequence of HH and HL is what people usually call an uptrend in structure terms, and a sequence of LH and LL is a downtrend. Anything mixed is a range or a transition.

Notice what this definition does not say. It does not predict anything. It labels what has already happened, and it does so with a delay. That is fine for a developer tool, because an indicator that describes the past accurately is far more useful than one that pretends to look ahead. Keep that framing in mind, because the delay is the part of the design you have to respect.

Detecting swings with ta.pivothigh and ta.pivotlow

Pine Script v6 gives you two built-in functions for swings. ta.pivothigh(source, leftbars, rightbars) returns the pivot's price on the bar where the pivot is confirmed, and na on every other bar. ta.pivotlow works the same way for lows. A pivot high at some bar needs leftbars bars before it that are not higher, and rightbars bars after it that are not higher. That second requirement is the delay.

If you set rightbars to 5, the function cannot know that bar X was a pivot until bar X + 5 has closed. At that point it reports the pivot price on bar X + 5, not on bar X. If you draw a label at bar_index when the function returns a value, the label lands five bars to the right of the real swing. The fix is to shift the drawing back by rightbars. We covered the full reasoning in why pivot labels arrive late, and the short version is that you place objects at bar_index - rightBars and accept that you only learn about them later.

Choosing the two lengths is a real design decision. Small values such as 2 and 2 find many small swings, so your structure flips often and the labels crowd together. Large values such as 15 and 15 find only major swings, which gives a calmer picture but a longer delay, because you wait 15 bars for confirmation. Making both lengths inputs lets you tune them per symbol and timeframe, and it is the first thing to expose.

Classifying each swing as HH, LH, HL or LL

To compare a new swing with the previous one, you need memory that survives from bar to bar. In Pine Script that is a var variable, which keeps its value between bars instead of resetting. If var is new to you, var, varip, and persistent state in Pine Script explains it properly.

The idea is simple. Keep lastHigh and lastLow as var float values, initialized to na. Each time a pivot is confirmed, compare it with the stored value, choose a label, draw it, and then overwrite the stored value with the new pivot. The first pivot of each kind has nothing to compare against, so it gets a neutral label.

pine
//@version=6
indicator("Market Structure Labels", overlay = true, max_labels_count = 500)

leftBars  = input.int(5, "Pivot left bars", minval = 1)
rightBars = input.int(5, "Pivot right bars", minval = 1)

var float lastHigh = na
var float lastLow  = na

ph = ta.pivothigh(high, leftBars, rightBars)
pl = ta.pivotlow(low, leftBars, rightBars)

if not na(ph)
    string highTag = na(lastHigh) ? "H" : ph > lastHigh ? "HH" : ph < lastHigh ? "LH" : "EH"
    label.new(bar_index - rightBars, ph, highTag,
         style = label.style_label_down,
         color = color.new(color.red, 20),
         textcolor = color.white,
         size = size.small)
    lastHigh := ph

if not na(pl)
    string lowTag = na(lastLow) ? "L" : pl > lastLow ? "HL" : pl < lastLow ? "LL" : "EL"
    label.new(bar_index - rightBars, pl, lowTag,
         style = label.style_label_up,
         color = color.new(color.teal, 20),
         textcolor = color.white,
         size = size.small)
    lastLow := pl

A few details are worth slowing down for. The EH and EL tags cover the rare case where two pivots sit at exactly the same price, so the ternary chain never falls through to something misleading. Assigning with := is what updates the var variable, and using = there would declare a new local instead. And max_labels_count = 500 raises the label cap to its maximum, because on a long history a label for every swing adds up quickly. If you hit the ceiling anyway, Pine Script label and line limits shows how to prune old objects.

Load this on a chart and read it left to right. In a clean trend you will see HH labels climbing above and HL labels climbing below. When a trend loses its shape, you will see an LH appear where an HH should have been, and that is usually the first visible change in structure.

Turning labels into a trend state

Labels are good for reading, but a script cannot act on a picture. To filter or alert on structure, you need a state: up, down, or neutral. The common definition is that the trend is up when the latest swing high is higher than the one before it and the latest swing low is higher than the one before it, down when both are lower, and neutral otherwise.

That needs two stored values per side, the latest pivot and the one before it. When a new pivot arrives, the old "latest" becomes the "previous" and the new pivot becomes the latest. The order matters: you copy first, then overwrite.

pine
//@version=6
indicator("Structure Trend State", overlay = true)

leftBars  = input.int(5, "Pivot left bars", minval = 1)
rightBars = input.int(5, "Pivot right bars", minval = 1)

var float lastHigh = na
var float prevHigh = na
var float lastLow  = na
var float prevLow  = na

ph = ta.pivothigh(high, leftBars, rightBars)
pl = ta.pivotlow(low, leftBars, rightBars)

if not na(ph)
    prevHigh := lastHigh
    lastHigh := ph

if not na(pl)
    prevLow := lastLow
    lastLow := pl

bool higherHigh = not na(prevHigh) and lastHigh > prevHigh
bool lowerHigh  = not na(prevHigh) and lastHigh < prevHigh
bool higherLow  = not na(prevLow) and lastLow > prevLow
bool lowerLow   = not na(prevLow) and lastLow < prevLow

int trend = higherHigh and higherLow ? 1 : lowerHigh and lowerLow ? -1 : 0

bgcolor(trend == 1 ? color.new(color.green, 90) : trend == -1 ? color.new(color.red, 90) : na)
plot(lastHigh, "Last swing high", color.new(color.red, 40), style = plot.style_stepline)
plot(lastLow, "Last swing low", color.new(color.teal, 40), style = plot.style_stepline)

The two step lines show the most recent confirmed swing high and swing low as horizontal levels that jump when a new pivot is confirmed. They are useful on their own, because the levels that define the current structure are exactly the ones traders tend to watch. The background tint shows the state at a glance. Because trend is recomputed on every bar from values that only change when a pivot is confirmed, it stays stable between pivots.

One design choice is easy to miss. This definition requires both sides to agree. If the highs are rising but the lows are falling, the state is neutral, which is what an expanding range looks like. If you would rather call it an uptrend whenever the highs rise, change the condition, but decide that on purpose instead of by accident.

Detecting a break of structure

The most interesting moment in this picture is when the structure changes. In an uptrend, the latest swing low is the level the trend has defended. If a bar closes below it, the sequence of higher lows has broken. Many people call this a break of structure, and it is a clean, rule-based event you can code in a line.

pine
//@version=6
indicator("Structure Breaks", overlay = true)

leftBars  = input.int(5, "Pivot left bars", minval = 1)
rightBars = input.int(5, "Pivot right bars", minval = 1)

var float lastHigh = na
var float prevHigh = na
var float lastLow  = na
var float prevLow  = na

ph = ta.pivothigh(high, leftBars, rightBars)
pl = ta.pivotlow(low, leftBars, rightBars)

if not na(ph)
    prevHigh := lastHigh
    lastHigh := ph

if not na(pl)
    prevLow := lastLow
    lastLow := pl

bool higherHigh = not na(prevHigh) and lastHigh > prevHigh
bool lowerHigh  = not na(prevHigh) and lastHigh < prevHigh
bool higherLow  = not na(prevLow) and lastLow > prevLow
bool lowerLow   = not na(prevLow) and lastLow < prevLow

int trend = higherHigh and higherLow ? 1 : lowerHigh and lowerLow ? -1 : 0

bgcolor(trend == 1 ? color.new(color.green, 90) : trend == -1 ? color.new(color.red, 90) : na)
plot(lastHigh, "Last swing high", color.new(color.red, 40), style = plot.style_stepline)
plot(lastLow, "Last swing low", color.new(color.teal, 40), style = plot.style_stepline)

bool breakDown = trend == 1 and ta.crossunder(close, lastLow)
bool breakUp   = trend == -1 and ta.crossover(close, lastHigh)

plotshape(breakDown, "Structure break down", shape.triangledown, location.abovebar,
     color.red, size = size.tiny)
plotshape(breakUp, "Structure break up", shape.triangleup, location.belowbar,
     color.teal, size = size.tiny)

alertcondition(breakDown and barstate.isconfirmed, "Structure break down",
     "Close below the last higher low")
alertcondition(breakUp and barstate.isconfirmed, "Structure break up",
     "Close above the last lower high")

The block below is the trend state script with the break logic added at the end. Two things keep this honest. First, the comparison uses close, so a wick that pokes through the level and recovers does not count. Second, gating the alert with barstate.isconfirmed means it only fires on a closed bar. Without that, the condition can be true mid-bar and false by the close, which produces the classic alert that fires and then disappears. barstate.isconfirmed in Pine Script and why your alert fires on the wrong bar go deeper on that.

The repainting question

Does this indicator repaint? The answer depends on what you mean. The swing points themselves do not move once confirmed. ta.pivothigh returns a value only after rightbars bars have closed, and on historical bars and on closed realtime bars that value never changes afterward. So the labels are placed in the past, in the right place, and stay there.

What does look different between history and live is the timing of knowledge. On a historical chart you see every label sitting neatly on its swing. In realtime, the label for a swing appears five bars after the swing, because that is when the data confirms it. If you judge the indicator only by looking at the finished chart, it seems to react instantly. It does not. Pine Script is not repainting here, but a chart review can make the delay invisible. For a full catalog of how scripts mislead, see repainting explained: the five kinds and how to prevent each.

The practical test is to step through the data one bar at a time and watch when each label first appears. A bar-by-bar replay makes the delay obvious. PineScripter's Bar replay does this for a chart run, stepping one bar at a time so you can see exactly when the script reacts.

Common mistakes and how to avoid them

The first mistake is drawing at bar_index instead of bar_index - rightBars. The swing looks as if it is lagging behind price by the pivot length, because the label is on the confirmation bar. The second is forgetting that na comparisons are never true. If prevHigh is na, then lastHigh > prevHigh evaluates to false, which is why the code above checks not na(prevHigh) explicitly. Without the guard, the first bars of history would silently report no structure instead of an honest neutral state, and the bug would be hard to spot.

The third is mixing up which value to compare against. Some scripts compare the new pivot with the highest high in a recent window instead of with the previous pivot. That is a different definition, not a bug, but it gives different labels, and readers who expect the swing-by-swing meaning will find it confusing. State your definition in the indicator's description.

The fourth is symbol and timeframe blindness. A pivot length that gives a readable picture on a one-hour chart can produce noise on a five-minute chart and almost nothing on a daily chart. Treat the two lengths as inputs and test a few values rather than hard-coding one. The fifth is object limits. Every label counts toward the cap, so a long history with short pivot lengths can exhaust it. Raise max_labels_count, widen the pivot lengths, or delete old labels as you go.

Extending the indicator

Once the core is working, the natural additions are small. You can store each swing in an array of custom objects if you want a history of the last several swings, which Pine Script arrays and custom types make manageable. You can draw a line from each pivot to the bar where price closes through it, which gives you a visual for every broken level. You can add a volatility measure so that a swing only counts if it moved at least a multiple of the average true range, which filters small wiggles without changing the pivot length. And you can combine structure with a higher timeframe by requesting the trend state from a larger chart, taking care to avoid the lookahead trap described in multi-timeframe analysis in Pine Script without repainting.

If you prefer a zone-based view of the same swings, the supply and demand zone build uses the same pivot machinery to mark areas instead of single levels.

Building it from a plain description

Everything above can be written by hand, and it is worth doing once so you understand what each line decides. The tedious part is the iteration afterward: changing the definition of trend, adding an ATR filter, restyling the labels, wiring alerts. Each change touches several places, and a small mistake in the order of prevHigh := lastHigh and lastHigh := ph produces labels that look almost right.

That is the kind of work where describing the result in plain English and letting a tool draft the code saves time. In PineScripter's AI editor you can describe a structure indicator, such as "label each swing as HH, HL, LH or LL and tint the background by trend, with an alert when the last higher low closes below", and get Pine Script v6 back. It checks its own code for compile errors, fixes them itself, and when you ask for a follow-up change it edits only the lines that change. The Chart runs the script on real crypto market data inside PineScripter, so you can see the labels and the trend tint before pasting anything into TradingView. If you want to write better descriptions, how to describe a complex indicator so the AI gets it right the first time covers the approach. You can see the plans on the pricing page.

Market structure is a way of describing price that is easy to say and surprisingly easy to get subtly wrong. Pick a pivot length on purpose, compare swing to swing, respect the confirmation delay, and gate your alerts on closed bars. Do those four things and the indicator will do what its labels claim.

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 a higher high and a higher low?

A higher high is a swing high above the previous swing high. A higher low is a swing low above the previous swing low. A run of both is how structure describes an uptrend.

Why do my HH and LL labels appear several bars late?

ta.pivothigh and ta.pivotlow only confirm a swing after the right-hand bars have closed. The function reports on the confirmation bar, so draw at bar_index minus the right length to place the label on the real swing.

What pivot length should I use?

There is no universal value. Short lengths such as 2 find many small swings and flip often. Longer lengths such as 15 give fewer, larger swings but confirm later. Expose both as inputs and test them on your symbol and timeframe.

Does a market structure indicator repaint?

Confirmed pivots do not move once reported. What differs between history and live is when you learn about them, since each swing is known only after the right-hand bars close.

How do I stop a structure alert firing and then vanishing?

Gate the condition with barstate.isconfirmed so it only fires on a closed bar, and compare close rather than high or low against the level.

Run your script on real market data and see what it plots →


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.