On this page

btc-orderflow

Real-time BTC order-flow terminal — a gpui client compiled to WebAssembly, backed by a tokio + axum + sqlx server, with a shared protocol crate for the wire format.

  • Rust
  • gpui
  • WASM
  • Axum
  • sqlx

btc-orderflow terminal

btc-orderflow — Technical Breakdown

A single-symbol (BTCUSDT-perp) orderflow terminal: a from-scratch Rust server that ingests Binance USD-M futures market data into TimescaleDB and streams it over WebSocket to a Rust GUI client compiled to WebAssembly. The client is built on gpui (Zed’s GPU-accelerated UI framework) plus a vendored fork of gpui-component, rendered into a browser <canvas>. Production runs at orderflow.j1mdev.net as one Docker container (server binary + static SPA) behind Dokploy/Traefik on a Hetzner VPS.

                 ┌────────────────────  Binance USD-M futures  ───────────────────┐
                 │  wss://fstream.binance.com            https://fapi.binance.com │
                 │  /market/stream (klines×9, aggTrade,  /fapi/v1/klines          │
                 │   forceOrder)  /stream (depth@100ms)  /fapi/v1/aggTrades       │
                 └───────┬──────────────────────────────────────┬─/fapi/v1/depth──┘
                         │ live WS                              │ REST gap-heal
                 ┌───────▼──────────────────────────────────────▼───────┐
                 │                    server (tokio)                    │
                 │  ingest ──► broadcast channels ──► DB writers        │
                 │     │            │                     │             │
                 │     │            ▼                     ▼             │
                 │     │      WS gateway (axum)     TimescaleDB         │
                 │     │      /ws /healthz /static  candles, trades,    │
                 │     │                            book_snapshots,     │
                 │     └── book maintainer          liquidations        │
                 └──────────────────┬───────────────────────────────────┘
                                    │ JSON frames over WS (protocol crate)
                 ┌──────────────────▼───────────────────────────────────┐
                 │              client (gpui → WASM → canvas)           │
                 │  market_data service ─► panels: Chart / Watchlist /  │
                 │  (refcounted subs)        Trades / Orderbook / Liqs  │
                 └──────────────────────────────────────────────────────┘

1. Repository layout

btc-orderflow/
├── crates/
│   ├── protocol/              # Shared wire types — serde-only, no I/O.
│   ├── server/                # Native binary: tokio + axum + sqlx.
│   │   └── migrations/        # Reversible sqlx migrations (.up.sql / .down.sql pairs).
│   └── client/                # WASM GUI: gpui + gpui-component.
├── vendor/gpui-component/     # Pinned fork (adds whole-window-edge docking),
│                              #   wired in via [patch] in the root Cargo.toml.
├── www/                       # Vite + Bun host page for the WASM blob.
├── fonts/                     # Embedded TTFs (browser has no system fonts).
├── docker-compose.yml         # Local TimescaleDB.
├── Dockerfile                 # 5-stage build → single runtime image.
├── .github/workflows/deploy.yml
└── Makefile                   # Entry point for every dev/build target.

It is a flat cargo workspace with mixed compilation targets: server builds for the host, client for wasm32-unknown-unknown, protocol for both. There is deliberately no default target in .cargo/config.toml, so a bare cargo check at the root fails — use make check (which runs per-crate, per-target checks).

Dependency pinning (load-bearing)

  • gpui / gpui_platform are git deps without a rev so they unify with the (also un-revved) gpui declared inside gpui-component. Pinning a rev splits them into two incompatible copies. Reproducibility comes from the committed Cargo.lock.
  • gpui-component is pinned to rev d3d6e56c… and then path-patched to vendor/gpui-component (the fork). The vendor tree is excluded from the workspace (it has its own [workspace]).
  • wasm-bindgen-cli must exactly match the wasm-bindgen crate version in Cargo.lock (currently 0.2.120); a mismatch produces JS bindings that reference symbols the WASM blob doesn’t export. Same pattern for sqlx-cli (0.8.6). Both are pinned in the Makefile and in the Dockerfile.

2. Wire protocol (crates/protocol)

Pure serde value types — compiles for both targets, no runtime deps. All frames are JSON. Client frames are tagged by "op", server frames by "type".

Subscription model

The client allocates a SubId(u32) per subscription and the server echoes it on every frame, so routing is purely ID-based. A Channel tagged enum on Subscribe selects the data kind:

