Skip to main content

Liquidation heatmap

The liquidation heatmap consumes a complete estimated time-by-price intensity surface from a derivatives backend. It never derives liquidations from the displayed order book and does not represent observed account liquidations.

Integration at a glance

There is one datafeed contract and three named chart presentations. Your backend returns the same typed LiquidationHeatmapSnapshot for every presentation. Presentation, threshold, palette, scale, opacity, multiplier, and normalization ceiling are renderer settings owned by the chart application.

Integration concernOwner
History, realtime snapshots, authentication, reconnect, and entitlementsYour datafeed and backend
Source symbol mapping, estimator methodology, units, and normalization referenceYour backend
Leverage bands, density field, strong levels, threshold, palette, and opacityChart presentation
Time scale, price scale, zoom, pan, and autoscaleThe host chart and its main series
Candles and every non-liquidation chart surfaceTheir existing independent contracts

Do not create three endpoints or transform the source data when a user switches presentations. A presentation switch re-renders the current time/price/intensity surface and does not change its provenance or units.

Prerequisites

The feed must:

  • implement getLiquidationHeatmapHistory;
  • explicitly return supportsLiquidationHeatmap: true from onReady() when the built-in control is production-ready;
  • return SdkSymbolInfo.supportsLiquidationHeatmap: false for each resolved instrument that has no mapped liquidation source in a mixed catalog;
  • optionally implement subscribeLiquidationHeatmap for live replacement snapshots; and
  • map every displayed chart symbol to an explicit derivatives sourceSymbol.

A feed without this capability remains valid and candles continue to work. An omitted feed flag preserves method-based discovery for backward compatibility. On mixed catalogs, the resolved symbol's explicit false overrides the feed-wide declaration and hides the built-in control for that instrument. A direct setLiquidationHeatmap(...) call remains an explicit activation request and still requires the history method.

Datafeed contract

getLiquidationHeatmapHistory is required. subscribeLiquidationHeatmap is optional and should stream complete replacement snapshots so reconnects never depend on missing browser state.

interface LiquidationHeatmapSnapshot {
symbol: SdkSymbolInfo
sourceSymbol: string
resolution: string
sourceTime: number
generatedAt: number
priceStep: number
referenceIntensity: number
minimumPrice: number
maximumPrice: number
method: LiquidationHeatmapMethod
columns: LiquidationHeatmapColumn[]
}

interface LiquidationHeatmapColumn {
time: number
endTime: number
markPrice: number
levels: Array<{
price: number
intensity: number
longIntensity: number
shortIntensity: number
}>
}

All timestamps are Unix milliseconds. sourceTime is the newest upstream fact incorporated into the surface and equals the newest column's time. generatedAt records when the backend produced this complete snapshot and cannot precede sourceTime.

Columns are ordered oldest to newest, and endTime is exclusive. Mark and level prices must fall inside the declared positive display-price range. Intensities are non-negative, and each level's intensity equals its long plus short intensity. referenceIntensity is the positive source-owned normalization reference. Presentation selection never changes these facts.

Method disclosure is mandatory

Every snapshot carries the facts needed to interpret intensity:

FieldMeaning
method.idStable estimator version
method.estimatedMust be true; cells are modeled, not observed account liquidations
method.sourceHuman-readable upstream and input description
method.intensityUnitUnit represented by every intensity value
method.longShare, shortSharePosition-side allocation assumptions
method.maintenanceMarginBufferBuffer used in liquidation-distance calculations
method.cohortHalfLifeHoursRetention assumption for historical exposure cohorts
method.leverageBandsLeverage values and weights used by the model

Do not use this contract for observed account positions or label its estimates as actual exchange positions. The renderer preserves the estimator metadata and does not reinterpret it.

Add the source to a feed

The wire response commonly identifies the instrument by string. The browser adapter attaches the already resolved chart symbol without changing the backend's methodology.

import type {
LiquidationHeatmapSnapshot,
MarketDataFeed,
SdkSymbolInfo,
} from '@tradescript/pro/sdk'

type WireSnapshot = Omit<LiquidationHeatmapSnapshot, 'symbol'> & {
symbol: string
sourceTime: number
generatedAt: number
}

