Code example

Create a Custom Footprint Chart

There is no built-in footprint renderer to configure. The chart draws exactly what your chart.candle callback draws - rows, numbers, imbalances, the candle itself - and nothing else. The Candlestick, Footprint, Heatmap and Bookmap Circles presets are scripts written against the same API, so a custom look is not an extension point: it is the normal way the chart works.

This page builds one from scratch. The end result is an imbalance footprint: a volume bar per price level, sell x buy numbers over it, diagonal buy/sell imbalances outlined, and a thin candle in its own lane.

1. Open the settings script

The gear icon on the chart opens the Chart Settings window. Every preset in the list is yours: the shipped looks are starting points, and editing one saves your version to your account as you type. Clicking a row applies that preset and opens its code; your edits reach the chart on Apply (Cmd/Ctrl+S).

The script is a static description of the chart, not a per-tick indicator: it runs once on apply, on a preset pick, and when the symbol or interval changes. The callback it registers is what runs on every frame, once per visible candle, with that candle already aggregated - so there is no history loop to write and no per-tick cost.

Starting from a preset is usually faster than starting from a blank editor. Everything below is written so it can be dropped in either way.

2. Draw something

A working footprint is nine lines. candle.rows maps a real price to the buy and sell volume that executed there, and draw.rectangle places a bar on that price row.

// The smallest footprint that draws something. Paste this over the settings
// script, hit Apply, and every candle becomes one bar per price level.
chart.candle((candle, draw) => {
  for (const key in candle.rows) {
    const { buys, sells } = candle.rows[key]
    draw.rectangle({
      price: Number(key),
      width: (buys + sells) / candle.maxLevelVolume,
      color: buys > sells ? 'rgba(59, 130, 246, 0.6)' : 'rgba(239, 68, 68, 0.6)',
    })
  }
})

width is a fraction of the candle column, not pixels: 1 is the full column. candle.maxLevelVolume is the busiest level across the whole visible window, so dividing by it gives every candle a bar length that means the same thing.

3. Set the geometry before the drawing

Every horizontal coordinate in the renderer is a fraction of one candle column, so the column width is the budget the whole layout divides up. A layout with a lane beside the rows needs to declare the width it was designed against, or the same fractions land on a different number of pixels at every zoom level.

chart.condenseLevel = 0           // 0 | 1 | 2 | 3 - merge adjacent price rows
chart.numberDisplay = 'sellBuy'   // 'sellBuy' | 'deltaVol'
chart.columnWidth = 108           // px per candle column
chart.columnGap = 6               // px between columns

// Everything horizontal is a FRACTION of that column, so name the regions once
// and let the drawing calls refer to them.
const ROWS_W = 0.80               // rows + numbers live in [0, 0.80]
const LANE_X = 0.84               // candle lane starts here
  • condenseLevel and numberDisplay shape the data the renderer receives - merged rows, and which pair of numbers is meaningful. They are not style.
  • columnWidth is the starting zoom as well as the layout budget. Zooming afterwards still works.
  • columnGap pins the space between columns in pixels. Leaving it at 0 keeps the automatic gap, which scales with the chart's size as well as the column - so a fixed-width column would keep a different share of itself on different screens.

4. Draw the rows

Inside the callback, loop the rows and give each one a bar. The choice worth thinking about is what the bar is scaled to, because that decides whether a long bar means "busy for this candle" or "busy for the session".

// Scaled to the busiest level in the VISIBLE window, so bar lengths are
// comparable between candles. Divide by a per-candle maximum instead and each
// candle uses the full width - comparable within a candle, not across the chart.
const total = buys + sells
const delta = buys - sells

draw.rectangle({
  price: p,
  width: (total / candle.maxLevelVolume) * ROWS_W,
  color: delta > 0
    ? 'rgba(59, 130, 246, 0.35)'
    : delta < 0
    ? 'rgba(239, 68, 68, 0.35)'
    : 'rgba(148, 163, 184, 0.30)',
})

5. Find the imbalances

