01Data path
→ session aggregates (spot / futures / whale)
→ 1-second bucket store → rolling window sums → DOM (throttled)
The dashboard server never sees trade data. public/orderflow.html loads
public/js/orderflow.js, which opens eight public websocket connections directly from the
browser. The page therefore works on a static host as well as on Render, and the hosting tier only
serves three small static files.
One adapter per venue
Each entry in the STREAMS array declares a URL builder, an optional
onOpen subscription message and an onMessage handler. Adapters only
translate payloads into { exchange, market, side, price, qty }; all maths happens once,
inside recordTrade().
Two presentation rules live outside the maths: the scheduleRender() throttle (200 ms)
and the one-second tick() that prunes buckets, repaints windows and updates the clock.
02Feed inventory
| # | Venue | Endpoint / subscription | Bucket |
|---|---|---|---|
| 1 | Binance Spot | wss://stream.binance.com:9443/ws/{sym}@aggTrade | Spot |
| 2 | Binance USD-M | wss://fstream.binance.com/ws/{sym}@aggTrade | Futures |
| 3 | Bybit Spot | wss://stream.bybit.com/v5/public/spot · publicTrade.{SYM} | Spot |
| 4 | Bybit Linear | wss://stream.bybit.com/v5/public/linear · publicTrade.{SYM} | Futures |
| 5 | OKX | wss://ws.okx.com:443/ws/v5/public · trades on {SYM}-USDT and {SYM}-USDT-SWAP | Spot / Futures |
| 6 | Gate.io Spot | wss://api.gateio.ws/ws/v4/ · spot.trades | Spot |
| 7 | Gate.io Futures | wss://fx-ws.gateio.ws/v4/ws/usdt · futures.trades | Futures |
| 8 | Coinbase | wss://ws-feed.exchange.coinbase.com · matches on {SYM}-USD and {SYM}-USDT | Spot |
The chip “Open feeds 6/8” counts sockets in the OPEN state, not markets that actually
produced trades. A venue can be connected and still contribute nothing if the pair is not listed there.
Coinbase fallback
Coinbase rejects a subscription if any requested product is unknown, so the adapter sends
{SYM}-USD and {SYM}-USDT together and, on an error frame, resubscribes to
{SYM}-USD only. The flag resets whenever the pair changes.
03Normalisation rules
| Venue | Price | Quantity | Side source |
|---|---|---|---|
| Binance | p | q | m = true → sell (buyer is maker), otherwise buy |
| Bybit | p | v | S (Buy / Sell) |
| OKX | px | sz | side (buy / sell) |
| Gate.io spot | result.price | result.amount | result.side |
| Gate.io futures | price | |size| | sign of size (positive = buy) |
| Coinbase | price | size | side (buy / sell) |
Every adapter also handles batch frames: Bybit and OKX deliver arrays in data, Gate futures
delivers an array in result, Gate spot usually a single object. Unknown or malformed values
are dropped before they ever reach the aggregates:
Market mapping is fixed per stream, except OKX, which inspects instId for
SWAP. A single OKX socket therefore feeds both the spot and the futures bucket.
Contract size normalisation
Futures venues quote size in contracts, not coins. Valuing a contract count at the coin price would overstate the flow by one to four orders of magnitude, so the adapters convert first:
OKX swap: qty = sz × ctVal
- Gate.io: multiplier is read once per pair from
/futures/usdt/contracts/{'{'}SYM{'}'}_USDT(CORS-enabled), e.g.0.0001for BTC. - OKX:
ctValis read from the public instruments endpoint; if that host is unreachable, theinstrumentswebsocket channel is used as a fallback. - If neither source resolves a multiplier, the affected prints are dropped instead of being guessed. The Gate futures socket is then not opened at all, so the feed counter stays honest.
- Binance, Bybit, OKX spot, Gate spot and Coinbase already report quantity in the base asset and need no conversion.
Side is always taker-side
A “buy” means an aggressive buyer crossed the spread. That is the only consistent definition across venues, and it is what makes cross-exchange aggregation meaningful.
04Formulas
Per print
spot[buy|sell] += amount · futures[buy|sell] += amount
if (amount ≥ 10000) whale[buy|sell] += amount
bucket[floor(now / 1000)] += amount on the same side
Per rolling window
windowSell = Σ bucket.sell where now − bucket.t ≤ window.ms
buyShare = windowBuy / (windowBuy + windowSell) × 100
net = windowBuy − windowSell
Combined read
combinedNet = (Σ net of enabled windows) / enabledCount
empty window contributes 50% and 0 net
Cards
cardPct = buy / (buy + sell) × 100
cardVol = buy + sell
All arithmetic uses JavaScript doubles. Nothing is rounded while aggregating; rounding only happens when a value is written to the DOM, which is why the underlying ordering never drifts.
Windows are second-aligned
Because the store is bucketed per second, a window boundary lands on a whole second. The maximum difference versus an exact millisecond cutoff is one second of prints, which does not change the meaning of a 1m–1d window.
05Memory & rendering
A busy pair can print hundreds of trades per second. Keeping every print for a 24-hour window would need millions of objects, so the store is folded into one bucket per second.
| Structure | Bound | Purpose |
|---|---|---|
state.buckets | ≤ ~86,460 entries (24h + 60s) | Buy/sell totals per second, pruned on every tick. |
state.metrics | 6 numbers | Session totals for spot, futures and whales. |
state.pendingRows | 240 | Prints waiting for the next paint batch. |
| Tape DOM | 40 rows | Newest prints; older rows are removed. |
state.pulse | 24 entries | The mini bar strip beside the tape heading. |
windowSums() walks the bucket array once, backwards, and freezes a window as soon as the
cursor passes its cutoff — so nine windows cost roughly one pass, not nine. Rendering is coalesced to at
most five repaints per second (RENDER_INTERVAL_MS = 200) no matter how fast prints arrive,
and the tape is inserted as a single DocumentFragment.
Why the 1d window stays empty at first
The bucket store only learns about prints while the page is open. There is no historical backfill, so a 1d window fills gradually and needs a genuinely long, visible session.
06Socket lifecycle
| Event | Behaviour |
|---|---|
| Page load / pair switch | connectAll() closes anything open, rebuilds eight entries and opens them. |
| Open | Optional subscription frame is sent; the feed counter and status pill update. |
| Close (unexpected) | That socket retries with exponential backoff: 1s, 2s, 4s, 8s, 16s, capped at 30s, plus 0–400 ms jitter. |
| Malformed frame | Parsing or adapter errors are swallowed; the feed keeps running. |
| Tab hidden | visibilitychange closes every socket and marks the status “Tab hidden”. |
| Tab visible again | All feeds reconnect unless you paused manually. |
| Page left | pagehide sets a leaving flag, closes every socket and clears the retry timers. |
| Back/forward cache | pageshow with persisted reconnects. |
| Pause button | userPaused blocks reconnection until you resume; the numbers keep their values. |
No orphan sockets
Before closing, each entry bumps a generation counter and nulls its handlers, so late messages from an old connection cannot leak into the current session. Retry timers are cleared on every close-all.
07Time handling
The tape shows arrival time from your browser’s clock, formatted as 24-hour
HH:MM:SS in your local zone. The full local date and time is available in the cell’s tooltip.
`${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`
- No timezone label is appended — the clock is already local, and printing “PKT” would be wrong for every other visitor.
- Exchange payload timestamps are not used: five venues, three timestamp formats, and no shared clock.
- Buckets are aligned with
Math.floor(now / 1000) * 1000, so window boundaries are stable. - Session start, elapsed time, next auto-refresh and the live clock all derive from the same
Date.now()source.
Your machine’s clock matters
If your operating system clock is skewed, arrival times and session timers will be skewed by the same amount. Enable automatic time sync if the seconds look wrong.
08Known limitations
| Limitation | Effect |
|---|---|
| No historical backfill | Windows only contain prints seen while the page was open and connected. |
| No order book | Depth, cancels, icebergs and spoofing are invisible; the tool measures executions only. |
| Aggregated Binance events | aggTrade merges same-price fills of one taker order into a single print. |
| Second-resolution windows | Window edges snap to whole seconds; sub-second sequencing is not modelled. |
| Uneven venue coverage | A pair may exist on two of five venues; the aggregate is then dominated by those two. |
| Contract-size lookups | If a futures multiplier cannot be resolved, those prints are skipped rather than valued 1:1 (see section 03). |
| Silent feeds | A socket can be open while the venue sends no prints (regional restriction or unlisted market). “Open feeds 8/8” means connected, not contributing. |
| Coinbase side semantics | The adapter passes match.side through exactly as delivered — Coinbase defines it against the resting order, unlike the taker-side venues. Treat Coinbase as supporting colour, not as a directional tie-breaker. |
| Browser clock | Arrival times inherit any skew in the operating system clock. |
| Network restrictions | Some ISPs or regions block specific exchange websockets; those feeds stay unreachable until they are. |
| Session-scoped totals | Cards and tape reset on reload, pair change, auto-refresh or manual reset — nothing is persisted. |
| Float arithmetic | Sums use IEEE-754 doubles; displayed values are rounded, so cent-level differences are normal. |
| Not an execution tool | No orders, no API keys, no account access, no position awareness. |
Do not treat this as a market-wide tape
It is a five-venue sample of public prints. Dark pools, OTC desks, internal matching and every venue not in the list are outside the measurement.
09File map & extending
| File | Role |
|---|---|
public/orderflow.html | Page shell: top bar, controls, timeframe row, cards, tape, footer. |
public/css/orderflow.css | All tool styles, namespaced .of-*; reuses the theme tokens from styles.css. |
public/js/orderflow.js | Adapters, aggregates, bucket store, rendering, lifecycle. |
public/orderflow-guide.html | Trader-facing explanation. |
public/orderflow-technical.html | This reference. |
public/orderflow-disclaimer.html | Risk, data and liability notice. |
public/js/pages.js | Shared theme toggle and TOC scroll-spy for the documentation pages. |
Adding a venue
- Add one object to
STREAMSwithkey,name,url(coin), optionalonOpen(ws, coin)andonMessage(data, emit, coin, ws). - Emit
{ exchange, market: 'Spot' | 'Futures', side: 'buy' | 'sell', price, qty }. - Add a colour to
EXCHANGE_COLORS. - Update the feed-count copy on this page and in the guide.
Other knobs
- Whale threshold:
WHALE_THRESHOLD(default10000). - Tape depth:
MAX_TAPE_ROWS(default40). - History depth:
MAX_LOG_MS(default 24h + 60s). - Timeframes: the
TIMEFRAMESarray; pills are generated from it, so the HTML does not need editing. - Auto-refresh options: the
AUTO_REFRESH_OPTIONSarray. - Repaint rate:
RENDER_INTERVAL_MS(default 200 ms).
Keep the CSS namespaced
The correlation dashboard is a separate page with generic selectors for header,
table, td and friends. New Order Flow styles must stay inside
.of-* classes so nothing bleeds across pages.
10Verification checklist
Syntax
node --check public/js/orderflow.js should pass with no output.
Server suite
npm test covers the correlation engine and must stay green; the Order Flow page does not touch it.
Live page
Open / (Home); the status pill turns live, “Open feeds” climbs, and rows start filling the tape.
Pause
Press Pause feed: the pill shows Paused, “Open feeds” drops to 0, and the numbers freeze instead of resetting.
Hidden tab
Switch tabs for a few seconds and come back: the feeds reconnect and only the newest prints appear.
Pair switch
Search ETHUSDT: the pair label, quantity header and session clock reset; no BTC prints remain.
Deep links
Every anchor in this page and in the guide should resolve to a real section id.
Debug hook
A read-only helper is exposed for smoke tests:
window.__ofDebug() returns the coin, print count, bucket count, open feeds and the three
metric buckets. It performs no writes and is safe to leave in production.