function attachSymbol(
snapshot: WireSnapshot,
symbol: SdkSymbolInfo,
expectedSourceSymbol: string,
): LiquidationHeatmapSnapshot {
const positive = (value: number): boolean => Number.isFinite(value) && value > 0
const nonNegative = (value: number): boolean => Number.isFinite(value) && value >= 0
const unitShare = (value: number): boolean => nonNegative(value) && value <= 1
const equal = (left: number, right: number): boolean => (
Math.abs(left - right) <= Math.max(1, Math.abs(left), Math.abs(right)) * 1e-9
)
if (
snapshot.symbol !== symbol.ticker || typeof expectedSourceSymbol !== 'string' ||
!expectedSourceSymbol.trim() || snapshot.sourceSymbol !== expectedSourceSymbol ||
typeof snapshot.resolution !== 'string' || !snapshot.resolution.trim() ||
!Number.isSafeInteger(snapshot.sourceTime) || snapshot.sourceTime <= 0 ||
!Number.isSafeInteger(snapshot.generatedAt) || snapshot.generatedAt < snapshot.sourceTime ||
!positive(snapshot.priceStep) || !positive(snapshot.referenceIntensity) ||
!positive(snapshot.minimumPrice) || !positive(snapshot.maximumPrice) ||
snapshot.maximumPrice <= snapshot.minimumPrice
) {
throw new Error('Liquidation snapshot identity or range is invalid')
}
const method = snapshot.method
if (
!method || typeof method.id !== 'string' || !method.id.trim() ||
method.estimated !== true || typeof method.source !== 'string' ||
!method.source.trim() || typeof method.intensityUnit !== 'string' ||
!method.intensityUnit.trim() || !unitShare(method.longShare) ||
!unitShare(method.shortShare) || !equal(method.longShare + method.shortShare, 1) ||
!nonNegative(method.maintenanceMarginBuffer) || !positive(method.cohortHalfLifeHours) ||
!Array.isArray(method.leverageBands) || method.leverageBands.length === 0 ||
method.leverageBands.some((band) => (
!positive(band.leverage) || !positive(band.weight)
)) || !equal(method.leverageBands.reduce((sum, band) => sum + band.weight, 0), 1)
) {
throw new Error('Liquidation snapshot methodology is invalid')
}
if (!Array.isArray(snapshot.columns) || snapshot.columns.length === 0) {
throw new Error('Liquidation snapshot columns are invalid')
}
snapshot.columns.forEach((column, index) => {
if (
!Number.isSafeInteger(column.time) || column.time <= 0 ||
!Number.isSafeInteger(column.endTime) || column.endTime <= column.time ||
!positive(column.markPrice) || column.markPrice < snapshot.minimumPrice ||
column.markPrice > snapshot.maximumPrice ||
(index > 0 && column.time <= snapshot.columns[index - 1].time) ||
!Array.isArray(column.levels) || column.levels.length === 0
) throw new Error('Liquidation snapshot column is invalid')
column.levels.forEach((level) => {
if (
!positive(level.price) || level.price < snapshot.minimumPrice ||
level.price > snapshot.maximumPrice ||
!nonNegative(level.intensity) || !nonNegative(level.longIntensity) ||
!nonNegative(level.shortIntensity) ||
!equal(level.intensity, level.longIntensity + level.shortIntensity)
) throw new Error('Liquidation snapshot level is invalid')
})
})
if (snapshot.sourceTime !== snapshot.columns.at(-1)?.time) {
throw new Error('Liquidation source time does not match the latest column')
}
return { ...snapshot, symbol }
}

