@imbingox/acex 0.3.0-beta.3 → 0.3.0-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -60,11 +60,14 @@ await client.stop();
60
60
 
61
61
  ### 同一个 client 同时使用 Binance + Juplend
62
62
 
63
- `createClient({ account: { juplend: { pollIntervalMs } } })` 只是配置 Juplend 账户的 polling 间隔,不代表这个 client 只能注册 Juplend。一个 `AcexClient` 可以同时注册 Binance 交易账户和 Juplend 借贷只读账户,用同一个 `AccountManager` 对比风险值。
63
+ `createClient({ account: { binance: { riskPollIntervalMs }, juplend: { pollIntervalMs } } })` 只是分别配置 Binance 风险/仓位校准间隔和 Juplend 账户 polling 间隔,不代表这个 client 只能注册某个 venue。一个 `AcexClient` 可以同时注册 Binance 交易账户和 Juplend 借贷只读账户,用同一个 `AccountManager` 对比风险值。
64
64
 
65
65
  ```ts
66
66
  const client = createClient({
67
67
  account: {
68
+ binance: {
69
+ riskPollIntervalMs: 5_000,
70
+ },
68
71
  juplend: {
69
72
  pollIntervalMs: 30_000,
70
73
  },
package/docs/api.md CHANGED
@@ -64,11 +64,14 @@ for await (const event of client.market.events.l1BookUpdates({
64
64
  await client.stop();
65
65
  ```
66
66
 
67
- 同一个 client 可以同时注册 Binance 交易账户和 Juplend 借贷只读账户。`account.juplend.pollIntervalMs` 只是 Juplend polling 配置,不会把 client 限定为 Juplend 专用:
67
+ 同一个 client 可以同时注册 Binance 交易账户和 Juplend 借贷只读账户。`account.binance.riskPollIntervalMs` 只配置 Binance 风险/仓位校准间隔;`account.juplend.pollIntervalMs` 只配置 Juplend polling 间隔,不会把 client 限定为某个 venue:
68
68
 
