Tutorial

Volume Profile Indicator in Pine Script: Build It With AI

Build a volume profile indicator in Pine Script v6 with arrays, loops, and boxes, add a value area, and learn how to prompt an AI for it.

•14 min read

You open a chart, switch on a volume profile, and a histogram appears down the side of the price axis. It shows where trading piled up and where price moved through without stopping. Then you decide you want your own version: your own number of rows, a lookback that you choose rather than the visible range, a highlighted value area, and a line at the busiest price. You search for how to do it in Pine Script and find that the usual tutorial tricks (a plot, a moving average, a ta.* call) do not apply. A profile is not a series that gets one value per bar. It is a distribution, and building a distribution means arrays, loops, and drawing objects, all in one script.

That is why this indicator is a good test of how well you know Pine Script, and why it is also a good test of any AI tool you ask to write it. This post builds a volume profile from first principles, explains each decision, and then shows how to describe the job to an AI editor so the first version is close to what you wanted.

What a volume profile actually measures

Most volume indicators answer "how much traded in this bar?" A volume profile answers a different question: "how much traded at this price?" It slices the price range of some window into equal rows, adds up the volume that belongs to each row, and draws the totals as horizontal bars. The longest bar marks the price level where the most volume traded. It is usually called the point of control, or POC.

A second common output is the value area, the band of rows around the POC that holds a chosen share of the total volume, traditionally 70 percent. Neither number tells you what price will do next. They describe the past: where activity concentrated inside the window you picked. Treat them as a description of history and nothing more.

TradingView ships its own volume profile tools, so building one yourself is about control and learning, not about filling a gap. You decide the window, the row count, the colors, and the rule for spreading volume across rows. You also end up understanding exactly what the picture means, which is the real reason to build it.

The one hard problem: bars do not have a single price

A bar has an open, high, low, and close, and its volume happened somewhere between the high and the low. A profile needs volume per price row, so you have to decide how to assign each bar's volume to rows. There are three common answers.

The simplest is to put the whole bar's volume in the row that contains its close. It is cheap and easy to read, but it throws away the fact that a tall bar covered many rows. The second is to spread the volume evenly across every row between the bar's low and high. It is still an approximation, since volume is rarely uniform inside a bar, but it respects the bar's range. The third is to use lower timeframe data, so that a one hour bar is rebuilt from its one minute bars, and assign each small bar's volume by its own close. Pine Script can do that with request.security_lower_tf, at the cost of more data, more complexity, and a hard limit on how many lower timeframe bars a script may pull.

The script below uses the second method. It is the best balance of honesty and simplicity, and it shows the pattern that the lower timeframe version would also use. The even spread is an assumption, and you should remember it when you read the result. A profile built from daily bars is a rough picture. One built from intrabar data is finer, but still a model.

Plan the data structures before the code

Think of the profile as three things: the price range, the number of rows, and an array with one float per row. Everything else follows from those.

The price range is the highest high and the lowest low over your lookback. The row height is that range divided by the number of rows. For any price, the row index is the price minus the lowest low, divided by the row height, rounded down. A price exactly at the top of the range would land one row too high, so the index is capped at the last row.

The array is a plain array<float> created with a fixed size and filled with zeros. If arrays are new to you, the arrays, matrices, and maps guide covers how they behave, including the fact that indices start at zero. For each bar in the window you work out the first and last row it touches, divide its volume by the number of rows it covers, and add that share to each of those rows.

When the loop ends you have the distribution. The maximum value gives you the POC row. The sum gives you the total you need for the value area. All that remains is drawing.

Why the work happens only on the last bar

A Pine Script runs once for every historical bar and then again on every update of the realtime bar. A profile for the latest window only matters on the latest bar, so it makes no sense to recompute and redraw it on thousands of past bars. The guard for this is barstate.islast, which is true only on the final bar of the chart.

