Charts
Examples

Delta histogram

Buy minus sell volume per bar in its own pane.

Delta histogram

Shows: ChartPane with its own price scale. Order flow per bar, g.keepVisible(0, 0) to keep the zero line in view, g.priceValue for autoscale, a hover tooltip and right-click menu entries to mark bars.

See also: Surfaces.

deltaHistogram.ts
import type { OrderFlowBar } from "@tradingthings/tt-create";
import { defineScript, format, input } from "@tradingthings/tt-create";

/** Bar width as a share of the bar spacing. */
const BAR_WIDTH = 0.7;

/**
 * Buy minus sell volume of each bar in its own pane: above zero when buyers
 * lifted the ask more. Hover shows the split, right click marks bars.
 */
export default defineScript({
  name: "Delta histogram",
  surface: { kind: "ChartPane", weight: 0.25 },
  inputs: {
    up: input.color({ default: "rgba(38,166,154,0.8)", label: "Positive" }),
    down: input.color({ default: "rgba(239,83,80,0.8)", label: "Negative" }),
    marked: input.color({ default: "#f59e0b", label: "Marked" }),
  },
  subscribe: (ctx) => ({ orderFlow: ctx.market.orderFlow() }),
  state: () => ({ marked: new Set<number>() }),
  draw: (s, frame, g, ctx) => {
    const bars = ctx.streams.orderFlow.bars;
    const { from: fromTime, to: toTime } = 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 });

    for (let i = lowerBound(bars, frame.barTimeAt(fromTime) ?? fromTime); i < bars.length; i += 1) {
      const bar = bars[i]!;

      if (bar.time > toTime) break;

      const x = frame.timeToX(bar.time);

      if (x === null) continue;

      const { delta } = mapBarToDelta(bar);
      const y = frame.priceToY(delta);
      const rect = { x: x - width / 2, y: Math.min(y, zero), width, height: Math.max(1, Math.abs(zero - y)) };
      const color = s.marked.has(bar.time) ? ctx.inputs.marked : delta >= 0 ? ctx.inputs.up : ctx.inputs.down;

      g.priceValue(bar.time, delta);
      g.rect(rect.x, rect.y, rect.width, rect.height, color);
      g.hit(`bar-${bar.time}`, { type: "Rect", ...rect });
    }

    const last = bars.at(-1);

    if (!last) return;

    const { delta } = mapBarToDelta(last);

    g.priceLabel(delta, format.volume(delta), delta >= 0 ? ctx.inputs.up : ctx.inputs.down, "#ffffff");
  },
  onHover: (_s, event, ctx) => {
    const bar = findHitBar(ctx.streams.orderFlow.bars, event?.hitId ?? null);

    if (!bar) return null;

    const { delta, buy, sell } = mapBarToDelta(bar);

    return {
      title: new Date(bar.time).toLocaleTimeString(),
      rows: [
        { label: "Delta", value: format.volume(delta), color: delta >= 0 ? ctx.inputs.up : ctx.inputs.down },
        { label: "Buy", value: format.volume(buy) },
        { label: "Sell", value: format.volume(sell) },
      ],
    };
  },
  onContextMenu: (s, event, ctx) => {
    const bar = findHitBar(ctx.streams.orderFlow.bars, event.hitId);

    if (bar) {
      const marked = s.marked.has(bar.time);

      return [
        {
          label: marked ? "Unmark delta bar" : "Mark delta bar",
          onSelect: () => (marked ? s.marked.delete(bar.time) : s.marked.add(bar.time)),
        },
      ];
    }

    return s.marked.size > 0 ? [{ label: "Clear marked delta bars", onSelect: () => s.marked.clear() }] : [];
  },
  legend: (_s, crosshairTime, ctx) => {
    const bars = ctx.streams.orderFlow.bars;
    const bar = crosshairTime === null ? bars.at(-1) : bars[lowerBound(bars, crosshairTime)];

    if (!bar || (crosshairTime !== null && bar.time !== crosshairTime)) return [];

    const { delta } = mapBarToDelta(bar);

    return [{ label: "Delta", value: format.volume(delta), color: delta >= 0 ? ctx.inputs.up : ctx.inputs.down }];
  },
});

/** Volume at the ask is buying, at the bid selling. */
function mapBarToDelta(bar: OrderFlowBar): { readonly delta: number; readonly buy: number; readonly sell: number } {
  let buy = 0;
  let sell = 0;

  for (const level of bar.levels) {
    buy += level.ask;
    sell += level.bid;
  }

  return { delta: buy - sell, buy, sell };
}

function findHitBar(bars: readonly OrderFlowBar[], hitId: string | null): OrderFlowBar | null {
  if (!hitId?.startsWith("bar-")) return null;

  const time = Number(hitId.slice(4));
  const bar = bars[lowerBound(bars, time)];

  return bar?.time === time ? bar : null;
}

/** 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;
}