Trading bot
A Node.js bot that reads two subscriptions, Effort Zones and Big Trades, 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
npm install @fathom-charts/sdk ws
npm install -D tsx
npx fathom-charts-types https://api.fathomcharts.com/v1/indicators fathom-charts-types.tsThe last command generates the 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.
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 (47 KB, described on Chart integration) 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 of the same instrument compare across indicators.
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);
}npx tsx replay.ts13:21:38 SELL 30743.75, stop 30763.00, target 30705.25 (zone:2026-09-20:4431)
13:27:05 target at 30705.25: +154 ticksAt 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 instead of the file: history() yields the same elements as the events of the sample.
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 plan. Each query is charged in compute units: estimate a long period first.
Go live
live.ts opens both subscriptions on one connection and hands their events to the same strategy.
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' }),
]);FATHOM_CHARTS_API_KEY=fc_test_… npx tsx live.ts- Large orders in
confirmedmode. The bot only needs final groups: it receives each one once, at the end of its burst. - Effort Zones in
livemode. 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 andload()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 plan.
- Orders. Sending the order to your broker, its sizing and its confirmation are yours to write, where
live.tssays so.