Key takeaways
- Visualize where Fair Value Gaps cluster across price levels with a directional histogram showing bullish and bearish gap density
- Track the Rolling POC line as a dynamic support/resistance level that shifts only when the underlying gap structure changes
- Use per-bin delta volume to separate genuinely contested price zones from one-sided imbalance areas in your algo strategy
- Run it locally with PineTS on Node.js for backtesting or real-time signal generation via the Binance data provider
Loading chartโฆ
What Is FVG Profile + Rolling POC?
You know how traditional volume profiles show you where trading activity clustered? BigBeluga’s FVG Profile takes that same concept and applies it to Fair Value Gaps instead. Rather than counting traded volume at each price level, this indicator counts how many bullish and bearish FVGs formed near each zone over a configurable lookback window. The result is a horizontal histogram that tells you exactly where price inefficiencies keep stacking up.
What really sets this apart is the Rolling POC (Point of Control) line. Unlike a static profile that only shows the final snapshot, the POC recalculates on every bar, tracking the price level with the densest FVG activity as it moves through time. Pair that with per-bin delta volume (bullish FVG volume minus bearish FVG volume) and you get a surprisingly complete picture of where the market’s structural imbalances live. I’ve been running this on weekly BTCUSDC charts and the POC line acts like a magnet for price reactions.
Running FVG Profile with PineTS
PineTS transpiles Pine Script to JavaScript and runs it locally against real market data. Skip the TradingView subscription; you’re pulling directly from Binance. Here’s the setup.
Installing PineTS
Grab PineTS from npm:
npm install pinets
Loading the Pine Script Indicator
You give PineTS a source file, a ticker, a timeframe, and a bar count. It handles transpilation and pulls the data from Binance. For this indicator, go with at least 500 bars; that’s what the default lookback expects.
Method 1: Batch Execution with pineTS.run()
The batch approach downloads historical data, runs the full indicator calculation in one pass, and gives you the complete context object with all plot values. Perfect for backtesting or one-off analysis.
// PineTS batch execution example template
import { PineTS, Provider } from 'pinets'
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const pineScript = readFileSync(join(__dirname, 'source.pine'), 'utf-8')
const pineTS = new PineTS(Provider.Binance, 'BTCUSDC', '1w', 500)
const ctx = await pineTS.run(pineScript)
console.log('Bars processed:', ctx.marketData.length)
console.log('Plots:', Object.keys(ctx.plots).join(', '))
for (const [name, plot] of Object.entries(ctx.plots)) {
const lastPoint = plot.data?.[plot.data.length - 1]
if (lastPoint) {
console.log(` ${name}: last value = ${lastPoint.value}`)
}
}
After it runs, ctx.plots gives you the full time series for every named plot. Pull the Rolling POC by bar index to feed the level directly into your signal logic. The MA plot comes along for free if you have it enabled.
Method 2: Live Streaming with pineTS.stream()
If you want the indicator to update in real time as new candles close, the streaming API is your friend. It reconnects to the Binance WebSocket feed and re-evaluates the full Pine Script on every tick.
Inputs
Main Settings
| Input | Type | Default | Description |
|---|---|---|---|
| Lookback Period | int | 500 | Number of bars to scan for FVGs |
| Profile Resolution (Bins) | int | 40 | Number of horizontal price rows in the profile |
| Horizontal Offset | int | 10 | Shifts the profile rightward to avoid overlapping candles |
Display Toggles
| Input | Type | Default | Description |
|---|---|---|---|
| Show Fair Value Gaps | bool | true | Draws individual FVG boxes on the chart |
| Show Strength Percentage | bool | true | Displays percentage labels per bin |
| Show Rolling POC Line | bool | true | Enables the dynamic Point of Control line |
| Show Delta Volume | bool | true | Shows bull minus bear FVG volume per bin |
| Show Bull / Bear FVG Count | bool | true | Labels each bin with bullish and bearish gap counts |
| Enable Heatmap | bool | false | Overlays a gradient showing gap density across the range |
Colors
| Input | Type | Default | Description |
|---|---|---|---|
| Bullish/Bearish Color | color pair | lime / orange | Colors for bullish and bearish profile segments |
| Background Color | color | gray (80% transparent) | Profile area background |
| POC Color | color | rgb(255, 102, 0) | Rolling POC line color |
Moving Average
| Input | Type | Default | Description |
|---|---|---|---|
| Type | string | None | MA type: None, SMA, EMA, SMMA (RMA), WMA, VWMA |
| Length | int | 25 | MA period (ignored when Auto is enabled) |
| Auto | bool | false | Automatically scales MA length to the lookback period |
| Source | source | close | Price source for the moving average |
Plots
| Plot Name | Type | Description |
|---|---|---|
| Rolling POC | Line (linebr) | SMA-smoothed (10-period) rolling Point of Control tracking peak FVG activity |
| MA | Line (linebr) | Configurable moving average overlay (disabled by default) |
Alerts
This indicator does not define alert conditions.
Understanding the Indicator
FVG Detection and Binning
The core mechanic is straightforward. A bullish FVG fires when high[i+2] < low[i], meaning two bars ago’s high is strictly below the current bar’s low, leaving a gap. Bearish FVG is the mirror: low[i+2] > high[i]. Nothing exotic, just classic three-bar gap detection.
Where it gets interesting is how BigBeluga handles the binning. The full price range across the lookback (default 500 bars) gets sliced into 40 horizontal bins. Each FVG’s midpoint (math.avg(high[i+2], low[i]) for bullish, inverse for bearish) gets mapped to its nearest bin. The script then increments both a gap count and a volume accumulator for that bin. So you end up with two parallel arrays per direction: how many FVGs hit that price zone, and how much volume was associated with the bar where each FVG formed.
Smart design choice. By using the midpoint of the gap rather than one edge, you get a more stable bin assignment that doesn’t flip based on whether the gap was barely touching a boundary.
The Rolling POC: Why It Matters
The static profile (rendered on the last bar) is useful, but the real gem is the get_poc() function that runs on every bar. It performs the same FVG detection and binning, but only cares about finding the single bin with the highest total count. That becomes the Point of Control for that bar.
The raw POC value gets smoothed with a 10-period SMA via ta.sma(rollingPoc, 10). This prevents the line from jumping erratically when a single new FVG enters or exits the lookback window. On BTCUSDC weekly, that smoothing creates a flowing level that price respects surprisingly well as dynamic support and resistance.
One thing to keep in mind: the POC loop iterates through all 500 bars on every single bar calculation. That’s a nested loop (500 bars times 40 bins for the profile), so on very low timeframes with large lookbacks you might notice some computation lag. On weekly or daily charts, it’s not a problem at all.
Delta Volume and Directional Bias
Look, counting FVGs is nice, but the delta volume feature is what turns this from a visualization into an actual trading tool. For each bin, the script calculates bullVol.get(k) - bearVol.get(k). Positive delta means bullish FVGs at that level carried more volume. Negative means bears dominated.
Not all gaps are created equal, though. A price zone with 8 bullish FVGs and 2 bearish FVGs might look strongly bullish in the count profile, but if those 2 bearish FVGs happened during massive sell-off candles, the delta volume flips the story entirely. The profile labels color-code by delta direction (green for bullish dominance, orange for bearish), so you can see this at a glance.
I find the combination of count and delta volume particularly useful near range boundaries. When you see a bin with high total gap count but near-zero delta, that’s a genuinely contested zone. When count and delta align strongly, it’s a one-sided magnet.
Using It for Algorithmic Signal Generation
Here’s where it gets practical for bot builders. The Rolling POC line is your primary signal. When price crosses above the POC, you’re moving away from the densest cluster of historical inefficiency, which often signals a breakout. When price gravitates back toward it, you’re entering a high-probability reaction zone.
I’ve been testing a simple approach: go long when price closes above the Rolling POC on a weekly candle and the most recent FVG at the POC level had positive delta volume. Exit when price closes below. It’s crude, but on trending pairs it actually filters out a lot of noise.
If you want to go deeper: pull the relative strength percentage for each bin. Bins over 50% with positive delta are where buyers kept showing up. Bins over 50% with negative delta are where sellers were. Stack those as levels in a grid bot instead of using fixed spacing, and the grid adapts to where the market’s actually been fighting.
The optional MA overlay helps confirm trend direction. With the “Auto” length feature enabled, the MA period scales automatically with your lookback, which means you don’t need to manually tune it when you change timeframes. That’s a nice quality-of-life touch for multi-timeframe scanning.
Performance Notes
No repainting. The Rolling POC is a historical calculation, the SMA is a straight lookback, and FVG detection requires the bar after the gap to confirm. So you’re always working with a 1-bar lag. That’s the correct behavior, not a bug.
The main performance consideration is the nested loop inside get_poc(). With the default 500-bar lookback and 40 bins, you’re running 20,000 iterations per bar. On weekly charts that’s barely noticeable. On 1-minute charts with 500 bars of lookback, expect some slowdown during the initial load. If you’re using PineTS in a production bot on low timeframes, consider dropping the lookback to 200 or reducing the bin count to 20.
The calc_bars_count = 5000 and max_bars_back = 5000 settings in the indicator declaration mean PineTS needs to fetch enough historical data to cover the lookback window plus warmup. With the default 500-bar lookback on a weekly chart, that’s about 10 years of data. Binance covers BTC pairs comfortably at that depth.
Important Notes
PineTS successfully compiles and executes this indicator (Pine v6). The Rolling POC and MA plots render correctly in both batch and streaming modes. The static profile (box drawings, labels, and heatmap) are visual elements that render on TradingView’s chart canvas only. In PineTS, you get the plot values (POC line, MA line) as numerical time series, exactly what you need for algo integration.
The indicator uses the Binance data provider by default. Since FVG detection depends on gap formation between candles, you’ll see different results on assets and timeframes where gaps are rare (like forex on lower timeframes). Crypto markets with 24/7 trading and volatile sessions produce the most FVGs, making this indicator particularly well-suited for digital assets.
One limitation to be aware of: the volume used in delta calculations comes from the bar where the FVG formed, not from subsequent volume that traded within the gap. This is a common simplification in FVG-based tools, but it means the “delta volume” is really “delta formation volume.” Keep that in mind when interpreting the directional bias at each level.
// This work is licensed under Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International
// https://creativecommons.org/licenses/by-nc-sa/4.0/
// ยฉ BigBeluga
//@version=6
indicator("Fair Value Gap Profile + Rolling POC [BigBeluga]", "FVG Profile + POC [Bigbeluga]", overlay = true, max_boxes_count = 500, max_bars_back = 5000, calc_bars_count = 5000)
// ๏ผฉ๏ผฎ๏ผฐ๏ผต๏ผด๏ผณ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ{
period = input.int(500, "Lookback Period", tooltip = "The number of bars back from the current price to analyze for Fair Value Gaps.")
binSize = input.int(40, "Profile Resolution (Bins)", tooltip = "The number of rows (price levels) the profile is divided into. Higher values create a more detailed but narrower profile.")
offset = input.int(10, "Horizontal Offset", tooltip = "Moves the profile further to the right of the current bar to avoid overlapping price action.")
displayFVG = input.bool(true, "Show Fair Value Gaps", tooltip = "When enabled, highlights individual FVG boxes directly on the price chart.")
displayPer = input.bool(true, "Show Strength Percentage", tooltip = "Displays the percentage of total gap activity for each price bin.")
showPOC = input.bool(true, "Show Rolling POC Line", tooltip = "Draws a line tracking the price level with the most FVG activity.")
DisplayDelta = input.bool(true, "Show Delta Volume", tooltip = "Shows the net volume difference between bullish and bearish gaps at that level.")
Displayqty = input.bool(true, "Show Bull / Bear FVG Count", tooltip = "Displays the total count of bullish (โฒ) and bearish (โผ) gaps found in each bin.")
heatMap = input.bool(false, "Enable Heatmap", tooltip = "Overlays a color gradient on the background based on the density of gap activity.", inline = "hm")
bullCol = input.color(color.lime, "Bullish/Bearish Color", tooltip = "Color used for bullish gaps and positive volume delta.", inline = "col")
bearCol = input.color(color.orange, "", tooltip = "Color used for bearish gaps and negative volume delta.", inline = "col")
bgColor = input.color(color.rgb(73, 73, 73, 80), "Background Colorใ
คใ
ค", tooltip = "The background color of the profile area.", inline = "col1")
pocCol = input.color(color.rgb(255, 102, 0), "POC Color", tooltip = "The color of the Rolling Point of Control line.")
topBot = array.new<float>()
colorNA = color.new(color.black, 100)
// }
// ๏ผฒ๏ผฏ๏ผฌ๏ผฌ๏ผฉ๏ผฎ๏ผง ๏ผฐ๏ผฏ๏ผฃ ๏ผฌ๏ผฏ๏ผง๏ผฉ๏ผฃ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ{
// We need a way to find the POC on every bar for the 'Rolling' effect
// This function finds which bin the current FVG falls into based on the global high/low range
get_poc() =>
float hi = ta.highest(high, period)
float lo = ta.lowest(low, period)
float stp = (hi - lo) / binSize
array<int> counts = array.new<int>(binSize, 0)
float pocPrice = na
for i = 0 to period - 1
// Bull FVG Logic
if high[i+2] < low[i]
avg = math.avg(high[i+2], low[i])
binIdx = math.floor((avg - lo) / stp)
if binIdx >= 0 and binIdx < binSize
counts.set(binIdx, counts.get(binIdx) + 1)
// Bear FVG Logic
if low[i+2] > high[i]
avg = math.avg(low[i+2], high[i])
binIdx = math.floor((avg - lo) / stp)
if binIdx >= 0 and binIdx < binSize
counts.set(binIdx, counts.get(binIdx) + 1)
// Find index of max activity
maxAct = counts.max()
if maxAct > 0
pocIdx = counts.indexof(maxAct)
pocPrice := lo + (stp * pocIdx) + (stp / 2)
pocPrice
rollingPoc = showPOC ? get_poc() : na
poc = ta.sma(rollingPoc, 10)
plot(poc, "Rolling POC", color = pocCol, linewidth = 2, show_last = period, style = plot.style_linebr)
if barstate.islast and showPOC
label.delete(label.new(bar_index, poc, "< PoC", style = label.style_label_left, color = colorNA, textcolor = pocCol)[1])
// }
// ๏ผฃ๏ผก๏ผฌ๏ผฃ๏ผต๏ผฌ๏ผก๏ผด๏ผฉ๏ผฏ๏ผฎ๏ผณ (Static Profile for Last Bar) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ{
if barstate.islast
var boxes = array.new<box>()
for b in boxes
b.delete()
boxes.clear()
for i = 0 to period - 1
topBot.push(high[i])
topBot.push(low[i])
step = (topBot.max() - topBot.min()) / binSize
bullGaps = array.new<int>(binSize, 0)
bearGaps = array.new<int>(binSize, 0)
bullVol = array.new<float>(binSize, 0.)
bearVol = array.new<float>(binSize, 0.)
for i = 0 to period - 1
avgBull = math.avg(high[i+2], low[i])
avgBear = math.avg(low[i+2], high[i])
if high[i+2] < low[i] // bullFVG
if displayFVG
boxes.push(box.new(bar_index[i]-2, high[i+2], bar_index[i]+1, low[i], color.new(bullCol, 10), 1, bgcolor = color.new(bullCol, 80)))
for k = 0 to binSize - 1
L = topBot.min() + step * k + step / 2
if math.abs(avgBull - L) < step / 2
bullGaps.set(k, bullGaps.get(k) + 1)
bullVol.set(k, bullVol.get(k) + volume[i+1])
if low[i+2] > high[i] // bearFVG
if displayFVG
boxes.push(box.new(bar_index[i]-2, low[i+2], bar_index[i]+1, high[i], color.new(bearCol, 10), 1, bgcolor = color.new(bearCol, 80)))
for k = 0 to binSize - 1
L = topBot.min() + step * k + step / 2
if math.abs(avgBear - L) < step / 2
bearGaps.set(k, bearGaps.get(k) + 1)
bearVol.set(k, bearVol.get(k) + volume[i+1])
max_ = 0.
for k = 0 to binSize - 1
delta = bullVol.get(k) - bearVol.get(k)
max_ := math.max(math.abs(delta), max_)
// ๏ผฐ๏ผฌ๏ผฏ๏ผด Profile Bars
for k = 0 to binSize - 1
L = topBot.min() + step * k
H = L + step
bullFVG_w = int(bullGaps.get(k) / math.max(1, bullGaps.max()) * 25)
bearFVG_w = int(bearGaps.get(k) / math.max(1, bearGaps.max()) * 25)
max_act = bullGaps.max() + bearGaps.max()
tot = (bullGaps.get(k) + bearGaps.get(k)) / math.max(1, max_act) * 100
delta = bullVol.get(k) - bearVol.get(k)
if bullGaps.get(k) != 0 or bearGaps.get(k) != 0
if heatMap
boxes.push(box.new(bar_index-period, H, bar_index+50+offset, L, bgcolor = color.from_gradient(tot, 0, 100, colorNA, color.new(chart.fg_color, 50)), border_color = colorNA))
boxes.push(box.new(bar_index+50+offset, H, bar_index+50+offset-bullFVG_w, L, bgcolor = bullCol, border_color = chart.bg_color, text = Displayqty ? "โฒ" + str.tostring(bullGaps.get(k)) : ""))
boxes.push(box.new(bar_index+50+offset-bullFVG_w, H, bar_index+50+offset-bullFVG_w-bearFVG_w, L, bgcolor = bearCol, border_color = chart.bg_color, text = Displayqty ? "โผ" + str.tostring(bearGaps.get(k)) : ""))
if displayPer
boxes.push(box.new(bar_index+ 50 + offset, H, bar_index+ 60 + offset, L, colorNA, 0, bgcolor = colorNA, text = str.tostring(tot, format.percent) + " |", text_halign = text.align_left, text_color = delta > 0 ? bullCol : bearCol))
if DisplayDelta
boxes.push(box.new(bar_index+ (displayPer ? 60 : 50) + offset, H, bar_index+ (displayPer ? 75 : 60) + offset, L, colorNA, 0, bgcolor = colorNA, text = "ฮ " + str.tostring(delta, format.volume), text_halign = text.align_left, text_color = delta > 0 ? bullCol : bearCol))
boxes.push(box.new(bar_index-period, topBot.max(), bar_index+50+offset, topBot.min(), colorNA, 0, bgcolor = bgColor))
line.delete(line.new(bar_index+50+offset, topBot.max(), bar_index+50+offset, topBot.min(), color = chart.fg_color)[1])
// }
// Smoothing MA inputs
var length = 2
if bar_index == last_bar_index - period
length := 2
length += 1
GRP = "Moving Average"
maTypeInput = input.string("None", "Type", options = ["None", "SMA", "EMA", "SMMA (RMA)", "WMA", "VWMA"], group = GRP, display = display.none)
maLengthInput = input.int(25, "Length", group = GRP, display = display.none, inline = "len")
autoLength = input.bool(false, "Auto", group = GRP, display = display.none, inline = "len")
src = input(close, title="Source", group = GRP)
pine_ema(src, length) =>
alpha = 2 / (length + 1)
sum = 0.0
sum := na(sum[1]) ? src : alpha * src + (1 - alpha) * nz(sum[1])
pine_rma(src, length) =>
alpha = 1/length
sum = 0.0
sum := na(sum[1]) ? ta.sma(src, length) : alpha * src + (1 - alpha) * nz(sum[1])
// Smoothing MA Calculation
ma(source, length, MAtype) =>
switch MAtype
"SMA" => ta.sma(source, length)
"EMA" => pine_ema(source, length)
"SMMA (RMA)" => pine_rma(source, length)
"WMA" => ta.wma(source, length)
"VWMA" => ta.vwma(source, length)
ma = ma(src, autoLength ? length : maLengthInput, maTypeInput)
plot(ma, color=color.yellow, title="MA", show_last = period, style = plot.style_linebr)
if barstate.islast and maTypeInput != "None"
label.delete(label.new(bar_index, ma, maTypeInput + ": " + str.tostring(autoLength ? length : maLengthInput), style = label.style_label_left, color = colorNA, textcolor = color.yellow)[1])
// }Disclaimer
QuantForge publishes technical material about indicators: what they do, how they are built, and how you can experiment with them in code and on charts. All of this is offered for education and illustration only. It is not financial, investment, or trading advice, and it should not be read as a recommendation to buy, sell, or hold any asset. Past or simulated performance does not guarantee future results. You are responsible for your own decisions; seek independent professional advice where appropriate.

![FVG Profile + Rolling POC [BigBeluga] – Fair Value Gap Heatmap with Delta Volume](https://quantforge.org/wp-content/uploads/2026/03/screenshot-6.webp)