Breakouts & Pullbacks

Category: Indicators By: Iván González Created: September 11, 2026, 9:30 AM
September 11, 2026, 9:30 AM
Indicators
0 Comments

Introduction

A new high is not a signal. It is a question: is what happens next normal, or unusual?

Most tools answer with a fixed number. Buy the 10% dip. Wait for the 38.2% retracement. Take profit after a 20% run. Those numbers come from somewhere, but they do not come from the instrument in front of you. A 10% pullback is a routine breather in a volatile small cap and a crisis in a bond future.

Breakouts & Pullbacks answers with the instrument’s own record. Every time price takes out its previous high after a meaningful pause, the whole move gets filed: how deep the pullback was, how many bars it lasted, how long the recovery took, and how far price ran afterwards. Then the live reading is ranked against that file.

The panel does not say “the pullback is 14%”. It says “the pullback is 14%, the median is 8.6%, the deepest was 41%, and this one is deeper than 82% of them”.

Theory Behind the Indicator

1. What counts as a breakout

The indicator tracks the running highest high. When price exceeds it, that is a new high — but most new highs are not interesting. In a healthy trend the high gets taken out every few bars, and that is continuation, not a breakout.

So there is one filter, and only one: the gap. The number of bars between the previous high and the bar that finally takes it out. If that gap is smaller than minimumGap (30 by default), nothing is recorded. The move only becomes a structure when the market spent real time below the old high before reclaiming it.

This is the entire detection logic. There are no oscillators, no bands, no pattern matching. One counter and one threshold.

2. The five points of a structure

Each recorded structure holds five references:

Start — the previous high, price and bar. This is the level that was defended.

End — the bar where the level is taken out, at the price of the old high. Start and End share a price, which is why the level appears on the chart as a horizontal line: it is the same number, held across time.

Lowest — the deepest point of the retreat between them. Not a pivot, not a swing detection: simply the lowest low since the previous high was set.

Highest — the furthest point reached after the break. This one is alive: the most recent structure keeps updating it on every bar, so its run-up leg grows until a new structure replaces it.

Last — the End of the previous structure, which is what lets the indicator measure the distance from one breakout to the next.

3. The six measurements

From those five points come six numbers, and they split into two families that point in opposite directions.

The pullback family — depth in percent, and duration in bars. How much of the advance was given back, and how long the market took to give it back.

The run-up family — the advance after the break, measured twice. Once from the broken level (what a breakout buyer would have captured) and once from the pullback low (what a bottom fisher would have captured). Both in percent and in bars. The second is always the larger number, and the gap between them is the price of waiting for confirmation.

Each is shown as median, maximum, current reading and percentile rank.

4. Why the colours run in opposite directions

The pullback rows are green at the top of the range. The run-up rows are red at the top of the range. Same gradient, inverted.

That is the whole thesis of the indicator in one design choice. A pullback in the 90th percentile is unusually deep — for a buyer, that is the interesting end. A run-up in the 90th percentile means the advance has already gone further than it usually does — for a buyer, that is the uninteresting end. The colour is not decoration, it is the reading.

How to Read It

The horizontal line is the old high, drawn from where it was set to where it was broken. The triangle hanging below it is the pullback: down to the low, back up to the break.

The dotted leg is what happened after. It runs from the broken level to the highest point reached since. On the most recent structure it is still growing.

The green dot marks the pullback low.

The panel has five columns. Factor, Median, Max, Current, and Rank. Read the Rank column first — it is the only one that does not depend on the units of the instrument.

The header line gives the gap of the last break and how many bars have passed since. A large gap means the level took a long time to reclaim; a large “bars ago” means the current structure is mature.

The Last structure block breaks the most recent one into its four phases: the advance before, the pullback, the recovery, and the advance after.

The Code

