Code example

Create a Custom Depth of Market

The ladder's columns are code. Its settings script registers a list of columns with dom.column, and the ladder draws every visible row through them. The Small, Depth Histogram and Stack / Pull presets are scripts written against the same API, so a custom ladder is not an extension point: it is the normal way the DOM works.

This page builds one from scratch. The end result is a centered ladder: price down the middle, resting depth mirrored around it, aggression outside that, stack/pull on the wings, and a session volume profile on the right.

Order entry is never at the mercy of what you write here. Resting orders, stops and your open position are drawn by the app on top of every column, and what pressing a column does comes from its entry side - a script decides where the bid sits and how it looks, never what a click on it places.

1. Open the settings script

The gear icon on the ladder opens the DOM 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 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.

A ladder is an order-entry surface, so it never goes blank on you. If the script throws, or registers no columns at all, the last working set of columns stays on screen and the error is reported instead.

2. Add a column to the ladder you already have

A script does not start from a blank ladder. dom.columns already holds the built-in columns - dom.price, dom.bid, dom.sells, dom.buys, dom.ask, dom.volume - so the smallest useful script is one column of your own and a list that says where it goes.

// The smallest useful change. Paste it over the DOM settings script and Save:
// every column you already have stays, and a net-delta column appears left of
// the bid.
const delta = dom.column({ width: 56 }, (row, draw) => {
  const net = row.recentBuy - row.recentSell
  if (net === 0) return
  draw.text({
    text: `${net > 0 ? '+' : ''}${net}`,
    color: net > 0 ? '#3b82f6' : '#ef4444',
  })
})

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

A column draws nothing until it is in dom.columns, and the array order is the ladder order. Every value the callback needs is already resolved on row - there is nothing to look up and no store to query.

3. Lay out the whole ladder

This layout wants price in the middle rather than on the left, which is a list you write yourself: assign dom.columns your own columns and the built-ins are simply not in it.

// dom.columns is the ladder, so the order you write here IS left to right.
// Putting the bid before the price column is what puts price in the middle.
const bid = dom.column({ width: 76, entry: 'bid' }, (row, draw) => {
  if (!row.aboveSpread && row.bid > 0) draw.text({ text: String(row.bid) })
})

const price = dom.column({ width: 64 }, (row, draw) => {
  draw.text({ text: row.priceText })
})

const ask = dom.column({ width: 76, entry: 'ask' }, (row, draw) => {
  if (!row.belowSpread && row.ask > 0) draw.text({ text: String(row.ask) })
})

dom.columns = [bid, price, ask]

entry is the part that has to be right. 'bid' makes a column place a bid limit below the spread and a buy stop above it, and 'ask' mirrors it. dom.bid and dom.ask already carry it; a column of your own does not, so a ladder built from scratch with no entry columns is one you cannot trade from.

4. Set the ladder config

Three settings shape the data and geometry your columns receive rather than how they look. They resolve authoritatively after each run: delete a line and that setting returns to its default instead of keeping the last value you set.

dom.rowHeight = 22        // px per row, clamped to 12-64
dom.condenseLevel = 0     // 0 off · 1 2x · 2 4x
dom.eraseTrades = 5       // seconds idle before a level drops its traded numbers

const BID = '59, 130, 246'
const ASK = '239, 68, 68'
const BIG = 250           // resting size worth calling out
  • dom.condenseLevel merges price levels, summing their depth and volume - a condensed ladder shows aggregate size per band. It also switches the Share values below to scale against the visible ladder rather than the whole book.
  • dom.eraseTrades resets a level's traded numbers once it has been idle that many seconds, so the counts describe the current move rather than the whole session. It is also what makes row.buyFade and row.sellFade non-zero - leave it at 0 and the fade-based washes in step 6 draw nothing.

5. Draw the depth

row.bidDepthShare and row.askDepthShare are already 0-1 fractions of the ladder's depth scale, and from / width are fractions of the column, so a proportional bar needs no arithmetic. Right-aligning the bid bar and left-aligning the ask one is what makes the two sides grow out of the price column instead of both pointing the same way.