This changes how you must think about the script. The execution model guide explains the bar by bar loop in detail, and this indicator leans on it. The expensive part sits inside if barstate.islast, so it runs once on history load and again on each realtime update, not on every historical bar.

There is one subtlety. Functions like ta.highest and ta.lowest keep an internal window that needs to see every bar. If you call them inside the barstate.islast block, they only see one bar and the compiler warns you. So compute the highest high and lowest low at the top level, on every bar, and only read the results inside the block.

The same rule explains the var declarations. The boxes and the POC line must survive from one execution to the next so that the next realtime update can delete them and draw fresh ones. Declaring them with var keeps them alive. The var and varip guide covers persistence in more depth.

The first version: a profile with a point of control

This version draws one box per row, scaled by volume, and a dashed line at the POC. It is deliberately small, so every line has a clear job.

pine
//@version=6
indicator("Volume Profile (Lookback Window)", overlay = true, max_boxes_count = 100, max_lines_count = 20)

lookback = input.int(100, "Lookback bars", minval = 10, maxval = 500)
rows     = input.int(24, "Rows", minval = 4, maxval = 80)
widthPct = input.int(35, "Profile width (% of lookback)", minval = 5, maxval = 90)

hi = ta.highest(high, lookback)
lo = ta.lowest(low, lookback)

var array<box> boxes = array.new<box>()
var line pocLine = na

if barstate.islast and bar_index >= lookback and hi > lo
    for b in boxes
        b.delete()
    boxes.clear()
    if not na(pocLine)
        pocLine.delete()

    step = (hi - lo) / rows
    bins = array.new<float>(rows, 0.0)

    for i = 0 to lookback - 1
        loIdx = math.min(rows - 1, int((low[i] - lo) / step))
        hiIdx = math.min(rows - 1, int((high[i] - lo) / step))
        share = nz(volume[i]) / (hiIdx - loIdx + 1)
        for k = loIdx to hiIdx
            bins.set(k, bins.get(k) + share)

    maxVol = math.max(bins.max(), 1e-10)
    pocIdx = bins.indexof(bins.max())
    left   = bar_index - lookback + 1
    maxLen = int(lookback * widthPct / 100.0)

    for k = 0 to rows - 1
        len = math.max(1, int(maxLen * bins.get(k) / maxVol))
        rowBottom = lo + k * step
        rowColor = k == pocIdx ? color.new(color.orange, 40) : color.new(color.gray, 70)
        boxes.push(box.new(left, rowBottom + step, left + len, rowBottom, border_color = na, bgcolor = rowColor))

    pocPrice = lo + (pocIdx + 0.5) * step
    pocLine := line.new(left, pocPrice, bar_index, pocPrice, color = color.orange, style = line.style_dashed)

Walk through the loop that fills the bins. The offset i counts back from the current bar, so low[i] is the low of the bar i bars ago. For each bar, loIdx and hiIdx are the rows containing its low and high. The math.min calls keep an index at the top edge of the range from overflowing the array. The bar's volume is divided by the number of rows it spans, and each of those rows receives its share.

The drawing loop converts each total into a bar length measured in chart bars. The longest row gets maxLen bars, which is a percentage of the lookback, and every other row is scaled against it. A box is placed between the row's bottom and top prices, starting at the left edge of the window. Using bar_index for the x coordinates means the profile is anchored to the window rather than to a time, which is why it grows rightward from the oldest bar in the window.

One detail that catches people out is the name len. It is only a local variable here, but it is easy to shadow something you meant to use. Keep names specific and short loops will stay readable.

Staying under the drawing limits

Every box and line counts against the script's drawing budget. The indicator declaration above sets max_boxes_count = 100, which is more than the 80 rows the input allows, so the script can never exceed its own limit. Pine Script has a hard ceiling on how many boxes and lines a script may hold, and when you exceed the declared limit it silently discards the oldest objects. A profile that quietly loses its bottom rows is a bug that looks like real data.

