Code example

Periodic Volume Profile

Build a periodic volume profile as a chart indicator. This example:

  • Splits the chart into 40-candle blocks.
  • Builds a Volume Profile for each block using footprint.profile().
  • Finds the highest traded volume across all blocks.
  • Draws each profile normalized to that value.
  • Renders a box showing the price range for each block.
// Periodic range boxes: 40-candle blocks, each with a high/low box and
// a left-adjusted volume profile whose width is a % of the block.

const BLOCK_CANDLES = 40
const PROFILE_PCT = 0.4   // longest profile bar = 40% of the block width

const blockMs = footprint.interval * BLOCK_CANDLES
const bucketKeys = Object.keys(footprint.buckets)

if (bucketKeys.length > 0) {
  let minTs = Infinity
  let maxTs = -Infinity
  for (const key of bucketKeys) {
    const ts = Number(key)
    if (ts < minTs) minTs = ts
    if (ts > maxTs) maxTs = ts
  }

  // First pass: gather each block's profile and track the global max per-price
  // total across every block, so bar widths are comparable between segments
  // (a bar's length reflects volume relative to the busiest level anywhere).
  type Block = {
    blockStart: number
    blockEnd: number
    index: number
    profile: Record<string, { buys: number; sells: number; total: number }>
    priceKeys: string[]
    hi: number
    lo: number
  }
  const blocks: Block[] = []
  let globalMaxTotal = 0
  let blockStart = Math.floor(minTs / blockMs) * blockMs
  let index = 0

  while (blockStart <= maxTs) {
    const blockEnd = blockStart + blockMs
    const profile = footprint.profile(blockStart, blockEnd - 1)
    const priceKeys = Object.keys(profile)

    if (priceKeys.length > 0) {
      let hi = -Infinity
      let lo = Infinity
      for (const pk of priceKeys) {
        const price = Number(pk)
        if (price > hi) hi = price
        if (price < lo) lo = price
        if (profile[pk].total > globalMaxTotal) globalMaxTotal = profile[pk].total
      }
      blocks.push({ blockStart, blockEnd, index, profile, priceKeys, hi, lo })
    }

    blockStart += blockMs
    index += 1
  }

  // Second pass: draw boxes and profiles normalized to the global max total.
  if (globalMaxTotal > 0) {
    for (const block of blocks) {
      if (block.hi > block.lo) {
        const even = block.index % 2 === 0
        chart.box({
          time1: block.blockStart,
          price1: block.hi,
          time2: block.blockEnd - footprint.interval,
          price2: block.lo,
          color: even ? "rgba(59, 130, 246, 0.08)" : "rgba(148, 163, 184, 0.08)",
          borderColor: "rgba(148, 163, 184, 0.3)",
        })
      }

      // Left-adjusted profile: widthTime scales the bar to a % of the block,
      // relative to the global max total so blocks are directly comparable.
      for (const pk of block.priceKeys) {
        const price = Number(pk)
        const { buys, sells, total } = block.profile[pk]
        // onPress makes the bar clickable: pressing it shows the buys/sells behind its width.
        const onPress = { text: `Buys: ${buys}
Sells: ${sells}` }
        chart.horizontalBar({
          price,
          widthTime: (total / globalMaxTotal) * PROFILE_PCT * blockMs,
          time: block.blockStart,
          anchor: "time",
          align: "right",
          color: "rgba(239, 68, 68, 0.65)",
          onPress,
        })
        chart.horizontalBar({
          price,
          widthTime: (buys / globalMaxTotal) * PROFILE_PCT * blockMs,
          time: block.blockStart,
          anchor: "time",
          align: "right",
          color: "rgba(59, 130, 246, 0.9)",
          onPress,
        })
      }
    }
  }
}

Each price level draws two bars with chart.horizontalBar: a red bar for total volume and a blue bar for buys on top of it. The onPress text reveals the buys and sells behind a bar's width when it is clicked.

Result

Chart with per-block range boxes and left-adjusted volume profiles rendered by the indicator