Skip to main content

Order-book liquidity heatmap

The order-book liquidity heatmap samples resting bid and ask size over time. It is a visualization of displayed L2 liquidity, not a liquidation map.

Required and optional contracts

ContractRequirementPurpose
subscribeDepthRequiredSupplies the current synchronized order book for live sampling
onReady().supportsLiquidityHeatmapRequired for live-only discovery; optional with materialized historyAdvertises the complete heatmap surface independently of raw L2 availability
onReady().supportsDepthRecommended when raw L2 is publicAdvertises getDepth and subscribeDepth independently of heatmap discovery
SdkSymbolInfo.supportsLiquidityHeatmapSet false for unsupported instrumentsOverrides a feed-wide surface capability in mixed catalogs
orderFlowHistory.getHeatmapHistoryPreferred optional historyReturns interval-aligned closing books with coverage evidence; it can be implemented without footprint history
getBookHistoryOptional history fallbackReturns timestamped raw book snapshots
getTimeAndSalesRequired for historical bubblesReturns recorded aggressor-classified trades, including sequence cursors for backward pagination
subscribeTimeAndSalesOptionalAdds aggressor-classified executed-volume bubbles and uses the latest trade for the reference-price path

Every depth callback must carry a complete book. Apply exchange deltas, detect sequence gaps, and resynchronize in the backend before invoking the SDK callback.

The surface requires subscribeDepth and either explicit supportsLiquidityHeatmap: true or mounted getHeatmapHistory. An explicit feed or resolved-symbol false always wins. supportsDepth alone never promotes the heatmap row.

Preserve trade bubbles across reloads

Resting book snapshots do not contain executed trades. Supply both recorded books and getTimeAndSales history to restore the heatmap and its bubbles. The data path is:

recorded exchange trades → MarketDataFeed.getTimeAndSales → SDK heatmap sampler → chart liquidity layer.

The SDK requests raw book history covering at least the last 24 hours (or 50 chart bars when that spans longer). It then requests tape across the available book history, in backward pages of up to 100,000 prints, with a safety limit of 64 pages. Each valid page is displayed immediately. A later failed page leaves earlier pages visible; it does not prove the requested history is complete.

Implement TimeAndSalesRequest.startTime and endTime as inclusive epoch-millisecond bounds. Return the newest limit matching prints ordered oldest to newest. Preserve stable exchange IDs in TimeAndSalesEntry.sequence; honor the exclusive beforeSequence cursor so trades sharing one millisecond can be paged without loss. Return an empty array [] at exhaustion, never null. Forward these request fields unchanged through controller adapters and host gateways. A latest-trades endpoint that ignores the historical bounds cannot restore past bubbles. The typed raw-tape adapter shows how to validate and forward these fields together with the live tape subscription.

Live samples displaced from the recent ring remain in a bounded historical archive. When columns are compacted, the sampler combines classified buy/sell volume by price bucket instead of dropping it. Older bubbles therefore represent aggregated volume at coarser time resolution; the columns setting is a display budget, not a backend retention duration. The backend must record book snapshots and trades even when no chart is open and persist both across process restarts.

For the first-party terminal, the local public-chart-data service records BTCUSDT, ETHUSDT, SOLUSDT, and BNBUSDT spot books and trades in its durable store with a rolling 24-hour retention policy, retaining up to 500 price levels per side at a 5-second cadence. A newly started recorder only has the history actually collected; this policy does not create missing past books.

Load detail for the visible range

BookHistoryRequest.untilMs is an inclusive upper bound paired with sinceMs. Apply both bounds before sampling or limiting rows. Returning samples from the whole day and filtering afterwards destroys the detail the chart requested.

