Skip to content

fix: normalize Binance user-data subscriptions - #101

Merged
outerlook merged 2 commits into
developfrom
fix/binance-user-data-contract
Aug 3, 2026
Merged

fix: normalize Binance user-data subscriptions#101
outerlook merged 2 commits into
developfrom
fix/binance-user-data-contract

Conversation

@outerlook

@outerlook outerlook commented Aug 3, 2026

Copy link
Copy Markdown
Member

Summary

  • normalize Binance spot balance events into canonical free/total balance snapshots
  • refresh the selected account through fetchBalance for delta-only balance events
  • parse executionReport events into canonical order snapshots and suppress list-level status frames
  • preserve venue-native events in both archive paths

Why

Binance spot user-data subscriptions returned raw WebSocket envelopes while broker clients decode BALANCE and ORDERS as typed snapshots. The raw balance envelope was rejected by the client and caused the subscription to reconnect repeatedly.

Per-fill Binance commission is intentionally omitted from order snapshots because the consumer interprets the fee field as cumulative. Emitting it as cumulative would misstate fees.

Verification

  • bun test test/subscribe-user-stream.test.ts — 12 passed, 0 failed
  • bun run build:ts && bun run lint — passed; lint reported warnings only
  • bun run format — 147 files checked, no changes required
  • bun test — 583 passed, 0 failed

No ClickHouse schema changes are included.

Summary by CodeRabbit

  • Improvements
    • Binance account updates now provide consistently formatted balance and order information.
    • Balance updates include refreshed asset quantities and relevant event details.
    • Order execution updates include standardized trade information, with invalid trade identifiers omitted.
    • Unnecessary order-list status notifications are no longer delivered to subscribers.
    • Original event data continues to be retained for archival purposes.

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 44 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 53f2014e-d8aa-456d-bfcf-844acca32a95

📥 Commits

Reviewing files that changed from the base of the PR and between edf9a5b and 9be14be.

📒 Files selected for processing (1)
  • test/subscribe-user-stream.test.ts

Walkthrough

Binance user-data subscriptions now normalize balance and execution-report events before delivery. listStatus events are archived but not emitted. Balance refresh, order normalization, metadata, trade IDs, and archival behavior receive expanded integration coverage.

Changes

Binance user-data stream

Layer / File(s) Summary
Balance and execution normalization
src/helpers/binance-user-data-normalization.ts
The new helpers normalize spot balances and execution reports. Balance events validate quantities, preserve metadata, use safeBalance, and fetch authoritative balances when required. Execution reports remove fee fields and add valid trade IDs.
Normalized event delivery
src/handlers/subscribe/handler.ts
The handler archives original events, skips listStatus delivery, and sends normalized balance and order events to subscribers.
Stream integration coverage
test/subscribe-user-stream.test.ts
Tests cover balance refreshes, secondary accounts, canonical orders, archival, and listStatus filtering with exchange and archiver test doubles.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant BinanceWebSocket
  participant SubscribeHandler
  participant NormalizationHelpers
  participant BrokerArchiver
  participant Subscriber
  BinanceWebSocket->>SubscribeHandler: user-data event
  SubscribeHandler->>BrokerArchiver: archive original payload
  SubscribeHandler->>NormalizationHelpers: normalize balance or execution report
  NormalizationHelpers-->>SubscribeHandler: canonical event
  SubscribeHandler->>Subscriber: emit canonical event
  SubscribeHandler-->>Subscriber: suppress listStatus event
Loading

Possibly related PRs

Poem

A rabbit watched the balance stream hop,
While orders shed fees and found their trade ID stop.
listStatus stayed archived in its burrow deep,
Canonical events reached subscribers in a leap.
“Normalize with care!” the bright ears cheer.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: normalizing Binance user-data subscriptions.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/binance-user-data-contract

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
test/subscribe-user-stream.test.ts (3)

552-555: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the archive tables, not only the row count.

This assertion checks that two rows carry the raw event. It does not check which tables received them. A regression that writes the same row to one table twice would still pass. The listStatus test at Line 772 already asserts per table. Apply the same check here to confirm both archive paths preserve the venue-native event.

♻️ Proposed change
-		await waitFor(() => archive.rows.length === 2);
-		expect(
-			archive.rows.map((row) => JSON.parse(String(row.row.payload_json))),
-		).toEqual([rawEvent, rawEvent]);
+		await waitFor(() => archive.rows.length >= 2);
+		expect(archive.rows).toHaveLength(2);
+		expect(archive.rows.map((row) => row.table).sort()).toEqual(
+			["broker_execution.balance_events", "market_data.cex_stream_events"].sort(),
+		);
+		expect(
+			archive.rows.map((row) => JSON.parse(String(row.row.payload_json))),
+		).toEqual([rawEvent, rawEvent]);