69
69
  ```ts
70
70
  const client = createClient({
71
71
  account: {
72
+ binance: {
73
+ riskPollIntervalMs: 5_000,
74
+ },
72
75
  juplend: {
73
76
  pollIntervalMs: 30_000,
74
77
  },
@@ -208,6 +211,9 @@ const client = createClient({
208
211
  streamReconnectDelayMs: 1_000,
209
212
  streamReconnectMaxDelayMs: 10_000,
210
213
  listenKeyKeepAliveMs: 30 * 60_000,
214
+ binance: {
215
+ riskPollIntervalMs: 5_000,
216
+ },
211
217
  juplend: {
212
218
  pollIntervalMs: 30_000,
213
219
  },
@@ -902,9 +908,6 @@ type PrivateRuntimeStatus =
902
908
  type PrivateRuntimeReason =
903
909
  | "credentials_missing" | "auth_failed" | "http_failed" | "rate_limited"
904
910
  | "ws_disconnected" | "heartbeat_timeout" | "reconciling";
905
- type PrivateRuntimeReason =
906
- | "credentials_missing" | "auth_failed"
907
- | "ws_disconnected" | "heartbeat_timeout" | "reconciling";
908
911
 
909
912
  type OrderSide = "buy" | "sell";
910
913
  type OrderStatus =
@@ -930,6 +933,9 @@ interface AccountRuntimeOptions {
930
933
  streamReconnectDelayMs?: number;
931
934
  streamReconnectMaxDelayMs?: number;
932
935
  listenKeyKeepAliveMs?: number;
936
+ binance?: {
937
+ riskPollIntervalMs?: number; // 默认 5_000
938
+ };
933
939
  juplend?: {
934
940
  pollIntervalMs?: number;
935
941
  };
@@ -1218,6 +1224,7 @@ interface RiskSnapshot {
1218
1224
  venue: Venue;
1219
1225
  equity?: BigNumber;
1220
1226
  riskRatio?: BigNumber;
1227
+ actualLeverage?: BigNumber;
1221
1228
  initialMargin?: BigNumber;
1222
1229
  maintenanceMargin?: BigNumber;
1223
1230
  exchangeTs?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imbingox/acex",
3
- "version": "0.3.0-beta.3",
3
+ "version": "0.3.0-beta.4",
4
4
  "description": "Multi-exchange trading SDK for market data, account, and order management",
5
5
  "repository": {
6
6
  "type": "git",
@@ -48,11 +48,11 @@
48
48
  "devDependencies": {
49
49
  "@biomejs/biome": "^2.4.10",
50
50
  "@changesets/cli": "^2.31.0",
51
+ "@mindfoldhq/trellis": "^0.4.0",
51
52
  "@types/bun": "latest",
52
53
  "typescript": "^6.0.2"
53
54
  },
54
55
  "dependencies": {
55
- "@mindfoldhq/trellis": "^0.4.0",
56
56
  "bignumber.js": "^11.0.0"
57
57
  }
58
58
  }
@@ -56,6 +56,7 @@ interface BinancePapiUmPosition {
56
56
  unrealizedProfit?: string;
57
57
  liquidationPrice?: string;
58
58
  leverage?: string;
59
+ notional?: string;
59
60
  positionSide?: string;
60
61
  updateTime?: number;
61
62
  }
@@ -285,14 +286,18 @@ function mapBalance(
285
286
  function mapAccountRisk(
286
287
  input: BinancePapiAccount,
287
288
  receivedAt: number,
289
+ positions: BinancePapiUmPosition[] = [],
288
290
  ): RawRiskUpdate | undefined {
289
291
  const uniMmr = firstString(input.uniMMR);
290
292
  const riskRatio = uniMmr
291
293
  ? new BigNumber(1).dividedBy(uniMmr).toString(10)
292
294
  : undefined;
295
+ const equity = firstString(input.accountEquity, input.totalEquity);
296
+ const actualLeverage = calculateActualLeverage(equity, positions);
293
297
  const risk: RawRiskUpdate = {
294
- equity: firstString(input.accountEquity, input.totalEquity),
298
+ equity,
295
299
  riskRatio,
300
+ actualLeverage,
296
301
  initialMargin: firstString(
297
302
  input.accountInitialMargin,
298
303
  input.totalInitialMargin,
@@ -308,6 +313,7 @@ function mapAccountRisk(
308
313
  if (
309
314
  !risk.equity &&
310
315
  !risk.riskRatio &&
316
+ !risk.actualLeverage &&
311
317
  !risk.initialMargin &&
312
318
  !risk.maintenanceMargin
313
319
  ) {
@@ -317,6 +323,34 @@ function mapAccountRisk(
317
323
  return risk;
318
324
  }
319
325
 
326
+ function calculateActualLeverage(
327
+ equity: string | undefined,
328
+ positions: BinancePapiUmPosition[],
329
+ ): string | undefined {
330
+ if (!equity) {
331
+ return undefined;
332
+ }
333
+
334
+ const equityValue = new BigNumber(equity);
335
+ if (!equityValue.isFinite() || equityValue.isZero()) {
336
+ return undefined;
337
+ }
338
+
339
+ const grossExposure = positions.reduce((total, position) => {
340
+ const notional = firstString(position.notional);
341
+ if (!notional) {
342
+ return total;
343
+ }
344
+
345
+ const value = new BigNumber(notional);
346
+ return value.isFinite() ? total.plus(value.absoluteValue()) : total;
347
+ }, new BigNumber(0));
348
+
349
+ return grossExposure.isZero()
350
+ ? undefined
351
+ : grossExposure.dividedBy(equityValue).toString(10);
352
+ }
353
+
320
354
  function mapUmPosition(
321
355
  input: BinancePapiUmPosition,
322
356
  receivedAt: number,
@@ -339,6 +373,22 @@ function mapUmPosition(
339
373
  };
340
374
  }
341
375
 
376
+ function mapAccountRefresh(
377
+ account: BinancePapiAccount,
378
+ positions: BinancePapiUmPosition[],
379
+ receivedAt: number,
380
+ ): RawAccountUpdate {
381
+ return {
382
+ positions: positions.flatMap((position) => {
383
+ const mapped = mapUmPosition(position, receivedAt);
384
+ return mapped ? [mapped] : [];
385
+ }),
386
+ risk: mapAccountRisk(account, receivedAt, positions),
387
+ exchangeTs: account.updateTime,
388
+ receivedAt,
389
+ };
390
+ }
391
+
342
392
  function mapOpenOrder(
343
393
  input: BinancePapiOpenOrder,
344
394
  receivedAt: number,
@@ -550,12 +600,35 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
550
600
  const mapped = mapUmPosition(position, receivedAt);
551
601
  return mapped ? [mapped] : [];
552
602
  }),
553
- risk: mapAccountRisk(account, receivedAt),
603
+ risk: mapAccountRisk(account, receivedAt, positions),
554
604
  exchangeTs: account.updateTime,
555
605
  receivedAt,
556
606
  };
557
607
  }
558
608
 
609
+ async refreshAccount(
610
+ credentials: AccountCredentials,
611
+ accountOptions?: Record<string, unknown>,
612
+ ): Promise<RawAccountUpdate> {
613
+ const receivedAt = Date.now();
614
+ const [account, positions] = await Promise.all([
615
+ this.signedRequest<BinancePapiAccount>(
616
+ "GET",
617
+ "/papi/v1/account",
618
+ credentials,
619
+ accountOptions,
620
+ ),
621
+ this.signedRequest<BinancePapiUmPosition[]>(
622
+ "GET",
623
+ "/papi/v1/um/positionRisk",
624
+ credentials,
625
+ accountOptions,
626
+ ),
627
+ ]);
628
+
629
+ return mapAccountRefresh(account, positions, receivedAt);
630
+ }
631
+
559
632
  async bootstrapOpenOrders(
560
633
  credentials: AccountCredentials,
561
634
  accountOptions?: Record<string, unknown>,
@@ -121,6 +121,7 @@ export interface RawPositionUpdate {
121
121
  export interface RawRiskUpdate {
122
122
  equity?: string;
123
123
  riskRatio?: string;
124
+ actualLeverage?: string;
124
125
  initialMargin?: string;
125
126
  maintenanceMargin?: string;
126
127
  exchangeTs?: number;
@@ -222,6 +223,10 @@ export interface PrivateUserDataAdapter {
222
223
  credentials: AccountCredentials,
223
224
  accountOptions?: Record<string, unknown>,
224
225
  ): Promise<RawAccountBootstrap>;
226
+ refreshAccount?(
227
+ credentials: AccountCredentials,
228
+ accountOptions?: Record<string, unknown>,
229
+ ): Promise<RawAccountUpdate>;
225
230
  bootstrapOpenOrders(
226
231
  credentials: AccountCredentials,
227
232
  accountOptions?: Record<string, unknown>,
@@ -75,6 +75,7 @@ export interface PrivateAccountDataConsumer {
75
75
  accountId: string,
76
76
  venue: Venue,
77
77
  update: RawAccountUpdate,
78
+ options?: { preserveStatus?: boolean },
78
79
  ): void;
79
80
  onPrivateAccountStreamState(
80
81
  accountId: string,
@@ -19,6 +19,9 @@ interface PrivateSubscriptionRecord {
19
19
  accountReady: boolean;
20
20
  orderReady: boolean;
21
21
  stream?: StreamHandle;
22
+ accountRefreshTimer?: ReturnType<typeof setTimeout>;
23
+ accountRefreshInFlight?: Promise<void>;
24
+ accountRefreshGeneration: number;
22
25
  startPromise?: Promise<void>;
23
26
  reconcilePromise?: Promise<void>;
24
27
  }
@@ -27,6 +30,16 @@ const DEFAULT_STREAM_OPEN_TIMEOUT_MS = 15_000;
27
30
  const DEFAULT_STREAM_RECONNECT_DELAY_MS = 1_000;
28
31
  const DEFAULT_STREAM_RECONNECT_MAX_DELAY_MS = 10_000;
29
32
  const DEFAULT_LISTEN_KEY_KEEPALIVE_MS = 30 * 60 * 1_000;
33
+ const DEFAULT_BINANCE_RISK_POLL_INTERVAL_MS = 5_000;
34
+
35
+ function normalizePositiveInterval(
36
+ value: number | undefined,
37
+ fallback: number,
38
+ ): number {
39
+ return value !== undefined && Number.isFinite(value) && value > 0
40
+ ? value
41
+ : fallback;
42
+ }
30
43
 
31
44
  export class PrivateSubscriptionCoordinator {
32
45
  private readonly context: ClientContext;
@@ -37,6 +50,7 @@ export class PrivateSubscriptionCoordinator {
37
50
  private readonly streamReconnectDelayMs: number;
38
51
  private readonly streamReconnectMaxDelayMs: number;
39
52
  private readonly listenKeyKeepAliveMs: number;
53
+ private readonly binanceRiskPollIntervalMs: number;
40
54
  private readonly juplendPollIntervalMs?: number;
41
55
  private readonly records = new Map<string, PrivateSubscriptionRecord>();
42
56
 
@@ -62,6 +76,10 @@ export class PrivateSubscriptionCoordinator {
62
76
  DEFAULT_STREAM_RECONNECT_MAX_DELAY_MS;
63
77
  this.listenKeyKeepAliveMs =
64
78
  options.listenKeyKeepAliveMs ?? DEFAULT_LISTEN_KEY_KEEPALIVE_MS;
79
+ this.binanceRiskPollIntervalMs = normalizePositiveInterval(
80
+ options.binance?.riskPollIntervalMs,
81
+ DEFAULT_BINANCE_RISK_POLL_INTERVAL_MS,
82
+ );
65
83
  this.juplendPollIntervalMs = options.juplend?.pollIntervalMs;
66
84
  }
67
85
 
@@ -81,6 +99,7 @@ export class PrivateSubscriptionCoordinator {
81
99
  } else {
82
100
  await this.ensureStream(record, account);
83
101
  await this.bootstrapAccount(record, account);
102
+ this.ensureAccountRefreshPolling(record);
84
103
  }
85
104
  } catch (error) {
86
105
  record.accountSubscribed = false;
@@ -96,6 +115,7 @@ export class PrivateSubscriptionCoordinator {
96
115
  }
97
116
 
98
117
  record.accountSubscribed = false;
118
+ this.stopAccountRefreshPolling(record);
99
119
  this.closeIfUnused(record);
100
120
  }
101
121
 
@@ -153,6 +173,7 @@ export class PrivateSubscriptionCoordinator {
153
173
 
154
174
  onClientStopping(): void {
155
175
  for (const record of this.records.values()) {
176
+ this.stopAccountRefreshPolling(record);
156
177
  this.closeStream(record);
157
178
  }
158
179
  }
@@ -164,6 +185,7 @@ export class PrivateSubscriptionCoordinator {
164
185
  }
165
186
 
166
187
  this.closeStream(record);
188
+ this.stopAccountRefreshPolling(record);
167
189
  this.records.delete(accountId);
168
190
  }
169
191
 
@@ -186,6 +208,7 @@ export class PrivateSubscriptionCoordinator {
186
208
  private async resumeRecord(record: PrivateSubscriptionRecord): Promise<void> {
187
209
  const account = this.getAccount(record.accountId);
188
210
  this.closeStream(record);
211
+ this.stopAccountRefreshPolling(record);
189
212
 
190
213
  try {
191
214
  if (record.venue === "juplend" && record.accountSubscribed) {
@@ -195,6 +218,7 @@ export class PrivateSubscriptionCoordinator {
195
218
  await this.ensureStream(record, account);
196
219
  if (record.accountSubscribed) {
197
220
  await this.bootstrapAccount(record, account);
221
+ this.ensureAccountRefreshPolling(record);
198
222
  }
199
223
  }
200
224
  if (record.ordersSubscribed) {
@@ -244,6 +268,7 @@ export class PrivateSubscriptionCoordinator {
244
268
  ordersSubscribed: false,
245
269
  accountReady: false,
246
270
  orderReady: false,
271
+ accountRefreshGeneration: 0,
247
272
  };
248
273
 
249
274
  this.records.set(account.accountId, record);
@@ -259,6 +284,7 @@ export class PrivateSubscriptionCoordinator {
259
284
  return;
260
285
  }
261
286
 
287
+ this.stopAccountRefreshPolling(record);
262
288
  this.closeStream(record);
263
289
  this.records.delete(record.accountId);
264
290
  }
@@ -268,6 +294,153 @@ export class PrivateSubscriptionCoordinator {
268
294
  record.stream = undefined;
269
295
  }
270
296
 
297
+ private ensureAccountRefreshPolling(record: PrivateSubscriptionRecord): void {
298
+ if (
299
+ record.venue !== "binance" ||
300
+ !record.accountSubscribed ||
301
+ record.accountRefreshTimer ||
302
+ record.accountRefreshInFlight
303
+ ) {
304
+ return;
305
+ }
306
+
307
+ this.scheduleAccountRefreshPoll(record);
308
+ }
309
+
310
+ private stopAccountRefreshPolling(record: PrivateSubscriptionRecord): void {
311
+ record.accountRefreshGeneration += 1;
312
+ if (record.accountRefreshTimer) {
313
+ clearTimeout(record.accountRefreshTimer);
314
+ record.accountRefreshTimer = undefined;
315
+ }
316
+ record.accountRefreshInFlight = undefined;
317
+ }
318
+
319
+ private scheduleAccountRefreshPoll(record: PrivateSubscriptionRecord): void {
320
+ if (record.venue !== "binance" || !record.accountSubscribed) {
321
+ return;
322
+ }
323
+
324
+ const generation = record.accountRefreshGeneration;
325
+ record.accountRefreshTimer = setTimeout(() => {
326
+ record.accountRefreshTimer = undefined;
327
+ if (
328
+ generation !== record.accountRefreshGeneration ||
329
+ record.venue !== "binance" ||
330
+ !record.accountSubscribed
331
+ ) {
332
+ return;
333
+ }
334
+
335
+ let latestAccount: RegisteredAccountRecord;
336
+ try {
337
+ latestAccount = this.getAccount(record.accountId);
338
+ } catch (error) {
339
+ this.handleAccountRefreshLookupError(record, error);
340
+ return;
341
+ }
342
+
343
+ record.accountRefreshInFlight = this.refreshAccount(
344
+ record,
345
+ latestAccount,
346
+ generation,
347
+ )
348
+ .catch(() => {})
349
+ .finally(() => {
350
+ if (generation !== record.accountRefreshGeneration) {
351
+ return;
352
+ }
353
+
354
+ record.accountRefreshInFlight = undefined;
355
+ if (record.accountSubscribed && record.venue === "binance") {
356
+ this.scheduleAccountRefreshPoll(record);
357
+ }
358
+ });
359
+ }, this.binanceRiskPollIntervalMs);
360
+ }
361
+
362
+ private handleAccountRefreshLookupError(
363
+ record: PrivateSubscriptionRecord,
364
+ error: unknown,
365
+ ): void {
366
+ this.stopAccountRefreshPolling(record);
367
+ if (error instanceof AcexError && error.code === "ACCOUNT_NOT_FOUND") {
368
+ return;
369
+ }
370
+
371
+ this.context.publishRuntimeError(
372
+ "adapter",
373
+ error instanceof Error
374
+ ? error
375
+ : new Error(`Failed to load ${record.venue} account for risk refresh`),
376
+ {
377
+ accountId: record.accountId,
378
+ venue: record.venue,
379
+ },
380
+ );
381
+ }
382
+
383
+ private async refreshAccount(
384
+ record: PrivateSubscriptionRecord,
385
+ account: RegisteredAccountRecord,
386
+ generation: number,
387
+ ): Promise<void> {
388
+ const adapter = this.getAdapter(record.venue);
389
+ if (!adapter.refreshAccount) {
390
+ return;
391
+ }
392
+
393
+ try {
394
+ const update = await adapter.refreshAccount(account.credentials ?? {}, {
395
+ ...account.options,
396
+ accountId: account.accountId,
397
+ });
398
+ if (
399
+ !record.accountSubscribed ||
400
+ generation !== record.accountRefreshGeneration
401
+ ) {
402
+ return;
403
+ }
404
+
405
+ record.accountReady = true;
406
+ this.accountConsumer.onPrivateAccountUpdate(
407
+ record.accountId,
408
+ record.venue,
409
+ update,
410
+ { preserveStatus: true },
411
+ );
412
+ } catch (error) {
413
+ if (
414
+ !record.accountSubscribed ||
415
+ generation !== record.accountRefreshGeneration
416
+ ) {
417
+ return;
418
+ }
419
+
420
+ this.context.publishRuntimeError(
421
+ "adapter",
422
+ error instanceof Error
423
+ ? error
424
+ : new Error(
425
+ `Failed to refresh ${record.venue} private account state`,
426
+ ),
427
+ {
428
+ accountId: record.accountId,
429
+ venue: record.venue,
430
+ },
431
+ );
432
+ this.accountConsumer.onPrivateAccountStreamState(
433
+ record.accountId,
434
+ record.venue,
435
+ {
436
+ runtimeStatus: "degraded",
437
+ ready: record.accountReady,
438
+ reason: "http_failed",
439
+ },
440
+ );
441
+ }
442
+ }
443
+
271
444
  private async ensureStream(
272
445
  record: PrivateSubscriptionRecord,
273
446
  account: RegisteredAccountRecord,
@@ -277,6 +277,7 @@ export class AccountManagerImpl
277
277
  accountId: string,
278
278
  venue: Venue,
279
279
  update: RawAccountUpdate,
280
+ options: { preserveStatus?: boolean } = {},
280
281
  ): void {
281
282
  const record = this.getOrCreateRecord(accountId, venue);
282
283
  if (!record.subscribed) {
@@ -358,16 +359,24 @@ export class AccountManagerImpl
358
359
  receivedAt: update.receivedAt,
359
360
  updatedAt: update.receivedAt,
360
361
  };
361
- record.status = {
362
- ...record.status,
363
- activity: "active",
364
- ready: true,
365
- runtimeStatus: "healthy",
366
- reason: undefined,
367
- lastReceivedAt: update.receivedAt,
368
- lastReadyAt: update.receivedAt,
369
- inactiveSince: undefined,
370
- };
362
+ record.status = options.preserveStatus
363
+ ? {
364
+ ...record.status,
365
+ activity: "active",
366
+ lastReceivedAt: update.receivedAt,
367
+ lastReadyAt: record.status.lastReadyAt ?? update.receivedAt,
368
+ inactiveSince: undefined,
369
+ }
370
+ : {
371
+ ...record.status,
372
+ activity: "active",
373
+ ready: true,
374
+ runtimeStatus: "healthy",
375
+ reason: undefined,
376
+ lastReceivedAt: update.receivedAt,
377
+ lastReadyAt: update.receivedAt,
378
+ inactiveSince: undefined,
379
+ };
371
380
  this.publishStatus(record);
372
381
  }
373
382
 
@@ -588,6 +597,10 @@ export class AccountManagerImpl
588
597
  input.riskRatio === undefined
589
598
  ? previous?.riskRatio
590
599
  : new BigNumber(input.riskRatio),
600
+ actualLeverage:
601
+ input.actualLeverage === undefined
602
+ ? previous?.actualLeverage
603
+ : new BigNumber(input.actualLeverage),
591
604
  initialMargin:
592
605
  input.initialMargin === undefined
593
606
  ? previous?.initialMargin
@@ -93,6 +93,7 @@ export interface RiskSnapshot {
93
93
  venue: Venue;
94
94
  equity?: BigNumber;
95
95
  riskRatio?: BigNumber;
96
+ actualLeverage?: BigNumber;
96
97
  initialMargin?: BigNumber;
97
98
  maintenanceMargin?: BigNumber;
98
99
  exchangeTs?: number;
@@ -36,6 +36,9 @@ export interface AccountRuntimeOptions {
36
36
  streamReconnectDelayMs?: number;
37
37
  streamReconnectMaxDelayMs?: number;
38
38
  listenKeyKeepAliveMs?: number;
39
+ binance?: {
40
+ riskPollIntervalMs?: number;
41
+ };
39
42
  juplend?: {
40
43
  pollIntervalMs?: number;
41
44
  };