const bid = dom.column({ width: 76, entry: 'bid' }, (row, draw) => {
  // Above the spread this cell is a stop-entry target, not depth - drawing a bar
  // there would put a bid-sized block on the wrong side of the market.
  if (!row.aboveSpread) {
    if (row.bidDepthShare > 0) {
      draw.rectangle({
        from: 1 - row.bidDepthShare,   // right-aligned, so it grows toward price
        width: row.bidDepthShare,
        color: `rgba(${BID}, 0.45)`,
        inset: 2,
      })
    }
    if (row.bid > 0) {
      draw.text({
        text: String(row.bid),
        color: row.bid >= BIG ? '#ffffff' : '#bfdbfe',
        at: 1,
        align: 'right',
        offsetX: -6,
      })
    }
  }

  // Outside the branch, so the stop-entry zone keeps its separator like every
  // other row - and after the fills, which span the row and would cover it.
  draw.line()
})

inset trims px off the top and bottom so the bar does not fill the row height - without it the bars of neighbouring rows read as one block. Calls paint in order, so backgrounds first, then bars, then text, then rules; a fill drawn after text covers it.

6. Show the aggression

row.recentBuy and row.recentSell are the size that traded at the level, and row.buyHighlight / row.sellHighlight flag the last print. The fades are the history behind it: 0-1 alphas that decay with age, ready to use as an opacity.

const sells = dom.column({ width: 48 }, (row, draw) => {
  // The last print, then the decaying wash for everything before it.
  if (row.sellHighlight) draw.rectangle({ color: 'rgba(255, 255, 255, 0.3)' })
  else if (row.sellFade > 0) draw.rectangle({ color: `rgba(${ASK}, ${row.sellFade})` })

  if (row.recentSell > 0) {
    draw.text({ text: String(row.recentSell), color: '#fca5a5', at: 1, align: 'right', offsetX: -6 })
  }
  draw.line()
})

The trade fades peak around 0.25, so they are a background wash and not a text alpha - used on text they are close to invisible. The stack/pull fades in the next step run the full 0-1 range, which is why those are applied to the number itself.

7. Put stack and pull on the wings

row.bidStackPull and row.offerStackPull are signed: depth added at the level, or pulled from it. Fading the number by row.bidStackFade / row.offerStackFade is what makes the column readable - it keeps only the changes from the last few seconds on screen.

const stackBid = dom.column({ width: 44 }, (row, draw, ctx) => {
  if (row.bidStackPull !== 0) {
    draw.text({
      text: String(row.bidStackPull),
      color: `rgba(${row.bidStackPull > 0 ? BID : ASK}, ${row.bidStackFade})`,
      size: ctx.smallTextSize,
      at: 1,
      align: 'right',
      offsetX: -4,
    })
  }
  draw.line()
})

Blue for stacked and red for pulled on both sides, so the colour means the direction of the change rather than the side of the book. ctx.smallTextSize is the ladder's secondary text size, so a wing column tracks the row height without hardcoding it - ctx also carries rowHeight, textSize and columnWidth in px.

8. Add the volume profile

row.volumeShare is the level's share of the busiest level, so the session profile is one rectangle and one label.

const profile = dom.column({ width: 72 }, (row, draw, ctx) => {
  if (row.volumeShare > 0) {
    draw.rectangle({ width: row.volumeShare, color: 'rgba(107, 114, 128, 0.85)', inset: 3 })
    draw.text({ text: String(row.volume), size: ctx.smallTextSize, at: 0, align: 'left', offsetX: 4 })
  }
  draw.line()
})

Guarding with an if rather than an early return keeps draw.line() reachable, so an empty level still gets its separator and the row rules run unbroken across the ladder.

9. The full script

Paste this over the DOM settings script and Save.

// DOM settings - edit, then Apply (Cmd/Ctrl+S).
// Centered ladder: price down the middle, resting depth mirrored around it,
// aggression outside that, stack/pull on the wings, session profile on the right.

dom.rowHeight = 22
dom.condenseLevel = 0
dom.eraseTrades = 5       // also what makes buyFade / sellFade non-zero