Replace broker_execution.balance_events with the table name that archiveSubscribeStreamInBackground uses for BALANCE.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/subscribe-user-stream.test.ts` around lines 552 - 555, Update the
archive assertion near the existing archive.rows check to verify each archive
table separately, including the table used by archiveSubscribeStreamInBackground
for BALANCE instead of asserting only the combined row count. Mirror the
per-table assertion pattern from the listStatus test and confirm both tables
contain the venue-native rawEvent.

558-595: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Add coverage for a rejected balance refresh.

These tests cover the successful refresh only. The fetchBalance double never rejects, so no test proves what the stream does when the REST refresh fails. That path currently ends the whole subscription, as noted on src/handlers/subscribe/handler.ts Line 246. Add a test where fetchBalance rejects, then assert that the subscription stays open and that a later outboundAccountPosition event is still delivered. The test locks in the error-isolation fix.

Do you want me to write this test?

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/subscribe-user-stream.test.ts` around lines 558 - 595, Add a test
alongside “refreshes the authoritative primary balance for balanceUpdate” that
makes primary.fetchBalance reject, emits a balanceUpdate, then emits a later
outboundAccountPosition event and verifies that event is still delivered through
the open subscription. Assert the rejected refresh does not terminate the
subscription, while preserving existing primary/secondary call expectations
where applicable.

297-304: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Give waitFor a real time budget to avoid flaky timeouts.

setTimeout(resolve, 0) yields one macrotask tick. Twenty ticks is a very short budget for the background archive writes that the callers wait on. archiveSubscribeStreamInBackground and archiveCexStreamEventInBackground are fire-and-forget and await their own promises, so a loaded CI runner can exceed twenty ticks and the helper throws.

Add a per-attempt delay and accept a configurable budget. Also note that the callers at Line 552 and Line 771 poll with strict equality on the row count. If archiving ever enqueues more rows than expected, the condition never becomes true and the failure surfaces as a generic timeout. Poll with >= and keep the exact-count assertion separate.

♻️ Proposed change
-async function waitFor(condition: () => boolean): Promise<void> {
-	for (let attempt = 0; attempt < 20; attempt += 1) {
-		if (condition()) return;
-		await new Promise((resolve) => setTimeout(resolve, 0));
-	}
-	throw new Error("Timed out waiting for test condition");
-}
+async function waitFor(
+	condition: () => boolean,
+	{ timeoutMs = 2_000, intervalMs = 10 }: { timeoutMs?: number; intervalMs?: number } = {},
+): Promise<void> {
+	const deadline = Date.now() + timeoutMs;
+	while (Date.now() < deadline) {
+		if (condition()) return;
+		await new Promise((resolve) => setTimeout(resolve, intervalMs));
+	}
+	if (condition()) return;
+	throw new Error(`Timed out waiting for test condition after ${timeoutMs}ms`);
+}

Then relax the call sites:

-		await waitFor(() => archive.rows.length === 2);
+		await waitFor(() => archive.rows.length >= 2);
+		expect(archive.rows).toHaveLength(2);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/subscribe-user-stream.test.ts` around lines 297 - 304, Update waitFor to
use a real per-attempt delay and accept a configurable timeout or attempt
budget, preserving its timeout failure behavior. In the call sites around
archiveSubscribeStreamInBackground and archiveCexStreamEventInBackground, poll
for row counts using greater-than-or-equal comparisons so extra archived rows do
not cause polling to time out. Keep exact row-count validation as a separate
assertion after waitFor completes.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/handlers/subscribe/handler.ts`:
- Around line 246-259: Wrap the per-event normalization and frame-writing flow
in the handler’s event loop with a try/catch so failures from
normalizeBinanceSpotBalanceEvent or normalizeBinanceExecutionReport are recorded
and then continue processing subsequent events instead of escaping the loop. Log
the failure and increment the available metric via archiveContext?.otelMetrics
if appropriate, while preserving the existing break behavior when
writeSubscribeFrame returns false.

In `@src/helpers/binance-user-data-normalization.ts`:
- Around line 22-24: Coalesce Binance balance refreshes in the normalization
flow around the outboundAccountPosition check by maintaining one in-flight
refresh per exchange and enforcing a minimum interval between refreshes. In
src/helpers/binance-user-data-normalization.ts lines 22-24, reuse the shared
promise and throttling state; in src/handlers/subscribe/handler.ts lines
246-249, trigger the refresh without awaiting it in the stream-draining loop, or
bound the wait so slow REST calls cannot block later events.

