Extending the DOM

The DOM ladder is dom.columns, left to right. The array already holds the ladder you see, so an empty settings script draws the normal ladder and every change is an edit to that list: drop a name, move one along, or assign a list of your own.

The script runs once, not per tick. It registers callbacks that the ladder invokes for each visible row on each frame, so a column costs nothing per market update no matter how fast the book is moving.

For a worked build of a ladder from scratch, see Create a Custom Depth of Market.

Your order entry is never at the mercy of a script. Resting orders, stops and your open position are drawn by the app on top of every column, and what pressing a column does is fixed by its entry side - a column can change where the bid sits and how it looks, never what a click on it places.

dom.columns

The ladder, left to right. It arrives holding the six built-in columns, so this line is the ladder you already see:

dom.columns = [dom.price, dom.bid, dom.sells, dom.buys, dom.ask, dom.volume]

Writing it changes nothing, which is the point: it is a list you edit. Leave dom.buys and dom.sells out to drop the traded columns, move dom.price along to put price in the middle, assign a shorter list to strip the ladder back. A script that never mentions dom.columns leaves it exactly as it is.

Each built-in is an object you can edit in place, so a small change stays small:

  • width - column width in px, e.g. dom.bid.width = 80.
  • draw - the render callback. Assign your own to keep a column where it is and change only what it paints.
  • header - a label for the column, e.g. dom.bid.header = "Bid". The ladder grows a header strip as soon as any column names one and has none otherwise, so labelling one column means labelling the ones beside it too. The strip is the app's chrome: you say what a header reads, not how it looks or where it sits.
  • entry - "bid" or "ask", already set on dom.bid and dom.ask. A bid column places a bid limit below the spread and a buy stop above it; ask mirrors it. Columns without it are display only. "both" is for a single merged book column: the side is resolved per row from the resting depth, so a bid row bids and an ask row offers. It places limits only - the rows a one-sided column would treat as its stop zone belong to the other side here.

dom.column(options, render) builds a column of your own. It draws nothing until you place it in dom.columns, which is also where you decide what it sits between.

The render callback receives (row, draw, ctx). draw.rectangle() fills part of the cell - from and width are fractions of the column, defaulting to the whole of it. draw.text() anchors at at (the same fraction, default 0.5) plus offsetX px.

draw.line() rules across the full column on one edge of the row ("top" by default, which reads as a line under the row above). Call it after any fill — a fill spans the whole row and would cover it.

Every option below has a default that matches what the ladder already does, so a column only states what it wants to differ.

// Narrow the built-in bid column, keeping how it draws.
dom.bid.width = 56

// Label the columns. One header is enough to put the strip on screen, so name
// every column you want read.
dom.bid.header = 'Bid'
dom.ask.header = 'Ask'
dom.price.header = 'Price'

// Repaint one. It keeps its place and its entry side - the ask column stays
// the column that places ask limits, whatever you draw in it.
dom.ask.draw = (row, draw) => {
  // Below the spread this cell is a stop-entry target, so it draws no depth -
  // but it still gets its row rule, which is why draw.line sits outside.
  if (!row.belowSpread) {
    draw.rectangle({ color: '#991b1b' })
    if (row.ask > 0) draw.text({ text: String(row.ask) })
  }

  draw.line()
}

// A column of your own.
const imbalance = dom.column({ width: 48 }, (row, draw) => {
  const ratio = row.bid / Math.max(1, row.ask)
  if (ratio < 3) return
  draw.text({ text: ratio.toFixed(1), color: '#3b82f6' })
})

// The ladder: your column to the left of the bid, and no volume profile.
dom.columns = [dom.price, imbalance, dom.bid, dom.sells, dom.buys, dom.ask]

Drawing a cell

Three primitives, all scoped to the current row and column. Anything you draw is clipped to your column, so a wide value can never bleed into a neighbour.

draw.rectangle() fills part of the cell:

  • color - CSS color (required). Any form works, including rgba() for translucent fills.
  • from, width - fractions of the column,0 to 1. Default from: 0, width: 1 - the whole cell.
  • inset - px trimmed off the top and bottom, for a bar that should not fill the row's full height.

