Skip to content
WebSocket · one endpoint · no account

Subscribe to a channel, receive frames

ws://127.0.0.1:8420/stream the same port as REST and MCP. The socket is deliberately a subset: subscribe to a stream and receive frames. Tracking a pair, reading configuration and reaching a venue all live on REST and MCP.

Channel families
10

6 market-typed, 4 global

Client ops
5

subscribe unsubscribe list ping chart

Overview

Bad ops, bad channels and malformed JSON are all answered with an error frame, and the socket stays open. There is no reconnect storm to design around, because there is nothing to reconnect from.

One JSON object per frame, in both directions. Client frames are discriminated by op, server frames by ev. Binary frames are refused. Any upgrade path other than /stream is refused with the same envelope REST uses, so a wrong URL tells you it was wrong rather than hanging.

Everything is lazy. A channel nobody is watching is never produced, and an idle socket costs nothing. Every subscriber to a channel receives byte-identical frames, so two consumers of the same stream can never disagree about what arrived.

Webhook subscriptions are not here. They live on REST and MCP, by decision a socket you have to keep open is not what a webhook is for. If you want to be told about a condition while your process is down, use a subscription; if you want a live feed while it is up, use this.

Security boundary

Local-first, not authenticatedbinds 127.0.0.1

The bind address is the security boundary

There is no account, no signup, no API key and no session. The engine is a process on your machine, and it binds to 127.0.0.1:8420. Anything that can reach the port has every channel on this page.

Treat the loopback interface as the whole of the access-control model. Do not move the bind to 0.0.0.0, onto a LAN interface, or behind a public reverse proxy. If you need the surface from another machine, tunnel it — ssh -L rather than exposing it.

Browsers get a narrower door than curl does. A request that carries an Origin header has to name an allowed origin, so a page you happen to have open in another tab is not a working client for your engine. A request with no origin curl, a server-side job, the MCP surface is unaffected. The engine can also be started behind a shared token, in which case every request carries it as X-Engine-Token and an unauthenticated call answers 401 unauthorized.

What is never returned

Exchange credential values are masked wherever configuration is echoed, and anything an exchange sends back is scrubbed before it reaches a response, a log line or an error message. A webhook signing secret is write-only in the strictest sense: no surface returns it, nothing logs it, and a subscription record reports only whether one is set.

Narrowing a session

Authentication and authorization are separate layers here: whether the socket opens at all is the question the card above answers, and what it may reach once open is the question X-PS-Scopes answers. Send it as a header on the upgrade request for /stream and the session is intersected with the grant the engine already holds. The header can only take capability away, never add it, so a key you have already narrowed stays narrowed however much the client asks for.

A session that subscribes outside its grant is answered with a SCOPE_DENIED error frame naming the scope it lacked, and the socket stays open, exactly like every other refusal on this page. The scope is checked before anything else about the frame is, so a capability you do not hold cannot be probed for by spelling.

narrow.js
const WebSocket = require('ws'); // The header is read on the UPGRADE request, so this is the last moment at// which a session can be narrowed. It can only take capability away.const ws = new WebSocket('ws://127.0.0.1:8420/stream', {    headers: { 'X-PS-Scopes': 'market.read,events.read' },}); ws.on('open', () => {    // Both of these are inside the grant above.    ws.send(JSON.stringify({ op: 'subscribe', channel: 'spot:trades:binance:BTCUSDT' }));    ws.send(JSON.stringify({ op: 'subscribe', channel: 'market-events' }));     // This one is not: { ev: 'error', code: 'SCOPE_DENIED', channel: 'orders' }    ws.send(JSON.stringify({ op: 'subscribe', channel: 'orders' }));});
The header is read once, on the upgrade. No frame renegotiates it open a second socket if you need a different grant.
Channel or opScope
flow · kline · compiledcontext.read
candles · trades · depthmarket.read
ordersorders.read
system:statusconfig.read
backtestbacktest.read
market-eventsevents.read
chartmarket.read
The last row is the chart op rather than a channel family: rendering reads a pair, so it is checked against the same scope the raw market channels are.

Ops and events

