Charts
Examples

Book imbalance

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

Book imbalance

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.

bookImbalance.ts
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;
}