ChannelParametersPayload types
candlestfCandle (OHLCV + quote_volume, trades, taker_buy_vol)
tradesTrade (aggTrade unit; agg_id doubles as pagination cursor)
footprinttf, price_bucketFootprintCell (per-bar, per-price-bucket bid/ask volume)
bookdepthBookLevel lists (snapshot + deltas; size == 0 removes a level)
liquidationsLiquidation (tape)
liquidation_barstfLiquidationBar (per-bar long/short sums, coin + USD)

Every channel follows the same frame triple: *Snapshot { server_v }*Tick/*Update { v }*HistoryPage (reply to a client HistoryPage { before_ms, count } request). Cross-channel control frames: Resnap { id } (server detected a gap — client must drop state and resubscribe), Status, Pong, Error.

Timeframes

Timeframe::ALL is 11 entries: 1s 5s 1m 5m 15m 30m 1h 2h 4h 6h 1d. 1s/5s have no Binance futures kline stream — they are synthesized from aggTrades (live, on the server) and from the trades table (history queries). Timeframe::is_native_kline() is the discriminator both the WS subscription list and the REST backfill loop filter on.

Domain conventions baked into the types

  • Trade.is_buyer_maker = true ⇒ taker sold (Binance convention, propagated unchanged). Volume delta over candles: 2 * taker_buy_vol - volume.
  • LiquidationSide is the side of the liquidated position, not Binance’s raw forced-order side — the server flips SELL→Long / BUY→Short once at ingest so no downstream consumer repeats the inversion.
  • The candle extras (quote_volume, trades, taker_buy_vol) are Option for exchange portability (other venues omit some of them), not as a TODO — Binance always populates them.

3. Server (crates/server)

One binary, one tokio runtime. main.rs boots in a fixed order: tracing → DB pool (8 conns) → migrations → writer tasks (which hold permanent broadcast receivers so the channels never go zero-consumer) → sub-second aggregator → book maintainer → Binance ingest → axum gateway → block on Ctrl-C.

3.1 Task and channel graph

Four typed tokio::sync::broadcast channels fan one ingest stream out to independent consumers:

ChannelCapacityProducersConsumers
kline Tick4 096Binance WS, subsec aggregatorkline DB writer, gateway candle forwarders
trade TradeTick32 768Binance WStrade DB writer, subsec aggregator, gateway trade/footprint forwarders
depth DepthDiff4 096Binance WSbook maintainer, gateway book forwarders
liquidation LiquidationTick256Binance WSliq DB writer, gateway liq/liq-bar forwarders

Lag on any receiver (RecvError::Lagged) is handled locally: writers log and skip; gateway forwarders send Resnap and exit (the client resubscribes); the subsec aggregator discards in-progress bars (a partial trade sequence would corrupt OHLC).

3.2 Binance ingest

Two parallel WebSocket connections, each in its own reconnect loop (exponential backoff 1 s → 30 s cap):

  1. Market stream — one combined-stream URL with 11 streams: btcusdt@kline_{1m,5m,15m,30m,1h,2h,4h,6h,1d} + btcusdt@aggTrade + btcusdt@forceOrder.
  2. Depth streambtcusdt@depth@100ms (separate connection so a book resync never disturbs the kline/trade flow).

Gap-heal runs before every connect attempt of the market loop — the same code path covers cold boot, Binance’s 24 h hard disconnect, and transient drops:

  • Klines: REST /fapi/v1/klines per native TF, cursor = MAX(open_time)+1ms from the DB, 1 500 rows/page, cold-start window 7 days (matches retention).
  • Trades: REST /fapi/v1/aggTrades, cursor = MAX(agg_id), 1 000 rows/page with a 600 ms inter-page delay (IP weight budget). Cold start 15 min; outages longer than 60 min skip the backfill and leave the table sparse rather than hammer REST.

3.3 Sub-second bar synthesis

run_subsec_aggregator subscribes to the trade broadcast and maintains rolling partial bars for 1s/5s per symbol: floor-align each trade’s timestamp to the bar boundary, accumulate OHLC/volume/taker-buy, emit a closed bar on rollover, and throttle open-bar emissions to one per 100 ms (raw trade flow is 100–200/sec). Synthesized bars are pushed onto the same kline broadcast as native bars, so the gateway treats them identically; the DB writer filters them out (sub-second history is always recomputed from trades via time_bucket).

3.4 Orderbook maintenance

run_book_maintainer implements Binance’s documented local-book algorithm:

  1. Subscribe to the depth broadcast first, then fetch REST /fapi/v1/depth?limit=1000.
  2. Validate the first diff overlaps the snapshot (U ≤ lastUpdateId+1 ≤ u), then require strict continuity (pu == previous.u) on every subsequent diff.
  3. Apply levels into a BTreeMap<Price, f64> per side (size == 0 deletes).
  4. Any sequence violation ⇒ reset to empty, sleep 2 s, re-bootstrap.

The book lives in a shared RwLock read by gateway forwarders, and a 1 s timer persists the top 50 levels per side to book_snapshots (the heatmap-replay corpus).

3.5 Storage (TimescaleDB)

TablePKChunkRetentionWriter cadenceConflict policy
candles(symbol, tf, open_time)1 day7 daysper closed barDO UPDATE (closed bar is canonical)
trades(symbol, ts, agg_id)1 hour48 h100 ms batched flushDO NOTHING (immutable)
book_snapshots(symbol, ts)1 hour48 h1 s timerDO NOTHING
liquidations(symbol, ts, price, qty)1 day7 days250 ms batched flushDO NOTHING

Notable query patterns (db.rs, ~6→now ~18 distinct queries, still in non-macro sqlx::query_as form — no compile-time DB needed):

  • Sub-second candles, footprint cells, and liquidation bars are computed on read from the raw tables with time_bucket(...), Timescale’s ordered first(price, agg_id) / last(...) aggregates, and FILTER (WHERE is_buyer_maker) conditional sums — there are no materialized aggregates.
  • The footprint snapshot captures MAX(agg_id) in the same statement as the cells, giving the live forwarder an exact dedupe watermark with no race window.
  • Bulk upserts use QueryBuilder::push_values, chunked under Postgres’s 65 535 bind-parameter cap.

3.6 WS gateway (gateway/)

axum router on LISTEN_ADDR (default 127.0.0.1:8787):

  • GET /healthz"ok".
  • GET /ws → upgrade, with an optional Origin allowlist (ALLOWED_ORIGINS, comma-separated; unset = no check for local dev). Production sets it to the public origin, so cross-site WS hijacking gets a 403.
  • Fallback → static SPA from STATIC_DIR (set in the container), with COOP/COEP headers (same-origin / require-corp — gpui_platform wants SharedArrayBuffer) and immutable cache headers on /assets/* and *.wasm.

Session model: per client, one reader loop + one writer task draining a mpsc(64) channel that all forwarders share. Each Subscribe spawns a per-SubId forwarder task (aborted on Unsubscribe or disconnect). HistoryPage requests spawn one-shot DB queries dispatched on the stored channel kind.

Snapshot-then-stream ordering (the core consistency trick): every forwarder subscribes to the broadcast before running its snapshot query, then dedupes the live stream against the snapshot tail — open_time for candles, agg_id for trades/footprint, ts_ms for liquidations. No tick can fall in the gap.

Conflation: trades, footprint, book, and liquidation forwarders batch live events into 100 ms windows (~10 Hz per subscription regardless of upstream rate). The book forwarder additionally re-filters its buffered deltas to the client’s top-N depth each window, and re-sends a full BookSnapshot every 5 s to bound any drift accumulated while the maintainer re-bootstraps. Snapshot sizes: 500 candles, 500 trades, 100 footprint bars, 200 liquidations, 100 liq bars; history pages are capped at 500 rows.


4. Client (crates/client)

4.1 Entry and platform glue

lib.rs::run(app) is the shared App lifecycle; wasm_entry::run (exported via #[wasm_bindgen]) initializes gpui_platform::single_threaded_web() and keeps the app alive after returning to JS by leaking the Rc<AppCell> (the pattern mirrored from gpui-component’s story-web). Browsers expose no system fonts, so three subsetted TTFs are embedded (Noto Sans SC, Noto Emoji, JetBrains Mono); icon SVGs come from longbridge’s CDN via gpui_component_assets::Assets::new(url).

www/ is a thin Vite host: index.html carries a loading shell that main.js removes after wasm.run() resolves. gpui creates its own anonymous <canvas> appended to <body> — the static #canvas in the HTML is decoration only (this matters to anything that queries the DOM, e.g. the screenshot feature selects body > canvas). main.js also stamps inputmode="none" onto gpui’s hidden focus <input> so mobile browsers don’t pop the soft keyboard on every tap.

init() order: gpui-component init → persistence purge of legacy keys → panel registry → services (market_data, symbols, recents, watchlist) → drawings → pickers → theme + font-size + prefs from localStorage → fonts → open a 1280×800 window hosting TerminalWorkspace under gpui-component’s Root.

4.2 Workspace shell

workspace.rs::TerminalWorkspace owns:

  • Top bar — title, Draw dropdown (12 tools), Objects popover (per-symbol drawing management), spacer, + Panel menu (driven by Kind::ALL), Layouts menu (saved dock layouts), screenshot button, settings button.
  • Dock area — gpui-component DockArea (LAYOUT_VERSION = 5; bump it to reset persisted layouts when panel IDs change). Layout changes debounce a 500 ms save to localStorage.
  • Bottom bar — WS connection status, clock, FPS, build version (threaded in from BUILD_SHA via build.rs).
  • Floating layer — a homegrown FloatingWindow (drag/resize/close chrome, outside-click dismiss) used for indicator settings, footprint render settings, drawing settings, and a code editor; plus FloatingStrip, a compact draggable strip that appears when a drawing is selected (color swatch / gear / delete), its position persisted.

Focus routing uses mouse-down trackers (LastFocusedTabPanel, LastFocusedChart globals holding WeakEntitys) instead of gpui focus events, because web-gpui’s focus rides a hidden <input> that would trigger the mobile keyboard. Watchlist row clicks and + Panel inserts route through these globals.

4.3 Panels

panels.rs::ContentPanel is one entity parameterized by Kind, with per-kind state slots and a render dispatch:

KindWhat it renders
ChartCandles/footprint with overlay + sub-pane indicators, drawings, crosshair readouts. The heavyweight panel (~5 k lines + a dedicated paint.rs).
WatchlistSymbol rows (last / change / change% from the D1 sub), drag-reorder, click-to-route to last-focused chart.
TradesLive tape, min-USD filter, coin/USD toggle; keeps its own filtered buffer.
OrderbookLive book at selectable depth with size-intensity coloring and price-bucket grouping.
LiquidationsLiquidation tape with side filter and coin/USD toggle.

The chart’s main render mode is itself pluggable (RenderKind): Candlestick, Cluster (footprint cells per price bucket inside each bar), or Profile (per-bar horizontal volume profile) — switching to a footprint mode lazily opens a footprint subscription with a chosen $ bucket size. Per-mode settings (color mode: bid/ask vs delta, value labels, imbalance thresholds) live in a floating settings form.

4.4 Market-data service (services/market_data.rs)

One persistent WS connection for the whole app, opened at boot, reconnecting forever with 1 s → 30 s backoff. The URL derives from window.location (wss://<host>/ws on https) with a ws://127.0.0.1:8787/ws fallback — same-origin in production, Vite-proxied in dev.

Each of the six channel kinds gets the same machinery, keyed by its own sub-key type (SubKey{symbol,tf}, TradeSubKey{symbol}, FootprintSubKey{symbol,tf,bucket_bits}, BookSubKey{symbol,depth}, …):

  • Refcounted ensure_* API — first ensure allocates a SubId and sends Subscribe; dropping the last SubscriptionHandle sends Unsubscribe (handles drain through a release pump so drops are allowed anywhere).
  • Routing — incoming frames map SubId → AnySubKey and update per-key caches (candle vectors, trade rings capped at 200, liquidation rings at 1 000, footprint cell maps keyed (open_time, bucket_bits), the live book level lists).
  • Event enums per channel (KlineEvent, TradeEvent, FootprintEvent, BookEvent, LiquidationEvent, LiquidationBarEvent) with the same shape: Snapshot / Tick|Update / Prepended / HistoryCapped / Resnap / StatusChanged. Panels subscribe to these and never touch the wire types.
  • History pagingload_older(...) sends HistoryPage keyed on the oldest held cursor, with in-flight guards against duplicate requests during fast pans.
  • Resnap — on a server Resnap (forwarder lagged out), the client clears the buffer and re-sends Subscribe, because the v1 server forwarder exits its broadcast subscription on lag.

Wire Candles are converted once at ingest into a client Candle that adds a pre-formatted date: SharedString (user-timezone aware) and a derived vwap.

4.5 Indicator framework (indicators/)

Trait-object plugin system. IndicatorKind defines compute(&[Candle], ComputeCtx) → IndicatorOutput, crosshair value_at, y_range, JSON param round-tripping, and an optional declarative settings_form(). ComputeCtx carries cross-channel inputs: the volume unit (coin/USD), a footprint-cell lookup (for VRVP), the visible time range, and liquidation bars — so indicators can consume any subscribed data without owning subscriptions.

IndicatorInstance wraps a boxed kind with a session-unique id, placement (overlay vs own sub-pane — some kinds support both), pane height, palette-rotated color slots, and a hidden flag. Output variants cover lines, multi-line, histogram, bands, MACD triple, a bar-stats table, volume profile, and liquidation bars.

Shipping indicators: Volume, Trades (count histogram), Volume Delta (histogram / CVD / both — needs taker_buy_vol), Bar Stats (per-bar metrics table), VRVP (visible-range volume profile, computed from footprint cells), and Liq Bars (long/short liquidation histogram, lazily opening a liquidation_bars subscription).

4.6 Volume profile (volume_profile/)

Shared compute/paint module used by both the VRVP indicator and the FRVP (fixed-range) drawing tool: buckets bid/ask volume by price over a time range, derives POC and a 60 % value area (VAH/VAL), and paints anchored horizontal bars in wireframe or heatmap mode.

4.7 Drawings (drawings/)

  • tool.rs — global active-tool state behind the top-bar Draw menu: Select, Line, H-Ray, H-Line, Arrow, Rectangle, Fibonacci, Anchored VWAP, FRVP, Text, Long, Short.
  • shapes.rs — serde-stable shape structs anchored in (time, price) data space (positions and FRVP use fractional bar indices), each with optional color/width/ label overrides and a PaneRef (main chart vs a specific indicator sub-pane).
  • service.rs — the store: BTreeMap<symbol, Vec<Drawing>> + selection, persisted to localStorage on every commit; preview_shape mutates in-memory only so 60 Hz drags don’t hammer storage. Deleting an indicator pane cleans up drawings pinned to it.
  • Chart-side: mouse-down with an active tool builds a CreatingDrawing, mouse-up commits to the service; selection draws handles and pops the floating strip. Delete/Backspace and Escape are bound on the chart’s focus scope.

4.8 Settings framework (settings_form/)

One declarative form system renders every settings surface (indicators, footprint render modes, drawings, app settings): SettingsForm → groups → typed fields (dropdown, number, text, color, switch, checkbox, multi-checkbox, action button) with per-field tooltips and visible_if predicates. Forms never mutate state directly — edits route through a typed target (e.g. IndicatorTarget<P> downcasts the instance’s params and patches them), which triggers recompute + repaint. Adding a setting to an indicator is ~5 lines.

4.9 Persistence

Everything goes through web_sys localStorage under versioned keys: btc_orderflow.{workspace,layouts,watchlist,recents,drawings,font_size,theme,dialog_animations}.v3, plus general prefs (timezone, chart density/padding). Chart prefs are mirrored into static atomics (f32::to_bits in AtomicU32) for lock-free reads on the paint path. A purge_v1() sweep removes keys inherited from the pre-fork demo.

4.10 Screenshot

Top-bar button → screenshot.rs captures the gpui canvas (body > canvas) via HtmlCanvasElement::toBlob before opening the preview dialog (so the dialog never occludes itself), sizes the dialog from the PNG’s IHDR dimensions to ~80 % viewport width, and offers Save (transient <a download>btc-orderflow_<timestamp>.png) and Copy (ClipboardItem constructed synchronously inside the click gesture for Safari compatibility). Failures surface as toasts.


5. Build, dev workflow, deployment

5.1 Local development

make install     # wasm-bindgen-cli + sqlx-cli (pinned) + bun deps
make db-up       # TimescaleDB via docker-compose
make server      # gap-heals, ingests, serves ws://127.0.0.1:8787
make dev         # debug WASM build + Vite on :3001 (proxies /ws to local server)
make dev-vps     # same, but /ws proxies to the production VPS backend

make dev-vps exists for client-only iteration against real prod data. Two subtleties it encodes: Vite’s rewriteWsOrigin stamps the raw target string as the Origin header, so the proxy target must be given in object form ({protocol: 'https:', host}) to satisfy the server’s origin allowlist; and Vite 8’s WS proxy needs socket.destroySoon, which Bun lacks — so that target runs Vite under Node. WASM changes always need ./scripts/build-wasm.sh + refresh: Vite hot-reloads JS, never the WASM blob.

5.2 Migrations

Reversible pairs in crates/server/migrations/ (make db-migration NAME=... generates them). The server applies pending .up.sql on boot via sqlx::migrate!; revert is deliberately manual (sqlx migrate revert --source ...).

5.3 Docker image (5 stages)

  1. cheflukemathwalker/cargo-chef:0.1.77-rust-bookworm, digest-pinned (an upstream re-tag would silently re-key every cached layer). rust-toolchain.toml is copied first so the rustup-install layer caches independently of source.
  2. plannercargo chef prepare distills the workspace to recipe.json. Re-runs every commit (~2 s) but emits byte-identical output when no manifest changed, keeping downstream layers cached.
  3. rust-builder — copies recipe.json and vendor/ (chef’s recipe only captures workspace-member manifests; without the real vendor tree the [patch] can’t resolve), then cargo chef cook for both the native server and the wasm client. This cook layer holds CARGO_HOME (including the multi-GB zed git checkout) plus every compiled dependency for both targets — it re-keys only when Cargo.toml/Cargo.lock/vendor/ change. BUILD_SHA/BUILD_REF args are declared after the cook so per-commit values can’t invalidate it. wasm-bindgen comes as a prebuilt musl binary (~3 s vs ~90 s for cargo install). Then the real cargo build compiles just the three workspace crates + the fork.
  4. frontend-builderoven/bun:1, bun install && bun --bun vite build against the wasm-bindgen output.
  5. runtimedebian:bookworm-slim + ca-certificates (rustls trust for Binance), tini (PID-1 signal handling — Docker’s SIGTERM would otherwise be dropped), curl (for the HEALTHCHECK against /healthz). Default env bakes LISTEN_ADDR=0.0.0.0:8787, STATIC_DIR=/app/dist; Dokploy supplies DATABASE_URL and ALLOWED_ORIGINS.

5.4 CI / deploy pipeline

.github/workflows/deploy.yml: push to main → buildx build with cache-from/cache-to: type=gha, mode=max → push ghcr.io/<repo>:latest + :sha-<short> → POST the Dokploy webhook (or rely on registry polling if unset).

The caching model is the crux: GHA’s cache backend persists image layers only — BuildKit RUN --mount=type=cache mounts do not survive between runs — so the Dockerfile is structured to put all expensive, slow-changing work into layers. Measured behavior: identical re-run ≈ 6 s; app-only commit ≈ 2–4 min (cook layer hits, only workspace crates rebuild); dependency-changing commit ≈ 10 min cold (cook re-runs ~5.5 min + the new ~1.1 GB layer re-exports to the cache ~4 min). If the 10 GB per-repo cache cap ever evicts the cook layer, the documented escape hatch is a registry-backed cache (type=registry on GHCR, no size cap).

Production topology: Hetzner CPX22 → Dokploy → Traefik (TLS) → the container. Cloudflare DNS-only. The server’s origin allowlist plus same-origin WS URL derivation means the deployed SPA needs zero runtime configuration.


6. Cross-cutting design properties

  • Idempotent ingest everywhere. Gap-heal overlaps, reconnect redeliveries, and writer retries are all absorbed by upserts (DO UPDATE for the one mutable canonical row — closed candles — and DO NOTHING for immutable event rows).
  • One consistency recipe per channel. Subscribe-before-snapshot + dedupe-by- cursor on the server; Resnap-and-resubscribe on the client. Per-subscription monotonic versions (server_v/v) make gaps detectable.
  • Raw events in, aggregates on read. Only raw klines/trades/book/liquidations are persisted; footprint cells, sub-second bars, and liquidation bars are time_bucket queries. Cost: repeated aggregation per snapshot; benefit: any bucket size / TF works retroactively with zero migration.
  • Backpressure by conflation, not buffering. 100 ms batch windows cap every subscription at ~10 Hz; broadcast capacities are sized so lag means “DB or client genuinely stuck”, and the response to lag is always resync, never unbounded queueing.
  • Browser-host constraints drive the client design: no system fonts (embedded TTFs), no filesystem (localStorage persistence), hidden-input focus quirks (mouse-down focus tracking), no path pickers (<a download> saves), SharedArrayBuffer headers (COOP/COEP at the gateway), one render canvas (screenshot reads it directly).

7. Current limitations (deliberate v1 scope)

  • Single symbol (SUPPORTED_SYMBOL = "BTCUSDT", validated per Subscribe); the ingest/task structure loops per-symbol cleanly when that gate is lifted.
  • History is bounded by retention: 7 days of candles/liquidations, 48 h of trades/book snapshots — so sub-second TFs, footprints, and the book heatmap replay only reach back two days.
  • Liquidation cascades are partially invisible upstream: Binance throttles forceOrder to ≤1 event/sec per symbol.
  • No auth/multi-tenancy — the gateway is anonymous, guarded only by the origin allowlist.
  • sqlx non-macro form trades compile-time column checking for not needing a live DB (or offline cache) at build time; mis-typed columns surface at runtime on first query.