WebSocket streams
Two long-lived WebSocket daemons run on Athena under supervisor: the user-data stream (per-account private channel — order fills, cancellations, account state) and the mark-price stream (public channel — ~1 Hz price updates for every Binance-listed symbol). Both are PHP processes built on Ratchet/Pawl + React\EventLoop, both are designed to run for days at a time, both push exchange events into the system in <100 ms — far ahead of any cron polling tick.
This is the subsystem lens view. For the box that hosts these daemons, see Athena.
The two streams
| Daemon | Channel | What it produces |
|---|---|---|
kraite:stream-binance-user-data | One authenticated WebSocket per Binance account (wss://fstream.binance.com/ws/<listenKey>) | ProcessUserDataEventJob per frame onto the user-data-stream queue. Order fills, cancels, replacements, account-state changes |
kraite:stream-binance-prices | Public !markPrice@arr@1s subscription | Bulk UPDATE on exchange_symbols.mark_price + mark_price_synced_at for every Binance-listed symbol, ~1 Hz |
Both are supervisor-managed with autostart=true / autorestart=true. Neither is a cron — restarting them is a supervisor operation, not a scheduler concern.
User-data stream — why push, not polling
The previous design polled order detail per open order on a 1-min cadence — ~36 Bitget HTTP calls per cycle for 6 positions × 6 orders. Linear fan-out: 200 accounts of equivalent shape would need ~1,200 calls/min against a 600/min per-IP private cap. Polling cannot scale past ~15 accounts per IP.
Push delivers each event in <100 ms with zero per-frame budget consumed against the rate-limit cap.
┌─────────────────┐ ┌──────────────┐
│ Binance │ push │ user-data │
│ user-data WS │────────►│ daemon (PHP) │
│ (per account) │ frames │ on Athena │
└─────────────────┘ └──────┬───────┘
│ dispatch
▼
┌──────────────────┐
│ user-data-stream │ Horizon queue
│ (Redis) │
└──────┬───────────┘
│
▼
ProcessUserDataEventJob
→ api_data_stream (raw)
→ Order::updateSaving
→ OrderObserver workflow
A 5-minute polling cron (kraite:cron-sync-orders) still runs as a safety net — catches missed frames in the rare WS-frame-loss / reconnect-race case.
Selective dispatch
Not every WS frame triggers a downstream workflow. The execution-type allowlist is gated by kraite.user_data_stream.<exchange>.dispatched_executions. Empty list = pure shadow mode (every frame audited into api_data_stream, no Order::updateSaving). Each execution type is enabled via config flip after its OrderObserver workflow has been verified end-to-end against live frames.
Production allowlist (Binance, since 2026-05-03): TRADE / AMENDMENT / CANCELED / EXPIRED / ALGO_NEW / ALGO_CANCELED / ALGO_EXPIRED / ALGO_FILLED. NEW / REJECTED / CALCULATED deliberately stay off — NEW would create defensive drift-detection noise on every placement ack, REJECTED is already caught synchronously at placement time, liquidations are out of scope.
Mark-price stream — chunked CASE/WHEN UPDATE
The mark-price daemon writes the same Binance tick to every matching exchange row in a single bulk UPDATE — Binance, Bybit, KuCoin, Bitget — using a chunked 500-row CASE/WHEN raw query that bypasses Eloquent entirely. 1 Hz × 568 symbols × 4 exchanges is too hot for the observer chain.
Binance tick (1 Hz, all symbols) ──► UPDATE exchange_symbols
SET mark_price = CASE id
WHEN 1 THEN 27451.20
WHEN 2 THEN 1842.55
... (500 rows / chunk)
END
WHERE id IN (...)
Replication across exchanges uses (token + quote) matching with token_mappers overrides for naming divergence (BTC→XBT on KuCoin, 1000SATS→10000SATS on KuCoin / Bybit, …).
gc_collect_cycles() runs after each batch — keeps the daemon's memory profile flat across multi-day uptime.
Reconnect + isolation
Architectural decision
Both daemons share a BaseWebsocketClient abstract: auto-pong, exponential-backoff reconnect (2^attempt, max 5), per-account error isolation. A single account's listenKey expiry, transient WS error, or malformed frame does not bring down the daemon or the other accounts' streams. This makes the user-data stream the first layer of fault containment, not the last.
Cross-lens links
- Athena (ingestion) — the box hosting both daemons
- Horizon queues —
user-data-streamconsumer side - Order lifecycle — what happens after a frame turns into an
Order::updateSaving