The chart controller sends the engine's current time range to the heatmap sampler. After a short debounce, it requests a padded visible window with the existing columns budget. Zooming into 30 minutes therefore spends that budget on those 30 minutes. Small pans reuse the loaded window, and stale responses from earlier pans cannot replace the current view. The coarse 24-hour history remains available while detail loads; recorded tape supplies bubbles for each detailed window. historyMode: 'bars' keeps interval-book presentation; use 'samples' for recorded intrabar detail. The caching datafeed forwards the raw book-history request and both time bounds unchanged.

For an HTTP adapter, carry untilMs to your backend alongside sinceMs and limit:

const query = new URLSearchParams({ since: String(request.sinceMs ?? 0) })
if (request.untilMs !== undefined) query.set('until', String(request.untilMs))
if (request.limit !== undefined) query.set('limit', String(request.limit))

Deeper recording applies to new snapshots. Older snapshots retain their original level count; a recorder cannot reconstruct depth it never received or retained.

Add depth and history to a feed

The adapter can wrap an existing bars feed. The example routes are host-owned; replace them with your gateway URLs and authentication.

import type {
DepthLevel,
MarketDataFeed,
SdkMarketDepth,
} from '@tradescript/pro/sdk'

interface DepthWireSnapshot {
symbol: string
time: number
bids: DepthLevel[]
asks: DepthLevel[]
}

export function withLiquidityHeatmap(
barsFeed: MarketDataFeed,
restBase = '/api/market-data',
websocketBase = 'wss://example.com/market-data',
): MarketDataFeed {
return {
...barsFeed,
async onReady() {
const current = await barsFeed.onReady?.() ?? {}
return {
...current,
supportsDepth: true,
supportsBookHistory: true,
supportsLiquidityHeatmap: true,
}
},
async getDepth(request): Promise<SdkMarketDepth> {
const levels = Math.max(1, Math.floor(request.levels ?? 500))
const response = await fetch(
`${restBase}/depth/${encodeURIComponent(request.symbol.ticker)}?levels=${levels}`,
)
if (!response.ok) throw new Error(`Depth request failed: ${response.status}`)
const book = parseBook(await response.json(), request.symbol.ticker)
return { ...book, symbol: request.symbol, timestamp: book.time }
},
subscribeDepth(subscription, callback) {
const levels = Math.max(1, Math.floor(subscription.levels ?? 500))
const socket = new WebSocket(
`${websocketBase}/depth/${encodeURIComponent(subscription.symbol.ticker)}?levels=${levels}`,
)
socket.addEventListener('message', (event) => {
const book = parseBook(JSON.parse(String(event.data)), subscription.symbol.ticker)
callback({ ...book, symbol: subscription.symbol, timestamp: book.time })
})
return () => socket.close()
},
async getBookHistory(request) {
const query = new URLSearchParams({
since: String(request.sinceMs ?? 0),
limit: String(request.limit ?? 1200),
})
if (request.untilMs !== undefined) query.set('until', String(request.untilMs))
const response = await fetch(
`${restBase}/book-history/${encodeURIComponent(request.symbol.ticker)}?${query}`,
)
if (!response.ok) throw new Error(`Book history failed: ${response.status}`)
const payload: unknown = await response.json()
if (!Array.isArray(payload)) throw new Error('Book history returned an invalid payload')
const snapshots = payload.map((value) => parseBook(value, request.symbol.ticker))
if (snapshots.some((book, index) => index > 0 && book.time <= snapshots[index - 1].time)) {
throw new Error('Book history is not ordered oldest to newest')
}
return {
symbol: request.symbol,
snapshots: snapshots.map(({ time, bids, asks }) => ({ time, bids, asks })),
}
},
}
}

function parseBook(value: unknown, expectedTicker: string): DepthWireSnapshot {
if (typeof value !== 'object' || value === null) throw new Error('Invalid book snapshot')
const book = value as Record<string, unknown>
const bids = book.bids
const asks = book.asks
if (
book.symbol !== expectedTicker || typeof book.time !== 'number' || !Number.isFinite(book.time) ||
!Array.isArray(bids) || !Array.isArray(asks) ||
!bids.every(isLevel) || !asks.every(isLevel) ||
bids.some((level, index) => index > 0 && level.price >= bids[index - 1].price) ||
asks.some((level, index) => index > 0 && level.price <= asks[index - 1].price)
) {
throw new Error('Book snapshot identity, time, levels, or sorting is invalid')
}
return { symbol: expectedTicker, time: book.time, bids, asks }
}