Send an op, get an ack. Four of the five are fire-and-ack; only chart is a request/response pair, which is why it is the only one that carries a correlator.

opFieldsWhat it does
subscribechannelJoin a channel. Idempotent a duplicate re-acks rather than failing
unsubscribechannelLeave a channel. Always acked, even if you were not subscribed
list—Every channel this connection currently holds
ping—Answered with a pong. The application-level liveness check
chartid?, platform, marketType, symbol, timeframe, + the chart optionsRender one PNG and reply once. The only request/response op on the socket everything else is an unsolicited push
{ "op": "subscribe", "channel": "spot:candles:binance:BTCUSDT:1m" }
{ "ev": "subscribed", "channel": "spot:candles:binance:BTCUSDT:1m" }
The ack carries the canonical channel name platform lower-cased, symbol upper-cased.
evFrameWhen
subscribed{ ev, channel }Your subscribe was accepted. The channel name is the canonical one
unsubscribed{ ev, channel, reason? }Your unsubscribe was processed, or the server retired the channel un-favoriting a pair sends one per channel it held, with reason pair-removed
channels{ ev, channels: [ … ] }The reply to list
pong{ ev }The reply to ping
update{ ev, channel, ts, data }The push. data is that channel’s payload
chart{ ev, id?, ts, data }The reply to a chart op, echoing your correlator
error{ ev, code, message, channel?, id? }Something you sent was refused. The socket stays open
closing{ ev, reason }Sent just before the server closes the socket
data carries the channel's payload the same content the matching REST route returns, in the same shape.

A real session

Open the socket and start sending. There is nothing to do first.

