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”.
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.
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.
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.
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.
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.
//---------------------------------------------------------------------------//
//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
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.
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.