Code example
Create a Custom Footprint Chart
There is no built-in footprint renderer to configure. The chart draws exactly what your chart.candles callback draws - rows, numbers, imbalances, the candle itself - and nothing else. The Gradient, Narrow and Profile 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. The left navbar applies a preset immediately - it overwrites the editor, so copy anything you want to keep first. Your own edits apply on Save (Cmd/Ctrl+S), and the navbar shows Custom once the script no longer matches a preset.
The script is a static description of the chart, not a per-tick indicator: it runs once on save, 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 Save, and every candle becomes one bar per price level.
chart.candles((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
const LANE_C = LANE_X + (1 - LANE_X) / 2condenseLevelandnumberDisplayshape the data the renderer receives - merged rows, and which pair of numbers is meaningful. They are not style.columnWidthis the starting zoom as well as the layout budget. Zooming afterwards still works.columnGappins the space between columns in pixels. Leaving it at0keeps 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
Nothing draws the candle unless you do. candle.widthPx is the column's drawable span in pixels, so dividing a pixel size by it converts to the fraction draw.rectangle wants - which is how a 2px wick stays 2px at every zoom.
if (candle.showCandle) {
const color = candle.close > candle.open ? UP : candle.close < candle.open ? DOWN : FLAT
// px -> fraction of THIS candle's column, so the wick and body keep their
// thickness at any zoom instead of growing with the column.
const px = (value) => value / candle.widthPx
draw.rectangle({
price: candle.high,
price2: candle.low,
from: LANE_C - px(2) / 2,
width: px(2),
color,
})
const top = Math.max(candle.open, candle.close)
const bot = Math.min(candle.open, candle.close)
// Keep a doji visible instead of collapsing it to zero height.
const pad = top === bot ? candle.increment * 0.08 : 0
draw.rectangle({
price: top + pad,
price2: bot - pad,
from: LANE_C - px(8) / 2,
width: px(8),
color,
})
}candle.showCandle is false when a bucket has rows but no OHLC - draw the rows and skip the candle. 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 Save.
// Chart settings - edit, then Save (Cmd/Ctrl+S) to apply.
// 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
const LANE_C = LANE_X + (1 - LANE_X) / 2
// 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.candles((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.
if (candle.showCandle) {
const color = candle.close > candle.open ? UP : candle.close < candle.open ? DOWN : FLAT
const px = (value) => value / candle.widthPx
draw.rectangle({ price: candle.high, price2: candle.low, from: LANE_C - px(2) / 2, width: px(2), color })
const top = Math.max(candle.open, candle.close)
const bot = Math.min(candle.open, candle.close)
const pad = top === bot ? candle.increment * 0.08 : 0
draw.rectangle({ price: top + pad, price2: bot - pad, from: LANE_C - px(8) / 2, width: px(8), color })
}
// 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.framefor overlays that span candles, anduse()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.