# Trading bot

A Node.js bot that reads two subscriptions, [Effort Zones](https://fathomcharts.com/docs/indicators/effort-zones.md) and [Big Trades](https://fathomcharts.com/docs/indicators/big-trades.md), and takes a trade when price rejects a zone that large orders pushed through. The same code replays a saved session, history, or follows the real-time stream.

> This page shows how to wire indicators into automated decisions, not a strategy to trade. The rule is deliberately simple and is shown on a single stretch of session, picked because the indicators read it well: it is not a backtest and proves nothing about future results. Any order your code sends is your decision and your risk.

## The rule

- **A backed zone.** An effort zone counts when large orders on its side add up to at least 200 contracts in the 120 seconds before it appears: the push that formed it was driven by large players.
- **A rejection.** On each final range bar, if the bar went into a backed zone and closed back outside it, on the side the zone defends (below a selling zone, above a buying zone), the bot enters at the close of that bar.
- **The exit.** Stop 2 ticks beyond the far edge of the zone, target at twice the risk. One trade at a time, one trade per zone.

Every input comes from the indicators: the zones and the range bars from Effort Zones, the large orders from Big Trades. The bot computes nothing on the trades themselves.

## Install

```bash
npm install @fathom-charts/sdk ws
npm install -D tsx
npx fathom-charts-types https://api.fathomcharts.com/v1/indicators fathom-charts-types.ts
```

The last command [generates the indicator types](https://fathomcharts.com/docs/sdk/typescript.md#indicator-types) that the code imports from `./fathom-charts-types.js`.

## The strategy

The strategy knows nothing about the network: it receives the objects of both subscriptions and calls `onEntry` and `onExit`. `load()` takes a snapshot, `update()` an upsert, `remove()` a remove. Prices are in ticks, as the indicators publish them.

**strategy.ts**

```ts
import type { BigTradesData, EffortZonesData } from './fathom-charts-types.js';

type Bar = Extract<EffortZonesData, { open: number }>;
type Zone = Extract<EffortZonesData, { side: 'buy' | 'sell' }>;
type Side = 'buy' | 'sell';

/** An object of either subscription, as the stream and the history deliver it. */
export type Update =
  | { indicator: 'effort-zones'; id: string; ts: string; data: EffortZonesData }
  | { indicator: 'big-trades'; id: string; ts: string; data: BigTradesData };

/** Prices in ticks, times in Unix nanoseconds. */
export interface Entry { side: Side; price: number; stop: number; target: number; zone: string; ts: string }
export interface Exit { reason: 'stop' | 'target'; price: number; ticks: number; ts: string }

export const RULES = {
  /** Large-order volume on the zone's side, in contracts, in the 120 seconds before the zone appears. */
  pressure: 200,
  pressureWindowNs: 120_000_000_000n,
  /** Stop beyond the far edge of the zone, in ticks. */
  stopTicks: 2,
  /** Target, as a multiple of the risk. */
  reward: 2,
};

/** Fades the first rejection of an effort zone that large orders pushed through. One trade at a time, one per zone. */
export class ZoneRetest {
  private zones = new Map<string, { zone: Zone; createdNs: bigint }>();
  private groups = new Map<string, BigTradesData>();
  private traded = new Set<string>();
  private position?: Entry;

  constructor(private readonly onEntry: (e: Entry) => void, private readonly onExit: (e: Exit) => void) {}

  /** A snapshot replaces everything known from that subscription. Past bars place no order. */
  load(indicator: Update['indicator'], items: { id: string; ts: string; data: unknown }[]) {
    if (indicator === 'big-trades') this.groups.clear();
    else this.zones.clear();
    for (const item of items) this.update({ indicator, ...item } as Update, false);
  }

  update(u: Update, live = true) {
    if (u.indicator === 'big-trades') {
      this.groups.set(u.id, u.data);
      return;
    }
    const [kind, , index] = u.id.split(':');
    if (kind === 'zone') {
      const zone = u.data as Zone;
      if (!zone.active) this.zones.delete(u.id);
      else if (!this.zones.has(u.id)) this.zones.set(u.id, { zone, createdNs: BigInt(u.ts) });
    } else if (kind === 'bar' && live) {
      this.onBar(u.data as Bar, Number(index), u.ts);
    }
  }

  remove(indicator: Update['indicator'], id: string) {
    if (indicator === 'big-trades') this.groups.delete(id);
    else this.zones.delete(id);
  }

  private onBar(bar: Bar, index: number, ts: string) {
    this.prune(BigInt(ts));
    const p = this.position;
    if (p) {
      const sell = p.side === 'sell';
      // The bar does not say which came first: the stop is assumed.
      const stopped = sell ? bar.high >= p.stop : bar.low <= p.stop;
      const reached = sell ? bar.low <= p.target : bar.high >= p.target;
      if (stopped || reached) {
        const price = stopped ? p.stop : p.target;
        this.position = undefined;
        this.onExit({ reason: stopped ? 'stop' : 'target', price, ticks: sell ? p.price - price : price - p.price, ts });
      }
      return;
    }
    for (const [id, { zone, createdNs }] of this.zones) {
      if (this.traded.has(id) || index <= zone.start || this.pressure(zone.side, createdNs) < RULES.pressure) continue;
      const sell = zone.side === 'sell';
      // Price came back into the zone and the bar closed back outside it, on the side the zone defends.
      const rejected = sell ? bar.high >= zone.low && bar.close < zone.low : bar.low <= zone.high && bar.close > zone.high;
      if (!rejected) continue;
      this.traded.add(id);
      const stop = sell ? Math.ceil(zone.high) + RULES.stopTicks : Math.floor(zone.low) - RULES.stopTicks;
      const risk = Math.abs(stop - bar.close);
      const target = sell ? bar.close - RULES.reward * risk : bar.close + RULES.reward * risk;
      this.position = { side: zone.side, price: bar.close, stop, target, zone: id, ts };
      this.onEntry(this.position);
      return;
    }
  }

  /** Volume of the large orders on `side` that ended in the window before `ns`. */
  private pressure(side: Side, ns: bigint) {
    let volume = 0;
    for (const g of this.groups.values()) {
      const end = BigInt(g.end);
      if (g.side === side && end <= ns && ns - end <= RULES.pressureWindowNs) volume += g.volume;
    }
    return volume;
  }

  /** Keeps the large orders an active zone can still count. */
  private prune(now: bigint) {
    let oldest = now;
    for (const { createdNs } of this.zones.values()) if (createdNs < oldest) oldest = createdNs;
    for (const [id, g] of this.groups) if (oldest - BigInt(g.end) > RULES.pressureWindowNs) this.groups.delete(id);
  }
}
```

Effort Zones publishes its bars, its EMA values and its zones in the same subscription: the bot tells them apart by the `id` prefix (`bar:`, `ema:`, `zone:`) and reads the bar index from its last segment. A bar of the snapshot is already in the past: `load()` keeps the zones it describes but places no order on it.

## Replay a session

Download the sample session [nq-2026-09-24.json](https://fathomcharts.com/examples/nq-2026-09-24.json) (47 KB, described on [Chart integration](https://fathomcharts.com/docs/chart-integration.md#the-sample-session)) next to the code. `replay.ts` hands its events to the strategy in the order the trades produced them: both snapshots first, then every change sorted by cursor. [Cursors](https://fathomcharts.com/docs/sdk/typescript.md#cursors) of the same instrument compare across indicators.

**replay.ts**

```ts
import { readFileSync } from 'node:fs';
import { compareCursors, type HistorySnapshot, type Mutation } from '@fathom-charts/sdk';
import { ZoneRetest, type Update } from './strategy.js';

type Indicator = Update['indicator'];
interface Sample {
  instrument: string;
  tickSizeNanos: string;
  subscriptions: Record<Indicator, { events: [HistorySnapshot, ...Mutation[]] }>;
}

const sample: Sample = JSON.parse(readFileSync(process.argv[2] ?? 'nq-2026-09-24.json', 'utf8'));
const tick = Number(sample.tickSizeNanos) / 1e9;
const price = (ticks: number) => (ticks * tick).toFixed(2);
const clock = (ns: string) => new Date(Number(BigInt(ns) / 1_000_000n)).toLocaleTimeString('en-US', { timeZone: 'America/New_York', hourCycle: 'h23' });

const strategy = new ZoneRetest(
  (e) => console.log(`${clock(e.ts)} ${e.side.toUpperCase()} ${price(e.price)}, stop ${price(e.stop)}, target ${price(e.target)} (${e.zone})`),
  (e) => console.log(`${clock(e.ts)} ${e.reason} at ${price(e.price)}: ${e.ticks > 0 ? '+' : ''}${e.ticks} ticks`),
);

// The state at the start of the range, then every change in cursor order: the order of the trades that produced them.
const mutations: [Indicator, Mutation][] = [];
for (const [indicator, { events: [snapshot, ...rest] }] of Object.entries(sample.subscriptions) as [Indicator, Sample['subscriptions'][Indicator]][]) {
  strategy.load(indicator, snapshot.items);
  for (const m of rest) mutations.push([indicator, m]);
}
mutations.sort(([, a], [, b]) => compareCursors(a.cursor, b.cursor));
for (const [indicator, m] of mutations) {
  if (m.t === 'remove') strategy.remove(indicator, m.id);
  else strategy.update({ indicator, id: m.id, ts: m.ts, data: m.data } as Update);
}
```

```bash
npx tsx replay.ts
```

```text
13:21:38 SELL 30743.75, stop 30763.00, target 30705.25 (zone:2026-09-20:4431)
13:27:05 target at 30705.25: +154 ticks
```

At 13:19, four large sell orders (538 contracts in 16 seconds) break a buying zone. The selling zone they leave at 30753.75–30762.50 is backed. At 13:21:38, a bar reaches 30753.75 and closes at 30743.75: the bot sells. Price comes back up to 30761.00, below the stop, then falls: the target is reached at 13:27:05. The six other zones of the stretch never trigger: none of them is backed, with at most 153 contracts of large orders on its side before it appears.

## Replay history

To run the bot over another period, take the events from [history](https://fathomcharts.com/docs/history.md) instead of the file: `history()` yields the same elements as the `events` of the sample.

```ts
import { FathomChartsRest } from '@fathom-charts/sdk';

const rest = new FathomChartsRest({ baseUrl: 'https://api.fathomcharts.com', apiKey: process.env.FATHOM_CHARTS_API_KEY! });
const range = { instrument: 'NQZ6', from: '1790269680000000000', to: '1790271090000000000', snapshot: true };
const { instruments } = await rest.instruments();
const sample = {
  instrument: 'NQZ6',
  tickSizeNanos: instruments.find((i) => i.symbol === 'NQZ6')!.tickSizeNanos,
  subscriptions: {
    'effort-zones': { events: await Array.fromAsync(rest.history({ ...range, indicator: 'effort-zones' })) },
    'big-trades': { events: await Array.fromAsync(rest.history({ ...range, indicator: 'big-trades', params: { minimum: 30 }, mode: 'confirmed' })) },
  },
};
```

Then run the rest of `replay.ts` on `sample`. With the Sandbox rights, history covers the last 7 days only; September 24, 2026 requires the [Historical](https://fathomcharts.com/docs/billing.md) plan. Each query is [charged](https://fathomcharts.com/docs/history.md#what-drives-the-cost) in compute units: [estimate](https://fathomcharts.com/docs/history.md#estimate-the-cost) a long period first.

## Go live

`live.ts` opens both subscriptions on one connection and hands their events to the same strategy.

**live.ts**

```ts
import { FathomChartsStream, type SubscribeOptions } from '@fathom-charts/sdk';
import { ZoneRetest, type Update } from './strategy.js';

const stream = new FathomChartsStream({
  url: 'wss://stream.fathomcharts.com/v1',
  apiKey: process.env.FATHOM_CHARTS_API_KEY!,
  onStateChange: (state) => console.log('connection', state),
});

let tick = 0;
const price = (ticks: number) => (ticks * tick).toFixed(2);

const strategy = new ZoneRetest(
  (e) => {
    console.log(`${e.side.toUpperCase()} ${price(e.price)}, stop ${price(e.stop)}, target ${price(e.target)}`);
    // Send the order to your broker here: your code, your account, your risk.
  },
  (e) => console.log(`${e.reason} at ${price(e.price)}: ${e.ticks > 0 ? '+' : ''}${e.ticks} ticks`),
);

async function follow(indicator: Update['indicator'], options: Omit<SubscribeOptions, 'instrument' | 'indicator'>) {
  for await (const event of stream.subscribe({ instrument: 'NQ.front', indicator, ...options })) {
    switch (event.type) {
      case 'subscribed': tick = Number(event.tickSizeNanos) / 1e9; break;
      case 'snapshot': strategy.load(indicator, event.items); break;
      case 'upsert': strategy.update({ indicator, id: event.id, ts: event.ts, data: event.data } as Update); break;
      case 'remove': strategy.remove(indicator, event.id); break;
      // A snapshot follows a reset: load() then replaces the state.
    }
  }
}

await Promise.all([
  follow('effort-zones', { mode: 'live' }),
  follow('big-trades', { params: { minimum: 30 }, mode: 'confirmed' }),
]);
```

```bash
FATHOM_CHARTS_API_KEY=fc_test_… npx tsx live.ts
```

- **Large orders in `confirmed` mode.** The bot only needs final groups: it receives each one once, at the end of its burst.
- **Effort Zones in `live` mode.** A zone is published in progress as soon as it is created, so the bot can act on it before it ends. Bars are always published once, final, at their close.
- **Reconnections.** The SDK resumes from the last cursor on its own. After a `reset`, a new snapshot follows and `load()` replaces the state.
- **A test key** receives the stream delayed by 10 minutes: enough to check that the bot behaves, not to trade. The real-time stream requires the [Live](https://fathomcharts.com/docs/billing.md) plan.
- **Orders.** Sending the order to your broker, its sizing and its confirmation are yours to write, where `live.ts` says so.
