Footprint charts
A footprint groups executed trades by chart interval and price row. Each row shows aggressive buy volume at the ask and aggressive sell volume at the bid. Order-book depth is not a substitute for this tape.
Source requirements
| Contract | Requirement | Purpose |
|---|---|---|
getTimeAndSales | One historical path | Returns aggressor-tagged prints over a bounded time window |
subscribeTimeAndSales | Required for a live forming footprint | Streams matching prints that advance the forming ladder; continuity quality is declared separately |
onReady().supportsFootprint | Required for an intentional raw-backed Footprint; optional for method-discovered materialized history | Controls built-in Footprint discovery independently of raw tape availability |
onReady().supportsTimeAndSales | Recommended when raw tape is public | Advertises getTimeAndSales and subscribeTimeAndSales; it never enables Footprint by itself |
SdkSymbolInfo.supportsFootprint | Set false for unsupported instruments | Overrides a feed-wide Footprint capability in mixed catalogs |
onReady().marketDataQuality.timeAndSales | Required to claim source aggressor or sequence quality | Declares provenance, continuity, gap detection, resync, and freshness; it does not repair a damaged tape |
orderFlowHistory.getFootprintHistory | Alternative historical path, preferred at scale | Returns server-materialized interval ladders with coverage evidence |
For a live and historical footprint, supply subscribeTimeAndSales plus either getTimeAndSales or orderFlowHistory.getFootprintHistory. A materialized-only feed can show its returned history, and a raw-history-only feed can show a static historical footprint, but neither can advance the forming bar without the live subscription. Lossless continuity is a separate production accuracy claim that requires the quality and reconciliation evidence below.
Each usable print needs finite epoch-millisecond time, finite price, positive size, and provider-owned aggressor: 'buy' | 'sell'. The SDK ignores 'between' or missing aggressor values because assigning them to a side would fabricate delta. For lossless paging and de-duplication, sequence must be a stable, safe integer from the source and live batches must arrive in ascending source order. Do not substitute browser receipt order or invent aggressor direction from candle movement.
Declare the executed-size unit once on SdkSymbolInfo.quantityUnit. Every tape size, ladder total, delta, POC volume, and minimum-volume threshold uses that same unit without browser-side conversion.
Add raw tape to a feed
import type {
MarketDataFeed,
TimeAndSalesEntry,
TimeAndSalesUpdate,
} from '@tradescript/pro/sdk'
export function withFootprintTape(
barsFeed: MarketDataFeed,
restBase = '/api/market-data',
websocketBase = 'wss://example.com/market-data',
): MarketDataFeed {
return {
...barsFeed,
async onReady() {
const current = await barsFeed.onReady?.()
return {
...(current ?? {}),
supportsFootprint: true,
supportsTimeAndSales: true,
marketDataQuality: {
...(current?.marketDataQuality ?? {}),
timeAndSales: {
aggressor: 'source',
snapshot: {
mode: 'snapshot',
sequence: 'source',
continuity: 'not-applicable',
gapDetection: 'none',
resync: 'not-required',
freshness: 'source-time',
},
stream: {
mode: 'update',
sequence: 'source',
continuity: 'gap-detectable',
gapDetection: 'sequence',
resync: 'snapshot-read',
freshness: 'source-time',
},
},
},
}
},
async getTimeAndSales(request): Promise<TimeAndSalesEntry[]> {
const query = new URLSearchParams({
start: String(request.startTime ?? 0),
end: String(request.endTime ?? Date.now()),
limit: String(request.limit ?? 100_000),
})
if (request.beforeSequence !== undefined) {
query.set('beforeSequence', String(request.beforeSequence))
}
const response = await fetch(
`${restBase}/tape/${encodeURIComponent(request.symbol.ticker)}?${query}`,
)
if (!response.ok) throw new Error(`Tape history failed: ${response.status}`)
return parseTapeEntries(await response.json())
},
subscribeTimeAndSales(subscription, callback) {
const socket = new WebSocket(
`${websocketBase}/tape/${encodeURIComponent(subscription.symbol.ticker)}`,
)
socket.addEventListener('message', (event) => {
callback(parseTapeUpdate(JSON.parse(String(event.data)), subscription.symbol.ticker))
})
return () => socket.close()
},
}
}
type QualifiedTapeEntry = TimeAndSalesEntry & {
aggressor: 'buy' | 'sell'
sequence: number
}
function parseTapeEntries(value: unknown): QualifiedTapeEntry[] {
if (!Array.isArray(value) || !value.every(isTapeEntry)) {
throw new Error('Tape history returned an invalid print')
}
if (value.some((entry, index) => index > 0 && entry.sequence <= value[index - 1].sequence)) {
throw new Error('Tape prints are not in ascending source-sequence order')
}
return value
}
function parseTapeUpdate(value: unknown, expectedTicker: string): TimeAndSalesUpdate {
if (typeof value !== 'object' || value === null) throw new Error('Invalid tape update')
const update = value as Record<string, unknown>
const symbol = update.symbol
if (!isTapeSymbol(symbol) || symbol.ticker !== expectedTicker) {
throw new Error('Tape update symbol mismatch')
}
if (typeof update.snapshot !== 'boolean') throw new Error('Tape update snapshot flag is missing')
return {
symbol,
snapshot: update.snapshot,
prints: parseTapeEntries(update.prints),
}
}
function isTapeEntry(value: unknown): value is QualifiedTapeEntry {
if (typeof value !== 'object' || value === null) return false
const print = value as Record<string, unknown>
const aggressor = print.aggressor
const sequence = print.sequence
return typeof print.time === 'number' && Number.isFinite(print.time) &&
typeof print.price === 'number' && Number.isFinite(print.price) &&
typeof print.size === 'number' && Number.isFinite(print.size) && print.size > 0 &&
(aggressor === 'buy' || aggressor === 'sell') &&
typeof sequence === 'number' && Number.isSafeInteger(sequence)
}
function isTapeSymbol(value: unknown): value is TimeAndSalesUpdate['symbol'] {
return typeof value === 'object' && value !== null &&
typeof (value as Record<string, unknown>).ticker === 'string'
}
The update's wire symbol is evidence, so validate it instead of overwriting it with the requested symbol. The runner also drops a live batch whose ticker does not match its active footprint symbol. Use the vendor trade id as sequence whenever it exists. Historical paging must honor the exclusive beforeSequence bound so trades sharing the same millisecond are neither skipped nor counted twice.
Enable the footprint
import type { ChartApi } from '@tradescript/pro/sdk'
export function enableFootprint(chart: ChartApi): void {
chart.setFootprint({
enabled: true,
bars: 50,
minBarSpace: 40,
type: 'cluster',
imbalanceRatio: 3,
showDelta: true,
showPoc: true,
showValueArea: true,
valueAreaRatio: 0.7,
candleMode: 'outline',
magnifier: {
enabled: true,
visibility: 'compact',
placement: 'bottom-left',
compactBarSpace: 40,
opacity: 0.84,
},
})
chart.setChartType('footprint')
}
chart.setChartType('footprint') is the single activation authority. Configure the
surface first with setFootprint(...), then select the chart type so startup uses
those options once. Calling setFootprint(...) while another chart type is active
only persists the configuration; it does not select Footprint or start tape.
If no configuration exists, selecting the Footprint chart type creates the defaults. Selecting another chart type stops the tape runner and clears its rendered layer but retains the configuration for re-entry.
The footprint chart type must be allowed by features.chartTypes, and the built-in Order Flow row also requires features.orderFlow plus at least one footprint data operation. Omit rowSize to let the SDK choose a tidy step near one basis point of the latest loaded close. Setting rowSize or bars changes bucket shape and restarts backfill. Presentation and derived-read options update the existing ladders.
supportsFootprint: false hides the built-in Footprint row. Materialized getFootprintHistory is discoverable when that flag is omitted. A raw-only implementation must declare supportsFootprint: true; supportsTimeAndSales only advertises the tape channel. Return SdkSymbolInfo.supportsFootprint: false for unsupported instruments in a mixed catalog.
A direct setChartType('footprint') call is an explicit host activation. Mount a working materialized or tape operation before making it.
Explicit fields passed to setFootprint win over stored user preferences. Omitted fields can inherit persisted Footprint defaults when user-settings storage is enabled. Pass every product-controlled field when an enterprise deployment requires one deterministic presentation.
Configuration reference
| Option | Default | Contract |
|---|---|---|
rowSize | Automatic tidy step near one basis point | Positive price represented by one ladder row; changing it restarts history |
bars | 50 | Closed historical ladders retained before the forming bar; values below 50 are raised to 50 |
imbalanceRatio | 3 | Diagonal opposite-side volume multiple that marks an imbalance |
imbalanceMinVolume | 0 | Quantity-unit floor below which a row cannot be imbalanced |
type | 'cluster' | Number ladders or per-row profile bars |
profileMode | 'buysell' | In profile mode, use standard, delta, total-volume, or split buy/sell encoding |
minBarSpace | 40 | CSS-pixel width at which full cluster labels fit |
showDelta, showPoc | true | Show each bar's delta footer and point-of-control row |
showValueArea | false | Shade rows inside the per-bar value area |
valueAreaRatio | 0.7 | Share of bar volume included around POC |
opacity | 1 | Overall layer opacity from 0..1 |
candleMode | 'normal' | Keep full candles, show outlines, or hide candles while Footprint is active |
colors | Shared Footprint palette | Optional buy, sell, POC, value-area, and profile overrides |
magnifier | Enabled in compact mode at bottom-left | Local detail visibility, placement, compact threshold, opacity, and panel colors |
Readability and detail magnifier
minBarSpace controls when full ladder labels fit inside each candle. Below that width, cluster mode progressively aggregates adjacent source rows into compact red/green pairs. From 5 through 9 CSS pixels per source bar, it uses split profile geometry because two number columns cannot fit; below 5 pixels it paints no footprint detail.
When the chart enters bucket mode, one display bucket represents multiple source candles and there is no one-to-one candle identity for a footprint ladder. The base footprint and magnifier therefore stay hidden until the user zooms back to source-bar mode. This is a rendering limit only: the retained ladder data and viewport are not changed.
The magnifier is a cluster-only local rendering aid over the same footprint bars. Profile mode already uses its slot as a histogram and does not show the magnifier. The magnifier never changes rowSize, requests different tape, alters the chart viewport, or restarts backfill. Its visibility modes are:
visibility | Behavior |
|---|---|
'compact' | Show below compactBarSpace; this is the default |
'always' | Keep the detail panel visible at the selected placement |
'hover' | Show only while the pointer is over footprint data |
placement accepts cursor or any fixed top-*, center-*, or bottom-* pane anchor. The default is bottom-left. Panel background, border, grid, and text colors live under magnifier.colors; buy, sell, POC, and value-area colors continue to inherit the main footprint palette.
Materialized history
Large tape windows are expensive to rebuild in every browser. Implement orderFlowHistory.getFootprintHistory to serve bounded interval bars with integer row ids and buy/sell totals.
The adapter below keeps the backend's symbol, interval, row size, and quantity unit as evidence. It rejects a mismatch before attaching the resolved SDK symbol.
import type {
FootprintHistoryBar,
FootprintHistoryRequest,
FootprintHistoryResult,
FootprintHistoryRow,
MarketDataFeed,
OrderFlowHistoryCoverage,
} from '@tradescript/pro/sdk'
interface FootprintHistoryWireResult {
symbol: string
interval: string
rowSize: number
quantityUnit: string
coverage: OrderFlowHistoryCoverage
bars: FootprintHistoryBar[]
forming?: FootprintHistoryBar
}
export function withMaterializedFootprintHistory(
base: MarketDataFeed,
restBase = '/api/market-data',
): MarketDataFeed {
return {
...base,
async onReady() {
const current = await base.onReady?.() ?? {}
return { ...current, supportsFootprint: true }
},
orderFlowHistory: {
...base.orderFlowHistory,
async getFootprintHistory(request): Promise<FootprintHistoryResult> {
if (request.symbol.supportsFootprint === false) {
throw new Error(`Footprint is not supported for ${request.symbol.ticker}`)
}
const quantityUnit = requireFootprintQuantityUnit(request)
const identity = request.symbol.canonicalSymbol ?? request.symbol.ticker
const limit = requireFootprintInteger(request.limit ?? 50, 'limit')
if (!Number.isFinite(request.rowSize) || request.rowSize <= 0) {
throw new Error('rowSize must be positive')
}
const query = new URLSearchParams({
interval: request.interval,
rowSize: String(request.rowSize),
limit: String(limit),
})
if (request.beforeTime !== undefined) {
query.set('beforeTime', String(requireFootprintInteger(request.beforeTime, 'beforeTime')))
}
const response = await fetch(
`${restBase}/order-flow/footprint/${encodeURIComponent(identity)}?${query}`,
)
if (!response.ok) {
throw new Error(`Materialized Footprint history failed: ${response.status}`)
}
return parseFootprintHistory(
await response.json(), request, identity, quantityUnit, limit,
)
},
},
}
}
function parseFootprintHistory(
value: unknown,
request: FootprintHistoryRequest,
identity: string,
quantityUnit: string,
limit: number,
): FootprintHistoryResult {
if (typeof value !== 'object' || value === null) {
throw new Error('Materialized Footprint history returned an invalid payload')
}
const result = value as FootprintHistoryWireResult
if (
result.symbol !== identity || result.interval !== request.interval ||
result.rowSize !== request.rowSize || result.quantityUnit !== quantityUnit ||
!Array.isArray(result.bars) ||
!result.bars.every((bar) => isFootprintBar(bar, true)) ||
(result.forming !== undefined && !isFootprintBar(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)) ||
!validFootprintCoverage(result.coverage, limit, result.bars.length)
) {
throw new Error('Materialized Footprint identity, unit, coverage, or bars are invalid')
}
return {
symbol: request.symbol,
interval: request.interval,
rowSize: request.rowSize,
coverage: result.coverage,
bars: result.bars,
...(result.forming === undefined ? {} : { forming: result.forming }),
}
}
function isFootprintBar(value: unknown, complete: boolean): value is FootprintHistoryBar {
if (typeof value !== 'object' || value === null) return false
const bar = value as FootprintHistoryBar
return Number.isSafeInteger(bar.key) && Number.isSafeInteger(bar.time) && bar.time > 0 &&
Number.isSafeInteger(bar.endTime) && bar.endTime > bar.time &&
Number.isSafeInteger(bar.firstTradeId) && Number.isSafeInteger(bar.lastTradeId) &&
bar.lastTradeId >= bar.firstTradeId && Number.isSafeInteger(bar.tradeCount) &&
bar.tradeCount >= 0 && typeof bar.startObserved === 'boolean' &&
typeof bar.endObserved === 'boolean' && bar.complete === complete &&
(!complete || (bar.startObserved && bar.endObserved)) &&
Array.isArray(bar.rows) && bar.rows.every(isFootprintRow) &&
new Set(bar.rows.map((row) => row.row)).size === bar.rows.length
}
function isFootprintRow(value: unknown): value is FootprintHistoryRow {
if (typeof value !== 'object' || value === null) return false
const row = value as FootprintHistoryRow
return Number.isSafeInteger(row.row) && Number.isFinite(row.buy) && row.buy >= 0 &&
Number.isFinite(row.sell) && row.sell >= 0
}
function validFootprintCoverage(
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 requireFootprintQuantityUnit(request: FootprintHistoryRequest): string {
const unit = request.symbol.quantityUnit?.trim()
if (!unit) throw new Error(`quantityUnit is required for ${request.symbol.ticker}`)
return unit
}
function requireFootprintInteger(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 the unit of every buy and sell total. A ready result must include the requested complete bars. Return truthful partial bars with warming or gap; reject unsupported symbols, authorization failures, and malformed data instead of fabricating empty coverage.
This Promise is one bounded history request and owns no subscription. A symbol, interval, rowSize, or bars change can issue another request. Live forming data remains a separate subscribeTimeAndSales stream with its own idempotent unsubscribe function.
Read materialized ladders headlessly
The mounted market-data controller exposes the same typed history without changing chart type or Footprint presentation.
import type {
ChartId,
ChartInterval,
ChartWidgetApi,
FootprintHistoryResult,
SdkSymbolInfo,
} from '@tradescript/pro/sdk'
export async function readFootprintHistory(
widget: ChartWidgetApi,
symbol: SdkSymbolInfo,
interval: ChartInterval,
rowSize: number,
chartId?: ChartId,
): Promise<FootprintHistoryResult> {
await widget.ready()
const data = widget.data(chartId)
const support = await data.getOperationSupport()
if (support.getFootprintHistory !== true) {
throw new Error('This chart datafeed does not expose materialized Footprint history')
}
return data.getFootprintHistory({
symbol,
interval,
rowSize,
limit: 50,
beforeTime: Date.now(),
})
}
getOperationSupport() proves that the nested feed operation is callable through this controller. getCapabilities() reports supportsFootprint separately. An unsupported controller call rejects with datafeed.unsupported and does not mutate the chart.
The response must echo the requested symbol, interval, and rowSize. A row-size mismatch is a hard contract error because changing the price bucket after aggregation changes every ladder. Return closed bars oldest to newest and put the current incomplete interval in forming, not in the closed list. Each bar carries:
- stable
key, inclusive UTCtime, and exclusive UTCendTime; - inclusive
firstTradeIdandlastTradeId,tradeCount, and source-boundary evidence; complete: trueonly after bothstartObservedandendObservedare true;- safe-integer row ids, where
price = row * rowSize, with finite non-negativebuyandselltotals.
The SDK uses each returned time as the chart-bar identity and consumes the integer rows directly; it does not re-bucket a materialized ladder in the browser. Return coverage as:
readywhen the requested number of complete bars is present;warmingwhile the materializer has partial truthful coverage;gapwhen a known source hole prevents complete history.
The SDK requests materialized history first. A ready response with at least the requested complete-bar count is used directly. A warming or gap response triggers raw-tape recovery when getTimeAndSales exists; if raw recovery is unavailable or fails, the truthful partial materialized bars remain the fallback. A row-size mismatch is reported as datafeed.orderFlowHistory and is not reinterpreted as raw data.
Time and bar alignment
All tape timestamps are Unix epoch milliseconds and are bucketed in UTC. Fixed-duration seconds, minutes, hours, and days align by flooring the timestamp to the interval from the Unix epoch. Multi-week bars are anchored to Monday 00:00 UTC from the Unix epoch week. Calendar-month bars start on the first day of their UTC month group, and calendar-year bars start on January 1 of their UTC year group. Tick footprints use contiguous source-sequence groups and therefore require safe-integer sequence values.
For materialized history, the backend owns the exact interval boundaries and must use the same UTC/session alignment as loadBars. The browser trusts the returned time; a ladder whose timestamp does not equal its chart bar's opening timestamp will not render on that candle.
Quality declaration
supportsFootprint: true declares the Footprint surface. supportsTimeAndSales: true separately declares raw tape availability, not tape quality. marketDataQuality.timeAndSales is the source-of-truth declaration used by customers and agents:
- set
aggressor: 'source'only when direction comes from the provider for every usable print; - set
sequence: 'source'only for provider-owned ordering, and pair it with truthfulcontinuity,gapDetection, andresyncvalues; - use
continuity: 'lossless'only when buffering and recovery make loss impossible within the published contract; - declare snapshot and stream quality separately because retained REST history and the live WebSocket can have different guarantees.
The SDK intersects this declaration with the operations actually mounted on the feed. Missing quality metadata means supported but unqualified data; it never means lossless. The footprint runner de-duplicates source sequences, but it does not repair an upstream sequence gap or infer a missing aggressor.
Reconciliation and lifecycle
Before release, reconcile each footprint bar against the source tape:
buyTotal + sellTotalequals total included trade size;deltaequalsbuyTotal - sellTotal;- the POC row has the greatest combined row volume;
- source sequence overlap does not double-count a print;
- historical prints and prints received during backfill merge once in time order.
Symbol and interval changes stop the old subscription and rebuild the ladders.
Passing null or { enabled: false } removes the saved Footprint configuration,
clears the layer, and closes its tape subscription. Merely selecting another chart
type closes and clears the active layer without discarding that configuration.
Failure behavior
Return an empty array only when the requested tape window genuinely contains no trades. Reject transport, authorization, unsupported-symbol, and retention failures; do not translate them into an empty market.
The runner's fallback order is materialized history, then raw tape, then truthful partial materialized history. Live prints received during a historical request remain visible and are merged once after recovery. If no historical source succeeds, the live forming footprint can continue from subsequent matching prints; the SDK does not synthesize the missing past. A live batch for another symbol is dropped. Leaving Footprint or changing symbol/interval unsubscribes the prior stream; customer adapters must make their unsubscribe function idempotent.
If source startup throws while entering Footprint, setChartType('footprint')
throws and restores the prior chart type, controller state, and rendered engine
state. The configured Footprint options remain available for a later retry.
Next steps
- TPO Market Profile — add the bars-only time-at-price profile.
- Order-book Liquidity Heatmap — add resting L2 liquidity.
- Gateway — preserve tape events under backpressure.
- Datafeed API — inspect exact footprint option, tape, and materialized-history types.