function isLevel(value: unknown): value is DepthLevel {
if (typeof value !== 'object' || value === null) return false
const level = value as Record<string, unknown>
return typeof level.price === 'number' && Number.isFinite(level.price) &&
typeof level.size === 'number' && Number.isFinite(level.size) && level.size > 0
}

The wire symbol is evidence, so validate it before attaching the resolved SDK symbol. Return raw history snapshots oldest to newest. Within every complete book, sort bids from highest to lowest price and asks from lowest to highest. Preserve zero-size deletes while rebuilding the source book, then omit deleted levels from the complete callback snapshot.

Add preferred materialized history

The preferred route returns interval-closing books instead of asking every browser to rebuild them. This adapter validates chart identity, display units, coverage, ordering, and complete-book shape before attaching the resolved SDK symbol.

import type {
DepthLevel,
HeatmapHistoryBar,
HeatmapHistoryResult,
MarketDataFeed,
OrderFlowHistoryCoverage,
OrderFlowHistoryRequest,
} from '@tradescript/pro/sdk'

interface HeatmapHistoryWireResult {
symbol: string
interval: string
quantityUnit: string
coverage: OrderFlowHistoryCoverage
bars: HeatmapHistoryBar[]
forming?: HeatmapHistoryBar
}

export function withMaterializedHeatmapHistory(
base: MarketDataFeed,
restBase = '/api/market-data',
): MarketDataFeed {
if (base.subscribeDepth === undefined) {
throw new Error('A live liquidity heatmap requires subscribeDepth')
}
return {
...base,
async onReady() {
const current = await base.onReady?.() ?? {}
return { ...current, supportsLiquidityHeatmap: true }
},
orderFlowHistory: {
...base.orderFlowHistory,
async getHeatmapHistory(request): Promise<HeatmapHistoryResult> {
if (request.symbol.supportsLiquidityHeatmap === false) {
throw new Error(`Liquidity heatmap is not supported for ${request.symbol.ticker}`)
}
const quantityUnit = requireQuantityUnit(request)
const identity = request.symbol.canonicalSymbol ?? request.symbol.ticker
const limit = requirePositiveInteger(request.limit ?? 50, 'limit')
const query = new URLSearchParams({
interval: request.interval,
limit: String(limit),
})
if (request.beforeTime !== undefined) {
query.set('beforeTime', String(requirePositiveInteger(request.beforeTime, 'beforeTime')))
}
const response = await fetch(
`${restBase}/order-flow/heatmap/${encodeURIComponent(identity)}?${query}`,
)
if (!response.ok) {
throw new Error(`Materialized heatmap history failed: ${response.status}`)
}
return parseHeatmapHistory(
await response.json(), request, identity, quantityUnit, limit,
)
},
},
}
}

function parseHeatmapHistory(
value: unknown,
request: OrderFlowHistoryRequest,
identity: string,
quantityUnit: string,
limit: number,
): HeatmapHistoryResult {
if (typeof value !== 'object' || value === null) {
throw new Error('Materialized heatmap history returned an invalid payload')
}
const result = value as HeatmapHistoryWireResult
if (
result.symbol !== identity || result.interval !== request.interval ||
result.quantityUnit !== quantityUnit || !Array.isArray(result.bars) ||
!result.bars.every((bar) => isHeatmapBar(bar, true)) ||
(result.forming !== undefined && !isHeatmapBar(result.forming, false)) ||
result.bars.some((bar, index) => index > 0 && bar.time <= result.bars[index - 1].time) ||
(result.forming !== undefined && result.forming.time <= (result.bars.at(-1)?.time ?? 0)) ||
!validCoverage(result.coverage, limit, result.bars.length)
) {
throw new Error('Materialized heatmap identity, unit, coverage, or bars are invalid')
}
return {
symbol: request.symbol,
interval: request.interval,
coverage: result.coverage,
bars: result.bars,
...(result.forming === undefined ? {} : { forming: result.forming }),
}
}