export function withLiquidationHeatmap(
barsFeed: MarketDataFeed,
sourceSymbolFor: (symbol: SdkSymbolInfo) => string,
restBase = '/api/market-data',
websocketBase = 'wss://example.com/market-data',
): MarketDataFeed {
return {
...barsFeed,
async onReady() {
const current = await barsFeed.onReady?.() ?? {}
return { ...current, supportsLiquidationHeatmap: true }
},
async getLiquidationHeatmapHistory(request) {
const limit = Math.min(500, Math.max(1, Math.floor(request.limit ?? 288)))
const sourceSymbol = sourceSymbolFor(request.symbol)
const response = await fetch(
`${restBase}/liquidation-heatmap/${encodeURIComponent(request.symbol.ticker)}?limit=${limit}`,
)
if (!response.ok) {
throw new Error(`Liquidation history failed: ${response.status}`)
}
return attachSymbol(await response.json() as WireSnapshot, request.symbol, sourceSymbol)
},
subscribeLiquidationHeatmap(subscription, callback) {
const limit = Math.min(500, Math.max(1, Math.floor(subscription.limit ?? 288)))
const sourceSymbol = sourceSymbolFor(subscription.symbol)
const socket = new WebSocket(
`${websocketBase}/liquidation-heatmap/${encodeURIComponent(subscription.symbol.ticker)}?limit=${limit}`,
)
socket.addEventListener('message', (event) => {
callback(attachSymbol(
JSON.parse(String(event.data)) as WireSnapshot,
subscription.symbol,
sourceSymbol,
))
})
return () => socket.close()
},
}
}

The source resolver is the host's explicit chart-to-derivatives identity map. It must reject an unsupported chart symbol instead of constructing a source name from ticker text. The example validates identity, freshness, methodology, ranges, ordering, and every numeric cell before invoking the SDK callback.

Add authentication and reconnect/backoff at the adapter boundary. Both REST and WebSocket routes accept the same inclusive limit range of 1..500; the reference backend defaults to 288 and publishes only that subscriber's newest requested window.

Read and stream through the headless controller

widget.data(chartId?) exposes the same authorized history and complete-snapshot stream without enabling the chart layer. Inspect operation support first so optional streaming stays explicit.

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

export async function observeLiquidationHeatmap(
widget: ChartWidgetApi,
symbol: SdkSymbolInfo,
receive: (snapshot: LiquidationHeatmapSnapshot) => void,
chartId?: ChartId,
): Promise<() => void> {
await widget.ready()
const data = widget.data(chartId)
const support = await data.getOperationSupport()
if (support.getLiquidationHeatmapHistory !== true) {
throw new Error('This chart datafeed does not expose liquidation heatmap history')
}

receive(await data.getLiquidationHeatmapHistory({ symbol, limit: 288 }))
if (support.subscribeLiquidationHeatmap !== true) return () => {}
return data.subscribeLiquidationHeatmap({ symbol, limit: 288 }, receive)
}

The returned unsubscribe function is idempotent, and widget/controller destruction closes any tracked stream. getCapabilities() reports supportsLiquidationHeatmap; getOperationSupport() proves whether each history or stream operation is callable. Unsupported calls reject with datafeed.unsupported.

Authorized agents can consume the same complete replacement stream through the discovered data.liquidationHeatmap channel. Its MCP lifecycle and policy requirements are documented in Controls and Resources.

Enable the layer

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

export function enableLiquidationHeatmap(chart: ChartApi): void {
chart.setLiquidationHeatmap({
enabled: true,
historyColumns: 288,
presentation: 'leverage-bands',
threshold: 0.1,
palette: 'aurora',
scale: 'log',
opacity: 0.88,
intensity: 1,
showColorScale: true,
})
}

Historical columns stop at their real endTime. When the newest column is visible, the renderer holds that latest complete estimated state through unused right-offset space to the physical plot edge so the surface stays attached to the right price axis. This is a visual continuation of the current state, not a fabricated future source column or a right-edge depth profile.

The layer is a passive overlay: enabling, updating, switching, or disabling it never changes the chart's time scale, price scale, visible range, zoom, pan, or autoscale behavior.

Passing null or { enabled: false } closes the subscription and clears the surface without changing the viewport.

historyColumns requests a count at the snapshot's declared resolution, not a fixed wall-clock duration. The SDK clamps it to 1..500; the default is 288. At the reference backend's 5m resolution, that default is 24 hours.

Choose a presentation

All three presentations consume the exact same snapshot. Their only difference is how normalized intensity is rendered:

PresentationIntended renderingDefault threshold behavior
'leverage-bands'Balanced leverage bands with low-intensity cells suppressed0.10; values below it are hidden
'density-field'Continuous time/price density with softer low-intensity detailNo threshold; the threshold control is hidden and the option is omitted
'strong-levels'Sparse, high-conviction liquidation levels0.85; values below it are hidden

