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 concern | Owner |
|---|---|
| History, realtime snapshots, authentication, reconnect, and entitlements | Your datafeed and backend |
| Source symbol mapping, estimator methodology, units, and normalization reference | Your backend |
| Leverage bands, density field, strong levels, threshold, palette, and opacity | Chart presentation |
| Time scale, price scale, zoom, pan, and autoscale | The host chart and its main series |
| Candles and every non-liquidation chart surface | Their 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: truefromonReady()when the built-in control is production-ready; - return
SdkSymbolInfo.supportsLiquidationHeatmap: falsefor each resolved instrument that has no mapped liquidation source in a mixed catalog; - optionally implement
subscribeLiquidationHeatmapfor 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:
| Field | Meaning |
|---|---|
method.id | Stable estimator version |
method.estimated | Must be true; cells are modeled, not observed account liquidations |
method.source | Human-readable upstream and input description |
method.intensityUnit | Unit represented by every intensity value |
method.longShare, shortShare | Position-side allocation assumptions |
method.maintenanceMarginBuffer | Buffer used in liquidation-distance calculations |
method.cohortHalfLifeHours | Retention assumption for historical exposure cohorts |
method.leverageBands | Leverage 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:
| Presentation | Intended rendering | Default threshold behavior |
|---|---|---|
'leverage-bands' | Balanced leverage bands with low-intensity cells suppressed | 0.10; values below it are hidden |
'density-field' | Continuous time/price density with softer low-intensity detail | No threshold; the threshold control is hidden and the option is omitted |
'strong-levels' | Sparse, high-conviction liquidation levels | 0.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
| Option | Default |
|---|---|
presentation | 'leverage-bands' |
threshold | Presentation-specific: 0.10, omitted for Density Field, or 0.85 |
historyColumns | 288 columns; with the reference backend's 5m resolution, 24 hours |
palette | 'aurora' |
scale | 'log' |
opacity | 0.88 |
intensity | 1 |
maxIntensity | Omitted; use referenceIntensity |
showBackground | true |
showColorScale | true |
Reference demo source
The TradeScript web demo intentionally supports four chart identities:
SPOT:BINANCE:BTCUSDTSPOT:BINANCE:ETHUSDTSPOT:BINANCE:SOLUSDTSPOT: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, completemethod, 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
- Order-book Liquidity Heatmap — render actual resting L2 size instead.
- Footprint Charts — render aggressor-tagged executed volume.
- Datafeed API — inspect every generated type and option.