@imbingox/acex 0.4.0-beta.17 → 0.4.0-beta.18

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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @imbingox/acex
2
2
 
3
+ ## 0.4.0-beta.18
4
+
5
+ ### Minor Changes
6
+
7
+ - 3edefc1: Extend the public rate limiter SPI with optional topology plans, bucket reserve headroom, request priority, opaque reservations, and bucket-level snapshots. The default limiter now supports Binance REST topology registration, fixed-window bucket budget admission, cancel-priority reserve for Binance PAPI request weight, usage-header reconciliation, request-not-sent refunds, jittered bucket-level 429 fallback, and bucket-level 429/418 blocking while remaining backward compatible with existing custom `RateLimiter` implementations.
8
+
3
9
  ## 0.4.0-beta.17
4
10
 
5
11
  ### Minor Changes
package/docs/api.md CHANGED
@@ -253,7 +253,7 @@ const client = createClient({
253
253
  });
254
254
  ```
255
255
 
256
- `clock` 只用于 outbound request / signing timestamp,不驱动 WebSocket freshness 的 received-at 时钟。需要自定义 REST 限流行为时可传 `rateLimiter`,否则使用默认 reactive limiter。Binance `riskPollIntervalMs` 默认 5s,用于风险和 mark-to-market 仓位刷新;`privateReconcileIntervalMs` 默认 60s,用于账户余额、仓位和订单状态 REST 对账,显式传 `0` 可关闭 private reconcile,但不关闭 risk polling。`sandbox`、`logger`、`logLevel` 目前是预留位。
256
+ `clock` 只用于 outbound request / signing timestamp,不驱动 WebSocket freshness 的 received-at 时钟。需要自定义 REST 限流行为时可传 `rateLimiter`,否则使用默认 bucket-aware budget limiter:它会注册 Binance REST topology,在 `beforeRequest` 中按固定窗口和 `rateLimit.utilizationTarget`(默认 0.9)主动预扣预算,接近上限时 sleep 到下一窗口;Binance PAPI request-weight 桶为 `priority:"cancel"` 保留 headroom,撤单请求仍计入真实 weight 但可使用保留区;响应后的 Binance usage header 会回填校正 bucket 用量,429/418 block 也会落到对应 bucket,缺少 `Retry-After` 的 429 会冷却到窗口结束并带小 jitter。Binance `riskPollIntervalMs` 默认 5s,用于风险和 mark-to-market 仓位刷新;`privateReconcileIntervalMs` 默认 60s,用于账户余额、仓位和订单状态 REST 对账,显式传 `0` 可关闭 private reconcile,但不关闭 risk polling。`sandbox`、`logger`、`logLevel` 目前是预留位。
257
257
 
258
258
  ### 4.2 `start()` / `stop()`
259
259
 
@@ -599,6 +599,9 @@ interface CreateClientOptions {
599
599
  sandbox?: boolean;
600
600
  clock?: { now(): number };
601
601
  rateLimiter?: RateLimiter;
602
+ rateLimit?: {
603
+ utilizationTarget?: number;
604
+ };
602
605
  logger?: Logger;
603
606
  logLevel?: "debug" | "info" | "warn" | "error";
604
607
  market?: {
@@ -641,14 +644,77 @@ interface RateLimitUsage {
641
644
  orderCount?: Record<string, number>;
642
645
  }
643
646
 
647
+ type RateLimitPriority = "normal" | "cancel" | "risk" | (string & {});
648
+ type RateLimitBucketKind = "request_weight" | "orders" | (string & {});
649
+ type RateLimitScopeDimension = "venue" | "account" | "endpoint";
650
+
651
+ interface RateLimitBucketReserve {
652
+ priority: RateLimitPriority;
653
+ units: number;
654
+ }
655
+
656
+ interface RateLimitBucketDescriptor {
657
+ id: string;
658
+ kind: RateLimitBucketKind;
659
+ limit: number;
660
+ intervalMs: number;
661
+ scope: readonly RateLimitScopeDimension[];
662
+ utilizationTarget?: number;
663
+ reserve?: RateLimitBucketReserve;
664
+ }
665
+
666
+ interface RateLimitCost {
667
+ bucketId: string;
668
+ cost: number;
669
+ }
670
+
671
+ interface RateLimitPlan {
672
+ id: string;
673
+ costs: readonly RateLimitCost[];
674
+ priority?: RateLimitPriority;
675
+ }
676
+
677
+ interface RateLimitTopology {
678
+ id: string;
679
+ buckets: readonly RateLimitBucketDescriptor[];
680
+ plans: readonly RateLimitPlan[];
681
+ }
682
+
683
+ interface RateLimitReservation {
684
+ readonly __opaqueRateLimitReservation?: never;
685
+ }
686
+
687
+ interface RateLimitTopologyRegistry {
688
+ registerRateLimitTopology(topology: RateLimitTopology): void;
689
+ }
690
+
691
+ interface RateLimitBucketSnapshot {
692
+ bucketId: string;
693
+ kind: RateLimitBucketKind;
694
+ limit: number;
695
+ intervalMs: number;
696
+ utilizationTarget?: number;
697
+ reserve?: RateLimitBucketReserve;
698
+ used?: number;
699
+ windowStartMs?: number;
700
+ windowEndMs?: number;
701
+ blockedUntil?: number;
702
+ retryAfterMs?: number;
703
+ state: "ok" | "rate_limited" | "banned";
704
+ updatedAt?: number;
705
+ }
706
+
644
707
  interface RateLimitRequestContext {
645
708
  scope: RateLimitScope;
709
+ planId?: string;
710
+ priority?: RateLimitPriority;
646
711
  }
647
712
 
648
713
  interface RateLimitResponseContext {
649
714
  status: number;
650
715
  headers?: Headers;
651
716
  usage?: RateLimitUsage;
717
+ reservation?: RateLimitReservation;
652
718
  }
653
719
 
654
720
  interface RateLimitTransportErrorContext {
@@ -656,6 +722,8 @@ interface RateLimitTransportErrorContext {
656
722
  headers?: Headers;
657
723
  retryAfterMs?: number;
658
724
  usage?: RateLimitUsage;
725
+ reservation?: RateLimitReservation;
726
+ requestNotSent?: boolean;
659
727
  }
660
728
 
661
729
  interface RateLimitSnapshot {
@@ -665,10 +733,13 @@ interface RateLimitSnapshot {
665
733
  retryAfterMs?: number;
666
734
  state: "ok" | "rate_limited" | "banned";
667
735
  updatedAt?: number;
736
+ buckets?: RateLimitBucketSnapshot[];
668
737
  }
669
738
 
670
739
  interface RateLimiter {
671
- beforeRequest(ctx: RateLimitRequestContext): Promise<void> | void;
740
+ beforeRequest(
741
+ ctx: RateLimitRequestContext,
742
+ ): Promise<RateLimitReservation | void> | RateLimitReservation | void;
672
743
  afterResponse(
673
744
  ctx: RateLimitRequestContext,
674
745
  response: RateLimitResponseContext,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imbingox/acex",
3
- "version": "0.4.0-beta.17",
3
+ "version": "0.4.0-beta.18",
4
4
  "description": "Multi-exchange trading SDK for market data, account, and order management",
5
5
  "repository": {
6
6
  "type": "git",
@@ -17,6 +17,7 @@ import {
17
17
  type BinanceMarketDefinition,
18
18
  loadBinanceMarkets,
19
19
  } from "./market-catalog.ts";
20
+ import { registerBinanceRateLimitTopology } from "./rate-limit-topology.ts";
20
21
  import { fetchBinanceServerTime } from "./server-time.ts";
21
22
  import {
22
23
  type BinanceStreamDescriptor,
@@ -61,7 +62,9 @@ export class BinanceMarketAdapter implements MarketAdapter {
61
62
  private readonly options: {
62
63
  readonly rateLimiter?: RateLimiter;
63
64
  } = {},
64
- ) {}
65
+ ) {
66
+ registerBinanceRateLimitTopology(this.options.rateLimiter);
67
+ }
65
68
 
66
69
  async loadMarkets(): Promise<MarketDefinition[]> {
67
70
  const markets = await loadBinanceMarkets(fetch, {
@@ -11,8 +11,12 @@ import type {
11
11
  RateLimitScope,
12
12
  } from "../../types/index.ts";
13
13
  import { parseBinanceRateLimitUsage } from "./rate-limit.ts";
14
+ import { getBinanceCatalogRateLimitPlanId } from "./rate-limit-topology.ts";
14
15
 
15
- type FetchLike = typeof fetch;
16
+ type FetchLike = (
17
+ input: string | URL | Request,
18
+ init?: RequestInit,
19
+ ) => Promise<Response>;
16
20
 
17
21
  export type BinanceMarketFamily = "spot" | "usdm" | "coinm";
18
22
 
@@ -240,8 +244,13 @@ async function requestCatalogJson<T>(
240
244
  venue: "binance",
241
245
  endpointKey,
242
246
  };
247
+ const requestContext = {
248
+ scope,
249
+ planId: getBinanceCatalogRateLimitPlanId(endpointKey),
250
+ };
243
251
 
244
- await rateLimiter?.beforeRequest({ scope });
252
+ const reservation =
253
+ (await rateLimiter?.beforeRequest(requestContext)) ?? undefined;
245
254
 
246
255
  try {
247
256
  const response = await httpRequest<T>({
@@ -252,32 +261,28 @@ async function requestCatalogJson<T>(
252
261
  jsonParseMode: "response",
253
262
  retryPolicy: {
254
263
  idempotent: true,
255
- maxAttempts: 3,
264
+ maxAttempts: 1,
256
265
  },
257
266
  messages: BINANCE_CATALOG_HTTP_MESSAGES,
258
267
  });
259
268
 
260
- await rateLimiter?.afterResponse(
261
- { scope },
262
- {
263
- status: response.status,
264
- headers: response.headers,
265
- usage: parseBinanceRateLimitUsage(response.headers),
266
- },
267
- );
269
+ await rateLimiter?.afterResponse(requestContext, {
270
+ status: response.status,
271
+ headers: response.headers,
272
+ usage: parseBinanceRateLimitUsage(response.headers),
273
+ reservation,
274
+ });
268
275
 
269
276
  return response.body;
270
277
  } catch (error) {
271
278
  if (isTransportError(error)) {
272
- await rateLimiter?.onTransportError(
273
- { scope },
274
- {
275
- status: error.status,
276
- headers: error.headers,
277
- retryAfterMs: error.retryAfterMs,
278
- usage: parseBinanceRateLimitUsage(error.headers),
279
- },
280
- );
279
+ await rateLimiter?.onTransportError(requestContext, {
280
+ status: error.status,
281
+ headers: error.headers,
282
+ retryAfterMs: error.retryAfterMs,
283
+ usage: parseBinanceRateLimitUsage(error.headers),
284
+ reservation,
285
+ });
281
286
  }
282
287
 
283
288
  throw error;
@@ -11,6 +11,7 @@ import type {
11
11
  AccountCredentials,
12
12
  PositionSide,
13
13
  RateLimiter,
14
+ RateLimitPriority,
14
15
  RateLimitScope,
15
16
  TimeProvider,
16
17
  VenueAccountCapabilities,
@@ -35,10 +36,17 @@ import type {
35
36
  } from "../types.ts";
36
37
  import { normalizeBinanceErrorCode } from "./error-codes.ts";
37
38
  import { parseBinanceRateLimitUsage } from "./rate-limit.ts";
39
+ import {
40
+ getBinancePapiRateLimitPlanId,
41
+ registerBinanceRateLimitTopology,
42
+ } from "./rate-limit-topology.ts";
38
43
 
39
44
  type TimerHandle = ReturnType<typeof setInterval>;
40
45
  type SignedRequestMethod = "GET" | "POST" | "DELETE";
41
- type FetchLike = typeof fetch;
46
+ type FetchLike = (
47
+ input: string | URL | Request,
48
+ init?: RequestInit,
49
+ ) => Promise<Response>;
42
50
 
43
51
  interface BinancePapiBalance {
44
52
  asset?: string;
@@ -172,18 +180,14 @@ const BINANCE_PAPI_WS_BASE_URL = "wss://fstream.binance.com/pm/ws";
172
180
  const DEFAULT_RECV_WINDOW = 5_000;
173
181
  const DEFAULT_HTTP_TIMEOUT_MS = 10_000;
174
182
  const USDM_QUOTE_ASSETS = ["FDUSD", "USDC", "BUSD", "USDT"];
175
- const SAFE_READ_RETRY_POLICY: HttpRetryPolicy = {
183
+ const SINGLE_ATTEMPT_IDEMPOTENT_POLICY: HttpRetryPolicy = {
176
184
  idempotent: true,
177
- maxAttempts: 3,
185
+ maxAttempts: 1,
178
186
  };
179
187
  const NO_RETRY_POLICY: HttpRetryPolicy = {
180
188
  idempotent: false,
181
189
  maxAttempts: 1,
182
190
  };
183
- const LISTEN_KEY_KEEPALIVE_RETRY_POLICY: HttpRetryPolicy = {
184
- idempotent: true,
185
- maxAttempts: 3,
186
- };
187
191
  function getBinancePapiHttpMessages(timeoutMs: number): HttpClientMessages {
188
192
  return {
189
193
  http: ({ status, statusText, url, rawBody }) =>
@@ -674,7 +678,9 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
674
678
  readonly signingClock?: TimeProvider;
675
679
  readonly rateLimiter?: RateLimiter;
676
680
  } = {},
677
- ) {}
681
+ ) {
682
+ registerBinanceRateLimitTopology(this.options.rateLimiter);
683
+ }
678
684
 
679
685
  normalizeVenueErrorCode(code: string) {
680
686
  return normalizeBinanceErrorCode(code);
@@ -692,7 +698,7 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
692
698
  credentials,
693
699
  accountOptions,
694
700
  undefined,
695
- SAFE_READ_RETRY_POLICY,
701
+ SINGLE_ATTEMPT_IDEMPOTENT_POLICY,
696
702
  ),
697
703
  this.signedRequest<BinancePapiAccount>(
698
704
  "GET",
@@ -700,7 +706,7 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
700
706
  credentials,
701
707
  accountOptions,
702
708
  undefined,
703
- SAFE_READ_RETRY_POLICY,
709
+ SINGLE_ATTEMPT_IDEMPOTENT_POLICY,
704
710
  ),
705
711
  this.signedRequest<BinancePapiUmPosition[]>(
706
712
  "GET",
@@ -708,7 +714,7 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
708
714
  credentials,
709
715
  accountOptions,
710
716
  undefined,
711
- SAFE_READ_RETRY_POLICY,
717
+ SINGLE_ATTEMPT_IDEMPOTENT_POLICY,
712
718
  ),
713
719
  ]);
714
720
 
@@ -734,7 +740,7 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
734
740
  credentials,
735
741
  accountOptions,
736
742
  undefined,
737
- SAFE_READ_RETRY_POLICY,
743
+ SINGLE_ATTEMPT_IDEMPOTENT_POLICY,
738
744
  ),
739
745
  this.signedRequest<BinancePapiUmPosition[]>(
740
746
  "GET",
@@ -742,7 +748,7 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
742
748
  credentials,
743
749
  accountOptions,
744
750
  undefined,
745
- SAFE_READ_RETRY_POLICY,
751
+ SINGLE_ATTEMPT_IDEMPOTENT_POLICY,
746
752
  ),
747
753
  ]);
748
754
 
@@ -768,7 +774,7 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
768
774
  credentials,
769
775
  accountOptions,
770
776
  undefined,
771
- SAFE_READ_RETRY_POLICY,
777
+ SINGLE_ATTEMPT_IDEMPOTENT_POLICY,
772
778
  );
773
779
 
774
780
  return {
@@ -797,7 +803,7 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
797
803
  orderId: request.orderId,
798
804
  origClientOrderId: request.clientOrderId,
799
805
  },
800
- SAFE_READ_RETRY_POLICY,
806
+ SINGLE_ATTEMPT_IDEMPOTENT_POLICY,
801
807
  );
802
808
 
803
809
  return mapOpenOrder(response, receivedAt);
@@ -870,6 +876,7 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
870
876
  origClientOrderId: request.clientOrderId,
871
877
  },
872
878
  NO_RETRY_POLICY,
879
+ "cancel",
873
880
  );
874
881
 
875
882
  const mapped = mapOpenOrder(response, receivedAt);
@@ -896,7 +903,8 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
896
903
  {
897
904
  symbol,
898
905
  },
899
- SAFE_READ_RETRY_POLICY,
906
+ SINGLE_ATTEMPT_IDEMPOTENT_POLICY,
907
+ "cancel",
900
908
  );
901
909
 
902
910
  // Venue responds {code,msg}; returned updates are synthesized from the
@@ -911,6 +919,7 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
911
919
  symbol,
912
920
  },
913
921
  NO_RETRY_POLICY,
922
+ "cancel",
914
923
  );
915
924
 
916
925
  if (response.code !== undefined && `${response.code}` !== "200") {
@@ -1216,10 +1225,18 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
1216
1225
  accountOptions?: Record<string, unknown>,
1217
1226
  queryParams?: Record<string, string | undefined>,
1218
1227
  retryPolicy?: HttpRetryPolicy,
1228
+ priority?: RateLimitPriority,
1219
1229
  ): Promise<T> {
1220
1230
  const { apiKey, secret } = requirePrivateCredentials(credentials);
1221
1231
  const scope = this.rateLimitScope(method, path, accountOptions);
1222
- await this.options.rateLimiter?.beforeRequest({ scope });
1232
+ const requestContext = {
1233
+ scope,
1234
+ planId: getBinancePapiRateLimitPlanId(method, path, queryParams),
1235
+ priority,
1236
+ };
1237
+ const reservation =
1238
+ (await this.options.rateLimiter?.beforeRequest(requestContext)) ??
1239
+ undefined;
1223
1240
 
1224
1241
  const params = new URLSearchParams();
1225
1242
  for (const [key, value] of Object.entries(queryParams ?? {})) {
@@ -1258,27 +1275,23 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
1258
1275
  messages: getBinancePapiHttpMessages(timeoutMs),
1259
1276
  });
1260
1277
 
1261
- await this.options.rateLimiter?.afterResponse(
1262
- { scope },
1263
- {
1264
- status: response.status,
1265
- headers: response.headers,
1266
- usage: parseBinanceRateLimitUsage(response.headers),
1267
- },
1268
- );
1278
+ await this.options.rateLimiter?.afterResponse(requestContext, {
1279
+ status: response.status,
1280
+ headers: response.headers,
1281
+ usage: parseBinanceRateLimitUsage(response.headers),
1282
+ reservation,
1283
+ });
1269
1284
 
1270
1285
  return response.body;
1271
1286
  } catch (error) {
1272
1287
  if (isTransportError(error)) {
1273
- await this.options.rateLimiter?.onTransportError(
1274
- { scope },
1275
- {
1276
- status: error.status,
1277
- headers: error.headers,
1278
- retryAfterMs: error.retryAfterMs,
1279
- usage: parseBinanceRateLimitUsage(error.headers),
1280
- },
1281
- );
1288
+ await this.options.rateLimiter?.onTransportError(requestContext, {
1289
+ status: error.status,
1290
+ headers: error.headers,
1291
+ retryAfterMs: error.retryAfterMs,
1292
+ usage: parseBinanceRateLimitUsage(error.headers),
1293
+ reservation,
1294
+ });
1282
1295
  }
1283
1296
 
1284
1297
  throw error;
@@ -1312,7 +1325,7 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
1312
1325
  "PUT",
1313
1326
  credentials,
1314
1327
  listenKey,
1315
- LISTEN_KEY_KEEPALIVE_RETRY_POLICY,
1328
+ SINGLE_ATTEMPT_IDEMPOTENT_POLICY,
1316
1329
  accountOptions,
1317
1330
  );
1318
1331
  }
@@ -1344,7 +1357,13 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
1344
1357
  "/papi/v1/listenKey",
1345
1358
  accountOptions,
1346
1359
  );
1347
- await this.options.rateLimiter?.beforeRequest({ scope });
1360
+ const requestContext = {
1361
+ scope,
1362
+ planId: getBinancePapiRateLimitPlanId(method, "/papi/v1/listenKey"),
1363
+ };
1364
+ const reservation =
1365
+ (await this.options.rateLimiter?.beforeRequest(requestContext)) ??
1366
+ undefined;
1348
1367
 
1349
1368
  const params = new URLSearchParams();
1350
1369
  if (listenKey) {
@@ -1371,27 +1390,23 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
1371
1390
  messages: getBinancePapiHttpMessages(timeoutMs),
1372
1391
  });
1373
1392
 
1374
- await this.options.rateLimiter?.afterResponse(
1375
- { scope },
1376
- {
1377
- status: response.status,
1378
- headers: response.headers,
1379
- usage: parseBinanceRateLimitUsage(response.headers),
1380
- },
1381
- );
1393
+ await this.options.rateLimiter?.afterResponse(requestContext, {
1394
+ status: response.status,
1395
+ headers: response.headers,
1396
+ usage: parseBinanceRateLimitUsage(response.headers),
1397
+ reservation,
1398
+ });
1382
1399
 
1383
1400
  return response.body;
1384
1401
  } catch (error) {
1385
1402
  if (isTransportError(error)) {
1386
- await this.options.rateLimiter?.onTransportError(
1387
- { scope },
1388
- {
1389
- status: error.status,
1390
- headers: error.headers,
1391
- retryAfterMs: error.retryAfterMs,
1392
- usage: parseBinanceRateLimitUsage(error.headers),
1393
- },
1394
- );
1403
+ await this.options.rateLimiter?.onTransportError(requestContext, {
1404
+ status: error.status,
1405
+ headers: error.headers,
1406
+ retryAfterMs: error.retryAfterMs,
1407
+ usage: parseBinanceRateLimitUsage(error.headers),
1408
+ reservation,
1409
+ });
1395
1410
  }
1396
1411
 
1397
1412
  throw error;