The pattern that avoids the problem is the one in the script: delete the old objects before drawing new ones, keep references in an array, and size the limit to your largest possible row count. The guide to drawing objects and the article on the 500 object cap explain the limits in more detail and show what happens when scripts forget to clean up.

The second version: adding a value area

The value area needs one more idea. Start at the POC row. Look at the row just above and the row just below, take whichever holds more volume, and add it to the area. Repeat until the area holds at least your chosen share of the total. The loop below does exactly that, stopping early if it runs out of rows on both sides.

pine
//@version=6
indicator("Volume Profile with Value Area", overlay = true, max_boxes_count = 100, max_lines_count = 20)

lookback = input.int(100, "Lookback bars", minval = 10, maxval = 500)
rows     = input.int(24, "Rows", minval = 4, maxval = 80)
widthPct = input.int(35, "Profile width (% of lookback)", minval = 5, maxval = 90)
vaPct    = input.int(70, "Value area (% of volume)", minval = 10, maxval = 95)

hi = ta.highest(high, lookback)
lo = ta.lowest(low, lookback)

var array<box> boxes = array.new<box>()
var array<line> lines = array.new<line>()

if barstate.islast and bar_index >= lookback and hi > lo
    for b in boxes
        b.delete()
    boxes.clear()
    for l in lines
        l.delete()
    lines.clear()

    step = (hi - lo) / rows
    bins = array.new<float>(rows, 0.0)

    for i = 0 to lookback - 1
        loIdx = math.min(rows - 1, int((low[i] - lo) / step))
        hiIdx = math.min(rows - 1, int((high[i] - lo) / step))
        share = nz(volume[i]) / (hiIdx - loIdx + 1)
        for k = loIdx to hiIdx
            bins.set(k, bins.get(k) + share)

    peak   = bins.max()
    maxVol = math.max(peak, 1e-10)
    pocIdx = bins.indexof(peak)
    total  = bins.sum()

    upIdx   = pocIdx
    dnIdx   = pocIdx
    covered = peak
    for j = 1 to rows
        if covered >= total * vaPct / 100.0
            break
        upNext = upIdx < rows - 1 ? bins.get(upIdx + 1) : -1.0
        dnNext = dnIdx > 0 ? bins.get(dnIdx - 1) : -1.0
        if upNext < 0 and dnNext < 0
            break
        if upNext >= dnNext
            upIdx += 1
            covered += upNext
        else
            dnIdx -= 1
            covered += dnNext

    left   = bar_index - lookback + 1
    maxLen = int(lookback * widthPct / 100.0)

    for k = 0 to rows - 1
        len = math.max(1, int(maxLen * bins.get(k) / maxVol))
        rowBottom = lo + k * step
        inArea = k >= dnIdx and k <= upIdx
        rowColor = k == pocIdx ? color.new(color.orange, 35) : inArea ? color.new(color.blue, 60) : color.new(color.gray, 78)
        boxes.push(box.new(left, rowBottom + step, left + len, rowBottom, border_color = na, bgcolor = rowColor))

    pocPrice = lo + (pocIdx + 0.5) * step
    vahPrice = lo + (upIdx + 1) * step
    valPrice = lo + dnIdx * step
    lines.push(line.new(left, pocPrice, bar_index, pocPrice, color = color.orange, style = line.style_dashed))
    lines.push(line.new(left, vahPrice, bar_index, vahPrice, color = color.blue, style = line.style_dotted))
    lines.push(line.new(left, valPrice, bar_index, valPrice, color = color.blue, style = line.style_dotted))

The value area loop is bounded by rows, so it can never run forever. The -1.0 sentinel means "no row available on this side," and because real bin values are never negative, comparing against it is safe. When both sides are exhausted the loop stops. The resulting top and bottom of the area are drawn as dotted lines, and the rows inside it get a different fill.

