Charts
Examples

TPO (chart type)

Market profile of each session, one letter per period in every price row it traded.

TPO (chart type)

Shows: ChartSeries. 1 minute bars whatever the chart's timeframe, the tpo helper, profiles built lazily in draw and cached per session with a version. The built-in TPO: "Duplicate and edit" in the chart type menu.

See also: Create a chart type.

tpo.ts
import type { TpoProfile } from "@tradingthings/tt-chart-lib";
import {
  defineScript,
  group,
  input,
  tab,
  tpo,
  type ChartFrame,
  type Graphics,
  type ScriptBar,
  type Session,
} from "@tradingthings/tt-create";

export type TpoSessionMode = "RTH" | "ETH" | "Day";

/** `[open, close)` in epoch ms. */
export interface TimeRange {
  readonly open: number;
  readonly close: number;
}

/**
 * The time range a profile covers inside a trading session: regular trading
 * hours, the overnight hours from the session open to the RTH open, or the
 * whole session.
 */
export const mapSessionToTpoRange = (session: Session, mode: TpoSessionMode): TimeRange => {
  switch (mode) {
    case "RTH":
      return session.rth;
    case "ETH":
      return { open: session.open, close: session.rth.open };
    case "Day":
      return { open: session.open, close: session.close };
  }
};

interface TpoSession {
  /** The trading session, to find a bar's session without `sessionOf`. */
  readonly session: Session;
  /** Part of the session in the profile. */
  readonly range: TimeRange;
  /** Bars inside `range`, ascending by time. */
  readonly bars: ScriptBar[];
  /** Bumped on each change of `bars`. */
  version: number;
  /** Profile of `version`, built by `draw`. `poc` is the POC price for the legend. */
  built: { readonly version: number; readonly profile: TpoProfile | null; readonly poc: string | null } | null;
}

interface TpoColors {
  readonly tpo: string;
  readonly valueArea: string;
  readonly poc: string;
  readonly initialBalance: string;
}

type Display = "Auto" | "Letters" | "Blocks";

/** Widest row as a share of the profile's time range. */
const SESSION_WIDTH = 0.8;
/** Widest letter cell in px. */
const MAX_CELL_WIDTH = 9;
/** `Auto` shows letters from this row height and cell width in px, else blocks. */
const MIN_LETTER_HEIGHT = 8;
const MIN_LETTER_WIDTH = 6;
const MIN_FONT_PX = 6;
const MAX_FONT_PX = 11;
/** Initial balance bar width and the gap between it and the letters. */
const IB_WIDTH = 2;
const IB_GAP = 3;
/** Dash of the last price line, in px. */
const LAST_PRICE_DASH: readonly [number, number] = [2, 2];
const LAST_PRICE_TEXT_COLOR = "#ffffff";

/**
 * TPO chart type: the market profile of each session from 1 minute bars, one
 * letter per period in every tick-aligned row the period traded in, drawn
 * from the session open. No candles.
 */
export default defineScript({
  name: "TPO",
  surface: { kind: "ChartSeries" },
  inputs: [
    tab("Inputs", [
      group("Profile", {
        session: input.select({ default: "RTH", options: ["RTH", "ETH", "Day"], label: "Session" }),
        period: input.int({ default: 30, min: 1, max: 240, label: "Period (minutes)" }),
        rowTicks: input.int({ default: 4, min: 1, max: 1000, label: "Row size (ticks)" }),
        display: input.select({ default: "Auto", options: ["Auto", "Letters", "Blocks"], label: "Display" }),
        valueArea: input.float({ default: 0.7, min: 0, max: 1, step: 0.05, label: "Value area" }),
        initialBalance: input.int({ default: 2, min: 0, max: 10, label: "Initial balance periods" }),
      }),
    ]),
    tab("Style", [
      group("Colors", { columns: 2 }, {
        tpo: input.color({ default: "rgba(120, 144, 156, 0.7)", label: "TPO" }),
        valueAreaColor: input.color({ default: "rgba(176, 190, 197, 0.9)", label: "Value area" }),
        poc: input.color({ default: "#ffb74d", label: "POC" }),
        initialBalanceColor: input.color({ default: "#42a5f5", label: "Initial balance" }),
      }),
    ]),
  ],
  subscribe: (ctx) => ({
    symbol: ctx.market.symbol(),
    // Letter periods need bars no longer than a period, whatever the chart's timeframe.
    bars: ctx.market.bars({ timeframe: "1m", warmup: "session" }),
  }),
  /** `sessions` ascending by open. */
  state: () => ({ sessions: [] as TpoSession[] }),
  on: {
    bars: (s, { bar }, ctx) => {
      let entry = findSession(s.sessions, bar.time);

      if (!entry) {
        const session = ctx.streams.symbol.info.sessionOf(bar.time);

        if (session.isErr()) return;

        entry = {
          session: session.value,
          range: mapSessionToTpoRange(session.value, ctx.inputs.session),
          bars: [],
          version: 0,
          built: null,
        };
        // Backfill brings older sessions.
        const after = s.sessions.findIndex((it) => it.session.open > session.value.open);

        s.sessions.splice(after === -1 ? s.sessions.length : after, 0, entry);
      }

      if (bar.time < entry.range.open || bar.time >= entry.range.close) return;

      const index = lowerBound(entry.bars, bar.time);

      if (entry.bars[index]?.time === bar.time) entry.bars[index] = bar;
      else entry.bars.splice(index, 0, bar);

      entry.version += 1;
    },
  },
  draw: (s, frame, g, ctx) => {
    const { inputs } = ctx;
    const settings = {
      periodMs: inputs.period * 60_000,
      tickSize: ctx.streams.symbol.info.tickSize,
      rowTicks: inputs.rowTicks,
      valueAreaFraction: inputs.valueArea,
      ibPeriods: inputs.initialBalance,
    };
    const colors: TpoColors = {
      tpo: inputs.tpo,
      valueArea: inputs.valueAreaColor,
      poc: inputs.poc,
      initialBalance: inputs.initialBalanceColor,
    };

    const { from, to } = frame.visibleTime;

    for (const entry of s.sessions) {
      if (entry.bars.length === 0) continue;

      if (entry.range.close < from || entry.range.open > to) continue;

      // Autoscale and the magnet follow the bars in view.
      for (let i = lowerBound(entry.bars, from); i < entry.bars.length && entry.bars[i]!.time <= to; i += 1) {
        g.priceValue(entry.bars[i]!.time, entry.bars[i]!.high);
        g.priceValue(entry.bars[i]!.time, entry.bars[i]!.low);
      }

      // Built here, cached on the session: history replays thousands of
      // bars, and only visible sessions need a profile.
      if (entry.built?.version !== entry.version) {
        const profile = tpo.build(entry.bars, { from: 0, to: entry.bars.length - 1 }, entry.range.open, settings);
        const poc = profile ? frame.formatPrice(profile.rows[profile.poc]!.price) : null;

        entry.built = { version: entry.version, profile, poc };
      }

      const { profile } = entry.built;
      const left = frame.timeToX(entry.range.open);
      const right = frame.timeToX(entry.range.close);

      if (!profile || left === null || right === null) continue;

      drawTpo(g, frame, profile, { left: left - frame.barSpacing / 2, right }, inputs.display, colors);
    }

    drawLastPrice(g, frame, s.sessions.findLast((it) => it.bars.length > 0)?.bars.at(-1) ?? null);
  },
  legend: (s, crosshairTime) => {
    const entry =
      crosshairTime === null
        ? s.sessions.findLast((it) => it.built)
        : s.sessions.find((it) => crosshairTime >= it.range.open && crosshairTime < it.range.close);
    const poc = entry?.built?.poc;

    return poc ? [{ label: "POC", value: poc }] : [];
  },
});

