Temps réel (WebSocket)
Une seule connexion WebSocket peut porter plusieurs subscriptions. Pour chacune, vous recevez d’abord l’état actuel (le snapshot), puis chaque changement, dans l’ordre. Après une coupure, vous reprenez exactement là où vous en étiez.
Le SDK TypeScript gère pour vous tout ce qui est décrit sur cette page : snapshot, reprise, doublons et reconnexion. Lisez la suite si vous écrivez votre propre client, ou pour comprendre ce qui se passe.
Se connecter
wss://stream.fathomcharts.com/v1- Depuis un serveur : ajoutez le header
Authorization: Bearer <votre clé>à l’ouverture. - Depuis un navigateur : ouvrez la connexion sans header, puis envoyez un stream token dans un message
auth, en premier, dans les 5 secondes.
Tous les messages, dans les deux sens, sont des objets JSON dont le champ t donne le type.
S’abonner
{
"t": "subscribe",
"sub": "bt",
"instrument": "NQ.front",
"indicator": "big-trades",
"params": { "minimum": 30 },
"mode": "live",
"from": "live"
}| Champ | Rôle |
|---|---|
sub | Nom que vous donnez à la subscription (1 à 64 bytes), unique sur la connexion. Chaque message qui le concerne le reprend. |
instrument | Contrat précis (NQZ6) ou contrat front month (NQ.front). Liste complète : GET /v1/instruments. |
indicator | Identifiant de l’indicateur dans le catalogue. |
params | Paramètres de l’indicateur. Ceux que vous omettez prennent leur valeur par défaut. |
mode | live (par défaut) : les objets en cours et terminés. confirmed : seulement les objets terminés. |
from | "live" (par défaut) : à partir de maintenant. {"cursor": "…"} : reprise après une coupure. {"time": "…"} : à partir d’un instant passé. |
Pour arrêter : {"t":"unsubscribe","sub":"bt"}. Aucune confirmation n’est envoyée ; ignorez les messages de cette subscription encore en route.
Si la subscription est refusée, vous recevez un message error qui porte son sub. La connexion et vos autres subscriptions continuent normalement.
{"sub":"bt","t":"error","code":"INVALID_PARAMETERS","message":"params.minimum: must be between 1 and 1000000"}Causes les plus fréquentes : paramètre invalide ou nom sub déjà pris (INVALID_PARAMETERS), indicateur ou instrument inconnu (UNKNOWN_INDICATOR, UNKNOWN_INSTRUMENT), droit manquant (FORBIDDEN), limite de votre offre atteinte (QUOTA_EXCEEDED). Toutes les causes sont sur la page Erreurs. Une erreur peut aussi survenir plus tard sur une subscription déjà acceptée : elle est alors fermée.
Ce que vous recevez
Chaque subscription commence toujours de la même façon :
subscribed: la subscription est acceptée ;snapshot: l’état de départ, c’est-à-dire les objets en cours et les derniers objets terminés (32 au plus) ;status: l’état du stream (en direct, marché fermé…) ;- puis un
upsertou unremoveà chaque changement, dans l’ordre.
Exemple réel : le footprint en barres d’une minute (timeframe: 60, groupTicks: 4) sur NQZ6, en mode confirmed, abonné juste avant l’ouverture du 24 septembre 2026. Le snapshot est vide, puis chaque barre arrive à sa clôture (ici celle de 13:33 UTC) :
{"sub":"fp","t":"subscribed","topic":"77ca0a9221a93f7eadd15e77f62cec69","cursor":"20720.111120.0"}
{"sub":"fp","t":"snapshot","cursor":"20720.111120.0","items":[]}
{"sub":"fp","t":"status","state":"live","lagMs":4}
{"sub":"fp","cursor":"20720.124235.0","t":"upsert","id":"1790256780000000000","final":true,"ts":"1790256840033077853","data":{"levels":[[122008,14,3],[122012,44,31],[122016,31,37],[122020,16,32],[122024,48,24],[122028,26,24],[122032,40,28],[122036,24,75],[122040,35,46],[122044,14,32],[122048,12,14],[122052,12,9],[122056,20,9],[122060,36,38],[122064,61,58],[122068,29,68],[122072,28,37],[122076,19,20],[122080,30,46],[122084,32,29],[122088,39,42],[122092,87,67],[122096,154,125],[122100,151,163],[122104,149,192],[122108,66,99],[122112,50,81],[122116,22,38],[122120,27,42],[122124,39,72],[122128,10,25],[122132,0,8]],"poc":122104}}En mode live, le snapshot contiendrait la barre en cours (final: false), et vous recevriez une nouvelle version de cette barre à chaque trade.
Pour tenir votre état à jour :
- à la réception du
snapshot, remplacez tout votre état local pour cette subscription par sesitems; - à chaque
upsert, remplacez l’objetidpar celui reçu, ou ajoutez-le. Unupsertcontient toujours l’objet complet : une barre footprint contient tous ses niveaux, pas seulement ceux qui ont changé ; - à chaque
remove, supprimez l’objetid; - un objet
final: falsepeut encore changer ; un objetfinal: trueest terminé.
Si le calcul demandé n’est pas encore prêt (une configuration que personne n’utilisait, par exemple), vous recevez un status warming juste après subscribed, et le snapshot arrive dès que le calcul est à jour. Rien n’est envoyé tant qu’il n’est pas exact.
Liste des messages
Messages que vous envoyez :
Type (t) | Champs | Rôle |
|---|---|---|
auth | token | Authentifie une connexion ouverte depuis un navigateur, avec un stream token. Premier message, dans les 5 s. |
subscribe | sub, instrument, indicator, params, mode, from | Ouvre une subscription. |
unsubscribe | sub | Ferme une subscription. |
ping | — | Le serveur répond pong. Facultatif. |
Messages que vous recevez :
Type (t) | Champs | Rôle |
|---|---|---|
subscribed | sub, topic, cursor | Subscription acceptée. topic identifie le stream suivi : deux subscriptions identiques ont le même. |
snapshot | sub, cursor, items | État de départ : les objets existants, chacun avec id, final, ts et data. |
upsert | sub, cursor, id, final, ts, data | Crée l’objet id, ou le remplace entièrement. |
remove | sub, cursor, id, final, ts | Supprime l’objet id. |
status | sub, state, lagMs | État du stream : en direct (live), en préparation (warming), source retardée ou interrompue, marché fermé. |
reset | sub, reason | Votre état local n’est plus valable : jetez-le, un nouveau snapshot suit. |
error | sub, code, message | Subscription refusée ou interrompue (avec son sub), ou message incompréhensible (sans sub). |
hb | cursors | Heartbeat toutes les 5 s, avec le dernier cursor de chaque subscription. |
notice | kind | Annonce une maintenance imminente (kind: "reconnect"). |
pong | — | Réponse à ping. |
Dans les messages, les dates (ts, etc.) sont des timestamps en nanosecondes depuis le 1er janvier 1970 (UTC), transmis sous forme de string pour ne rien perdre en précision. Les prix sont en ticks ; les moyennes (un VWAP, par exemple) peuvent avoir des décimales.
Cursors et reprise
Chaque snapshot, upsert et remove porte un cursor, par exemple 20720.111434.0 : sa position dans le stream. Les cursors ne reculent jamais. Ils sont identiques pour tous les clients, en direct comme sur l’historique.
Gardez, pour chaque subscription, le dernier cursor reçu. Le heartbeat hb, envoyé toutes les 5 secondes, le fait avancer même quand il ne se passe rien :
{"t":"hb","cursors":{"bt":"20720.112164.0","fp":"20720.124235.0"}}Après une coupure, rouvrez une connexion et renvoyez le même subscribe avec ce cursor :
{"t":"subscribe","sub":"bt","instrument":"NQ.front","indicator":"big-trades","params":{"minimum":30},"mode":"confirmed","from":{"cursor":"20720.112164.0"}}- Coupure courte (jusqu’à 15 minutes environ, moins sur un marché très actif) : vous recevez
subscribed, puis directement tout ce que vous avez manqué, puis unstatus. Pas de snapshot : gardez votre état local tel quel. - Coupure trop longue : vous recevez un
reset(raisoncursor_expired) suivi d’un nouveau snapshot. Repartez de celui-ci.
Après une reprise, un message déjà traité peut vous parvenir à nouveau. Ignorez tout upsert ou remove dont le cursor est inférieur ou égal au dernier que vous avez traité.
Pour comparer deux cursors, comparez leurs trois nombres un à un, comme des entiers (le SDK fournit compareCursors). Ne les comparez jamais comme des strings (9 passerait après 10), et ne les fabriquez jamais vous-même : utilisez ceux que vous avez reçus.
Démarrer dans le passé
Avec from: {"time": "<instant>"}, vous recevez l’état des objets à cet instant, puis tout ce qui s’est passé depuis, et le stream enchaîne en direct sans interruption. C’est le moyen le plus simple de remplir un graphique avec l’historique récent avant de suivre le marché.
{"t":"subscribe","sub":"bt","instrument":"NQ.front","indicator":"big-trades","params":{"minimum":30},"from":{"time":"1790256600000000000"}}- Cela passe par l’historique : il faut l’offre Historique, ou la Sandbox sur ses 7 derniers jours. Sinon, la subscription est refusée avec
FORBIDDEN. - La partie passée est décomptée de votre compute budget, comme une requête historique (voir Limites). Si son coût estimé dépasse ce qui vous reste, la subscription reçoit une erreur
QUOTA_EXCEEDEDet elle est fermée. - Avec des données différées, le stream rejoint le direct différé : l’instant demandé doit remonter à plus de 10 minutes.
- L’historique vous est envoyé au rythme où vous le lisez.
État du stream
Un message status suit chaque snapshot et chaque reprise, puis chaque changement d’état. Seul le dernier reçu compte.
{"sub":"bt","t":"status","state":"live","lagMs":4}state | Signification |
|---|---|
live | Le stream suit le marché. lagMs donne la latence de traitement estimée, en millisecondes (hors différé de 10 minutes des données différées). |
warming | Le calcul se prépare. Rien n’est envoyé tant qu’il n’est pas exact. |
source_delayed | Les données du marché n’arrivent plus pour le moment. Reconnexion en cours, rien n’est perdu. |
source_down | Données du marché interrompues depuis plus de 30 secondes. Aucune donnée n’est inventée ; le stream reprendra là où il s’est arrêté. |
market_closed | Le marché est fermé (pause quotidienne, week-end). |
Reset
Un reset signifie que votre état local pour cette subscription n’est plus valable. Jetez-le et oubliez votre dernier cursor : un nouveau snapshot suit et devient votre nouveau point de départ.
{"sub":"bt","t":"reset","reason":"roll"}reason | Cause |
|---|---|
roll | NQ.front suit désormais un nouveau contrat (rollover). Un nouveau subscribed arrive avant le snapshot. |
cursor_expired | Votre cursor de reprise est trop ancien pour être servi, ou le serveur n’a pas pu combler une interruption de son côté. |
entitlement_changed | Votre accès est passé du différé au temps réel, ou l’inverse (début ou fin de l’offre Live). Un nouveau subscribed arrive avant le snapshot. |
engine_change | Nouvelle version du calcul, annoncée dans le changelog. |
Garder la connexion vivante
Le serveur envoie un hb toutes les 5 secondes, même sans subscription. Si vous ne recevez plus rien pendant 15 secondes, considérez la connexion comme perdue et reconnectez-vous.
Dans l’autre sens, le serveur envoie régulièrement des pings WebSocket. Les navigateurs et les bibliothèques WebSocket courantes y répondent automatiquement, tant que votre programme lit la connexion. Si le serveur ne reçoit plus rien de votre part pendant 30 secondes, il ferme la connexion (close code 4008).
Backpressure
Si votre programme lit les messages moins vite qu’ils n’arrivent, le serveur les garde dans un buffer, dans une certaine limite :
- au-delà de 256 Ko dans le buffer, le serveur n’envoie plus que la dernière version de chaque objet en cours. Comme chaque
upsertcontient l’objet complet, votre état reste exact. Les objets terminés et les suppressions ne sont jamais sautés ; - au-delà de 1 Mo, la connexion est fermée (close code
4008). Reconnectez-vous et reprenez au dernier cursor : vous recevrez ce qui manquait.
Pour l’éviter, lisez la connexion en continu et traitez les messages à part (queue, worker) plutôt que de bloquer la lecture. Un client lent ne ralentit jamais les autres.
Fermetures et reconnexion
Avant une maintenance, le serveur envoie {"t":"notice","kind":"reconnect"}, puis ferme la connexion dans les 20 secondes (close code 1012). Continuez à lire jusqu’à la fermeture, puis reconnectez-vous et reprenez au dernier cursor : vous ne perdez rien.
| Code | Nom | Cause | Que faire |
|---|---|---|---|
4001 | UNAUTHORIZED | Clé ou token invalide, expiré ou déjà utilisé, ou message auth absent ou trop tardif. | Ne reconnectez pas tant que la clé ou le token n’est pas corrigé. |
4003 | FORBIDDEN | Accès refusé : clé sans le scope stream, adresse IP non autorisée, clé révoquée, ou nouvelle offre qui ne couvre plus vos subscriptions ouvertes. | Ne reconnectez pas avec cette clé avant d’avoir corrigé ses scopes ou ses restrictions. |
4008 | SLOW_CONSUMER | Votre client ne lit plus assez vite (plus de 1 Mo en attente), ou ne donne plus signe de vie depuis 30 s (raison PING_TIMEOUT). | Reconnectez immédiatement et reprenez au dernier cursor reçu. |
4029 | TOO_MANY_CONNECTIONS | Nombre maximal de connexions simultanées de votre offre atteint, toutes clés confondues. | Fermez une autre connexion, ou regroupez vos subscriptions sur une seule. |
1012 | SERVICE_RESTART | Maintenance ou mise à jour du service, annoncée par un message notice. | Reconnectez et reprenez au dernier cursor reçu. |
1013 | TRY_AGAIN_LATER | Service momentanément saturé, ou session expirée côté serveur. | Reconnectez avec un backoff exponentiel. |
1011 | INTERNAL_ERROR | Erreur interne à l’ouverture de la connexion. | Reconnectez avec un backoff exponentiel. |
Règles de reconnexion :
- sur
4001ou4003, ne vous reconnectez pas : corrigez d’abord la clé ou ses droits ; - sur
4029, fermez une autre connexion avant de réessayer ; - sur
4008, reconnectez-vous immédiatement ; - dans tous les autres cas (coupure réseau,
1011,1012,1013, 15 secondes sans message), reconnectez-vous avec un backoff exponentiel plafonné à 30 secondes, avec du jitter pour que tous les clients ne reviennent pas en même temps. Revenez au délai initial dès qu’une subscription est acceptée ; - à chaque reconnexion, renvoyez tous vos
subscribeavecfrom: {"cursor": …}. Depuis un navigateur, demandez un nouveau stream token.
Le SDK applique ces règles. Sur 4001 et 4003, il arrête les subscriptions avec une erreur FathomChartsError (code UNAUTHORIZED ou FORBIDDEN).
Exemple complet
Un client Node 22 sans SDK : état par subscription, reprise par cursor, doublons ignorés et reconnexion.
// Node 22+: global WebSocket (undici) accepts request headers.
const URL = 'wss://stream.fathomcharts.com/v1';
const KEY = process.env.FATHOM_CHARTS_API_KEY!;
type Item = { id: string; final: boolean; ts: string; data: unknown };
type Sub = { request: Record<string, unknown>; cursor?: string; objects: Map<string, Item> };
const subs = new Map<string, Sub>([
['bt', {
request: { instrument: 'NQ.front', indicator: 'big-trades', params: { minimum: 30 }, mode: 'live' },
objects: new Map(),
}],
]);
const FATAL = new Set([4001, 4003]);
let attempt = 0;
function connect() {
const ws = new WebSocket(URL, { headers: { Authorization: `Bearer ${KEY}` } });
ws.onopen = () => {
for (const [sub, s] of subs) {
const from = s.cursor ? { cursor: s.cursor } : 'live';
ws.send(JSON.stringify({ t: 'subscribe', sub, ...s.request, from }));
}
};
ws.onmessage = (event) => {
const m = JSON.parse(String(event.data));
if (m.t === 'hb') {
for (const [sub, cursor] of Object.entries(m.cursors as Record<string, string>)) {
const s = subs.get(sub);
if (s && (s.cursor === undefined || compareCursors(cursor, s.cursor) > 0)) s.cursor = cursor;
}
return;
}
const s = m.sub === undefined ? undefined : subs.get(m.sub);
if (!s) return;
switch (m.t) {
case 'subscribed':
attempt = 0;
break;
case 'snapshot':
s.objects = new Map(m.items.map((i: Item) => [i.id, i]));
s.cursor = m.cursor;
break;
case 'upsert':
case 'remove':
// Equal-or-older cursors were already applied (duplicates after a resume).
if (s.cursor !== undefined && compareCursors(m.cursor, s.cursor) <= 0) return;
if (m.t === 'upsert') s.objects.set(m.id, { id: m.id, final: m.final, ts: m.ts, data: m.data });
else s.objects.delete(m.id);
s.cursor = m.cursor;
if (m.t === 'upsert' && m.final) handle(m.sub, m.data);
break;
case 'reset':
// A snapshot follows and sets the new reference: drop local state and cursor.
s.objects.clear();
s.cursor = undefined;
break;
case 'error':
// The server has closed this subscription: do not resubscribe it.
subs.delete(m.sub);
console.error(m.sub, m.code, m.message);
break;
}
};
ws.onclose = (event) => {
if (FATAL.has(event.code)) {
console.error('closed', event.code, event.reason);
return;
}
const ceiling = Math.min(30_000, 250 * 2 ** attempt++);
setTimeout(connect, event.code === 4008 ? 0 : ceiling / 2 + Math.random() * (ceiling / 2));
};
}
// Total order of cursors <utcDay>.<rank>.<k>: numeric comparison of the three fields.
function compareCursors(a: string, b: string): number {
const x = a.split('.').map(BigInt), y = b.split('.').map(BigInt);
for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] < y[i] ? -1 : 1;
return 0;
}
function handle(sub: string, data: unknown) {
console.log(sub, data);
}
connect();