Order blocks are the most-requested Pine Script feature with the least agreement about what they are. Search for an order block indicator and you will find dozens, all drawing different boxes on the same chart, all describing themselves as identifying institutional activity. That is not because most of them are wrong. It is because the term has no formal definition, so each implementation encodes its author’s interpretation, and the code is the only place that interpretation is written down precisely.
Short answer
An order block, in the sense the term is normally used, is the last opposing candle before an impulsive move that breaks structure. The bullish version is the final down candle before a sharp rally that takes out a prior swing high; the bearish version is the mirror image. Coding it means making three subjective decisions explicit: what counts as impulsive, what counts as breaking structure, and how far back to look. This article shows how to detect and draw one in Pine Script v6 without repainting. It is a coding tutorial and makes no claim that these zones predict anything.
Key facts
- There is no official or standardised definition of an order block, so two indicators can both be internally consistent and disagree completely on the same chart.
- The common definition is the last down candle before an up move that breaks a prior swing high, and the mirror for bearish blocks.
- Detecting one requires a confirmed structural break, and confirmation is only available after the fact, which means the zone is identified some bars after the candle it marks.
- Any implementation that identifies a zone using the developing bar will repaint, because the developing bar can still change.
- Zones are drawn with box.new(), which is subject to the per-script drawing limit, so max_boxes_count and deliberate deletion both matter.
- ta.pivothigh() and ta.pivotlow() need bars on both sides to confirm a pivot, so a pivot with a lookforward of n is only known n bars later.
- Storing zones in an array of a user-defined type keeps the code readable and lets you cap how many are retained.
- The mitigation rule, meaning when a zone is considered used up, is a separate decision from detection and changes what the indicator shows more than the detection rule does.
| Decision | Common choice | Alternative | Effect on output |
|---|---|---|---|
| Impulse test | Break of a prior swing point | Candle range vs ATR | Structure is stricter, fewer zones |
| Zone boundaries | Full high-to-low of the candle | Body only, ignoring wicks | Body-only gives tighter zones |
| Origin candle | Last opposing candle | Whole consolidation before the move | Wider zones, fewer of them |
| Mitigation | Price trades back through it | Candle closes beyond it | Close-based zones survive longer |
| Retention | Keep the last N zones | Keep until mitigated | Affects drawing count directly |
Start by writing the definition down
This step is not optional and it is the one people skip. Because the term is informal, the code you write is the definition, and if you have not stated it in words first you will end up with whatever the implementation happens to do. Then when the boxes look wrong you have no reference to check them against, and debugging becomes a matter of taste rather than a matter of fact.
A complete definition names five things. What direction of candle qualifies as the origin. What has to happen after it for the move to count as impulsive. How the structural reference is established, which usually means how a swing point is identified. Where the zone’s upper and lower boundaries sit. And what ends the zone’s life. Miss any one and the code will make the choice for you silently.
Here is a complete one, and it is the definition the code in this article implements: a bullish order block is the most recent bearish candle within the last twenty bars before price closes above a confirmed swing high; the zone spans that candle’s full high to low; the zone is mitigated when a later candle closes below its low; at most five unmitigated zones are retained. Every clause in that sentence maps onto a line of Pine, and every clause is a choice you are free to make differently.
Being clear about the epistemics here matters. Nothing in this article claims those zones mark institutional orders, predict reversals, or carry information. The premise is contested and this is a coding tutorial. What it can do honestly is show you how to turn a precise description of a chart pattern into code that detects it consistently, which is a genuinely useful skill regardless of what you believe the pattern means.
Structure first: confirmed swing points
Everything depends on knowing where the prior swing highs and lows are, and this is where the honest version of an order block indicator diverges from the flattering one. A swing high is a bar whose high exceeds the highs of some number of bars on either side. The bars on the left are already known. The bars on the right are not, which means a swing high can only be confirmed after those bars have printed.
ta.pivothigh() and ta.pivotlow() implement exactly this, and their two arguments are the number of bars required to the left and to the right. The function returns na until the right-hand bars exist, then returns the pivot value. The consequence is unavoidable: with a lookforward of five, every pivot is known five bars late. There is no setting that removes the lag, because the lag is the confirmation.
This is the point at which many published indicators quietly cheat, by identifying pivots using the current developing bar or by referencing the future through lookahead. Both produce zones that appear at the perfect moment on historical bars and behave completely differently in real time. If you take one thing from this article, take this: a zone that appears on the candle it marks, with no delay, on historical data, is not detecting anything. It is reading an answer that was not available at the time.
//@version=6
indicator("Confirmed swings", overlay = true, max_boxes_count = 100)
pivotLen = input.int(5, "Swing lookback and lookforward", minval = 1)
// Both arguments matter: pivotLen bars to the left AND to the right must
// confirm the pivot. That right-hand requirement is why the value only
// becomes available pivotLen bars after the pivot bar itself.
swingHigh = ta.pivothigh(high, pivotLen, pivotLen)
swingLow = ta.pivotlow(low, pivotLen, pivotLen)
// Hold the most recent confirmed levels so later bars can compare to them.
var float lastSwingHigh = na
var float lastSwingLow = na
if not na(swingHigh)
lastSwingHigh := swingHigh
if not na(swingLow)
lastSwingLow := swingLow
plot(lastSwingHigh, "Last confirmed swing high", color = color.new(color.red, 40), style = plot.style_stepline)
plot(lastSwingLow, "Last confirmed swing low", color = color.new(color.teal, 40), style = plot.style_stepline)Plot these before writing anything else. Watching the stepline levels update on the chart shows you exactly how late confirmation arrives, which calibrates your expectations for everything built on top. If the delay looks unacceptable, reduce the lookforward, but understand that you are trading confirmation for speed rather than getting something for free.
Finding the origin candle
Once a structural break is confirmed, meaning price has closed above the last confirmed swing high, the origin candle is the most recent bearish candle before that move. Finding it means walking backwards through recent bars until you hit one whose close is below its open, then stopping.
Two details in that loop deserve attention. First, it needs a bound, because an unbounded search on every bar is both slow and unnecessary; twenty bars is generous for what is meant to be the immediate origin of an impulse. Second, the loop should only run when a break has just been confirmed, not on every bar. A loop that executes on all bars of a long chart is one of the more common reasons a Pine script becomes slow, and here it would be doing pointless work almost all the time.
The zone boundaries come from that candle, and this is another genuine choice. Using the full high to low includes the wicks, producing a wider zone that price is more likely to touch. Using only the body produces a tighter zone that is touched less often. Neither is correct; they are different definitions and they will give you different-looking charts. Whichever you pick, note it in a comment, because in three months the boxes on your chart will not tell you which you chose.
//@version=6
indicator("Order block detection", overlay = true, max_boxes_count = 100)
pivotLen = input.int(5, "Swing lookback and lookforward", minval = 1)
searchBars = input.int(20, "Bars to search for the origin", minval = 1, maxval = 100)
useBodies = input.bool(false, "Use candle bodies instead of wicks")
maxZones = input.int(5, "Zones to keep", minval = 1, maxval = 20)
// A user-defined type keeps the zone's four facts together and makes the
// array of zones readable rather than four parallel arrays.
type Zone
float top
float bottom
int startBar
box drawing
var array<Zone> bullZones = array.new<Zone>()
swingHigh = ta.pivothigh(high, pivotLen, pivotLen)
var float lastSwingHigh = na
if not na(swingHigh)
lastSwingHigh := swingHigh
// The structural break: a close above the last CONFIRMED swing high, and
// only on the bar where it first happens.
brokeStructure = not na(lastSwingHigh) and close > lastSwingHigh and close[1] <= lastSwingHigh
if brokeStructure
// Walk back for the most recent bearish candle. Bounded, and only
// running on break bars rather than on every bar.
originOffset = -1
for offset = 1 to searchBars
if close[offset] < open[offset]
originOffset := offset
break
if originOffset > 0
zoneTop = useBodies ? math.max(open[originOffset], close[originOffset]) : high[originOffset]
zoneBottom = useBodies ? math.min(open[originOffset], close[originOffset]) : low[originOffset]
drawing = box.new(bar_index - originOffset, zoneTop, bar_index, zoneBottom,
border_color = color.new(color.teal, 40),
bgcolor = color.new(color.teal, 88))
array.push(bullZones, Zone.new(zoneTop, zoneBottom, bar_index - originOffset, drawing))
// Cap retention explicitly, deleting the drawing as well as the
// record. Dropping the record alone leaks the box.
if array.size(bullZones) > maxZones
oldest = array.shift(bullZones)
box.delete(oldest.drawing)The break condition includes a comparison against the previous bar, which is what makes it fire once rather than on every bar while price remains above the level. Without it you would create a new zone on each bar of the move, exhaust the drawing allowance quickly, and end up with the oldest zones silently deleted. That failure is covered in our guide to the drawing limits.
Mitigation changes the picture more than detection
Deciding when a zone stops being relevant has a larger effect on what your chart looks like than the detection rule does, and it gets a fraction of the attention. If zones never expire, they accumulate until the drawing allowance runs out and older ones vanish silently. If they expire the instant price touches them, most disappear almost immediately. Somewhere between those is a rule you have chosen deliberately.
The two common rules are worth contrasting because they behave very differently. Wick-based mitigation, where any trade through the zone ends it, is strict and clears zones quickly. Close-based mitigation, where a candle has to close beyond the zone, is looser and keeps zones alive through brief penetrations. On a volatile instrument the difference is dramatic, and neither is more correct; they encode different ideas about what a touch means.
Whichever you choose, extend the zone forward as bars print rather than leaving it as a fixed-width box behind price. A box that stops at the bar it was created on is hard to read against later price action, which is the only thing you actually want to compare it against. Updating the right edge each bar is one call and makes the drawing useful.
//@version=6
// Continuing the script above: extend live zones forward, and remove them
// when the mitigation rule fires.
closeBasedMitigation = input.bool(true, "Require a close beyond the zone")
if array.size(bullZones) > 0
// Iterate backwards so removing an element does not skip the next one.
for i = array.size(bullZones) - 1 to 0
zone = array.get(bullZones, i)
// Keep the right edge at the current bar so the zone stays
// comparable against new price action.
box.set_right(zone.drawing, bar_index)
mitigated = closeBasedMitigation ? close < zone.bottom : low < zone.bottom
if mitigated
box.delete(zone.drawing)
array.remove(bullZones, i)Iterating backwards is not a style preference. Removing an element from an array shifts every later element down by one, so a forward loop skips the item immediately after each removal. This is a general Pine hazard rather than an order-block one, and it produces a bug that looks like the mitigation rule working intermittently, which is a miserable thing to debug.
Why published order block indicators disagree
With the five decisions laid out, the disagreement between indicators stops being mysterious. Two implementations that use body-only versus wick-inclusive boundaries will draw different-sized zones. Two that use different pivot lookforwards will find different structural breaks. Two with different mitigation rules will show different numbers of live zones on the same chart at the same moment. None of them is malfunctioning.
This has a direct practical consequence. Comparing your implementation to a published one and concluding yours is wrong because the boxes differ is a mistake; you are comparing two definitions, not an implementation against a specification. The only meaningful check is whether your code implements the definition you wrote down. That is why writing it down first is the whole method rather than a preliminary.
It also means you should be sceptical of screenshots. An order block indicator whose zones sit beautifully at the origin of every move on historical data may be doing that because it repaints, or because its author tuned the parameters against the visible history, or because the definition itself was shaped to fit the examples. The way to tell is to watch it in real time on a forward period, which is slower and much more informative than any backtest of a drawing tool.
Repainting, and how to check for it
A zone that changes after it appears is repainting, and with order blocks the risk is high because detection depends on confirmation. The specific danger is using the developing bar in the break condition. If a zone is created because the current unclosed bar is above a level, and that bar then closes back below, the zone was created on information that turned out to be false, and on historical data you never see this because every historical bar is already closed.
The structural defence is to derive every condition from confirmed data. Use pivots with a genuine lookforward. Compare against the previous bar’s close rather than the developing one where the rule allows it. And if you send alerts, use the once-per-bar-close frequency so a signal cannot fire on a value that later changes.
The empirical check takes patience and is worth it. Load the indicator, note the zones currently on the chart, and come back after a session. Zones that were there should still be there, in the same places. Anything that moved or vanished without a mitigation event tells you the detection is using information that is not stable. Our guide to repainting covers the five distinct kinds and how each shows up.
Performance, and the drawing budget
Order block indicators are unusually easy to make slow, because they combine the two expensive things: loops and drawing objects. The loop in the detection step is bounded and conditional, which is what keeps it cheap. Moving it outside the break condition, so it runs on every bar, multiplies its cost by the number of bars on the chart and is the single most common performance mistake in this kind of script.
The drawing side needs an explicit budget. Boxes are capped per script, the default allowance is well below the maximum, and when it is exhausted the oldest are deleted silently. That is why the code caps retention and deletes the drawing along with the record. Removing an array element without deleting its box leaves the drawing on the chart with nothing tracking it, which is a leak that shows up as zones you cannot get rid of.
Set max_boxes_count deliberately and write a comment saying what the script draws. Then test on a long chart and scroll to the left edge. If the oldest zones are missing, the allowance is being exhausted regardless of what your retention cap says, and the cause is almost always a zone being created more often than intended. Our guide to the drawing limits works through the diagnosis.
Where a Pine-focused workflow helps
This is a genuinely difficult script to get from a general-purpose chat assistant, and the reason is instructive. There is no canonical definition to draw on, so a model asked for an order block indicator produces something plausible that encodes an unstated definition, frequently with a repainting detection step because that is what much of the published code it learned from does. You get working code that implements a rule nobody articulated.
PineScripter is the product we build, and the part that matters for a script like this is the shape of the interaction rather than the model. Because it edits in place and shows a diff, you can state your definition, see it implemented, then change one clause at a time and watch what happens to the zones. That is how you find out that mitigation matters more than detection, and it is not something a full regenerated script per question can teach you. It also retrieves the Pine manual, so array iteration order and drawing limits come from documentation rather than pattern-matching.
What no tool provides is the definition or the judgement. Whether these zones mean anything is a question about markets, not about code, and this article deliberately does not answer it. What code can give you is consistency: a rule applied the same way on every bar, which you can then examine honestly rather than through screenshots chosen after the fact.
Frequently asked questions
What is an order block in trading?
In common usage it is the last opposing candle before an impulsive move that breaks market structure, so a bullish order block is the final down candle before a rally that takes out a prior swing high. There is no official definition, which is why implementations differ. The claim that these zones mark institutional orders is an interpretation rather than an established fact.
How do I code an order block indicator in Pine Script?
Confirm swing points with ta.pivothigh() and ta.pivotlow(), detect a close beyond the most recent confirmed swing, then walk backwards a bounded number of bars to find the most recent opposing candle. Draw the zone from that candle with box.new(), store it in an array so you can extend and delete it, and define explicitly what mitigates it.
Do order block indicators repaint?
Many do. The risk comes from detection depending on confirmation: if a zone is created using the developing bar, and that bar closes differently, the zone was based on information that turned out to be false. On historical data this is invisible because every bar is already closed. Using confirmed pivots and comparing against closed bars avoids it.
Why does my order block indicator show different zones than a published one?
Because you are comparing two definitions rather than an implementation against a specification. Wick-inclusive versus body-only boundaries, different pivot lookforwards, and different mitigation rules all change the output. Neither indicator is malfunctioning; they encode different choices.
Why do my order block zones disappear from older bars?
You have exhausted the drawing allowance. Boxes are capped per script and the default is well below the maximum, and when the cap is reached the oldest are deleted with no error. Raise max_boxes_count, and more importantly check that you are not creating a zone on every bar of a move rather than only on the bar where the break is confirmed.
Should I use candle bodies or wicks for the zone?
That is a definition choice, not a correctness question. Wick-inclusive zones are wider and get touched more often; body-only zones are tighter and get touched less. Pick one, write it in a comment, and stay consistent, because the boxes on your chart will not tell you later which you chose.
The practical takeaway
The hard part of an order block indicator is not the Pine Script, it is committing to a definition. Write the five decisions down as a sentence, implement each clause visibly, use confirmed pivots so the thing does not repaint, cap your drawings deliberately, and then judge the result by watching it forward rather than by how neatly the boxes sit on history.
Because getting this right means changing one clause of the definition at a time and watching what happens, PineScripter is our product and edits in place so each change is a reviewable diff rather than a new script. It cannot tell you whether these zones mean anything, and watching the indicator forward on a live chart is still the only honest test.
Sources
- Pine Script v6 reference: ta.pivothigh()
- TradingView Pine Script documentation: boxes and drawing objects
- TradingView Pine Script documentation: user-defined types
Related reading: repainting explained, the label and line drawing limits, user-defined types, arrays, matrices and maps, turning a trading idea into Pine Script.
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.