History
Compute an indicator over a past range, with the same parameters as in real time. You get exactly what the real-time stream published at that moment.
History requires the Historical plan, or the Sandbox rights limited to the last 7 days (a test key, or a live key without a plan), and a key with the history scope. With the Live plan alone, it is rejected with 403 FORBIDDEN.
Make a query
curl https://api.fathomcharts.com/v1/indicator-queries \
-H "Authorization: Bearer $FATHOM_CHARTS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"instrument":"NQZ6","indicator":"big-trades","params":{"minimum":30},"from":"1790256600000000000","to":"1790258100000000000"}'| Field | Required | Description |
|---|---|---|
instrument | yes | A specific contract (NQZ6) or the front month (NQ.front), as in real time. Case-sensitive: nqz6 is rejected with 404 UNKNOWN_INSTRUMENT. An expired contract (NQU6) can still be queried. A symbol designates its most recent contract (symbols come back every 10 years: NQZ6 is the December 2026 contract); over a past range, an alias designates the contract it pointed to then. |
indicator | yes | The indicator id from the catalog. |
params | no | The indicator parameters, exactly as in real time. |
from, to | yes | Start (inclusive) and end (exclusive) of the range: a timestamp in nanoseconds since January 1, 1970 UTC, as a string. The range is bounded by the time our servers received the trades (reception time), which trails their market time (ts) by a few milliseconds. Here, 13:30 to 13:55 UTC on September 24, 2026. |
mode | no | live (default): every version published live, intermediate ones (final: false) included. confirmed: only the final versions (final: true). As on the WebSocket, at the same cost. |
snapshot | no | true: the first page also carries the state of the objects at from. See State at the start of the range. |
limit | no | Maximum number of results per page, from 1 to 10,000. Default: 1,000. A page is also bounded in size: with large objects, it holds fewer (see Next pages). |
cursor | no | To get the next page: the next value of the previous page. |
untilCursor | no | Stops the query at this cursor, inclusive, before to if needed. |
from and to must be Unix nanoseconds, from 1000000000000000000 to 18446744073709551615, with from not in the future and to after from. Otherwise the query is rejected with 400 INVALID_PARAMETERS, and errors gives the field and the rule ({"path":"/from","message":"must be Unix nanoseconds, from 1000000000000000000 to 18446744073709551615"}, must not be in the future, must be after from). A value in seconds or milliseconds is therefore rejected right away.
The query is rejected with 403 FORBIDDEN if your plan does not cover the requested range: no history included, a range older than your plan allows, or a range ending within the last 10 minutes while your data is delayed. It is rejected with 402 NOT_COVERED if the data itself is not available: see Available range.
Read the response
{
"instrument": "NQZ6",
"tickSizeNanos": "250000000",
"items": [
{"cursor":"20720.111416.0","t":"upsert","id":"1790256600533489285:0","final":false,"ts":"1790256600533489285","data":{"side":"sell","volume":31,"trades":15,"start":"1790256600533489285","end":"1790256600533489285","first":122041,"last":122027,"price":122027,"vwap":122034.25806451614}},
{"cursor":"20720.111417.0","t":"upsert","id":"1790256600533489285:0","final":false,"ts":"1790256600533489285","data":{"side":"sell","volume":34,"trades":16,"start":"1790256600533489285","end":"1790256600533489285","first":122041,"last":122026,"price":122026,"vwap":122033.5294117647}}
],
"next": null,
"computeUnits": 46811
}Above, the first two of the 323 items of this range (next is null: everything fits in one page).
instrument: the contract actually read. With an alias such asNQ.front, it is the contract the alias designated atfrom(the front month at that time), not today’s. The whole range reads that contract, even if it crosses a rollover: for a long range, make one query per contract;tickSizeNanos: the tick size of that contract in billionths, as a string ("250000000"= 0.25);items: the updates, in order, in the same shape as in real time;next: send it back to get the next page, ornullonce everything has been served;computeUnits: what this page cost against your compute budget.
Each item holds:
| Field | Content |
|---|---|
t | upsert (the object is created or entirely replaced) or remove (it is deleted). |
cursor | The position of the update in the stream, the same as in real time. See Cursors. |
id | The stable id of the object. |
final | true for the last version of the object, false while it can still change. |
ts | The market time of the trade that produced this version, in nanoseconds, as a string. Since the range is bounded by reception time, the first items can have a ts a few milliseconds before from. A version produced by a session open (closing bar of the previous session, objects completed by the Sunday reset) carries the time of the first trade of that open, often exactly 18:00:00.000000000 New York time. |
data | The object data, described on the indicator page. Absent from a remove. |
In live mode, history contains every version published live, including intermediate ones (final: false). Above, the cluster of sell trades shows up as soon as it crosses the 30-contract threshold, then is republished on every trade that extends it, up to its final version (66 contracts, final: true). If you only care about the finished result, send "mode": "confirmed": you only receive the final versions, at the same cost. An object still in progress at to then does not appear: its final version comes after to.
Objects that started before from are computed exactly as they were live, at no extra cost. Such an object only appears in items if it changes during the range: a zone opened before from and updated during it appears exactly as it did live, a zone opened before from and left unchanged does not. To get those objects as well, ask for the state at the start of the range.
An empty list (items: [] and next: null) means the computation ran and found nothing in the range, for example no big trade above the threshold. It is never a disguised error: if data is missing you get 402 NOT_COVERED; on 409 WARMING, retry in a few seconds.
State at the start of the range
With "snapshot": true, the first page also carries a snapshot field: the state of the objects at from, like the snapshot of a subscription.
{
"instrument": "NQZ6",
"tickSizeNanos": "250000000",
"snapshot": { "cursor": "20720.110992.0", "items": [ … ] },
"items": [ … ],
"next": null,
"computeUnits": 46811
}snapshot.items: the objects in progress atfrom, those that can still change or be removed (for example all thekey-levelslevels), then the last 32 completed objects of the period of the last trade beforefrom, exactly as in a subscription snapshot, shaped{id, final, ts, data}(not, nocursor). Inconfirmedmode, only the objects in their final version;snapshot.cursor: the position of this state in the stream, that is the cursor of the last update published beforefrom, as for a subscription snapshot. Theitemsthat follow all come after it.
Start from the snapshot, then apply the items in order: you get exactly the state a client subscribed live had. Later pages carry no snapshot. The snapshot is free. With the SDK, history() first yields the snapshot as { t: 'snapshot', cursor, items }, then the updates.
Next pages
As long as next is not null, send the same query again with "cursor": "<value of next>".
- A page can hold fewer than
limitresults, or none at all, before the range is done: onlynext: nullmarks the end. A page is also bounded in size: onfootprint, where every bar carries all its levels, a page requested withlimit: 10000comes back with about 5,400 results. Always follownext. nextis signed: it is only valid for your account and for the same query (sameinstrument,indicator,params,from,to,modeanduntilCursor). Otherwise the query is rejected with400 INVALID_PARAMETERS.limitmay change from one page to the next.- After an update to how the indicator is computed, a
nextreceived before it is rejected with410 CURSOR_EXPIRED: start over from the beginning.
With the SDK, FathomChartsRest walks the pages for you. history() yields results one at a time, as you read them (and pages() yields one page at a time):
import { FathomChartsRest } from '@fathom-charts/sdk';
import type { BigTradesData } from './fathom-charts-types.js';
const rest = new FathomChartsRest({ baseUrl: 'https://api.fathomcharts.com', apiKey: process.env.FATHOM_CHARTS_API_KEY! });
const query = {
instrument: 'NQZ6',
indicator: 'big-trades',
params: { minimum: 30 },
from: '1790256600000000000',
to: '1790258100000000000',
};
for await (const m of rest.history<BigTradesData>(query)) {
if (m.t === 'upsert' && m.final && m.data) console.log(m.cursor, m.data.side, m.data.volume);
}On error, the SDK throws a FathomChartsError carrying the error code. When you hit the rate limit, it waits and retries automatically.
Cursors
The cursor of an item is a string <utcDay>.<rank>.<k>: the UTC day of the trade (by its reception time), its rank within that day, then the rank of the update for that trade. These are the same cursors as in real time.
- To compare two cursors, compare their three numbers one by one. They are unsigned 32-bit integers: read them as a
numberor aBigInt, never as a signed 32-bit integer (int32), and never compare them as strings. k = 4294967295(the largest value) marks the position after every update of that trade. You will see it, for example, in the cursor of a snapshot when nothing has been published yet since the start of the computation period.- Only use cursors received from the API: an altered cursor is not always detected.
The next of a page is something else entirely: a signed string, tied to your account and your query. Do not alter it or build it: send it back as is.
Estimate the cost
Every query is charged against your monthly compute budget. Before every query, ask for an estimate: same request body, nothing is charged. limit, snapshot and cursor are accepted and ignored; untilCursor is taken into account (an untilCursor before from gives 0 compute units).
curl https://api.fathomcharts.com/v1/indicator-queries/estimate \
-H "Authorization: Bearer $FATHOM_CHARTS_API_KEY" \
-H "Content-Type: application/json" \
-d '{"instrument":"NQZ6","indicator":"big-trades","params":{"minimum":30},"from":"1790256600000000000","to":"1790258100000000000"}'{
"instrument": "NQZ6",
"tickSizeNanos": "250000000",
"trades": 46811,
"computeUnits": 46811,
"coveredFrom": "1790121600000000000",
"coveredTo": "1790258100000000000",
"missingDays": [],
"remainingComputeUnits": 19999953189
}computeUnits: the cost of the query, that is the number of trades in the range (trades) multiplied by the indicator’s cost per trade (computeUnitsPerTradein the catalog, currently 1 for every indicator);remainingComputeUnits: what is left of this month’s budget, before this query (here, after the query of the previous example);coveredFrom,coveredTo: the data used, which starts beforefromwhen the indicator needs earlier trades (here from September 23, 00:00 UTC). For a weekly indicator,coveredFromis the Saturday 00:00 UTC that starts the week of the last trade beforefrom: the computation starts over from the start of that trading week. Whenfromfalls in a market closure, or at the Sunday reopen before its first trade, it is therefore the previous week’s Saturday: the first trade of the reopen still publishes the end of the previous week (Friday’s last bar and the objects it completes). Only the requested range, fromfromtoto, is charged;missingDays: the days the query would fail on with402 NOT_COVERED(a day not over yet, or before the start of our data). The estimate is not refused when the computation needs to read back days before the start of history: it lists them here. If the range touches the current UTC day, that day is always listed: the estimate does not cover it;instrument,tickSizeNanos: the contract that would be read and its tick size, as in a response.
The estimate’s computeUnits is exactly what the query will charge you. On a range never requested before, the estimate can take a few seconds. mode is accepted and does not change the cost.
With the SDK: await rest.estimate(query).
A query that would cost more than your remaining budget is rejected before it starts, with 429 QUOTA_EXCEEDED. The Retry-After header tells you when your budget renews.
What drives the cost
A query costs the number of trades in the requested range (from from included to to excluded, by reception time) multiplied by the indicator’s cost per trade (computeUnitsPerTrade in the catalog, currently 1 for every indicator). A range without trades, a Saturday for example, costs nothing. What precedes from and the indicator needs (the start of the week for a weekly indicator, for example) is never charged.
- The cost follows the requested range. The first 25 minutes of the US cash session (from 09:30 New York time) on September 24, 2026 on NQZ6 hold 46,811 trades: 46,811 compute units on
big-tradesas onfootprint, however much history the indicator needs beforefrom. - Order of magnitude: NQ trades roughly 300,000 to 1 million times per trading day. A full day therefore costs 300,000 to 1 million compute units.
- On the Sandbox, the month’s 50 million compute units cover about 50 to 165 trading days at 1 compute unit per trade: far more than the 7 days available, on several indicators. Estimate before every query: it is free and gives the exact cost.
- Response time: a short range on a weekly indicator answers more slowly late in the week than early in the week, without costing more.
- A live WebSocket subscription (
from: "live") never costs anything, even while the computation is warming up (warming). Of a subscription withfrom: {"time": …}, only the part before midnight UTC today is charged, like a historical query over the same range; the part of the current day costs nothing (see Starting in the past).
Available range
Historical queries and exports cover the completed days, in UTC: a day becomes available after midnight UTC (20:00 in New York in summer, 19:00 in winter). A range that touches the current day, or that needs data before the start of our history, is rejected with 402 NOT_COVERED, and the message gives the limit served, the same over REST and on the WebSocket:
the history of NQZ6 starts on <day>: <day> is before it: the range starts before the start of the contract’s history;<indicator> with these parameters replays NQZ6 from <origin> to serve <day>: the history of NQZ6 starts on <day>: the range is covered, but the computation needs to read back days before the start of history (for examplekey-levelswith manydays,weeksormonths);the history of NQZ6 has no data for <day>, …, N more: days are missing inside the history (at most 10 are named);<day> is not closed yet: history serves trades up to <RFC 3339 time>: the day is not over yet.
A symbol designates its most recent contract: NQZ6 is the December 2026 contract, and a range before its history is rejected right away with the starts on message, even though an older NQZ6 (December 2016) existed. 402 NOT_COVERED means the data is not available; 403 FORBIDDEN, that your plan does not cover the request (depth, delay, missing plan). To include the current day, subscribe on the WebSocket with from: {"time": …}: the stream chains history and live data with no gap (see Starting in the past).
Depth depends on your plan: the last 7 days on the Sandbox (from within the 7 × 24 hours before the request), all of our history with the Historical plan, no history with the Live plan alone. The estimate lists in missingDays the days that would not be served. Each contract has its own history start date, given by the 402 NOT_COVERED message; expired contracts can still be queried, even though they no longer appear in GET /v1/instruments (see Instruments).
Hand off to real time
To load the past and then follow the market with no gap and no duplicate, you have two options:
- the simplest: subscribe on the WebSocket with
from: {"time": "<start>"}. You receive the past, then live data, back to back. See Starting in the past; - in two steps: load the completed days over REST, with
toat midnight UTC today, then subscribe on the WebSocket withfrom: {"time": "<that same instant>"}. The snapshot gives the state at that instant, then the stream replays the current day and continues live. The current day always goes through the WebSocket: REST history stops at midnight UTC.