Charts
Examples

Trade size distribution

How many live trades of each size, buys stacked on sells.

Trade size distribution

Shows: Canvas: no time or price scale, the script draws its own axes and bars in pane px. Reacts to the crosshair with onCrosshair.

See also: Surfaces.

tradeSizeDistribution.ts
import { defineScript, input } from "@tradingthings/tt-create";

/** Lower bound of each size bucket, the last one open-ended. */
const BUCKETS = [1, 2, 3, 5, 10, 20, 50, 100] as const;
/** Space for the axes, in px. */
const AXIS = { left: 44, bottom: 20, top: 12, right: 12 } as const;
const TICKS = 4;

interface Bucket {
  buy: number;
  sell: number;
}

/**
 * How many live trades of each size, buys stacked on sells. Draws its own
 * axes on a free canvas. Marks the bucket of the last trade at the
 * crosshair's bar.
 */
export default defineScript({
  name: "Trade size distribution",
  surface: { kind: "Canvas", weight: 0.35 },
  inputs: {
    buy: input.color({ default: "rgba(38,166,154,0.85)", label: "Buy" }),
    sell: input.color({ default: "rgba(239,83,80,0.85)", label: "Sell" }),
  },
  subscribe: (ctx) => ({ trades: ctx.market.trades() }),
  state: () => ({
    buckets: BUCKETS.map((): Bucket => ({ buy: 0, sell: 0 })),
    /** Bucket of the last trade up to the crosshair's bar, `null` without crosshair. */
    crosshairBucket: null as number | null,
  }),
  on: {
    trades: (s, trade) => {
      const bucket = s.buckets[mapSizeToBucket(trade.size)]!;

      if (trade.aggressor === "Sell") bucket.sell += 1;
      else bucket.buy += 1;
    },
  },
  draw: (s, frame, g, ctx) => {
    const { theme } = frame;
    const plot = {
      x: AXIS.left,
      y: AXIS.top,
      width: frame.width - AXIS.left - AXIS.right,
      height: frame.height - AXIS.top - AXIS.bottom,
    };

    if (plot.width <= 0 || plot.height <= 0) return;

    const bottom = plot.y + plot.height;
    const max = Math.max(1, ...s.buckets.map((b) => b.buy + b.sell));
    const toY = (count: number) => bottom - (count / max) * plot.height;
    const text = { color: theme.text, font: theme.font };

    // Axes: counts on the left, sizes below.
    g.line(plot.x, plot.y, plot.x, bottom, { color: theme.grid, width: 1 });
    g.line(plot.x, bottom, plot.x + plot.width, bottom, { color: theme.grid, width: 1 });

    for (let i = 0; i <= TICKS; i += 1) {
      const count = Math.round((max * i) / TICKS);
      const y = toY(count);

      g.line(plot.x - 4, y, plot.x, y, { color: theme.grid, width: 1 });
      g.text(String(count), plot.x - 6, y, { ...text, align: "right" });
    }

    const slot = plot.width / BUCKETS.length;

    for (const [i, bucket] of s.buckets.entries()) {
      const x = plot.x + i * slot + slot * 0.15;
      const width = slot * 0.7;
      const sellTop = toY(bucket.sell);
      const top = toY(bucket.sell + bucket.buy);

      g.rect(x, sellTop, width, bottom - sellTop, ctx.inputs.sell);
      g.rect(x, top, width, sellTop - top, ctx.inputs.buy);

      if (i === s.crosshairBucket) g.rect(x, plot.y, width, 2, theme.text);

      g.text(mapBucketToLabel(i), x + width / 2, bottom + AXIS.bottom / 2, { ...text, align: "center" });
      g.hit(`bucket-${i}`, { type: "Rect", x, y: plot.y, width, height: plot.height });
    }

    if (s.buckets.every((b) => b.buy + b.sell === 0)) {
      g.text("Waiting for live trades", plot.x + plot.width / 2, plot.y + plot.height / 2, {
        ...text,
        align: "center",
      });
    }
  },
  onHover: (s, event) => {
    const index = event?.hitId?.startsWith("bucket-") ? Number(event.hitId.slice(7)) : null;
    const bucket = index === null ? undefined : s.buckets[index];

    if (index === null || !bucket) return null;

    return `${mapBucketToLabel(index)} lots: ${bucket.buy} buys, ${bucket.sell} sells`;
  },
  onCrosshair: (s, event, ctx) => {
    if (!event) {
      s.crosshairBucket = null;

      return;
    }

    const trades = ctx.streams.trades;
    // Last trade at or before the crosshair's bar.
    let i = trades.length - 1;

    while (i >= 0 && trades.time[i]! > event.time) i -= 1;

    s.crosshairBucket = i >= 0 ? mapSizeToBucket(trades.size[i]!) : null;
  },
  widgets: (s) => [
    {
      id: "controls",
      placement: { type: "Corner", corner: "TopRight" },
      widgets: [
        { type: "Badge", text: `${s.buckets.reduce((sum, b) => sum + b.buy + b.sell, 0)} trades` },
        {
          type: "Button",
          label: "Reset",
          onClick: () => {
            for (const bucket of s.buckets) {
              bucket.buy = 0;
              bucket.sell = 0;
            }
          },
        },
      ],
    },
  ],
});

function mapSizeToBucket(size: number): number {
  let index = 0;

  while (index + 1 < BUCKETS.length && size >= BUCKETS[index + 1]!) index += 1;

  return index;
}

function mapBucketToLabel(index: number): string {
  const low = BUCKETS[index]!;
  const next = BUCKETS[index + 1];

  if (next === undefined) return `${low}+`;

  return next - 1 === low ? String(low) : `${low}-${next - 1}`;
}