//---------------------------------------------------------------------------//
//PRC_Breakouts & Pullbacks
//version = 0
//11.09.2026
//Ivan Gonzalez @ www.prorealcode.com
//Author: Trendoscope
//Sharing ProRealTime knowledge
//---------------------------------------------------------------------------//
//Every time price prints a new all time high at least minimumGap bars after the
//previous one, the whole move is stored as a breakout structure: the old high,
//the pullback low that followed it, the bar where that high was finally taken
//out, and the highest point reached afterwards. Each structure is drawn as a
//triangle plus the run up leg, and the panel ranks the live readings against
//every structure found on the chart.
//---------------------------------------------------------------------------//
DEFPARAM drawonlastbaronly = true

//-----Detection-------------------------------------------------------------//
minimumGap = 30        //Minimum gap in bars between two all time high breaks (10 or more)

//-----Memory----------------------------------------------------------------//
maxStored  = 200       //Breakout structures kept for the statistics
maxDrawn   = 20        //Breakout structures drawn on the chart (most recent ones)

//-----Display---------------------------------------------------------------//
showPanel  = 1         //Show the statistics panel
showDetail = 1         //Show the last breakout breakdown under the panel
markLows   = 1         //Mark the pullback low of every drawn structure

//-----Panel geometry, pixels from the top right corner----------------------//
dashX   = -660         //x of the Factor column
dashY   = -40          //y of the header row
labelW  = 170          //gap between the Factor column and the first value column
dashCol = 110          //width of the value columns
rowH    = 20           //row height

//-----Colours---------------------------------------------------------------//
strucR = 190           //Breakout structure (amber, the yellow of the original
strucG = 130           //is unreadable on the default light background of PRT)
strucB = 0
lowR = 205             //Low end of the panel gradient
lowG = 45
lowB = 45
topR = 0               //High end of the panel gradient
topG = 150
topB = 80
txtR = 60              //Neutral text
txtG = 60
txtB = 60

//---------------------------------------------------------------------------//
//                             BREAKOUT ENGINE                               //
//---------------------------------------------------------------------------//
//Ring of maxStored + 1 slots: the spare slot is always the next write position
//and never belongs to the reading window, so a structure written on an early
//tick of the live bar can never be read as the oldest one (learning 235).
bkRing = maxStored + 1

IF barindex = 0 THEN
   athP = high
   athX = 0
   llP  = low
   llX  = 0
   nTot = 0
ENDIF

//-----Bars elapsed since the high that is being taken out-------------------//
bkGap = 0
IF high > athP THEN
   bkGap = barindex - athX
ENDIF

//-----Previous all time high, captured before it is overwritten-------------//
prevP = athP
prevX = athX
IF high > athP THEN
   athP = high
   athX = barindex
ENDIF

//-----Running low since the last all time high------------------------------//
IF low <= llP THEN
   llP = low
   llX = barindex
ENDIF

//-----A wide enough gap turns the break into a structure--------------------//
IF bkGap >= minimumGap THEN
   sl = nTot MOD bkRing
   $bkSP[sl] = prevP        //old all time high, price
   $bkSX[sl] = prevX        //old all time high, bar
   $bkEX[sl] = barindex     //bar where that high is taken out
   $bkLP[sl] = llP          //pullback low, price
   $bkLX[sl] = llX          //pullback low, bar
   $bkHP[sl] = athP         //highest point reached after the break
   $bkHX[sl] = athX
   nTot = nTot + 1
ENDIF

//-----A new high restarts the running low-----------------------------------//
IF bkGap > 0 THEN
   llP = low
   llX = barindex
ENDIF

//-----The most recent structure keeps growing its run up--------------------//
IF nTot > 0 THEN
   sl = (nTot - 1) MOD bkRing
   $bkHP[sl] = athP
   $bkHX[sl] = athX
ENDIF

nCnt = min(nTot, maxStored)