This is the part a preset does not do, and the reason to write your own. A diagonal imbalance compares aggressive buying at one price with aggressive selling one tick below it - the two sides of the same spread - and flags the level when one side is several times the other.

// Diagonal imbalance compares the buys at a price with the sells one row BELOW
// it - the two sides of the same spread - not the sells printed beside them.
//
// Re-key the rows by ROW INDEX so a neighbour is index +/- 1. Adding
// candle.increment to a price and looking the result up as a string is
// float-fragile (100.01 + 0.01 does not stringify to "100.02"); a row index is
// exact, and a missing index is simply a level with no volume.
const byRow = {}
for (const key in candle.rows) {
  byRow[Math.round(Number(key) / candle.increment)] = candle.rows[key]
}

const row = Math.round(p / candle.increment)
const below = byRow[row - 1]
const above = byRow[row + 1]

const buyImbalance = buys >= MIN_SIZE && buys >= FACTOR * ((below && below.sells) || 0)
const sellImbalance = sells >= MIN_SIZE && sells >= FACTOR * ((above && above.buys) || 0)

Outline the half of the row that belongs to the imbalanced side rather than the whole row, so a buy and a sell imbalance can both show on the same level. A rectangle with a transparent color and a borderColor is an outline.

6. Add the numbers

candle.showRowText is the chart's readability gate. Honour it or the numbers turn to mush as soon as the chart is zoomed out. Reading candle.numberDisplay instead of hardcoding a pair keeps the gear control working.

if (candle.showRowText) {
  const left = candle.numberDisplay === 'sellBuy' ? sells : delta
  const right = candle.numberDisplay === 'sellBuy' ? buys : total

  draw.text({ price: p, text: String(left), at: ROWS_W / 2, align: 'right', offsetX: -3, size: 10, scale: 'x' })
  draw.text({ price: p, text: String(right), at: ROWS_W / 2, align: 'left', offsetX: 3, size: 10, scale: 'x' })
}

at anchors text at a column fraction - here the center of the rows region, not the center of the column - and offsetX nudges it in pixels from there. size is a base size, not fixed pixels; scale: 'x' ties it to the column width. Omitting size hands sizing to the chart, which caps text at about 0.7x the row height - on short rows that leaves most of the column empty, which reads as a huge gap between candles no matter what columnGap says.

7. Draw the candle

Registering a renderer took the candles over, so put them back with one call. draw.candlestick() takes the same from / width column fractions as draw.rectangle, plus style (filled, the default, or hollow), up / down / flat colours, and wickPx.

// The whole candle, in the lane you left for it. draw.candlestick() with no
// arguments is the plain candle a chart draws when nothing takes it over.
draw.candlestick({ from: LANE_X, width: 1 - LANE_X })

// Anything draw.candlestick can't express is still yours to draw:
// draw.rectangle({ price: candle.high, price2: candle.low, from: ..., color })

It draws nothing on a bucket with rows but no OHLC, so it needs no candle.showCandle guard. A hollow body leaves the rows readable behind it and its wick stops at the body edges, which is what the built-in footprint presets use; a filled one runs the wick the whole way. There is a matching candle.showSummary gate for the delta and volume labels above and below it.

8. The full script

Paste this over the settings script and Apply.

// Chart settings - edit, then Apply (Cmd/Ctrl+S).
// Imbalance footprint: a volume bar per price row with sell x buy numbers over
// it, diagonal buy/sell imbalances outlined, and a thin candle in its own lane
// on the right.

chart.condenseLevel = 0           // 0 | 1 | 2 | 3  (merge adjacent price levels)
chart.numberDisplay = 'sellBuy'   // 'sellBuy' | 'deltaVol'
chart.columnWidth = 108           // px per candle column; 0 follows the zoom
chart.columnGap = 6               // px between columns; 0 is the automatic gap

// Column layout, as fractions of the candle column - the same coordinates
// draw.rectangle's `from`/`width` and draw.text's `at` use.
const ROWS_W = 0.80               // volume bars and numbers live in [0, 0.80]
const ROWS_C = ROWS_W / 2         // ...so this is their center
const LANE_X = 0.84               // candle lane starts here

