Examples

Chart integration

Put the Effort Zones and the Big Trades on a candlestick chart built with TradingView Lightweight Charts. The chart below is drawn by the code of this page, from 23 minutes of the NQ session of September 24, 2026.

What happened

The candles are the Effort Zones range bars (40 ticks each), the yellow line is their 20-period EMA, the boxes are the zones (blue for buying, coral for selling) and the dots are the large orders, sized by volume. Times are New York time.

  • 13:11:48: a buying effort zone forms at 30762.00–30768.00.
  • 13:19:08 to 13:19:24: four large sell orders, of 159, 40, 252 and 87 contracts, hit the bid: 538 contracts in 16 seconds. Price goes through the buying zone, which ends.
  • 13:20:16: a selling effort zone forms at 30753.75–30762.50, where those sellers pushed price down.
  • 13:21 to 13:24: price comes back into the zone twice, up to 30761.00, and every bar closes back below it: the sellers defend the level.
  • 13:26:39: low at 30700.25, about 53 points below the zone in under three minutes.
  • 13:29:36: a buying effort zone forms at the low, with large buy orders, and price rebounds to 30755.

The sample session

Download nq-2026-09-24.json (47 KB): NQZ6, from 13:08:00 to 13:31:30 New York time. For each indicator, it holds exactly what FathomChartsRest.history() yields for that range with snapshot: true: the state at the start of the range, then every change with its cursor.

JSON
{
  "instrument": "NQZ6",
  "tickSizeNanos": "250000000",
  "from": "1790269680000000000",
  "to": "1790271090000000000",
  "subscriptions": {
    "effort-zones": {
      "params": {},
      "mode": "live",
      "events": [
        { "t": "snapshot", "cursor": "20720.334600.4294967295", "items": [{ "id": "zone:2026-09-20:4375", "final": false, "ts": "1790269433958539505", "data": { "active": true, "high": 123172, "low": 123094, "side": "buy", "start": 4375 } }, "…"] },
        { "cursor": "20720.334627.0", "t": "upsert", "id": "bar:2026-09-20:4394", "final": true, "ts": "1790269680554218199", "data": { "close": 123174, "high": 123214, "low": 123174, "open": 123214, "start": "1790269671038696969" } }
      ]
    },
    "big-trades": { "params": { "maximum": 0, "minimum": 30, "priceMode": "last" }, "mode": "confirmed", "events": ["…"] }
  }
}

Prices are in ticks: multiply by the tick size (tickSizeNanos / 10⁹, 0.25 for NQ) to get a price.

Install

BASH
npm install lightweight-charts
npx fathom-charts-types https://api.fathomcharts.com/v1/indicators src/fathom-charts-types.ts

The second command generates the indicator types the code imports from ./fathom-charts-types.js.

The chart

sessionChart() creates the chart and returns a small object that you feed with the objects of both subscriptions: snapshot() replaces every object of a subscription, upsert() adds or replaces one, remove() deletes one. It redraws at most once per frame, so it takes a live stream as well as a whole session at once.

chart.ts
import {
  CandlestickSeries, LineSeries, createChart, createSeriesMarkers,
  type IPrimitivePaneView, type ISeriesPrimitive, type SeriesMarker, type Time,
} from 'lightweight-charts';
import type { BigTradesData, EffortZonesData } from './fathom-charts-types.js';

type Bar = Extract<EffortZonesData, { open: number }>;
type Zone = Extract<EffortZonesData, { side: 'buy' | 'sell' }>;
type Indicator = 'effort-zones' | 'big-trades';
interface Item { id: string; data?: unknown }

const BUY = '#7c9bff';
const SELL = '#ff9989';
const clock = (ns: string) =>
  new Date(Number(BigInt(ns) / 1_000_000n)).toLocaleTimeString('en-US', { timeZone: 'America/New_York', hourCycle: 'h23' });

/**
 * A chart of Effort Zones range bars, with their EMA, their zones and the Big Trades groups. Feed it the objects of both
 * subscriptions as they arrive: from the stream, from history, or from a saved session.
 */