//---------------------------------------------------------------------------//
//                          STATISTICS AND DRAWING                           //
//---------------------------------------------------------------------------//
IF islastbarupdate AND nCnt > 0 THEN
   
   slL    = (nTot - 1) MOD bkRing
   lastSP = $bkSP[slL]
   lastSX = $bkSX[slL]
   lastEX = $bkEX[slL]
   lastLP = $bkLP[slL]
   lastLX = $bkLX[slL]
   lastHP = $bkHP[slL]
   lastHX = $bkHX[slL]
   
   //-----Live readings, the same ones the original pushes on the last bar---//
   belowBrk = 0
   IF high < lastSP THEN
      belowBrk = 1
   ENDIF
   
   $cur[1] = (lastHP - llP) * 100 / lastHP     //pullback from the running high
   $cur[2] = llX - lastHX
   
   IF belowBrk = 1 THEN
      $cur[3] = 0
      $cur[4] = 0
      $cur[5] = (high - llP) * 100 / llP
      $cur[6] = barindex - llX
   ELSE
      $cur[3] = (lastHP - lastSP) * 100 / lastSP
      $cur[4] = lastHX - lastEX
      $cur[5] = (lastHP - lastLP) * 100 / lastLP
      $cur[6] = lastHX - lastLX
   ENDIF
   
   //-----Median, max and percent rank of the six factors--------------------//
   FOR fk = 1 TO 6 DO
      
      nW = 0
      FOR ii = 0 TO nCnt - 1 DO
         sl = (nTot - nCnt + ii) MOD bkRing
         sP = $bkSP[sl]
         sX = $bkSX[sl]
         eX = $bkEX[sl]
         lP = $bkLP[sl]
         lX = $bkLX[sl]
         hP = $bkHP[sl]
         hX = $bkHX[sl]
         IF fk = 1 THEN
            vv = (sP - lP) * 100 / sP       //pullback, percent
         ELSIF fk = 2 THEN
            vv = lX - sX                    //pullback, bars
         ELSIF fk = 3 THEN
            vv = (hP - sP) * 100 / sP       //run up after the break, percent
         ELSIF fk = 4 THEN
            vv = hX - eX                    //run up after the break, bars
         ELSIF fk = 5 THEN
            vv = (hP - lP) * 100 / lP       //total run up from the low, percent
         ELSE
            vv = hX - lX                    //total run up from the low, bars
         ENDIF
         nW = nW + 1
         $wk[nW] = vv
      NEXT
      
      //The original only pushes the live run up while price is still below
      //the broken high, so the sample size differs from the pullback one.
      IF fk <= 2 OR belowBrk = 1 THEN
         nW = nW + 1
         $wk[nW] = $cur[fk]
      ENDIF
      
      //Percent rank of the live reading, counted before the array is sorted.
      curV = $cur[fk]
      cLE  = 0
      FOR ii = 1 TO nW DO
         IF $wk[ii] <= curV THEN
            cLE = cLE + 1
         ENDIF
      NEXT
      IF nW > 1 THEN
         $stRnk[fk] = (cLE - 1) * 100 / (nW - 1)
      ELSE
         $stRnk[fk] = 100
      ENDIF
      
      //Bubble sort, the only way to get an exact median in ProBuilder.
      FOR ii = 1 TO nW - 1 DO
         FOR jj = 1 TO nW - ii DO
            IF $wk[jj] > $wk[jj+1] THEN
               vv = $wk[jj]
               $wk[jj] = $wk[jj+1]
               $wk[jj+1] = vv
            ENDIF
         NEXT
      NEXT
      
      hm = floor(nW / 2)
      IF nW MOD 2 = 0 THEN
         $stMed[fk] = ($wk[hm] + $wk[hm+1]) / 2
      ELSE
         $stMed[fk] = $wk[hm+1]
      ENDIF
      $stMax[fk] = $wk[nW]
      
   NEXT
   
   //-----Structures-------------------------------------------------------//
   nDraw = min(nCnt, maxDrawn)
   FOR ii = nCnt - nDraw TO nCnt - 1 DO
      sl = (nTot - nCnt + ii) MOD bkRing
      sP = $bkSP[sl]
      sX = $bkSX[sl]
      eX = $bkEX[sl]
      lP = $bkLP[sl]
      lX = $bkLX[sl]
      hP = $bkHP[sl]
      hX = $bkHX[sl]
      
      //Old high held as a level until it is taken out
      DRAWSEGMENT(sX, sP, eX, sP) COLOURED(strucR, strucG, strucB) STYLE(line, 1)
      //Pullback leg and recovery leg
      DRAWSEGMENT(sX, sP, lX, lP) COLOURED(strucR, strucG, strucB) STYLE(line, 1)
      DRAWSEGMENT(lX, lP, eX, sP) COLOURED(strucR, strucG, strucB) STYLE(line, 1)
      //Run up leg, from the break to the highest point reached afterwards
      IF hX > eX THEN
         DRAWSEGMENT(eX, sP, hX, hP) COLOURED(strucR, strucG, strucB) STYLE(dottedline, 1)
      ENDIF
      
      IF markLows = 1 THEN
         DRAWPOINT(lX, lP, 3) COLOURED(topR, topG, topB)
      ENDIF
   NEXT
   
   //-----Statistics panel--------------------------------------------------//
   IF showPanel = 1 THEN
      
      cx1 = dashX
      cx2 = dashX + labelW
      cx3 = cx2 + dashCol
      cx4 = cx3 + dashCol
      cx5 = cx4 + dashCol
      
      boxL = cx1 - 90
      boxR = cx5 + 55
      boxT = dashY + rowH + 14
      boxB = dashY - 6 * rowH - 10
      IF showDetail = 1 THEN
         boxB = dashY - 11 * rowH - 10
      ENDIF
      
      DRAWRECTANGLE(boxL, boxT, boxR, boxB) ANCHOR(TOPRIGHT, XSHIFT, YSHIFT) COLOURED(150, 150, 150, 200) FILLCOLOR(250, 250, 250, 190)
      
      //The two hidden plots of the original, which only lived in the data window
      gapV   = lastEX - lastSX
      sinceV = barindex - lastEX
      DRAWTEXT("Last break: gap #gapV# bars, #sinceV# bars ago", cx2, dashY + rowH, sansserif, bold, 10) ANCHOR(TOPRIGHT, XSHIFT, YSHIFT) COLOURED(txtR, txtG, txtB)
      
      DRAWTEXT("Factor", cx1, dashY, sansserif, bold, 10) ANCHOR(TOPRIGHT, XSHIFT, YSHIFT) COLOURED(txtR, txtG, txtB)
      DRAWTEXT("Median", cx2, dashY, sansserif, bold, 10) ANCHOR(TOPRIGHT, XSHIFT, YSHIFT) COLOURED(txtR, txtG, txtB)
      DRAWTEXT("Max", cx3, dashY, sansserif, bold, 10) ANCHOR(TOPRIGHT, XSHIFT, YSHIFT) COLOURED(txtR, txtG, txtB)
      DRAWTEXT("Current", cx4, dashY, sansserif, bold, 10) ANCHOR(TOPRIGHT, XSHIFT, YSHIFT) COLOURED(txtR, txtG, txtB)
      DRAWTEXT("Rank %", cx5, dashY, sansserif, bold, 10) ANCHOR(TOPRIGHT, XSHIFT, YSHIFT) COLOURED(txtR, txtG, txtB)
      
      FOR fk = 1 TO 6 DO
         yy = dashY - fk * rowH
         pc = $stRnk[fk]
         
         //A deep pullback is an opportunity, a long run up is exhaustion, so
         //rows 1 and 2 read green at the top of the range and rows 3 to 6 red.
         IF fk <= 2 THEN
            rr = round(lowR + (topR - lowR) * pc / 100)
            gg = round(lowG + (topG - lowG) * pc / 100)
            bb = round(lowB + (topB - lowB) * pc / 100)
         ELSE
            rr = round(topR + (lowR - topR) * pc / 100)
            gg = round(topG + (lowG - topG) * pc / 100)
            bb = round(topB + (lowB - topB) * pc / 100)
         ENDIF
         
         IF fk = 1 THEN
            DRAWTEXT("Pullback %", cx1, yy) ANCHOR(TOPRIGHT, XSHIFT, YSHIFT) COLOURED(rr, gg, bb)
         ELSIF fk = 2 THEN
            DRAWTEXT("Pullback bars", cx1, yy) ANCHOR(TOPRIGHT, XSHIFT, YSHIFT) COLOURED(rr, gg, bb)
         ELSIF fk = 3 THEN
            DRAWTEXT("Runup %", cx1, yy) ANCHOR(TOPRIGHT, XSHIFT, YSHIFT) COLOURED(rr, gg, bb)
         ELSIF fk = 4 THEN
            DRAWTEXT("Runup bars", cx1, yy) ANCHOR(TOPRIGHT, XSHIFT, YSHIFT) COLOURED(rr, gg, bb)
         ELSIF fk = 5 THEN
            DRAWTEXT("Total runup %", cx1, yy) ANCHOR(TOPRIGHT, XSHIFT, YSHIFT) COLOURED(rr, gg, bb)
         ELSE
            DRAWTEXT("Total runup bars", cx1, yy) ANCHOR(TOPRIGHT, XSHIFT, YSHIFT) COLOURED(rr, gg, bb)
         ENDIF
         
         IF fk MOD 2 = 1 THEN
            medV = round($stMed[fk] * 100) / 100
            maxV = round($stMax[fk] * 100) / 100
            curW = round($cur[fk] * 100) / 100
         ELSE
            medV = round($stMed[fk])
            maxV = round($stMax[fk])
            curW = round($cur[fk])
         ENDIF
         rnkV = round(pc * 10) / 10
         
         DRAWTEXT("#medV#", cx2, yy) ANCHOR(TOPRIGHT, XSHIFT, YSHIFT) COLOURED(rr, gg, bb)
         DRAWTEXT("#maxV#", cx3, yy) ANCHOR(TOPRIGHT, XSHIFT, YSHIFT) COLOURED(rr, gg, bb)
         DRAWTEXT("#curW#", cx4, yy) ANCHOR(TOPRIGHT, XSHIFT, YSHIFT) COLOURED(rr, gg, bb)
         DRAWTEXT("#rnkV#", cx5, yy) ANCHOR(TOPRIGHT, XSHIFT, YSHIFT) COLOURED(rr, gg, bb)
      NEXT
      
      //-----Last structure, the content of the tooltip of the original------//
      IF showDetail = 1 THEN
         
         dy = dashY - 7 * rowH
         
         pbPrice = round((lastSP - lastLP) * 100) / 100
         pbPct   = round((lastSP - lastLP) * 10000 / lastSP) / 100
         pbBars  = lastLX - lastSX
         recBars = lastEX - lastLX
         ruPrice = round((lastHP - lastSP) * 100) / 100
         ruPct   = round((lastHP - lastSP) * 10000 / lastSP) / 100
         ruBars  = lastHX - lastEX
         
         DRAWTEXT("Last structure", cx1, dy, sansserif, bold, 10) ANCHOR(TOPRIGHT, XSHIFT, YSHIFT) COLOURED(txtR, txtG, txtB)
         
         IF nTot > 1 THEN
            slP = (nTot - 2) MOD bkRing
            befPrice = round((lastSP - $bkSP[slP]) * 100) / 100
            befPct   = round((lastSP - $bkSP[slP]) * 10000 / $bkSP[slP]) / 100
            befBars  = lastSX - $bkEX[slP]
            DRAWTEXT("Runup before", cx1, dy - rowH) ANCHOR(TOPRIGHT, XSHIFT, YSHIFT) COLOURED(txtR, txtG, txtB)
            DRAWTEXT("#befPrice# (#befPct# %) / #befBars# bars", cx3, dy - rowH) ANCHOR(TOPRIGHT, XSHIFT, YSHIFT) COLOURED(txtR, txtG, txtB)
         ENDIF
         
         DRAWTEXT("Pullback", cx1, dy - 2 * rowH) ANCHOR(TOPRIGHT, XSHIFT, YSHIFT) COLOURED(txtR, txtG, txtB)
         DRAWTEXT("#pbPrice# (#pbPct# %) / #pbBars# bars", cx3, dy - 2 * rowH) ANCHOR(TOPRIGHT, XSHIFT, YSHIFT) COLOURED(txtR, txtG, txtB)
         
         DRAWTEXT("Recovery", cx1, dy - 3 * rowH) ANCHOR(TOPRIGHT, XSHIFT, YSHIFT) COLOURED(txtR, txtG, txtB)
         DRAWTEXT("#recBars# bars", cx3, dy - 3 * rowH) ANCHOR(TOPRIGHT, XSHIFT, YSHIFT) COLOURED(txtR, txtG, txtB)
         
         DRAWTEXT("Runup after", cx1, dy - 4 * rowH) ANCHOR(TOPRIGHT, XSHIFT, YSHIFT) COLOURED(txtR, txtG, txtB)
         DRAWTEXT("#ruPrice# (#ruPct# %) / #ruBars# bars", cx3, dy - 4 * rowH) ANCHOR(TOPRIGHT, XSHIFT, YSHIFT) COLOURED(txtR, txtG, txtB)
         
      ENDIF
   ENDIF
