WebSocket streams
Two long-lived WebSocket daemons run on Kraite 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 host that runs these daemons, see Kraite.
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 Kraite │
└─────────────────┘ └──────┬───────┘
│ dispatch
▼
┌──────────────────┐
│ user-data-stream │ Horizon queue
│ (Redis) │
└──────┬───────────┘
│
▼
ProcessUserDataEventJob
→ api_data_stream (raw)
├─ order update → Order::updateSaving
│ → OrderObserver workflow
└─ flat account update
→ CancelPositionOpenOrdersJob (priority)
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.
Manual-flat safety branch
An account-position update does not enter the order execution-type allowlist. It has one narrow independent action: when Binance reports quantity zero for a locally-open position, ProcessUserDataEventJob creates a high-priority CancelPositionOpenOrdersJob root for that position's remaining DCA LIMIT orders.
The branch matches LONG and SHORT explicitly in hedge mode. A one-way BOTH row matches only when there is one locally-open position for the account and symbol. Non-zero updates, unrelated symbols, ambiguous local matches, and positions without a live LIMIT are passive. Replayed frames deduplicate against the live emergency cancellation.
Why this is separate from replacement
The reduce-only order fill still starts PreparePositionReplacementJob, which queries the exchange and owns final close-versus-replace reconciliation. The flat account update does not bypass that workflow. It removes only the immediate exposure risk: an opening LIMIT must not remain executable after the operator has flattened the position. TP and SL orders stay under the normal lifecycle.
The normalized position-update contract is exchange-neutral. Binance is the first producer; future Bitget, Bybit, and KuCoin private streams can feed the same worker rule without adding an exchange-specific cancellation path.
Mark-price stream — chunked CASE/WHEN UPDATE
The mark-price daemon writes each Binance tick to matching rows on active exchanges in a single bulk UPDATE, using a chunked 500-row CASE/WHEN raw query that bypasses Eloquent. Production currently activates Binance only; disabled exchange evidence remains stored but is excluded from the live map.
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 (...)
If another exchange is re-enabled, replication uses (token + quote) matching with token_mappers overrides for naming divergence.
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.
Reconnect-forever is availability, not recovery
Retrying forever keeps a daemon alive but does not guarantee it recovers. On 2026-07-02 a transient network blip wedged the mark-price daemon's DNS resolver inside its ReactPHP event loop; it reconnected ~46,000 times over four hours, every attempt failing, no prices landing — until a manual restart cleared it in seconds. A fresh connector per attempt does not clear a loop-level wedge; only a fresh process does.
Sustained no-data self-exit
The mark-price stream is strict-data — ~1 Hz frames are always expected. If no real price frame arrives for 5 minutes, the daemon stops its loop so supervisor respawns a clean process, turning a multi-hour blackout into a ~10-second blip. It tracks time-since-last-data-frame separately from time-since-last-anything (a reconnect or a keepalive ping never resets it), so both failure shapes trip it: never-reconnects and connects-but-silent. The user-data stream is exempt — silence there is normal on a quiet account, so it never self-exits. A frozen mark price is what surfaces the operator-facing "Mark price stale" alert for any symbol the bot has skin in (open position or tradeable).
One process, N accounts — bounding the restart blast radius
The user-data daemon multiplexes one WebSocket per account inside a single process, so any restart resets every account at once. Harmless at one account; a storm at a hundred. Three amplifiers are bounded so a restart stays quiet at fleet scale:
| Amplifier | Before | After |
|---|---|---|
| Notifications | one "connected" alert per account per (re)connect → 100 per restart | one boot-summary per restart; per-account connect is log-only; only connect failures page |
| Reconnect burst | all N handshakes in the same tick from one production IP | connects staggered on a controlled ramp |
| Memory self-exit | fixed 512 MB ceiling, crossed by normal load around ~43 accounts → the daemon crash-loops, resetting everyone | ceiling scales with the live account count (≈200 MB base + 25 MB/account) so it fires only on a real leak |
Design rule
A single-process multiplexed daemon must treat "restart = N resets" as a design constraint from day one — bound the blast radius (summary notifications, staggered reconnect, scale-aware limits) before the account count grows. A fixed resource ceiling on a per-account-scaling daemon is a latent crash-loop. A future step is to shard the daemon into several processes of fewer accounts each, so one restart only resets a shard.
Cross-lens links
- Kraite host — the host running both daemons
- Horizon queues —
user-data-streamconsumer side - Order lifecycle — what happens after a frame turns into an
Order::updateSaving