A volume histogram under the chart tells you when volume was high, but you still have to decide what “high” means and connect each bar with the price action above it. Volume Bubbles, by QuantAlgo, puts that answer directly on the price chart. Every bar with unusual volume gets a bubble, placed under the low of a bullish candle or above the high of a bearish one, and sized by how exceptional the volume is: small, medium or big.
The bubbles make climaxes, breakouts with real participation and absorption at support or resistance visible at a glance, without leaving the price pane.
Most volume filters compare the bar with a multiple of the average volume, for example 2x. The problem is that the average is pulled up by the few very large bars, and a 2x rule means very different things on a quiet instrument and on a hectic one. This indicator uses percentiles: a bar is elevated when its volume is at or above, say, the 90th percentile of the recent bars, that is, when only 10% of those bars traded more. Percentiles adapt to the shape of each instrument’s volume distribution.
The percentile is computed with linear interpolation between the two nearest ranks, and the window includes the current bar.
A single lookback can be fooled: after a quiet week, any normal bar looks big against the last 20 bars. So each bar is checked against three windows at the same time, short (20 bars), medium (50) and long (100). The consensus mode decides how many of them must agree:
The same test runs with three percentile levels:
Each bar gets the largest size it qualifies for.
A bubble is a buy cluster when the candle closes at or above its open, and a sell cluster when it closes below. Buy bubbles sit under the low, sell bubbles above the high, so the bubble points to the side that was pushing.
smallPct, mediumPct, bigPct (defaults: 75, 90, 97): percentile thresholds of the three cluster sizes.consensusMode (default: 2): number of windows that must agree, 1 = any, 2 = majority, 3 = all.shortLen, midLen, longLen (defaults: 20, 50, 100): lookback windows. midLen is also the length of the average used for the ratio.showSmall, showMedium, showBig (default: 1): show each cluster size.colorPreset (default: 5): 0 Classic, 1 Aqua, 2 Cosmic, 3 Cyber, 4 Neon, 5 Custom.cBuyR, cBuyG, cBuyB, cBuyA and cSellR, cSellG, cSellB, cSellA: custom buy and sell colours with their opacity (0 to 255), used with the Custom preset.showLabels (default: 1): numbers inside the bubbles.labelContent (default: 0): 0 = volume, 1 = ratio, 2 = volume and ratio.maxBubbles (default: 500): number of recent bubbles kept on the chart.The indicator needs volume data. On an instrument without volume a warning is shown in the middle of the chart.
//---------------------------------------------------------------
//PRC_Volume-Bubbles
//version = 0
//25.09.2026
//Iván González @ www.prorealcode.com
//Author: QuantAlgo
//Sharing ProRealTime knowledge
//--------------------------------------------------------------------//
defparam drawonlastbaronly = true
//--------------------------------------------------------------------//
// SETTINGS
//--------------------------------------------------------------------//
smallPct = 75 // Small cluster percentile
mediumPct = 90 // Medium cluster percentile
bigPct = 97 // Big cluster percentile
consensusMode = 2 // windows that must agree: 1 = any, 2 = majority (2 of 3), 3 = all
shortLen = 20 // short window
midLen = 50 // medium window (also the average for the ratio)
longLen = 100 // long window
showSmall = 1
showMedium = 1
showBig = 1
colorPreset = 5 // 0 Classic, 1 Aqua, 2 Cosmic, 3 Cyber, 4 Neon, 5 Custom
showLabels = 1 // numbers inside the bubbles
labelContent = 0 // 0 = Volume, 1 = Ratio, 2 = Volume + Ratio
maxBubbles = 500 // bubbles kept on the chart (the most recent ones)
// Custom colours (colorPreset = 5), alpha 0-255
cBuyR = 0
cBuyG = 255
cBuyB = 170
cBuyA = 153
cSellR = 255
cSellG = 0
cSellB = 0
cSellA = 204
//--------------------------------------------------------------------//
// COLOURS
//--------------------------------------------------------------------//
IF colorPreset = 0 THEN
buyR = 0
buyG = 255
buyB = 0
buyA = 153
sellR = 255
sellG = 0
sellB = 0
sellA = 204
ELSIF colorPreset = 1 THEN
buyR = 0
buyG = 191
buyB = 255
buyA = 153
sellR = 255
sellG = 140
sellB = 0
sellA = 204
ELSIF colorPreset = 2 THEN
buyR = 73
buyG = 255
buyB = 206
buyA = 153
sellR = 153
sellG = 50
sellB = 204
sellA = 204
ELSIF colorPreset = 3 THEN
buyR = 0
buyG = 204
buyB = 204
buyA = 204
sellR = 255
sellG = 102
sellB = 0
sellA = 153
ELSIF colorPreset = 4 THEN
buyR = 255
buyG = 255
buyB = 0
buyA = 153
sellR = 255
sellG = 0
sellB = 255
sellA = 204
ELSE
buyR = cBuyR
buyG = cBuyG
buyB = cBuyB
buyA = cBuyA
sellR = cSellR
sellG = cSellG
sellB = cSellB
sellA = cSellA
ENDIF
//--------------------------------------------------------------------//
// PERCENTILE TESTS (linear interpolation, window includes the current bar)
// volume >= percentile p of the last N bars <=>
// count of bars with volume <= current volume >= ceil((N - 1) * p / 100) + 1
//--------------------------------------------------------------------//
$winL[1] = shortLen
$winL[2] = midLen
$winL[3] = longLen
$pctL[1] = smallPct
$pctL[2] = mediumPct
$pctL[3] = bigPct
hitsS = 0
hitsM = 0
hitsB = 0
FOR w = 1 TO 3 DO
nW = $winL[w]
IF barindex >= nW - 1 THEN
cntLE = 0
FOR j = 0 TO nW - 1 DO
IF volume[j] <= volume THEN
cntLE = cntLE + 1
ENDIF
NEXT
FOR q = 1 TO 3 DO
kNeed = ceil((nW - 1) * $pctL[q] / 100 - 0.000000001) + 1
IF cntLE >= kNeed THEN
IF q = 1 THEN
hitsS = hitsS + 1
ELSIF q = 2 THEN
hitsM = hitsM + 1
ELSE
hitsB = hitsB + 1
ENDIF
ENDIF
NEXT
ENDIF
NEXT
//--------------------------------------------------------------------//
// CONSENSUS AND CLUSTER SIZE
//--------------------------------------------------------------------//
isBig = hitsB >= consensusMode
isMed = NOT isBig AND hitsM >= consensusMode
isSmall = NOT isBig AND NOT isMed AND hitsS >= consensusMode
isSmallVis = isSmall AND showSmall = 1
isMedVis = isMed AND showMedium = 1
isBigVis = isBig AND showBig = 1
isAnyVis = isSmallVis OR isMedVis OR isBigVis
// buy / sell by candle direction
isSell = close < open
IF isSell THEN
bubY = high
ELSE
bubY = low
ENDIF
avgVol = average[midLen](volume)
IF avgVol > 0 THEN
volRatio = volume / avgVol
ELSE
volRatio = 0
ENDIF
//--------------------------------------------------------------------//
// BUBBLES: stored in a ring buffer and redrawn on the last bar, so the live
// bar does not pile up one bubble per tick while its volume grows
//--------------------------------------------------------------------//
once nBub = 0
IF isAnyVis THEN
IF isBigVis THEN
bSize = 5
ELSIF isMedVis THEN
bSize = 4
ELSE
bSize = 3
ENDIF
// volume formatted as K / M / B
IF volume >= 1000000000 THEN
vFmt = round(volume / 1000000000 * 100) / 100
vUnit = 3
ELSIF volume >= 1000000 THEN
vFmt = round(volume / 1000000 * 100) / 100
vUnit = 2
ELSIF volume >= 1000 THEN
vFmt = round(volume / 1000 * 100) / 100
vUnit = 1
ELSE
vFmt = round(volume)
vUnit = 0
ENDIF
slot = nBub - floor(nBub / maxBubbles) * maxBubbles
$bX[slot] = barindex
$bY[slot] = bubY
$bGap[slot] = (high - low) * 0.3
$bSz[slot] = bSize
$bSell[slot] = isSell
$bV[slot] = vFmt
$bU[slot] = vUnit
$bRt[slot] = round(volRatio * 10) / 10
nBub = nBub + 1
ENDIF
IF islastbarupdate AND nBub > 0 THEN
nDraw = min(nBub, maxBubbles)
FOR i = 0 TO nDraw - 1 DO
IF $bSell[i] = 1 THEN
bR = sellR
bG = sellG
bB = sellB
bA = sellA
ELSE
bR = buyR
bG = buyG
bB = buyB
bA = buyA
ENDIF
xB = $bX[i]
yB = $bY[i]
IF $bSz[i] = 5 THEN
DRAWPOINT(xB, yB, 5) COLOURED(bR, bG, bB, bA)
ELSIF $bSz[i] = 4 THEN
DRAWPOINT(xB, yB, 4) COLOURED(bR, bG, bB, bA)
ELSE
DRAWPOINT(xB, yB, 3) COLOURED(bR, bG, bB, bA)
ENDIF
// numbers inside the bubble
IF showLabels = 1 THEN
IF labelContent = 2 THEN
vY = yB + $bGap[i] / 2
rY = yB - $bGap[i] / 2
ELSE
vY = yB
rY = yB
ENDIF
vFmt = $bV[i]
rFmt = $bRt[i]
IF labelContent = 0 OR labelContent = 2 THEN
IF $bU[i] = 3 THEN
DRAWTEXT("#vFmt#B", xB, vY, sansserif, standard, 9) COLOURED(0, 0, 0)
ELSIF $bU[i] = 2 THEN
DRAWTEXT("#vFmt#M", xB, vY, sansserif, standard, 9) COLOURED(0, 0, 0)
ELSIF $bU[i] = 1 THEN
DRAWTEXT("#vFmt#K", xB, vY, sansserif, standard, 9) COLOURED(0, 0, 0)
ELSE
DRAWTEXT("#vFmt#", xB, vY, sansserif, standard, 9) COLOURED(0, 0, 0)
ENDIF
ENDIF
IF labelContent = 1 OR labelContent = 2 THEN
DRAWTEXT("#rFmt#x", xB, rY, sansserif, standard, 9) COLOURED(0, 0, 0)
ENDIF
ENDIF
NEXT
ENDIF
//--------------------------------------------------------------------//
// NO VOLUME WARNING
//--------------------------------------------------------------------//
once cumVol = 0
cumVol = cumVol + volume
IF islastbarupdate AND cumVol = 0 THEN
DRAWTEXT("NO VOLUME DATA: this instrument does not provide volume, Volume Bubbles needs it", 0, 0, sansserif, bold, 14) COLOURED(255, 0, 0) ANCHOR(middle, xshift, yshift)
ENDIF
RETURN
Volume Bubbles turns the volume question into something you can read on the price chart: which bars traded unusually, how unusual they were, and which side was pushing. The percentile and consensus logic keeps the signals meaningful across instruments with very different volume profiles.