Examples
Book imbalance
Bid minus ask size near the price, one histogram bar per chart bar.

Shows: ChartPane. Samples the book stream once per second in the handler, averages the samples of each bar in draw.
See also: Streams and history.
import type { BookSnapshot } from "@tradingthings/tt-create";
import { defineScript, input } from "@tradingthings/tt-create";
/** Imbalance samples kept per this many ms: the last snapshot in each wins. */
const SAMPLE_MS = 1_000;
/** Bar width as a share of the bar spacing. */
const BAR_WIDTH = 0.7;
const SIMULATED_COLOR = "#f59e0b";
interface Sample {
readonly time: number;
/** Bid minus ask size near the price. */
readonly value: number;
}
/**
* Bid minus ask size within N ticks of the price, one histogram bar per
* chart bar: the mean of its samples. Above zero when bids outweigh asks.
*/
export default defineScript({
name: "Book imbalance",
surface: { kind: "ChartPane", weight: 0.25 },
inputs: {
ticks: input.int({ default: 10, min: 1, max: 80, label: "Ticks from price" }),
up: input.color({ default: "rgba(38,166,154,0.8)", label: "Bids ahead" }),
down: input.color({ default: "rgba(239,83,80,0.8)", label: "Asks ahead" }),
},
subscribe: (ctx) => ({ book: ctx.market.book({ history: "2h" }) }),
state: () => ({ samples: [] as Sample[] }),
on: {
book: (s, book, ctx) => {
const time = Math.floor(book.time / SAMPLE_MS) * SAMPLE_MS;
const sample = { time, value: mapBookToImbalance(book, ctx.inputs.ticks) };
if (s.samples.at(-1)?.time === time) s.samples[s.samples.length - 1] = sample;
else s.samples.push(sample);
},
},
draw: (s, frame, g, ctx) => {
const { from, to } = frame.visibleTime;
const zero = frame.priceToY(0);
const width = Math.max(1, frame.barSpacing * BAR_WIDTH);
g.keepVisible(0, 0);
g.line(0, zero, frame.width, zero, { color: frame.theme.grid, width: 1 });
const bar = { time: NaN, sum: 0, count: 0 };
const flush = () => {
if (bar.count === 0) return;
const value = bar.sum / bar.count;
const x = frame.timeToX(bar.time);
if (x === null) return;
const y = frame.priceToY(value);
g.priceValue(bar.time, value);
g.rect(x - width / 2, Math.min(y, zero), width, Math.max(1, Math.abs(zero - y)), value >= 0 ? ctx.inputs.up : ctx.inputs.down);
};
for (let i = lowerBound(s.samples, frame.barTimeAt(from) ?? from); i < s.samples.length; i += 1) {
const sample = s.samples[i]!;
if (sample.time > to) break;
const time = frame.barTimeAt(sample.time) ?? sample.time;
if (time !== bar.time) {
flush();
bar.time = time;
bar.sum = 0;
bar.count = 0;
}
bar.sum += sample.value;
bar.count += 1;
}
flush();
const last = s.samples.at(-1);
if (last) g.priceLabel(last.value, formatSize(last.value), last.value >= 0 ? ctx.inputs.up : ctx.inputs.down, "#ffffff");
},
legend: (s, _crosshairTime, ctx) => {
const last = s.samples.at(-1);
return [
...(ctx.streams.book.simulated ? [{ label: "Book", value: "Simulated", color: SIMULATED_COLOR }] : []),
...(last
? [{ label: "Imbalance", value: formatSize(last.value), color: last.value >= 0 ? ctx.inputs.up : ctx.inputs.down }]
: []),
];
},
});
function mapBookToImbalance(book: BookSnapshot, ticks: number): number {
let bid = 0;
let ask = 0;
for (let i = 0; i < ticks; i += 1) {
bid += book.bids[i]?.size ?? 0;
ask += book.asks[i]?.size ?? 0;
}
return bid - ask;
}
function formatSize(value: number): string {
return Math.round(value).toLocaleString("en-US");
}
/** First index with `time >= target`. Live data is the newest: checked first. */
function lowerBound(list: readonly { readonly time: number }[], target: number): number {
const last = list.length - 1;
if (last < 0 || list[last]!.time < target) return list.length;
let low = 0;
let high = last;
while (low < high) {
const mid = (low + high) >> 1;
if (list[mid]!.time < target) low = mid + 1;
else high = mid;
}
return low;
}