function isHeatmapBar(value: unknown, complete: boolean): value is HeatmapHistoryBar {
if (typeof value !== 'object' || value === null) return false
const bar = value as HeatmapHistoryBar
return Number.isSafeInteger(bar.key) && Number.isSafeInteger(bar.time) && bar.time > 0 &&
Number.isSafeInteger(bar.endTime) && bar.endTime > bar.time &&
Number.isSafeInteger(bar.snapshotTime) && bar.snapshotTime > 0 &&
Number.isSafeInteger(bar.lastUpdateId) && bar.lastUpdateId >= 0 &&
bar.complete === complete && validBookSide(bar.bids, 'bid') &&
validBookSide(bar.asks, 'ask')
}

function validBookSide(value: unknown, side: 'bid' | 'ask'): value is DepthLevel[] {
if (!Array.isArray(value) || !value.every((level) => (
typeof level?.price === 'number' && Number.isFinite(level.price) && level.price > 0 &&
typeof level.size === 'number' && Number.isFinite(level.size) && level.size > 0
))) return false
return !value.some((level, index) => index > 0 && (
side === 'bid' ? level.price >= value[index - 1].price : level.price <= value[index - 1].price
))
}

function validCoverage(
value: unknown,
requested: number,
returnedBars: number,
): value is OrderFlowHistoryCoverage {
if (typeof value !== 'object' || value === null) return false
const coverage = value as OrderFlowHistoryCoverage
const validStatus = coverage.status === 'ready' || coverage.status === 'warming' || coverage.status === 'gap'
const validOldest = coverage.oldestTime === undefined ||
(Number.isSafeInteger(coverage.oldestTime) && coverage.oldestTime > 0)
const validNewest = coverage.newestTime === undefined ||
(Number.isSafeInteger(coverage.newestTime) && coverage.newestTime > 0)
const orderedBounds = coverage.oldestTime === undefined || coverage.newestTime === undefined ||
coverage.oldestTime <= coverage.newestTime
return validStatus && coverage.requested === requested &&
Number.isSafeInteger(coverage.completeBars) && coverage.completeBars >= returnedBars &&
validOldest && validNewest && orderedBounds &&
(coverage.status !== 'ready' || (coverage.completeBars >= requested && returnedBars >= requested))
}

function requireQuantityUnit(request: OrderFlowHistoryRequest): string {
const unit = request.symbol.quantityUnit?.trim()
if (!unit) throw new Error(`quantityUnit is required for ${request.symbol.ticker}`)
return unit
}