ENDIF

RETURN

Settings Worth Touching

Minimum Gap is the setting, and everything else is cosmetic. It decides what counts as a pause worth measuring. At 30 on a daily chart you are asking for roughly six weeks below the old high. Lower it to 10 or 15 and you get many more structures — a bigger sample, but a sample that now includes ordinary consolidations, which drags the median down and makes today’s pullback look worse than it is. Raise it to 60 or 100 and every structure is a genuine correction, but you may end up with three of them on the whole chart.

The trade-off is unusually clean here: this dial buys sample size with relevance, at a fixed exchange rate. Decide which one you are short of.

Breakouts drawn is set to 20 and only affects clutter. The statistics always use the full stored set regardless of what is drawn, so lowering it costs you nothing analytically.

Breakouts stored is set to 200 and is a ceiling that a normal chart never reaches. It exists so that a very long intraday chart does not slow the panel down. If you do hit it, the oldest structures drop out of the sample.

Panel geometry — dashX, dashY, dashCol, rowH — moves and resizes the panel in pixels from the top right corner. The panel is wide because it has five columns; if it crowds your chart, raise dashX towards zero and lower dashCol.

Conclusion

The idea worth stealing is not the triangles. It is ranking a live measurement against the instrument’s own record instead of against a number you chose.

Every threshold in technical analysis has this problem. A 10% pullback, a 2×ATR stop, a 20-bar consolidation: they are all constants applied to something that is not constant. Replacing the constant with a percentile costs one array and one counting loop, and the result carries its meaning across instruments and across regimes without you retuning anything.

The second idea is cheaper still: measure the same advance from two anchors. Run-up from the broken level and run-up from the pullback low differ by exactly the cost of waiting for confirmation. That number is rarely calculated and it is the honest price of every “wait for the retest” rule ever written.

Download
Filename: PRC_Breakouts-and-Pullbacks.itf
Downloads: 7
Iván González Legend
As an architect of digital worlds, my own description remains a mystery. Think of me as an undeclared variable, existing somewhere in the code.
Author’s Profile

Comments

Logo Logo
Loading...