FlowscopeDocs

Liquidity profile

Plot a rolling point of control and value area on price, and watch order-book imbalance at five depths in a separate window.

Traded volume and resting liquidity answer two different questions. The volume profile tells you where the market has agreed on price. The order book tells you where participants are waiting right now. This script puts both in view.

On the price pane you get a rolling point of control (POC) and value area computed over the last N bars, with the area between the value-area high and low shaded. The POC line changes colour as it migrates up or down. A separate window shows order-book imbalance: two lines for a near and a far depth, and a heatmap with five depth bands per bar, from red (asks dominate) to green (bids dominate).

liquidity-profile.fs
script "Liquidity profile"

input (
  profileBars = input.int(48, title: "Profile length (bars)", min: 2, max: 500)
  vaPct = input.float(70.0, title: "Value area (%)", min: 50.0, max: 95.0)
  historyBars = input.int(120, title: "Heatmap columns (bars)", min: 10, max: 500)
)

data (
  chart = subscribe(data.ohlcv)
  vol = subscribe(data.volume)
  book = subscribe(data.book)
)

window liq = window(title: "Book imbalance") {
  lines: chart.panel(title: "Imbalance, near vs far"),
  ladder: heatmap.new(title: "Imbalance by depth", x: axis.index, y: axis.category, capacity: historyBars * 5, min: -1.0, max: 1.0, palette: [color.red, color.rgb(30, 34, 42), color.green]),
}

plot (
  poc = plot.line(title: "POC", color: color.amber, width: 2, style: linestyle.step)
  vah = plot.line(title: "VAH", color: color.withAlpha(color.purple, 160), style: linestyle.step)
  val = plot.line(title: "VAL", color: color.withAlpha(color.purple, 160), style: linestyle.step)
  near = plot.line(title: "Imbalance 0.25%", color: color.cyan, width: 2, on: liq.lines)
  far = plot.line(title: "Imbalance 2%", color: color.gray, on: liq.lines)
)

fill valueArea = fill.between(vah, val, color: color.withAlpha(color.purple, 30))

state lastPoc: float? = null

on chart.close {
  // Volume profile over the last `profileBars` bars.
  let prof = vol.profile(profileBars)
  let level = prof.poc()
  let area = prof.valueArea(vaPct)
  let prev = lastPoc
  if level != null {
    let p = level.price
    let shade = prev == null ? color.amber : p > (prev ?? p) ? color.green : p < (prev ?? p) ? color.red : color.amber
    poc.plot(p, color: shade)
    lastPoc = p
  }
  if area != null {
    vah.plot(area.highPrice)
    val.plot(area.lowPrice)
  }

  // (bid value - ask value) / total within a band around the mid: -1 .. +1.
  near.plot(book.imbalance(0.25))
  far.plot(book.imbalance(2.0))
  liq.ladder.set(chart.index, "0.1%", book.imbalance(0.1))
  liq.ladder.set(chart.index, "0.25%", book.imbalance(0.25))
  liq.ladder.set(chart.index, "0.5%", book.imbalance(0.5))
  liq.ladder.set(chart.index, "1%", book.imbalance(1.0))
  liq.ladder.set(chart.index, "2%", book.imbalance(2.0))
}

How it works

A rolling profile in three calls

data.volume is a snapshot source: it has no handler of its own, and you query it from another subscription’s handler. On a chart, its snapshots come from the footprints of the chart’s bars. vol.profile(profileBars) merges the last profileBars bar profiles into one, and the profile answers questions through methods:

Flowscope Script
let prof = vol.profile(profileBars)
let level = prof.poc()
let area = prof.valueArea(vaPct)

poc() returns the VolumeLevel with the most volume, valueArea(70.0) a VolumeArea with lowPrice, highPrice and the volume inside. Both are null until footprints exist, which leaves gaps rather than invented levels. While the script runs, the chart loads footprints for the bars on screen; bars further back stay empty.

POC migration

The POC of the previous bar is kept in state. The nested conditional compares it with the new value and picks a colour: rising POC in green, falling in red, unchanged in amber. A POC that keeps rising while price ranges is a sign that volume is building higher.

Imbalance from book depth

book.imbalance(pct) compares the resting value within pct percent of the mid price on each side: +1 means all of it is on the bid, -1 all of it on the ask. Values rather than quantities keep the measure comparable across coins; pass unit: bookunit.base to compare quantities. book.depth(pct) returns the underlying BookDepth record when you need the sums themselves.

On a chart the book comes from what the chart has recorded, the same data as the liquidity heatmap. Bars before the recording started read null and stay empty.

A heatmap that scrolls

The heatmap has one column per bar (axis.index, keyed by chart.index) and one row per depth band (axis.category, keyed by name). Its capacity holds historyBars columns of five cells; as new cells arrive, the oldest drop out. min:, max: and palette: pin the colours, so 0 is always the neutral middle.

Adapting it

  • Session profile. Count the bars since the session opened with ta.barsSince(...) and pass that count to vol.profile to anchor POC and value area to the session.
  • Imbalance alert. Trigger an alert when the near imbalance stays above 0.5 for several bars while price is at the value-area low.
  • Volume in a range. prof.summary(from: low, to: high) sums buy and sell volume between two prices, for example around yesterday’s POC.