# Chart integration

Put the [Effort Zones](https://fathomcharts.com/docs/indicators/effort-zones.md) and the [Big Trades](https://fathomcharts.com/docs/indicators/big-trades.md) on a candlestick chart built with [TradingView Lightweight Charts](https://tradingview.github.io/lightweight-charts/). The chart below is drawn by the code of this page, from 23 minutes of the NQ session of September 24, 2026.

*Effort Zones range bars, EMA and zones, with the Big Trades groups, on NQ from 13:08 to 13:31 New York time on September 24, 2026* ([sample session](https://fathomcharts.com/examples/nq-2026-09-24.json))

## 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](https://fathomcharts.com/examples/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()`](https://fathomcharts.com/docs/sdk/typescript.md#history-fathomchartsrest) 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](https://fathomcharts.com/docs/sdk/typescript.md#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**

```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](https://fathomcharts.com/docs/authentication.md#browser-access) 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](https://fathomcharts.com/docs/websocket.md#starting-in-the-past)), or load the session from [history](https://fathomcharts.com/docs/history.md) 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.