Two simplifications are worth knowing about. This tie-breaking rule (take the larger neighbour) is a simple version of the classic method, which compares pairs of rows at a time. And the profile is a snapshot of one window, so as the window rolls forward the rows and the POC will move. That movement is a property of the method, not a bug.

How to check that the profile is right

An indicator that draws something is not necessarily correct. Three checks catch most mistakes.

First, the total. Add up the bins and compare to the sum of volume over the same window. They should match to rounding error. If the totals differ, a bar was skipped or counted twice, usually because of an off by one in the loop bounds.

Second, the extremes. The bottom row should start at the lowest low in the window, and the top row should end at the highest high. If the profile floats away from the real range, the row height or the offset is wrong.

Third, a hand check on a few bars. Pick a short lookback such as 10, set the rows to 4, and work the numbers out on paper for those bars. A tiny case you can verify by hand is worth more than ten large ones you cannot.

If you want to see what the script does bar by bar without pasting it into TradingView each time, PineScripter's chart and indicator analysis tools run a script on real market data inside the app. They cover crypto spot markets, so a stock-only workflow still needs TradingView itself, and the data is capped at 5,000 bars per run. For a lookback profile that is plenty.

Common problems and how to fix them

The most frequent complaint is that the profile disappears. Usually the cause is the guard condition: bar_index >= lookback fails on a young chart, or hi > lo fails when every bar in the window has the same price. A chart with fewer bars than the lookback draws nothing by design.

The second is a profile that looks lopsided to one side. That is normal when volume concentrated in one part of the range, but check the row count first. With too few rows, one huge row dominates. With too many, volume spreads thin and the picture gets noisy. Values between 20 and 40 rows usually read well on a window of 100 to 300 bars.

The third is runtime cost. The nested loops run lookback times for the outer loop and up to rows times for the inner one, but only on the last bar, so the cost stays modest. If you remove the barstate.islast guard the same work repeats on every bar and the script can hit execution limits. The guide to slow scripts explains why.

Getting an AI to write this on the first pass

Volume profile is a hard prompt for a general chatbot, because the request hides many decisions. How should a bar's volume be spread? Over what window? How many rows? Should old drawings be deleted? What happens when the data is short? A model left to guess will make each choice silently, and you find out only when the picture looks wrong.

The fix is to say the decisions out loud. A prompt like the following removes most of the guessing:

Build a Pine Script v6 indicator that draws a volume profile for the last N bars as horizontal boxes anchored to the left edge of the window. Spread each bar's volume evenly across the rows between its low and high. Use an input for the lookback, the number of rows, and the profile width. Highlight the row with the most volume and draw a line at it. Add a value area covering 70 percent of the volume, shown by a different fill and two dotted lines. Delete and redraw everything on the last bar only.

Every sentence in that prompt names a decision. The guide to describing complex indicators goes deeper on this habit. In PineScripter's AI code editor, the AI sees compile errors from a built-in linting engine and fixes them itself, so a slip such as a mistyped type or a misplaced var does not come back to you as a pasted error message. Follow-up requests, such as "make the value area 68 percent" or "color the profile by up and down volume," edit only the affected lines, and the Code Changes view shows you what moved. You still need to read the result. The checks in the previous section apply to code from any source.

Where to take it next

Once the structure is clear, the extensions are small. You can anchor the window to a session or a pivot instead of a fixed bar count, split each row into up volume and down volume, extend the POC line to the right as a reference level, or move to lower timeframe volume for finer rows. Each one reuses the same skeleton: an array of bins, a loop that fills it, and a drawing pass that cleans up after itself.

If you want a ready workspace for building and refining scripts like this, you can look at the PineScripter plans. The point of the exercise stands even without it: a volume profile is a good way to learn arrays, loops, and drawing objects together, and once you can build one, most other distribution style indicators stop looking intimidating.

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.

Describe your indicator in plain English and get Pine Script that compiles →


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.