Charts
Examples

Session VWAP with bands

Volume weighted average price per session, with standard deviation bands.

Session VWAP with bands

Shows: ChartOverlay. Bars with warmup: "session", sessions from sessionOf, sums updated once per bar in the handler, the forming bar replaced on each update. draw builds one polyline and one band polygon per session.

See also: Streams and history.

session-vwap.ts
import { defineScript, input, type ScriptBar } from "@tradingthings/tt-create";

/** Volume sums of a session's confirmed bars. */
interface Sums {
  readonly volume: number;
  /** Sum of typical price × volume. */
  readonly priceVolume: number;
  /** Sum of typical price² × volume, for the standard deviation. */
  readonly squareVolume: number;
}

interface VwapPoint {
  readonly time: number;
  /** Open of the point's session: the line breaks between sessions. */
  readonly session: number;
  readonly vwap: number;
  readonly deviation: number;
}

const EMPTY: Sums = { volume: 0, priceVolume: 0, squareVolume: 0 };

/**
 * Volume weighted average price of each session, with bands at N standard
 * deviations. The sums update once per bar in the handler, `draw` only
 * reads the points.
 */
export default defineScript({
  name: "Session VWAP",
  surface: { kind: "ChartOverlay" },
  inputs: {
    bands: input.float({ default: 2, min: 0, step: 0.5, label: "Bands (std dev)" }),
    line: input.lineStyle({ default: { color: "#f59e0b", width: 2 }, label: "VWAP" }),
    fill: input.color({ default: "rgba(245,158,11,0.12)", label: "Band fill" }),
  },
  subscribe: (ctx) => ({
    symbol: ctx.market.symbol(),
    // Back to the session open, so the first session's VWAP is complete.
    bars: ctx.market.bars({ warmup: "session" }),
  }),
  state: () => ({
    /** Open and close of the current session, `null` before the first bar. */
    session: null as { readonly open: number; readonly close: number } | null,
    /** Sums of the confirmed bars of the current session. */
    sums: EMPTY,
    /** Ascending by time. The forming bar's point is replaced on each update. */
    points: [] as VwapPoint[],
  }),
  on: {
    bars: (s, { bar, confirmed }, ctx) => {
      const last = s.points.at(-1);

      // Older bars arrive as backfill when the chart scrolls left. This
      // example skips them: its sums only run forward.
      if (last && bar.time < last.time) return;

      // `sessionOf` is slow (time zones): called once per session, not per bar.
      if (!s.session || bar.time >= s.session.close) {
        const session = ctx.streams.symbol.info.sessionOf(bar.time);

        if (session.isErr()) return;

        if (session.value.open !== s.session?.open) s.sums = EMPTY;

        s.session = { open: session.value.open, close: session.value.close };
      }

      const sums = addBar(s.sums, bar);

      if (sums.volume === 0) return;

      const vwap = sums.priceVolume / sums.volume;
      const variance = Math.max(0, sums.squareVolume / sums.volume - vwap * vwap);
      const point = { time: bar.time, session: s.session.open, vwap, deviation: Math.sqrt(variance) };

      if (last?.time === bar.time) s.points[s.points.length - 1] = point;
      else s.points.push(point);

      if (confirmed) s.sums = sums;
    },
  },
  draw: (s, frame, g, ctx) => {
    const { bands, line, fill } = ctx.inputs;
    const { from, to } = frame.visibleTime;
    let session: number | null = null;
    let center: number[] = [];
    let upper: number[] = [];
    let lower: number[] = [];

    const flush = () => {
      if (bands > 0 && upper.length >= 4) {
        // Upper edge left to right, lower edge back: one closed band.
        const outline = [...upper];

        for (let i = lower.length - 2; i >= 0; i -= 2) outline.push(lower[i]!, lower[i + 1]!);

        g.polygon(outline, fill);
      }

      g.polyline(center, line);
      center = [];
      upper = [];
      lower = [];
    };

    for (const p of s.points) {
      if (p.time < from || p.time > to) continue;

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

      if (x === null) continue;

      if (p.session !== session) {
        flush();
        session = p.session;
      }

      center.push(x, frame.priceToY(p.vwap));
      upper.push(x, frame.priceToY(p.vwap + bands * p.deviation));
      lower.push(x, frame.priceToY(p.vwap - bands * p.deviation));
    }

    flush();

    const last = s.points.at(-1);

    if (last) g.priceLabel(last.vwap, frame.formatPrice(last.vwap), line.color, "#000000");
  },
  legend: (s, crosshairTime) => {
    const point = crosshairTime === null ? s.points.at(-1) : s.points.findLast((p) => p.time <= crosshairTime);

    return point ? [{ label: "VWAP", value: point.vwap.toFixed(2) }] : [];
  },
});

function addBar(sums: Sums, bar: ScriptBar): Sums {
  const typical = (bar.high + bar.low + bar.close) / 3;

  return {
    volume: sums.volume + bar.volume,
    priceVolume: sums.priceVolume + typical * bar.volume,
    squareVolume: sums.squareVolume + typical * typical * bar.volume,
  };
}