/** The session that holds `time`. Live bars are in the last one: searched from the end. */
function findSession(sessions: readonly TpoSession[], time: number): TpoSession | undefined {
  const entry = sessions.findLast((it) => it.session.open <= time);

  return entry && time < entry.session.close ? entry : undefined;
}

/** First index with `time >= target`. Live bars are 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;
}

/** The newest close as a dashed line across the pane and a price axis label. */
function drawLastPrice(g: Graphics, frame: ChartFrame, bar: ScriptBar | null): void {
  if (!bar) return;

  const color = bar.close >= bar.open ? frame.theme.up : frame.theme.down;
  const y = frame.priceToY(bar.close);

  g.line(0, y, frame.width, y, { color, width: 1, dash: LAST_PRICE_DASH });
  g.priceLabel(bar.close, frame.formatPrice(bar.close), color, LAST_PRICE_TEXT_COLOR);
}

/**
 * Letters or blocks of each row from `box.left`, brighter in the value area,
 * the POC row in its own color, and the initial balance as a bar left of
 * them. Row edges sit on device pixels.
 */
function drawTpo(
  g: Graphics,
  frame: ChartFrame,
  profile: TpoProfile,
  box: { readonly left: number; readonly right: number },
  display: Display,
  colors: TpoColors
): void {
  const { rows, rowHeight, maxCount, poc, valueArea, initialBalance } = profile;
  const snap = (y: number) => Math.round(y * frame.dpr) / frame.dpr;
  const gap = 1 / frame.dpr;
  const lettersLeft = box.left + IB_WIDTH + IB_GAP;
  const cellWidth = Math.min(MAX_CELL_WIDTH, ((box.right - lettersLeft) * SESSION_WIDTH) / maxCount);
  // Decided once per profile, so snapping cannot mix letters and blocks.
  const rowPx = frame.priceToY(rows[0]!.price) - frame.priceToY(rows[0]!.price + rowHeight);
  const letters = display === "Auto" ? rowPx >= MIN_LETTER_HEIGHT && cellWidth >= MIN_LETTER_WIDTH : display === "Letters";
  const font = { ...frame.theme.font, sizePx: Math.max(MIN_FONT_PX, Math.min(MAX_FONT_PX, rowPx - 1)) };

  if (initialBalance) {
    const top = snap(frame.priceToY(initialBalance.high));
    const bottom = snap(frame.priceToY(initialBalance.low));

    g.rect(box.left, top, IB_WIDTH, Math.max(gap, bottom - top), colors.initialBalance);
  }

  for (let k = 0; k < rows.length; k += 1) {
    const row = rows[k]!;
    const top = snap(frame.priceToY(row.price + rowHeight));
    const height = snap(frame.priceToY(row.price)) - top;
    const color =
      k === poc ? colors.poc : k >= valueArea.from && k <= valueArea.to ? colors.valueArea : colors.tpo;

    row.periods.forEach((period, j) => {
      const x = lettersLeft + j * cellWidth;

      if (letters) {
        g.text(tpo.letter(period), x + cellWidth / 2, top + height / 2, {
          color,
          font,
          align: "center",
          baseline: "middle",
        });
      } else {
        g.rect(x, top, Math.max(gap, cellWidth - gap), Math.max(gap, height - gap), color);
      }
    });
  }
}