const BID = '59, 130, 246'
const ASK = '239, 68, 68'
const BIG = 250           // resting size worth calling out

const stackBid = dom.column({ width: 44 }, (row, draw, ctx) => {
  if (row.bidStackPull !== 0) {
    draw.text({
      text: String(row.bidStackPull),
      color: `rgba(${row.bidStackPull > 0 ? BID : ASK}, ${row.bidStackFade})`,
      size: ctx.smallTextSize,
      at: 1,
      align: 'right',
      offsetX: -4,
    })
  }
  draw.line()
})

const sells = dom.column({ width: 48 }, (row, draw) => {
  if (row.sellHighlight) draw.rectangle({ color: 'rgba(255, 255, 255, 0.3)' })
  else if (row.sellFade > 0) draw.rectangle({ color: `rgba(${ASK}, ${row.sellFade})` })
  if (row.recentSell > 0) {
    draw.text({ text: String(row.recentSell), color: '#fca5a5', at: 1, align: 'right', offsetX: -6 })
  }
  draw.line()
})

const bid = dom.column({ width: 76, entry: 'bid' }, (row, draw) => {
  if (!row.aboveSpread) {
    if (row.bidDepthShare > 0) {
      draw.rectangle({
        from: 1 - row.bidDepthShare,
        width: row.bidDepthShare,
        color: `rgba(${BID}, 0.45)`,
        inset: 2,
      })
    }
    if (row.bid > 0) {
      draw.text({
        text: String(row.bid),
        color: row.bid >= BIG ? '#ffffff' : '#bfdbfe',
        at: 1,
        align: 'right',
        offsetX: -6,
      })
    }
  }
  draw.line()
})

const price = dom.column({ width: 64 }, (row, draw) => {
  if (row.priceHighlight) draw.rectangle({ color: 'rgba(255, 255, 255, 0.3)' })
  if (row.isUserTrade) draw.rectangle({ from: 0, width: 0.08, color: '#facc15' })
  draw.text({ text: row.priceText })
  draw.line()
})

const ask = dom.column({ width: 76, entry: 'ask' }, (row, draw) => {
  if (!row.belowSpread) {
    if (row.askDepthShare > 0) {
      draw.rectangle({ width: row.askDepthShare, color: `rgba(${ASK}, 0.45)`, inset: 2 })
    }
    if (row.ask > 0) {
      draw.text({
        text: String(row.ask),
        color: row.ask >= BIG ? '#ffffff' : '#fecaca',
        at: 0,
        align: 'left',
        offsetX: 6,
      })
    }
  }
  draw.line()
})

const buys = dom.column({ width: 48 }, (row, draw) => {
  if (row.buyHighlight) draw.rectangle({ color: 'rgba(255, 255, 255, 0.3)' })
  else if (row.buyFade > 0) draw.rectangle({ color: `rgba(${BID}, ${row.buyFade})` })
  if (row.recentBuy > 0) {
    draw.text({ text: String(row.recentBuy), color: '#93c5fd', at: 0, align: 'left', offsetX: 6 })
  }
  draw.line()
})

const stackAsk = dom.column({ width: 44 }, (row, draw, ctx) => {
  if (row.offerStackPull !== 0) {
    draw.text({
      text: String(row.offerStackPull),
      color: `rgba(${row.offerStackPull > 0 ? BID : ASK}, ${row.offerStackFade})`,
      size: ctx.smallTextSize,
      at: 0,
      align: 'left',
      offsetX: 4,
    })
  }
  draw.line()
})

const profile = dom.column({ width: 72 }, (row, draw, ctx) => {
  if (row.volumeShare > 0) {
    draw.rectangle({ width: row.volumeShare, color: 'rgba(107, 114, 128, 0.85)', inset: 3 })
    draw.text({ text: String(row.volume), size: ctx.smallTextSize, at: 0, align: 'left', offsetX: 4 })
  }
  draw.line()
})

dom.columns = [stackBid, sells, bid, price, ask, buys, stackAsk, profile]

Where to go next