export function sessionChart(container: HTMLElement, tickSizeNanos: string) {
  const tick = Number(tickSizeNanos) / 1e9;
  // The objects of each subscription, by id: what the snapshot gave, then each upsert and remove.
  const objects: Record<Indicator, Map<string, unknown>> = { 'effort-zones': new Map(), 'big-trades': new Map() };
  let bars = new Map<number, Bar>();
  let indices: number[] = [];
  let zones: Zone[] = [];

  // Range bars have no fixed duration: the horizontal axis is the bar index, labelled with the time of its first trade.
  const label = (time: Time) => {
    const bar = bars.get(time as number);
    return bar ? clock(bar.start) : '';
  };
  const chart = createChart(container, {
    autoSize: true,
    layout: { background: { color: '#080a10' }, textColor: '#a9b2cc', fontSize: 11 },
    grid: { vertLines: { color: '#151926' }, horzLines: { color: '#151926' } },
    rightPriceScale: { borderColor: '#222737' },
    timeScale: { borderColor: '#222737', tickMarkFormatter: label },
    localization: { timeFormatter: label, priceFormatter: (p: number) => p.toFixed(2) },
  });
  const candles = chart.addSeries(CandlestickSeries, {
    upColor: '#dfe6f8', downColor: '#6f7c9e', borderVisible: false, wickUpColor: '#dfe6f8', wickDownColor: '#6f7c9e',
  });
  const ema = chart.addSeries(LineSeries, {
    color: '#f2c48d', lineWidth: 1, priceLineVisible: false, lastValueVisible: false, crosshairMarkerVisible: false,
  });
  const markers = createSeriesMarkers(candles, []);

  // Each zone is a box from the bar that created it to the bar where it ended, or to the last bar while it is active.
  const boxes: ISeriesPrimitive<Time> = {
    paneViews: (): IPrimitivePaneView[] => [{
      zOrder: () => 'bottom',
      renderer: () => ({
        draw: (target) => target.useMediaCoordinateSpace(({ context: ctx }) => {
          const first = indices[0], last = indices[indices.length - 1];
          const x = (i: number) => chart.timeScale().timeToCoordinate(Math.min(Math.max(i, first), last) as Time);
          for (const z of zones) {
            if ((z.end ?? last) < first) continue;
            const x1 = x(z.start), x2 = x(z.end ?? last);
            const y1 = candles.priceToCoordinate(z.high * tick), y2 = candles.priceToCoordinate(z.low * tick);
            if (x1 === null || x2 === null || y1 === null || y2 === null) continue;
            const color = z.side === 'buy' ? BUY : SELL;
            ctx.fillStyle = `${color}2e`;
            ctx.strokeStyle = color;
            ctx.fillRect(x1, y1, x2 - x1, y2 - y1);
            ctx.strokeRect(x1, y1, x2 - x1, y2 - y1);
          }
        }),
      }),
    }],
  };
  candles.attachPrimitive(boxes);

  let pending = 0;
  function draw() {
    pending = 0;
    // Effort Zones publishes three kinds of objects, told apart by the id prefix: bar:, ema: and zone:<week>:<index>.
    bars = new Map();
    zones = [];
    const line: { time: Time; value: number }[] = [];
    for (const [id, data] of objects['effort-zones']) {
      const [kind, , index] = id.split(':');
      if (kind === 'bar') bars.set(Number(index), data as Bar);
      else if (kind === 'ema') line.push({ time: Number(index) as Time, value: (data as { value: number }).value * tick });
      else zones.push(data as Zone);
    }
    indices = [...bars.keys()].sort((a, b) => a - b);
    candles.setData(indices.map((i) => {
      const b = bars.get(i)!;
      return { time: i as Time, open: b.open * tick, high: b.high * tick, low: b.low * tick, close: b.close * tick };
    }));
    ema.setData(line.filter((p) => bars.has(p.time as number)).sort((a, b) => (a.time as number) - (b.time as number)));

    // A large order sits on the bar where it started, at its price; its size follows its volume.
    const list: SeriesMarker<Time>[] = [];
    for (const data of objects['big-trades'].values()) {
      const g = data as BigTradesData;
      const index = indices.findLast((i) => BigInt(bars.get(i)!.start) <= BigInt(g.start));
      if (index === undefined) continue;
      list.push({
        time: index as Time, position: 'atPriceMiddle', price: g.price * tick, shape: 'circle',
        color: g.side === 'buy' ? BUY : SELL, size: Math.sqrt(g.volume / 30), text: g.volume >= 100 ? String(g.volume) : undefined,
      });
    }
    markers.setMarkers(list.sort((a, b) => (a.time as number) - (b.time as number)));
  }
  // Updates come in bursts: one redraw per frame.
  const later = () => { pending ||= requestAnimationFrame(draw); };

  return {
    /** A snapshot replaces every object of its subscription. */
    snapshot(indicator: Indicator, items: Item[]) {
      objects[indicator] = new Map(items.map((item) => [item.id, item.data]));
      later();
    },
    upsert(indicator: Indicator, item: Item) {
      objects[indicator].set(item.id, item.data);
      later();
    },
    remove(indicator: Indicator, id: string) {
      objects[indicator].delete(id);
      later();
    },
    fit() {
      draw();
      chart.timeScale().fitContent();
    },
    destroy() {
      cancelAnimationFrame(pending);
      chart.remove();
    },
  };
}