function requirePositiveInteger(value: number, field: string): number {
if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${field} must be a positive integer`)
return value
}

The wire symbol echoes canonicalSymbol when present, otherwise ticker. The wire-only quantityUnit proves that every returned size uses the resolved symbol's declared unit. Do not return ready until both coverage counts and returned complete bars satisfy the request. Reject unsupported symbols, authorization failures, source gaps disguised as success, and invalid payloads instead of returning an empty ready result.

getHeatmapHistory is one bounded request and opens no long-lived resource. Enabling, changing symbol or interval, or changing sampler shape can issue a new request. The live subscribeDepth function owns the stream and its idempotent unsubscribe lifecycle.

Read materialized history headlessly

Use the mounted chart's authorized market-data controller when another host surface or agent needs the same interval books. Check operation support before calling the required controller method.

import type {
ChartId,
ChartInterval,
ChartWidgetApi,
HeatmapHistoryResult,
SdkSymbolInfo,
} from '@tradescript/pro/sdk'

export async function readHeatmapHistory(
widget: ChartWidgetApi,
symbol: SdkSymbolInfo,
interval: ChartInterval,
chartId?: ChartId,
): Promise<HeatmapHistoryResult> {
await widget.ready()
const data = widget.data(chartId)
const support = await data.getOperationSupport()
if (support.getHeatmapHistory !== true) {
throw new Error('This chart datafeed does not expose materialized heatmap history')
}
return data.getHeatmapHistory({
symbol,
interval,
limit: 50,
beforeTime: Date.now(),
})
}

getOperationSupport() is the callable-operation authority after capability declarations, mounted methods, and entitlements are intersected. getCapabilities() reports the resolved feed capabilities. Calling an unsupported method rejects with datafeed.unsupported; it does not return fabricated empty history.

Enable the layer

import type { ChartApi } from '@tradescript/pro/sdk'

export function enableLiquidityHeatmap(chart: ChartApi): void {
chart.setOrderFlowHeatmap({
enabled: true,
presentationMode: 'density',
palette: 'thermal',
scale: 'log',
depthLevels: 500,
columns: 1200,
showBubbles: true,
showDepthProfile: true,
})
}

Configuration reference

Sampler options change the data window and restart the source lifecycle:

OptionDefaultContract
bucketSizeAutomatic tidy step from the live midpointPositive price distance represented by one heat row
buckets240Positive integer rows kept around the midpoint per sample
columnMs250Positive sampling cadence in milliseconds
columns1200Positive maximum retained sample count before compaction
depthLevels500Positive levels requested from each book side

Presentation options re-render retained samples without restarting the source:

OptionDefaultContract
presentationMode'density''density' keeps continuous intensity, 'bands' quantizes it into five steps, and 'levels' keeps only normalized intensity at or above 0.6
palette'thermal'Registered heatmap palette id
renderMode'gradient''gradient' maps intensity through the palette; 'solid' paints every included cell at the palette maximum
opacityMode'fade'Below-minSize cells fade to black, clamp to the floor color, or are cut out
scale'log'Logarithmic or linear normalization between minSize and the ceiling
intensity1Positive multiplier applied after normalization and clamped to 0..1
minSize0Resting-size floor in quantityUnit
maxSizeAutomatic high-quantile referenceOptional normalization ceiling in quantityUnit; it must exceed minSize
opacity1Overall layer opacity from 0..1
showGridfalseDraw cell separators when the current zoom leaves room
showBids, showAskstrueInclude each resting-book side
showBubblestrueDraw classified executed-volume bubbles when tape is available
bubbleStyle, bubbleScale, bubbleMinVolume'sphere', 1, 0Bubble geometry, radius multiplier 0.25..3, and quantity-unit floor
showBidAskLines, showLastPriceLinetrueDraw sampled book edges and the tape-or-midpoint reference price
showDepthProfile, showHoverReadout, showColorScaletrueControl the newest-book profile, crosshair size readout, and scale legend

columnMs, columns, bucketSize, buckets, and depthLevels shape the sampler. Changing one restarts sampling. SDK callers must pass positive finite values, using integers for counts.

bucketSize is in the symbol's display-price units. Declare SdkSymbolInfo.quantityUnit so every DepthLevel.size, tape size, threshold, hover readout, and color-scale endpoint has one explicit unit such as shares, contracts, or BTC. The SDK never converts these quantities.

columns is the maximum retained sample count, not a fixed wall-clock duration. Older samples compact while the recent live tail stays at full cadence, retaining available history over at least 24 hours or 50 chart bars, whichever spans longer. Palette, opacity, scale, bubbles, grid, and line visibility update without discarding history.

The built-in settings UI offers columnMs values of 100, 250, 500, or 1000 milliseconds. It accepts columns from 120 to 7200 and depthLevels from 20 to 5000. Those are UI bounds, not claims about a venue's retention or book depth. Enterprise hosts should cap values at the limits their backend actually serves.

Without classified tape, showLastPriceLine follows the synchronized book midpoint. With subscribeTimeAndSales, it follows the latest trade. Only prints carrying aggressor: 'buy' | 'sell' enter bubbles; missing or 'between' aggressors are ignored rather than assigned to a side.

The newest synchronized book always reaches the right price axis through the chart's existing right-offset space. There is no extendBars integration option and the heatmap never adds synthetic bars or changes the time scale. Configure chart spacing through the normal time-scale APIs, independently of this layer.

Historical warm start

orderFlowHistory.getHeatmapHistory is the strongest production contract because it reports ready, warming, or gap coverage for complete chart intervals. This nested operation is independent of getFootprintHistory; implement only the materializers your backend owns. The SDK falls back to getBookHistory when materialized history is unavailable.

The materialized response must echo the requested symbol and interval. Return closed bars oldest to newest and place the current incomplete interval in forming. Each bar carries its inclusive time, exclusive endTime, source snapshotTime, source lastUpdateId, completeness, and a full sorted book. The coverage block reports the requested and complete-bar counts plus retained time bounds. An identity or interval mismatch invalidates that tier instead of being repaired in the browser.

Without either history method, sampling begins with the first synchronized live book at its observation time. Earlier chart time remains empty. Enterprise integrations should retain real books and expose coverage.

Depth quality and provenance

Declare onReady().marketDataQuality.depth only to the level the backend can prove. Per-frame SdkMarketDepth.quality can carry observation time, source time, sequence coverage, continuity, and resynchronization state. A mounted depth method without quality metadata remains usable but unqualified; it is never treated as lossless.

Materialized snapshotTime and lastUpdateId preserve the source boundary used for each interval close. Raw history must preserve its own observation times. Neither path turns sampled or retained books into actual venue history when that history was never recorded.

The right-edge depth profile

The right edge shows the newest book as a depth profile. Empty cells and the profile background are transparent, so the chart background remains visible. This profile represents current resting size, not predicted future liquidity or liquidation data.

Disable it while keeping the historical heat field:

chart.setOrderFlowHeatmap({ enabled: true, showDepthProfile: false })

Lifecycle and failures

Passing null or { enabled: false } stops the sampler, calls every unsubscribe handle, and clears the layer. Symbol or interval changes rebuild the sampler against the new identity.

setOrderFlowHeatmap merges any stored user heatmap preferences into fields the host omits. Pass explicit presentation and visibility fields when the host must enforce one deterministic enterprise default. Settings changed in the built-in modal become the stored defaults for later calls.

On a sequence gap, stop publishing the damaged book until the backend has applied a new authoritative snapshot. Do not repair missing levels in the browser.

If subscribeDepth is missing, or either live subscription throws during startup, enabling fails immediately, every subscription already opened by that attempt is closed, and active heatmap state rolls back to off. Materialized-history and raw-history failures fall through to the next available tier; if neither succeeds, sampling starts at the first synchronized live observation. Teardown is idempotent and ignores transport cleanup races. Candles and unrelated chart surfaces remain independent.

supportsLiquidityHeatmap governs built-in discovery, while supportsDepth describes the raw L2 channel. Materialized history can establish surface support when the feed flag is omitted; a live-only integration must explicitly declare the surface. Return SdkSymbolInfo.supportsLiquidityHeatmap: false for every unsupported instrument in a mixed catalog.

A direct setOrderFlowHeatmap(...) call still requires working subscribeDepth, even when the host hides the menu.

Next steps

  • Liquidation Heatmap — integrate the separate derivatives time/price/intensity surface.
  • Footprint Charts — add aggressor-tagged executed volume.
  • Gateway — handle sequence recovery, backpressure, authentication, and fan-out.
  • Datafeed API — inspect exact heatmap, depth, and history types.