draw.text() writes a value:

  • text - the string (required). Numbers need String(...).
  • color, size - CSS color and px size. Default to the ladder's own text color and ctx.textSize, so a plain draw.text({ text }) already tracks the row height. Pass ctx.smallTextSize for a secondary value.
  • at - anchor as a fraction of the column, default 0.5 (center).
  • align - "left", "center" (default) or "right", about that anchor.
  • offsetX - px nudge after the anchor, for padding off an edge.

draw.line() rules across the full column - the ladder's row separators:

  • edge - "top" (default) or "bottom".
  • color, width - CSS color and thickness in px, defaulting to the ladder's own separator and 1. A bare draw.line() is the row separator you already see.

Calls paint in order, so a fill drawn after text covers it. Draw backgrounds first, then bars, then text, then rules.

// A depth cell built from all three primitives, every option spelled
// out - most of these are already the default.
dom.bid.draw = (row, draw, ctx) => {
  // 1. background
  draw.rectangle({ color: '#1e40af' })

  // 2. proportional bar, right-aligned, inset 3px top and bottom
  draw.rectangle({
    from: 1 - row.bidDepthShare,
    width: row.bidDepthShare,
    color: 'rgba(37, 99, 235, 0.7)',
    inset: 3,
  })

  // 3. the number, nudged off the right edge
  draw.text({
    text: String(row.bid),
    color: '#d1d5db',
    size: ctx.textSize,
    at: 1,
    align: 'right',
    offsetX: -4,
  })

  // 4. row rule on top
  draw.line({ edge: 'top', color: '#1e293b', width: 1 })
}

The row

Every value a column needs is resolved before your callback runs - there is nothing to look up and no store to query.

  • price, priceText - the level, raw and formatted for the instrument.
  • bid, ask - resting size.
  • recentBuy, recentSell, volume - traded size.
  • bidStackPull, offerStackPull - depth added or pulled.
  • buyFade, sellFade, bidStackFade, offerStackFade - 0-1 alphas that decay with age, ready to use as opacity.
  • aboveSpread, belowSpread - which side of the market this row is on.
  • buyHighlight, sellHighlight, priceHighlight, isUserTrade - flags for the last trade and your position.
  • volumeShare, bidDepthShare, askDepthShare - 0-1 fractions of the ladder's scale maxima, so a bar is just width: row.bidDepthShare.
  • bidStackShare, offerStackShare - the stack/pull against that same depth scale, unsigned, so the bar sizes itself to the instrument's book instead of a hard-coded contract count. The sign stays on bidStackPull.

ctx carries rowHeight, textSize, smallTextSize and columnWidth in px, so a column can size things without hardcoding the ladder's dimensions.

// A single column showing net delta per level,
// shaded by how one-sided the level is.
const delta = dom.column({ width: 56 }, (row, draw, ctx) => {
  const net = row.recentBuy - row.recentSell
  if (net === 0) return

  draw.text({
    text: String(net),
    color: net > 0 ? '#3b82f6' : '#ef4444',
    size: ctx.textSize,
  })
})

dom.columns = [dom.price, dom.bid, delta, dom.ask, dom.volume]

Ladder config

These shape the data and geometry your columns receive, not how they look:

  • dom.rowHeight - row height in px, clamped to 12-64. Default 24.
  • dom.condenseLevel - merge price levels: 0 off (default), 1 2x, 2 4x. Merged rows sum their depth and volume, so a condensed ladder shows aggregate size per band.
  • dom.eraseTrades - seconds a level may sit idle before its traded numbers reset, so the counts describe the current move rather than the whole session. Also what drives buyFade / sellFade. 0 keeps everything; a new ladder opens on 5.

They resolve authoritatively after each run: delete a line and that setting returns to its default rather than keeping the last value you set.

When a script fails

A ladder is an order-entry surface, so it never goes blank on you. If your script throws, or leaves dom.columns empty, the last working set of columns stays on screen and the error is reported instead. Fix the script and save again.

dom.rowHeight = 20
dom.condenseLevel = 1
dom.eraseTrades = 5