A few choices worth knowing:

  • Bar index as time. A range bar closes after 40 ticks of range, not after a fixed duration, and several bars can open within the same second. The horizontal axis is therefore the bar index (the last segment of the bar: id), and its labels show the time of each bar’s first trade (start).
  • Zones are drawn in bars. A zone’s start and end are bar indices. A zone still active has no end: its box runs to the last bar.
  • Large orders on their starting bar. A group is placed on the last bar that opened before its start, at its price.
  • Attribution. Lightweight Charts is licensed under Apache 2.0 and asks for a link to TradingView on pages that use it: the logo in the corner of the chart, shown by default (layout.attributionLogo), takes care of it. Keep the notice of its NOTICE file with your code as well.

Load the sample

Serve the page and the sample file from the same folder (npx vite, for example):

TS
import { sessionChart } from './chart.js';

type Event = { t: 'snapshot'; items: { id: string; data: unknown }[] } | { t: 'upsert' | 'remove'; id: string; data?: unknown };
interface Sample { tickSizeNanos: string; subscriptions: Record<'effort-zones' | 'big-trades', { events: Event[] }> }

const sample: Sample = await (await fetch('nq-2026-09-24.json')).json();
const chart = sessionChart(document.getElementById('chart')!, sample.tickSizeNanos);
for (const indicator of ['effort-zones', 'big-trades'] as const) {
  for (const e of sample.subscriptions[indicator].events) {
    if (e.t === 'snapshot') chart.snapshot(indicator, e.items);
    else if (e.t === 'upsert') chart.upsert(indicator, e);
    else chart.remove(indicator, e.id);
  }
}
chart.fit();

The page only needs a sized element: <div id="chart" style="height: 420px"></div>.

Go live

The same chart follows the real-time stream. In a browser, the SDK opens the connection with a stream token that your server requests with its key:

TS
import { FathomChartsStream } from '@fathom-charts/sdk';
import { sessionChart } from './chart.js';

const stream = new FathomChartsStream({
  url: 'wss://stream.fathomcharts.com/v1',
  streamToken: async () => {
    const response = await fetch('/api/fathom-charts-token', { method: 'POST' });
    const { token } = (await response.json()) as { token: string };
    return token;
  },
});

let chart: ReturnType<typeof sessionChart> | undefined;

async function follow(indicator: 'effort-zones' | 'big-trades', params: Record<string, unknown>) {
  for await (const event of stream.subscribe({ instrument: 'NQ.front', indicator, params })) {
    if (event.type === 'subscribed') chart ??= sessionChart(document.getElementById('chart')!, event.tickSizeNanos);
    else if (event.type === 'snapshot') chart!.snapshot(indicator, event.items);
    else if (event.type === 'upsert') chart!.upsert(indicator, event);
    else if (event.type === 'remove') chart!.remove(indicator, event.id);
  }
}

await Promise.all([follow('effort-zones', {}), follow('big-trades', { minimum: 30 })]);
  • After a reset, the SDK hands you a new snapshot: snapshot() replaces the objects, nothing else to do.
  • The Effort Zones snapshot keeps the last 32 final objects, bars and EMA values included: the chart starts with a few bars. To open on more of the session, subscribe with from: { time } (see Starting in the past), or load the session from history first.
  • A large order arrives as soon as it reaches minimum and grows while the burst lasts (live mode): the dot grows with it. With mode: 'confirmed', it only appears once final.