---

Nitpick comments:
In `@test/subscribe-user-stream.test.ts`:
- Around line 552-555: Update the archive assertion near the existing
archive.rows check to verify each archive table separately, including the table
used by archiveSubscribeStreamInBackground for BALANCE instead of asserting only
the combined row count. Mirror the per-table assertion pattern from the
listStatus test and confirm both tables contain the venue-native rawEvent.
- Around line 558-595: Add a test alongside “refreshes the authoritative primary
balance for balanceUpdate” that makes primary.fetchBalance reject, emits a
balanceUpdate, then emits a later outboundAccountPosition event and verifies
that event is still delivered through the open subscription. Assert the rejected
refresh does not terminate the subscription, while preserving existing
primary/secondary call expectations where applicable.
- Around line 297-304: Update waitFor to use a real per-attempt delay and accept
a configurable timeout or attempt budget, preserving its timeout failure
behavior. In the call sites around archiveSubscribeStreamInBackground and
archiveCexStreamEventInBackground, poll for row counts using
greater-than-or-equal comparisons so extra archived rows do not cause polling to
time out. Keep exact row-count validation as a separate assertion after waitFor
completes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e7642d62-eeff-4a0c-aa8f-aab44fffec52

📥 Commits

Reviewing files that changed from the base of the PR and between a9d3d1b and edf9a5b.

📒 Files selected for processing (3)
  • src/handlers/subscribe/handler.ts
  • src/helpers/binance-user-data-normalization.ts
  • test/subscribe-user-stream.test.ts
📜 Review details
⏰ Context from checks skipped due to timeout. (1)
  • GitHub Check: test
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2026-04-14T06:47:01.283Z
Learnt from: csmithington
Repo: usherlabs/cex-broker PR: 38
File: src/client.dev.ts:0-0
Timestamp: 2026-04-14T06:47:01.283Z
Learning: In this codebase, gRPC action constants such as `FetchTicker`, `FetchFees`, and `FetchAccountId` should be sourced from `src/helpers/constants.ts` and imported from there (e.g., used by both `src/client.dev.ts` and `src/server.ts`). Do not import or reference generated proto TypeScript artifacts (for example `./proto/cex_broker/Action`), since those generated files are git-ignored and won’t be available/committed consistently.

Applied to files:

  • src/handlers/subscribe/handler.ts
  • src/helpers/binance-user-data-normalization.ts
🔇 Additional comments (13)
src/helpers/binance-user-data-normalization.ts (5)

4-16: LGTM!


26-54: LGTM!


57-66: LGTM!


77-81: 🗄️ Data Integrity & Integration

No change required for fee or fees. ORDERS frames have no required fee fields, and the archive stores the raw Binance event in payload_json without reading these fields.

			> Likely an incorrect or invalid review comment.

72-72: 🎯 Functional Correctness

Keep the Exchange parameter type. @usherlabs/ccxt 0.0.14 declares both parseWsOrder and safeBalance on the base Exchange class.

			> Likely an incorrect or invalid review comment.
src/handlers/subscribe/handler.ts (2)

9-12: LGTM!


240-245: 🎯 Functional Correctness

Keep the listStatus skip. The guards admit only the event types handled by the normalizers.

test/subscribe-user-stream.test.ts (6)

6-7: LGTM!

Also applies to: 244-254


125-139: LGTM!


155-231: LGTM!


365-374: LGTM!


638-711: LGTM!


771-781: 📐 Maintainability & Code Quality

No ordering change is required.

Both helpers schedule FIFO microtasks. The handler invokes them in event order, so each table retains [listStatus, executionReport].

			> Likely an incorrect or invalid review comment.

Comment thread src/handlers/subscribe/handler.ts
Comment thread src/helpers/binance-user-data-normalization.ts
@outerlook

Copy link
Copy Markdown
Member Author

Addressed the review pass in commit 9be14be: archive tests now assert each destination table explicitly, asynchronous archive waits use a real time budget, readiness polling accepts the minimum expected count, and exact counts remain separately asserted. The suggested continue-on-normalization-error and asynchronous/throttled refresh behaviors were intentionally not adopted because they can silently drop, stale, or reorder authoritative account state. Verification: focused subscription tests 12/12, TypeScript build, targeted lint, formatting, and the full suite 583/583.

@outerlook
outerlook merged commit ffda65e into develop Aug 3, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant