Skip to content

Commit ffda65e

Browse files
authored
Merge pull request #101 from usherlabs/fix/binance-user-data-contract
fix: normalize Binance user-data subscriptions
2 parents a9d3d1b + 9be14be commit ffda65e

3 files changed

Lines changed: 455 additions & 56 deletions

File tree

src/handlers/subscribe/handler.ts

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@ import type { Exchange } from "@usherlabs/ccxt";
33
import { authenticateRequest } from "../../helpers/auth";
44
import {
55
BinanceSpotUserDataStream,
6-
type BinanceUserDataEvent,
76
isBinanceBalanceUserDataEvent,
87
isBinanceOrderUserDataEvent,
98
} from "../../helpers/binance-user-data-stream";
9+
import {
10+
normalizeBinanceExecutionReport,
11+
normalizeBinanceSpotBalanceEvent,
12+
} from "../../helpers/binance-user-data-normalization";
1013
import {
1114
type BrokerPoolEntry,
1215
createBroker,
@@ -208,19 +211,6 @@ async function streamBinanceUserData(
208211
}
209212

210213
const receivedTimestamp = Date.now();
211-
if (
212-
!(await writeSubscribeFrame(call, isClosed, {
213-
data: JSON.stringify({
214-
subscriptionId: message.subscriptionId,
215-
event,
216-
} satisfies BinanceUserDataEvent),
217-
timestamp: receivedTimestamp,
218-
symbol,
219-
type: subscriptionType,
220-
}))
221-
) {
222-
break;
223-
}
224214
const archiveSubscriptionType =
225215
subscriptionType === SubscriptionType.BALANCE ? "BALANCE" : "ORDERS";
226216
archiveSubscribeStreamInBackground(archiveContext?.archiver, {
@@ -246,6 +236,27 @@ async function streamBinanceUserData(
246236
},
247237
);
248238
}
239+
240+
if (
241+
subscriptionType === SubscriptionType.ORDERS &&
242+
event.e === "listStatus"
243+
) {
244+
continue;
245+
}
246+
const data =
247+
subscriptionType === SubscriptionType.BALANCE
248+
? await normalizeBinanceSpotBalanceEvent(broker, event)
249+
: normalizeBinanceExecutionReport(broker, event);
250+
if (
251+
!(await writeSubscribeFrame(call, isClosed, {
252+
data: JSON.stringify(data),
253+
timestamp: receivedTimestamp,
254+
symbol,
255+
type: subscriptionType,
256+
}))
257+
) {
258+
break;
259+
}
249260
}
250261
} finally {
251262
userDataStream.close();
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import type { Exchange } from "@usherlabs/ccxt";
2+
import { asRecord } from "./shared/guards";
3+
4+
function requireQuantity(
5+
entry: Record<string, unknown>,
6+
key: "f" | "l",
7+
): string {
8+
const value = entry[key];
9+
if (typeof value === "string" && value.trim().length > 0) {
10+
return value;
11+
}
12+
if (typeof value === "number" && Number.isFinite(value)) {
13+
return String(value);
14+
}
15+
throw new Error(`Invalid Binance balance quantity: ${key}`);
16+
}
17+
18+
export async function normalizeBinanceSpotBalanceEvent(
19+
exchange: Exchange,
20+
event: Record<string, unknown>,
21+
): Promise<unknown> {
22+
if (event.e !== "outboundAccountPosition") {
23+
return exchange.fetchBalance({ type: "spot" });
24+
}
25+
26+
if (!Array.isArray(event.B)) {
27+
throw new Error("Invalid Binance outboundAccountPosition balances");
28+
}
29+
30+
const timestamp =
31+
typeof event.E === "number" && Number.isFinite(event.E)
32+
? event.E
33+
: undefined;
34+
const balance: Record<string, unknown> = {
35+
info: event,
36+
...(timestamp !== undefined && {
37+
timestamp,
38+
datetime: new Date(timestamp).toISOString(),
39+
}),
40+
};
41+
42+
for (const rawEntry of event.B) {
43+
const entry = asRecord(rawEntry);
44+
const asset = entry?.a;
45+
if (!entry || typeof asset !== "string" || asset.length === 0) {
46+
throw new Error("Invalid Binance outboundAccountPosition asset");
47+
}
48+
balance[asset] = {
49+
free: requireQuantity(entry, "f"),
50+
used: requireQuantity(entry, "l"),
51+
};
52+
}
53+
54+
return exchange.safeBalance(balance);
55+
}
56+
57+
function getTradeId(value: unknown): string | undefined {
58+
if (
59+
(typeof value === "number" && Number.isFinite(value)) ||
60+
(typeof value === "string" && value.length > 0)
61+
) {
62+
const tradeId = String(value);
63+
return tradeId === "-1" ? undefined : tradeId;
64+
}
65+
return undefined;
66+
}
67+
68+
export function normalizeBinanceExecutionReport(
69+
exchange: Exchange,
70+
event: Record<string, unknown>,
71+
): Record<string, unknown> {
72+
const parsed = asRecord(exchange.parseWsOrder(event));
73+
if (!parsed) {
74+
throw new Error("Binance executionReport did not parse as an order");
75+
}
76+
77+
// executionReport commission is for the latest fill, while order stream
78+
// consumers interpret fee fields as cumulative snapshots.
79+
const { fee: _fee, fees: _fees, ...order } = parsed;
80+
const tradeId = getTradeId(event.t);
81+
return tradeId === undefined ? order : { ...order, tradeId };
82+
}

0 commit comments

Comments
 (0)