stream.js
const WebSocket = require('ws');const ws = new WebSocket('ws://127.0.0.1:8420/stream');const send = (frame) => ws.send(JSON.stringify(frame)); ws.on('open', () => {    send({ op: 'ping' });    send({ op: 'subscribe', channel: 'spot:candles:binance:BTCUSDT:1m' });    send({ op: 'subscribe', channel: 'spot:depth:binance:BTCUSDT' });    send({ op: 'subscribe', channel: 'spot:flow:binance:BTCUSDT' });    send({ op: 'subscribe', channel: 'system:status' });    send({ op: 'subscribe', channel: 'market-events' });}); ws.on('message', (raw) => {    const frame = JSON.parse(raw.toString('utf8'));     // Everything is discriminated by `ev`. Errors arrive here too the socket    // is not closed for a protocol mistake, so there is nothing to reconnect.    if (frame.ev === 'error') return console.error(frame.code, frame.message);    if (frame.ev === 'update') return handle(frame.channel, frame.data);});
terminal
# the same socket, from the terminal one NDJSON line per updatenode server/cli.js watch spot:candles:binance:BTCUSDT:1m,spot:depth:binance:BTCUSDTnode server/cli.js watch spot:flow:binance:BTCUSDT,system:status
The CLI that ships with the engine speaks this socket, so you can watch any channel without writing a client at all.

Channel grammar

Colon-separated, and parsed strictly. Square brackets mark an optional trailing part; anything else is exact, so a stray extra part is a typo rather than a narrower channel and is refused as one.

PatternCarries
{spot|futures}:flow:{platform}:{symbol}[:{section}]Live orderflow. Without a section you get the unfiltered stream; with one you get only that read’s updates
{spot|futures}:kline:{platform}:{symbol}:{timeframe}[:{section}]Structural analysis, one frame per closed bar. With a section, that read alone
{spot|futures}:compiled:{platform}:{symbol}[:{kline|orderflow}]The whole compiled context on a timer. The optional part takes one half of it
{spot|futures}:candles:{platform}:{symbol}:{timeframe}Raw OHLCV bars
{spot|futures}:trades:{platform}:{symbol}Executed prints, in batches
{spot|futures}:depth:{platform}:{symbol}Order-book snapshots on a timer
system:statusEngine lifecycle events the one two-part channel
ordersOrder records as they change. Global, because a record carries its own market type
backtestBacktest jobs as they run. Global, because a job carries the market it was run on
market-eventsScored market events as they are detected. Global, because an event carries its own pair
An invalid channel earns an error frame that quotes this whole grammar back to you.

Channels are canonicalised on parse platform lower-cased, symbol upper-cased and the ack carries the canonical name, so two spellings of one channel can never split into two registrations on your connection.

Market-typed channels serve tracked pairs only. Subscribing to a pair the engine is not watching earns not_favorited rather than a silent empty stream, and a kline or candles channel on an untracked timeframe earns unknown_timeframe with the tracked ones listed.

The global channels — orders, backtest and market-events carry no market type and no pair, so the tracked-pair rule does not apply to them at all; each is gated by a capability scope instead. They are also the only single-part channel names, which is why none of them takes a prefix.

1m3m5m15m30m1h4h1d1w

Three cadences

The difference matters more than anything else on this page: it decides how often your handler runs and how fresh the value in it is.

  • Event-push — flow, kline, candles, trades, orders, backtest, market-events. An engine emit becomes a frame. Orderflow pushes as the market moves; a kline channel fires on closed bars only, one frame per bar close; candles pushes the in-progress bar on a throttle and every closed bar the moment it closes.
  • Timer-poll — compiled and depth. Nothing upstream emits a whole compiled context or a whole book, so each is read on its own interval while it has subscribers.
  • Lifecycle — system:status. Engine events as they happen, so a client can tell a quiet market from a degraded feed.

A sectioned channel is a server-side filter, not a different stream: subscribing to one read gives you exactly the frames the unfiltered channel would have carried for that read, and nothing else. Subscribe to both and you get both the filter does not remove anything from the unfiltered channel.

Module channels

Two of the global channels are pushed by the engine’s own feature modules rather than by a market feed. Neither is about one pair, both open with a snapshot of what the engine is already holding, and each is gated by its own scope.

ChannelScopeCarries
backtestbacktest.readEvery status change and every progress patch, for every backtest job on this engine
market-eventsevents.readEvery market event the detector scores, and every re-score of one you have already been sent
Neither name takes a market-type prefix, and neither is checked against your tracked pairs the scope is the only gate.
{ "op": "subscribe", "channel": "backtest" }
{ "ev": "update", "channel": "backtest", "ts": 1786324564292,  "data": { "jobs": [ … ] } }{ "ev": "update", "channel": "backtest", "ts": 1786324566431,  "data": { "jobId": "…", "status": "running", "progress": { … } } }
The first frame is the snapshot; every frame after it is one job. The percentage never goes backwards, so it is safe to drive a progress bar with it directly.

Subscribing sends exactly one snapshot frame, and that frame is bounded: the most recent jobs on backtest, the most recent events on market-events. It is a starting position, not a history ask the matching REST route for anything older, and treat every frame after it as a patch to what you are already holding.

On the events channel the type field discriminates the frame: it is event for one the detector has just scored, and event-update when a repeat re-scores an event you already have, so a client keyed by event id updates in place rather than showing the same event twice.

Job statuses

queuedrunningdonefailedcancelled

A job frame carries one of these, plus a coarse phase name meant for display. The terminal ones are done, failed and cancelled: a job that reaches one of those sends nothing further, so it is safe to stop watching it.

Read keys

sectionpath

Read keys are enumerated by the engine, live

A pair carries sixteen structural reads per tracked timeframe and eight live orderflow reads. Each is addressable on its own, and every parameter spelled section takes one of those keys.

A flow channel on this socket accepts one further key that has no REST or MCP counterpart a live-only filter with no snapshot behind it so the set it takes is one wider than the eight above. The error frame enumerates whichever set the channel you named actually accepts.

This page does not print the key list, because the engine already publishes it and a copy here would be the stale one. To see the keys a pair exposes right now, read one compiled context over REST, or subscribe with a deliberate typo and read the error frame. Both answers come from the running engine, so they are correct for the version you actually have.

The chart op

Every other frame the server sends is an unsolicited push. A chart is a question, so its answer echoes your correlator and you can keep two of them apart.

The op takes the same parameters as the REST chart route — platform, marketType, symbol, timeframe, plus optional width, height, style, theme, indicators, bars and showVolume. Add id and it comes back on the reply; omit it and the reply is uncorrelated.

A render never blocks the rest of your socket: ops already in flight are answered first, and a render that outlives the connection that asked for it is discarded rather than written anywhere. A connection that asks for more renders than it is allowed to have in flight gets too_many_requests, not a dropped socket.

Charts on three surfaces

REST, MCP and this socket all render the same chart from the same code path. The CLI does not a terminal client has nowhere to put a PNG.

Error frames

Every refusal is an error frame on an open socket. Switch on code, never on message text.

The API is its own reference

Errors name the field and enumerate the valid set

Nothing is silently substituted, clamped or ignored. A parameter outside its allowed set is refused, and the refusal carries the offending field name followed by every value that would have worked. A typo in a timeframe, a style, a read key or an operator is therefore one request away from an answer, and you never need a hardcoded copy of an enum to write a correct client.

The same discipline applies to defaults: omit an optional parameter and the engine applies its own configured value. Send a bad one and it tells you. It will not quietly pick something near what you asked for.

CodeWhen
invalid_channelThe channel string matches no pattern. The message quotes the whole grammar
not_favoritedThe pair is not tracked, so there is no stream behind that channel
unknown_timeframeThe pair is tracked but not on that timeframe. The message lists the ones it does track
unknown_sectionThe optional section part is not a read key of that channel family. The message names the family and lists the valid keys
unknown_includeThe optional part of a compiled channel is neither kline nor orderflow
subscription_limitThis connection is at its channel ceiling
SCOPE_DENIEDThis session was narrowed and does not hold the scope that channel or op requires. The message names the scope it wanted
invalid_jsonThe frame was not valid JSON
invalid_messageThe frame was binary, or JSON but not an object
unknown_opop is not one of subscribe, unsubscribe, list, ping, chart
invalid_requestA chart op was malformed a bad correlator, a bad enum, an out-of-range dimension
not_foundA chart op named a pair with nothing to draw
too_many_requestsThis connection already has its maximum chart renders in flight
timeoutA chart op exceeded its deadline while sourcing bars
render_failedThe chart renderer failed unexpectedly
internal_errorMessage processing failed unexpectedly
16 codes. An error frame carries channel when the mistake was about a channel, and id when it was about a chart op.

Connection behaviour

The limits exist and the engine tells you about them, in the frame that names them. Nothing here is silent.

  • Ceilings on what you ask for are reported, not enforced by disconnect. A connection has a maximum number of channels and a maximum number of chart renders in flight. Crossing either earns an error frame that states the limit; the socket stays open and the subscriptions you already hold are untouched.
  • The one ceiling that does close a socket is on what you fail to read. A consumer that stops draining its side while frames keep arriving is dropped with close code 1013 and the reason slow-consumer, rather than being allowed to grow the engine’s heap on its behalf. Two other closes are routine and both name themselves the same way: 1012 listener-moved when the engine rebinds its listener, and 1001 server-shutdown when it is going down. Reconnect on all three.
  • Heartbeats are the server’s job. The server pings on an interval and drops a connection that stops answering. Your own ping op is a separate, application-level check you can run whenever you want proof the far end is processing frames rather than merely holding a socket open.
  • Shutdown is announced. Before the server closes a socket it sends a closing frame with a reason, so a client can tell an intentional shutdown from a network failure and back off accordingly.
  • Reconnect and re-subscribe. Subscriptions are per-connection and are not restored for you. On reconnect, replay your subscribe frames; they are idempotent, so it is safe to send the full set every time.
  • A degraded engine still answers. If an upstream feed drops, the socket stays up and system:status says so. You are never left guessing whether silence means a calm market or a broken pipe.

Four surfaces, one port

Coverage is deliberately uneven, and flattening it would be the easiest thing on this page to get wrong. Market context is on all four. Chart rendering is on three a terminal client has nowhere to put a PNG. Webhook subscriptions are on two, REST and MCP, by decision: a socket you have to keep open is not what a webhook is for.

ws://127.0.0.1:8420/stream

WebSocket subscribe and push. 10 channel families, 5 client ops, 8 server events.

You are here

node server/cli.js

CLI a terminal client over REST and /stream. 11 commands, payload on stdout.

Ships with the engine