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 — 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_platformare git deps without arevso they unify with the (also un-revved)gpuideclared inside gpui-component. Pinning a rev splits them into two incompatible copies. Reproducibility comes from the committedCargo.lock.gpui-componentis pinned to revd3d6e56c…and then path-patched tovendor/gpui-component(the fork). The vendor tree is excluded from the workspace (it has its own[workspace]).wasm-bindgen-climust exactly match thewasm-bindgencrate version inCargo.lock(currently 0.2.120); a mismatch produces JS bindings that reference symbols the WASM blob doesn’t export. Same pattern forsqlx-cli(0.8.6). Both are pinned in theMakefileand in theDockerfile.
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:
| Channel | Parameters | Payload types |
|---|---|---|
candles | tf | Candle (OHLCV + quote_volume, trades, taker_buy_vol) |
trades | — | Trade (aggTrade unit; agg_id doubles as pagination cursor) |
footprint | tf, price_bucket | FootprintCell (per-bar, per-price-bucket bid/ask volume) |
book | depth | BookLevel lists (snapshot + deltas; size == 0 removes a level) |
liquidations | — | Liquidation (tape) |
liquidation_bars | tf | LiquidationBar (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.LiquidationSideis the side of the liquidated position, not Binance’s raw forced-order side — the server flipsSELL→Long/BUY→Shortonce at ingest so no downstream consumer repeats the inversion.- The candle extras (
quote_volume,trades,taker_buy_vol) areOptionfor 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:
| Channel | Capacity | Producers | Consumers |
|---|---|---|---|
kline Tick | 4 096 | Binance WS, subsec aggregator | kline DB writer, gateway candle forwarders |
trade TradeTick | 32 768 | Binance WS | trade DB writer, subsec aggregator, gateway trade/footprint forwarders |
depth DepthDiff | 4 096 | Binance WS | book maintainer, gateway book forwarders |
liquidation LiquidationTick | 256 | Binance WS | liq 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):
- Market stream — one combined-stream URL with 11 streams:
btcusdt@kline_{1m,5m,15m,30m,1h,2h,4h,6h,1d}+btcusdt@aggTrade+btcusdt@forceOrder. - Depth stream —
btcusdt@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/klinesper native TF, cursor =MAX(open_time)+1msfrom 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:
- Subscribe to the depth broadcast first, then fetch REST
/fapi/v1/depth?limit=1000. - Validate the first diff overlaps the snapshot (
U ≤ lastUpdateId+1 ≤ u), then require strict continuity (pu == previous.u) on every subsequent diff. - Apply levels into a
BTreeMap<Price, f64>per side (size == 0deletes). - 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)
| Table | PK | Chunk | Retention | Writer cadence | Conflict policy |
|---|---|---|---|---|---|
candles | (symbol, tf, open_time) | 1 day | 7 days | per closed bar | DO UPDATE (closed bar is canonical) |
trades | (symbol, ts, agg_id) | 1 hour | 48 h | 100 ms batched flush | DO NOTHING (immutable) |
book_snapshots | (symbol, ts) | 1 hour | 48 h | 1 s timer | DO NOTHING |
liquidations | (symbol, ts, price, qty) | 1 day | 7 days | 250 ms batched flush | DO 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 orderedfirst(price, agg_id)/last(...)aggregates, andFILTER (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,
+ Panelmenu (driven byKind::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_SHAviabuild.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; plusFloatingStrip, 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:
| Kind | What it renders |
|---|---|
| Chart | Candles/footprint with overlay + sub-pane indicators, drawings, crosshair readouts. The heavyweight panel (~5 k lines + a dedicated paint.rs). |
| Watchlist | Symbol rows (last / change / change% from the D1 sub), drag-reorder, click-to-route to last-focused chart. |
| Trades | Live tape, min-USD filter, coin/USD toggle; keeps its own filtered buffer. |
| Orderbook | Live book at selectable depth with size-intensity coloring and price-bucket grouping. |
| Liquidations | Liquidation 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 — firstensureallocates aSubIdand sendsSubscribe; dropping the lastSubscriptionHandlesendsUnsubscribe(handles drain through a release pump so drops are allowed anywhere). - Routing — incoming frames map
SubId → AnySubKeyand 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 paging —
load_older(...)sendsHistoryPagekeyed 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-sendsSubscribe, 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 aPaneRef(main chart vs a specific indicator sub-pane).service.rs— the store:BTreeMap<symbol, Vec<Drawing>>+ selection, persisted to localStorage on every commit;preview_shapemutates 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)
- chef —
lukemathwalker/cargo-chef:0.1.77-rust-bookworm, digest-pinned (an upstream re-tag would silently re-key every cached layer).rust-toolchain.tomlis copied first so the rustup-install layer caches independently of source. - planner —
cargo chef preparedistills the workspace torecipe.json. Re-runs every commit (~2 s) but emits byte-identical output when no manifest changed, keeping downstream layers cached. - rust-builder — copies
recipe.jsonandvendor/(chef’s recipe only captures workspace-member manifests; without the real vendor tree the[patch]can’t resolve), thencargo chef cookfor 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 whenCargo.toml/Cargo.lock/vendor/change.BUILD_SHA/BUILD_REFargs 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 forcargo install). Then the realcargo buildcompiles just the three workspace crates + the fork. - frontend-builder —
oven/bun:1,bun install && bun --bun vite buildagainst the wasm-bindgen output. - runtime —
debian:bookworm-slim+ ca-certificates (rustls trust for Binance), tini (PID-1 signal handling — Docker’s SIGTERM would otherwise be dropped), curl (for theHEALTHCHECKagainst/healthz). Default env bakesLISTEN_ADDR=0.0.0.0:8787,STATIC_DIR=/app/dist; Dokploy suppliesDATABASE_URLandALLOWED_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 UPDATEfor the one mutable canonical row — closed candles — andDO NOTHINGfor 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_bucketqueries. 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 perSubscribe); 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
forceOrderto ≤1 event/sec per symbol. - No auth/multi-tenancy — the gateway is anonymous, guarded only by the origin allowlist.
sqlxnon-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.