// Diagonal imbalance thresholds.
const FACTOR = 3                  // times the other side to count as imbalanced
const MIN_SIZE = 10               // ignore thin rows - 3 against 0 is not a signal

const UP = 'rgba(59, 130, 246, 0.9)'
const DOWN = 'rgba(239, 68, 68, 0.9)'
const FLAT = 'rgba(148, 163, 184, 0.9)'
const TEXT = 10

chart.candle((candle, draw) => {
  // Rows re-keyed by row index, so a neighbouring price level is index +/- 1.
  const byRow = {}
  for (const key in candle.rows) {
    byRow[Math.round(Number(key) / candle.increment)] = candle.rows[key]
  }

  for (const key in candle.rows) {
    const { buys, sells } = candle.rows[key]
    const p = Number(key)
    const total = buys + sells
    const delta = buys - sells

    // Volume bar, scaled to the busiest level in the visible window so bar
    // lengths mean the same thing from one candle to the next.
    draw.rectangle({
      price: p,
      width: (total / candle.maxLevelVolume) * ROWS_W,
      color: delta > 0
        ? 'rgba(59, 130, 246, 0.35)'
        : delta < 0
        ? 'rgba(239, 68, 68, 0.35)'
        : 'rgba(148, 163, 184, 0.30)',
    })

    // Diagonal imbalance: buys against the sells one row BELOW (the two sides of
    // the same spread), sells against the buys one row above.
    const row = Math.round(p / candle.increment)
    const below = byRow[row - 1]
    const above = byRow[row + 1]

    if (buys >= MIN_SIZE && buys >= FACTOR * ((below && below.sells) || 0)) {
      draw.rectangle({ price: p, from: ROWS_C, width: ROWS_C, color: 'rgba(0, 0, 0, 0)', borderColor: UP })
    }
    if (sells >= MIN_SIZE && sells >= FACTOR * ((above && above.buys) || 0)) {
      draw.rectangle({ price: p, from: 0, width: ROWS_C, color: 'rgba(0, 0, 0, 0)', borderColor: DOWN })
    }

    // Point of control for this candle.
    if (p === candle.poc) {
      draw.rectangle({ price: p, from: 0, width: ROWS_W, color: 'rgba(0, 0, 0, 0)', borderColor: 'rgba(255, 255, 0, 0.6)' })
    }

    if (candle.showRowText) {
      const left = candle.numberDisplay === 'sellBuy' ? sells : delta
      const right = candle.numberDisplay === 'sellBuy' ? buys : total
      draw.text({ price: p, text: String(left), at: ROWS_C, align: 'right', offsetX: -3, size: TEXT, scale: 'x' })
      draw.text({ price: p, text: String(right), at: ROWS_C, align: 'left', offsetX: 3, size: TEXT, scale: 'x' })
    }
  }

  // The candle itself, in the lane the rows left empty.
  draw.candlestick({ from: LANE_X, width: 1 - LANE_X, up: UP, down: DOWN, flat: FLAT })

  // Delta above, volume below.
  if (candle.showSummary && candle.showCandle) {
    const deltaColor = candle.delta > 0 ? '#3b82f6' : candle.delta < 0 ? '#ef4444' : '#9ca3af'
    const sign = candle.delta > 0 ? '+' : ''

    draw.text({
      price: candle.high + candle.increment,
      text: `${sign}${candle.delta}`,
      at: 0.5,
      align: 'center',
      color: deltaColor,
      size: TEXT,
      scale: 'x',
    })
    draw.text({
      price: candle.low - candle.increment,
      text: String(candle.volume),
      at: 0.5,
      align: 'center',
      color: '#9ca3af',
      size: TEXT,
      scale: 'x',
    })
  }
})

Where to go next

  • Settings script reference - every value the gear panel honours, including chart.frame for overlays that span candles, and use() to run an indicator on this chart.
  • Periodic volume profile - the same drawing ideas as an indicator, over a time range instead of a candle.
  • Code API overview - the full reference, in a form you can paste into an LLM.