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.
6 market-typed, 4 global
subscribe unsubscribe list ping chart
On this page
Overview
A protocol mistake never disconnects you
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
19 capability scopes · a session narrows, never widens
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.
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' }));});| Channel or op | Scope |
|---|---|
| flow · kline · compiled | context.read |
| candles · trades · depth | market.read |
| orders | orders.read |
| system:status | config.read |
| backtest | backtest.read |
| market-events | events.read |
| chart | market.read |
Ops and events
5 client ops · 8 server 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.
| op | Fields | What it does |
|---|---|---|
subscribe | channel | Join a channel. Idempotent a duplicate re-acks rather than failing |
unsubscribe | channel | Leave 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 |
chart | id?, platform, marketType, symbol, timeframe, + the chart options | Render 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" }| ev | Frame | When |
|---|---|---|
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 |
A real session
No handshake, no auth frame, no subscribe protocol to negotiate
Open the socket and start sending. There is nothing to do first.
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);});# 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:statusChannel grammar
10 families
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.
| Pattern | Carries |
|---|---|
{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:status | Engine lifecycle events the one two-part channel |
orders | Order records as they change. Global, because a record carries its own market type |
backtest | Backtest jobs as they run. Global, because a job carries the market it was run on |
market-events | Scored market events as they are detected. Global, because an event carries its own pair |
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.
Three cadences
Push, bar-close, timer
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; aklinechannel fires on closed bars only, one frame per bar close;candlespushes the in-progress bar on a throttle and every closed bar the moment it closes. - Timer-poll —
compiledanddepth. 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
Job progress and scored events, on the same socket
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.
| Channel | Scope | Carries |
|---|---|---|
backtest | backtest.read | Every status change and every progress patch, for every backtest job on this engine |
market-events | events.read | Every market event the detector scores, and every re-score of one you have already been sent |
{ "op": "subscribe", "channel": "backtest" }{ "ev": "update", "channel": "backtest", "ts": 1786324564292, "data": { "jobs": [ … ] } }{ "ev": "update", "channel": "backtest", "ts": 1786324566431, "data": { "jobId": "…", "status": "running", "progress": { … } } }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
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
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
The one question this socket answers
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.
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
Answered, never punished
Every refusal is an error frame on an open socket. Switch on code, never on message text.
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.
| Code | When |
|---|---|
invalid_channel | The channel string matches no pattern. The message quotes the whole grammar |
not_favorited | The pair is not tracked, so there is no stream behind that channel |
unknown_timeframe | The pair is tracked but not on that timeframe. The message lists the ones it does track |
unknown_section | The optional section part is not a read key of that channel family. The message names the family and lists the valid keys |
unknown_include | The optional part of a compiled channel is neither kline nor orderflow |
subscription_limit | This connection is at its channel ceiling |
SCOPE_DENIED | This session was narrowed and does not hold the scope that channel or op requires. The message names the scope it wanted |
invalid_json | The frame was not valid JSON |
invalid_message | The frame was binary, or JSON but not an object |
unknown_op | op is not one of subscribe, unsubscribe, list, ping, chart |
invalid_request | A chart op was malformed a bad correlator, a bad enum, an out-of-range dimension |
not_found | A chart op named a pair with nothing to draw |
too_many_requests | This connection already has its maximum chart renders in flight |
timeout | A chart op exceeded its deadline while sourcing bars |
render_failed | The chart renderer failed unexpectedly |
internal_error | Message processing failed unexpectedly |
Connection behaviour
What the socket does when things go wrong
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
1013and the reasonslow-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-movedwhen the engine rebinds its listener, and1001 server-shutdownwhen 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
pingop 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
closingframe 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:statussays 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