threshold is normalized from 0 to 1. It is a visibility control, not a currency amount and not a backend filter. Omit it for 'density-field'; the renderer ignores a retained value. If you omit it for a banded presentation, the renderer uses that presentation's default above.

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

const defaultThreshold: Partial<Record<LiquidationHeatmapPresentation, number>> = {
'leverage-bands': 0.1,
'strong-levels': 0.85,
}

export function selectLiquidationPresentation(
chart: ChartApi,
presentation: LiquidationHeatmapPresentation,
): void {
const current = chart.getLiquidationHeatmap() ?? { enabled: true }
const { threshold: _previousThreshold, ...withoutThreshold } = current
const threshold = defaultThreshold[presentation]

chart.setLiquidationHeatmap({
...withoutThreshold,
presentation,
...(threshold === undefined ? {} : { threshold }),
})
}

Changing presentation, threshold, palette, scale, opacity, intensity, maxIntensity, background, or color-scale visibility updates the active rendering without requesting a different source. Changing historyColumns restarts the liquidation source lifecycle because the requested history window changed.

intensity is a non-negative multiplier applied after normalization. maxIntensity is an optional positive ceiling in method.intensityUnit; omit it to use the snapshot's referenceIntensity. Neither option mutates backend values.

Defaults

OptionDefault
presentation'leverage-bands'
thresholdPresentation-specific: 0.10, omitted for Density Field, or 0.85
historyColumns288 columns; with the reference backend's 5m resolution, 24 hours
palette'aurora'
scale'log'
opacity0.88
intensity1
maxIntensityOmitted; use referenceIntensity
showBackgroundtrue
showColorScaletrue

Reference demo source

The TradeScript web demo intentionally supports four chart identities:

  • SPOT:BINANCE:BTCUSDT
  • SPOT:BINANCE:ETHUSDT
  • SPOT:BINANCE:SOLUSDT
  • SPOT:BINANCE:BNBUSDT

Each maps explicitly to the corresponding PERP:BINANCE:* USD-M source. The open-interest-leverage-v1 model uses five-minute futures open-interest notional and futures prices, a disclosed leverage distribution, a maintenance-margin buffer, and decaying entry cohorts. Its intensityUnit is estimated-usd-notional; it is not account-level liquidation data.

Failure behavior

A failed history request emits a recoverable datafeed.liquidationHeatmap chart error and leaves candles operational. A synchronous subscription-start failure closes any partial subscription and rolls active heatmap state back to off. Unsupported symbols should return an explicit not-supported response; upstream outages should return an error, not an empty fabricated surface.

Treat invalid snapshots as contract failures before invoking the SDK callback. In particular, reject mismatched source or chart symbols, invalid freshness, incomplete method metadata, invalid shares or leverage weights, prices outside the declared range, inconsistent side totals, and columns that are out of order.

The SDK accepts a new snapshot only when neither generatedAt nor sourceTime moves backward and at least one of them advances. A recomputation can therefore advance generatedAt at the same source point, and a source update produced in the same generation millisecond can advance sourceTime. Exact duplicate pairs and any regression are ignored. The last accepted snapshot can remain visible during a transient interruption, but its sourceTime still exposes its age. The datafeed owns reconnect and backoff; the reference backend returns an error while refresh is failing and does not replay its cached snapshot as fresh.

Production verification

Before enabling the capability for customers, verify that:

  • the first history response contains the requested symbol, exact mapped sourceSymbol, sourceTime, generatedAt, complete method, and at least one ordered column;
  • leverage bands, density field, and strong levels can be selected without opening a second feed or changing snapshot metadata;
  • strong levels hides cells below its selected threshold and density field remains continuous;
  • live callbacks replace the complete snapshot, advance at least one freshness timestamp without regressing either, and do not duplicate or append stale columns;
  • a symbol change tears down the old subscription before requesting the new mapped source;
  • enabling, switching, zooming, panning, and disabling the layer never changes the host-owned viewport or makes loaded cells disappear; and
  • a history or streaming failure leaves candles and unrelated overlays usable.

Next steps