@imbingox/acex 0.4.0-beta.2 → 0.4.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/docs/api.md CHANGED
@@ -967,6 +967,8 @@ interface AccountRuntimeOptions {
967
967
 
968
968
  interface CreateClientOptions {
969
969
  sandbox?: boolean; // 预留,当前不生效
970
+ clock?: TimeProvider; // 注入签名/请求时钟,默认本地时钟
971
+ rateLimiter?: RateLimiter; // 注入限流器,默认 reactive(观测 + Retry-After 退避,不主动节流)
970
972
  logger?: Logger; // 预留
971
973
  logLevel?: LogLevel; // 预留
972
974
  market?: MarketRuntimeOptions;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imbingox/acex",
3
- "version": "0.4.0-beta.2",
3
+ "version": "0.4.0-beta.4",
4
4
  "description": "Multi-exchange trading SDK for market data, account, and order management",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,6 +1,7 @@
1
1
  import { SubscriptionMultiplexer } from "../../internal/subscription-multiplexer.ts";
2
2
  import type {
3
3
  MarketDefinition,
4
+ RateLimiter,
4
5
  VenueMarketCapabilities,
5
6
  } from "../../types/index.ts";
6
7
  import type {
@@ -53,8 +54,16 @@ export class BinanceMarketAdapter implements MarketAdapter {
53
54
  private multiplexer: BinanceMarketMultiplexer | undefined;
54
55
  private multiplexerConfig: BinanceMultiplexerConfig | undefined;
55
56
 
57
+ constructor(
58
+ private readonly options: {
59
+ readonly rateLimiter?: RateLimiter;
60
+ } = {},
61
+ ) {}
62
+
56
63
  async loadMarkets(): Promise<MarketDefinition[]> {
57
- const markets = await loadBinanceMarkets();
64
+ const markets = await loadBinanceMarkets(fetch, {
65
+ rateLimiter: this.options.rateLimiter,
66
+ });
58
67
  this.definitions.clear();
59
68
 
60
69
  for (const market of markets) {
@@ -2,8 +2,15 @@ import { toCanonical } from "../../internal/decimal.ts";
2
2
  import {
3
3
  type HttpClientMessages,
4
4
  httpRequest,
5
+ isTransportError,
5
6
  } from "../../internal/http-client.ts";
6
- import type { MarketDefinition, MarketType } from "../../types/index.ts";
7
+ import type {
8
+ MarketDefinition,
9
+ MarketType,
10
+ RateLimiter,
11
+ RateLimitScope,
12
+ } from "../../types/index.ts";
13
+ import { parseBinanceRateLimitUsage } from "./rate-limit.ts";
7
14
 
8
15
  type FetchLike = typeof fetch;
9
16
 
@@ -224,21 +231,55 @@ function normalizeDerivativesSymbol(
224
231
  async function requestCatalogJson<T>(
225
232
  fetchFn: FetchLike,
226
233
  url: string,
234
+ rateLimiter: RateLimiter | undefined,
235
+ endpointKey: string,
227
236
  ): Promise<T> {
228
- const response = await httpRequest<T>({
229
- fetchFn,
230
- url,
231
- timeoutMs: DEFAULT_HTTP_TIMEOUT_MS,
232
- parseAs: "json",
233
- jsonParseMode: "response",
234
- retryPolicy: {
235
- idempotent: true,
236
- maxAttempts: 3,
237
- },
238
- messages: BINANCE_CATALOG_HTTP_MESSAGES,
239
- });
240
-
241
- return response.body;
237
+ const scope: RateLimitScope = {
238
+ venue: "binance",
239
+ endpointKey,
240
+ };
241
+
242
+ await rateLimiter?.beforeRequest({ scope });
243
+
244
+ try {
245
+ const response = await httpRequest<T>({
246
+ fetchFn,
247
+ url,
248
+ timeoutMs: DEFAULT_HTTP_TIMEOUT_MS,
249
+ parseAs: "json",
250
+ jsonParseMode: "response",
251
+ retryPolicy: {
252
+ idempotent: true,
253
+ maxAttempts: 3,
254
+ },
255
+ messages: BINANCE_CATALOG_HTTP_MESSAGES,
256
+ });
257
+
258
+ await rateLimiter?.afterResponse(
259
+ { scope },
260
+ {
261
+ status: response.status,
262
+ headers: response.headers,
263
+ usage: parseBinanceRateLimitUsage(response.headers),
264
+ },
265
+ );
266
+
267
+ return response.body;
268
+ } catch (error) {
269
+ if (isTransportError(error)) {
270
+ await rateLimiter?.onTransportError(
271
+ { scope },
272
+ {
273
+ status: error.status,
274
+ headers: error.headers,
275
+ retryAfterMs: error.retryAfterMs,
276
+ usage: parseBinanceRateLimitUsage(error.headers),
277
+ },
278
+ );
279
+ }
280
+
281
+ throw error;
282
+ }
242
283
  }
243
284
 
244
285
  function sortMarkets(
@@ -251,19 +292,26 @@ function sortMarkets(
251
292
 
252
293
  export async function loadBinanceMarkets(
253
294
  fetchFn: FetchLike = fetch,
295
+ options: { readonly rateLimiter?: RateLimiter } = {},
254
296
  ): Promise<BinanceMarketDefinition[]> {
255
297
  const [spot, usdm, coinm] = await Promise.all([
256
298
  requestCatalogJson<BinanceSpotExchangeInfo>(
257
299
  fetchFn,
258
300
  BINANCE_SPOT_EXCHANGE_INFO_URL,
301
+ options.rateLimiter,
302
+ "GET /api/v3/exchangeInfo",
259
303
  ),
260
304
  requestCatalogJson<BinanceDerivativesExchangeInfo>(
261
305
  fetchFn,
262
306
  BINANCE_USDM_EXCHANGE_INFO_URL,
307
+ options.rateLimiter,
308
+ "GET /fapi/v1/exchangeInfo",
263
309
  ),
264
310
  requestCatalogJson<BinanceDerivativesExchangeInfo>(
265
311
  fetchFn,
266
312
  BINANCE_COINM_EXCHANGE_INFO_URL,
313
+ options.rateLimiter,
314
+ "GET /dapi/v1/exchangeInfo",
267
315
  ),
268
316
  ]);
269
317
 
@@ -4,11 +4,15 @@ import {
4
4
  type HttpClientMessages,
5
5
  type HttpRetryPolicy,
6
6
  httpRequest,
7
+ isTransportError,
7
8
  } from "../../internal/http-client.ts";
8
9
  import { createManagedWebSocket } from "../../internal/managed-websocket.ts";
9
10
  import type {
10
11
  AccountCredentials,
11
12
  PositionSide,
13
+ RateLimiter,
14
+ RateLimitScope,
15
+ TimeProvider,
12
16
  VenueAccountCapabilities,
13
17
  VenueOrderCapabilities,
14
18
  } from "../../types/index.ts";
@@ -27,6 +31,7 @@ import type {
27
31
  RawRiskUpdate,
28
32
  StreamHandle,
29
33
  } from "../types.ts";
34
+ import { parseBinanceRateLimitUsage } from "./rate-limit.ts";
30
35
 
31
36
  type TimerHandle = ReturnType<typeof setInterval>;
32
37
  type SignedRequestMethod = "GET" | "POST" | "DELETE";
@@ -204,6 +209,14 @@ function getNumberOption(
204
209
  : undefined;
205
210
  }
206
211
 
212
+ function getStringOption(
213
+ options: Record<string, unknown> | undefined,
214
+ key: string,
215
+ ): string | undefined {
216
+ const value = options?.[key];
217
+ return typeof value === "string" && value.length > 0 ? value : undefined;
218
+ }
219
+
207
220
  function signQuery(query: string, secret: string): string {
208
221
  return createHmac("sha256", secret).update(query).digest("hex");
209
222
  }
@@ -588,6 +601,8 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
588
601
  private readonly options: {
589
602
  readonly fetchFn?: FetchLike;
590
603
  readonly httpTimeoutMs?: number;
604
+ readonly signingClock?: TimeProvider;
605
+ readonly rateLimiter?: RateLimiter;
591
606
  } = {},
592
607
  ) {}
593
608
 
@@ -784,7 +799,7 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
784
799
  credentials: AccountCredentials,
785
800
  callbacks: PrivateStreamCallbacks,
786
801
  options: PrivateStreamOptions,
787
- _accountOptions?: Record<string, unknown>,
802
+ accountOptions?: Record<string, unknown>,
788
803
  ): StreamHandle {
789
804
  let closed = false;
790
805
  let listenKey: string | undefined;
@@ -806,17 +821,19 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
806
821
 
807
822
  const key = listenKey;
808
823
  listenKey = undefined;
809
- void this.closeUserDataStream(credentials, key).catch((error) => {
810
- callbacks.onError(
811
- error instanceof Error
812
- ? error
813
- : new Error("Failed to close Binance PAPI listenKey"),
814
- );
815
- });
824
+ void this.closeUserDataStream(credentials, key, accountOptions).catch(
825
+ (error) => {
826
+ callbacks.onError(
827
+ error instanceof Error
828
+ ? error
829
+ : new Error("Failed to close Binance PAPI listenKey"),
830
+ );
831
+ },
832
+ );
816
833
  };
817
834
 
818
835
  const ready = (async () => {
819
- listenKey = await this.startUserDataStream(credentials);
836
+ listenKey = await this.startUserDataStream(credentials, accountOptions);
820
837
  if (closed) {
821
838
  closeListenKey();
822
839
  return;
@@ -827,15 +844,17 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
827
844
  return;
828
845
  }
829
846
 
830
- void this.keepAliveUserDataStream(credentials, listenKey).catch(
831
- (error) => {
832
- callbacks.onError(
833
- error instanceof Error
834
- ? error
835
- : new Error("Failed to keep Binance PAPI listenKey alive"),
836
- );
837
- },
838
- );
847
+ void this.keepAliveUserDataStream(
848
+ credentials,
849
+ listenKey,
850
+ accountOptions,
851
+ ).catch((error) => {
852
+ callbacks.onError(
853
+ error instanceof Error
854
+ ? error
855
+ : new Error("Failed to keep Binance PAPI listenKey alive"),
856
+ );
857
+ });
839
858
  }, options.listenKeyKeepAliveMs);
840
859
 
841
860
  websocket = createManagedWebSocket<BinancePrivateMessage>({
@@ -903,6 +922,9 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
903
922
  retryPolicy?: HttpRetryPolicy,
904
923
  ): Promise<T> {
905
924
  const { apiKey, secret } = requirePrivateCredentials(credentials);
925
+ const scope = this.rateLimitScope(method, path, accountOptions);
926
+ await this.options.rateLimiter?.beforeRequest({ scope });
927
+
906
928
  const params = new URLSearchParams();
907
929
  for (const [key, value] of Object.entries(queryParams ?? {})) {
908
930
  if (value !== undefined) {
@@ -911,7 +933,11 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
911
933
  }
912
934
  params.set(
913
935
  "timestamp",
914
- `${getNumberOption(accountOptions, "timestamp") ?? Date.now()}`,
936
+ `${
937
+ getNumberOption(accountOptions, "timestamp") ??
938
+ this.options.signingClock?.now() ??
939
+ Date.now()
940
+ }`,
915
941
  );
916
942
  params.set(
917
943
  "recvWindow",
@@ -921,31 +947,58 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
921
947
 
922
948
  const url = `${BINANCE_PAPI_REST_BASE_URL}${path}?${params.toString()}`;
923
949
  const timeoutMs = this.options.httpTimeoutMs ?? DEFAULT_HTTP_TIMEOUT_MS;
924
- const response = await httpRequest<T>({
925
- fetchFn: this.options.fetchFn,
926
- url,
927
- method,
928
- headers: {
929
- "X-MBX-APIKEY": apiKey,
930
- },
931
- timeoutMs,
932
- parseAs: "json",
933
- emptyBody: "empty_object",
934
- retryPolicy: retryPolicy ?? NO_RETRY_POLICY,
935
- messages: getBinancePapiHttpMessages(timeoutMs),
936
- });
950
+ try {
951
+ const response = await httpRequest<T>({
952
+ fetchFn: this.options.fetchFn,
953
+ url,
954
+ method,
955
+ headers: {
956
+ "X-MBX-APIKEY": apiKey,
957
+ },
958
+ timeoutMs,
959
+ parseAs: "json",
960
+ emptyBody: "empty_object",
961
+ retryPolicy: retryPolicy ?? NO_RETRY_POLICY,
962
+ messages: getBinancePapiHttpMessages(timeoutMs),
963
+ });
964
+
965
+ await this.options.rateLimiter?.afterResponse(
966
+ { scope },
967
+ {
968
+ status: response.status,
969
+ headers: response.headers,
970
+ usage: parseBinanceRateLimitUsage(response.headers),
971
+ },
972
+ );
973
+
974
+ return response.body;
975
+ } catch (error) {
976
+ if (isTransportError(error)) {
977
+ await this.options.rateLimiter?.onTransportError(
978
+ { scope },
979
+ {
980
+ status: error.status,
981
+ headers: error.headers,
982
+ retryAfterMs: error.retryAfterMs,
983
+ usage: parseBinanceRateLimitUsage(error.headers),
984
+ },
985
+ );
986
+ }
937
987
 
938
- return response.body;
988
+ throw error;
989
+ }
939
990
  }
940
991
 
941
992
  private async startUserDataStream(
942
993
  credentials: AccountCredentials,
994
+ accountOptions?: Record<string, unknown>,
943
995
  ): Promise<string> {
944
996
  const response = await this.userStreamRequest<BinanceListenKeyResponse>(
945
997
  "POST",
946
998
  credentials,
947
999
  undefined,
948
1000
  NO_RETRY_POLICY,
1001
+ accountOptions,
949
1002
  );
950
1003
  if (!response.listenKey) {
951
1004
  throw new Error("Binance PAPI did not return a listenKey");
@@ -957,24 +1010,28 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
957
1010
  private async keepAliveUserDataStream(
958
1011
  credentials: AccountCredentials,
959
1012
  listenKey: string,
1013
+ accountOptions?: Record<string, unknown>,
960
1014
  ): Promise<void> {
961
1015
  await this.userStreamRequest<Record<string, never>>(
962
1016
  "PUT",
963
1017
  credentials,
964
1018
  listenKey,
965
1019
  LISTEN_KEY_KEEPALIVE_RETRY_POLICY,
1020
+ accountOptions,
966
1021
  );
967
1022
  }
968
1023
 
969
1024
  private async closeUserDataStream(
970
1025
  credentials: AccountCredentials,
971
1026
  listenKey: string,
1027
+ accountOptions?: Record<string, unknown>,
972
1028
  ): Promise<void> {
973
1029
  await this.userStreamRequest<Record<string, never>>(
974
1030
  "DELETE",
975
1031
  credentials,
976
1032
  listenKey,
977
1033
  NO_RETRY_POLICY,
1034
+ accountOptions,
978
1035
  );
979
1036
  }
980
1037
 
@@ -983,8 +1040,16 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
983
1040
  credentials: AccountCredentials,
984
1041
  listenKey?: string,
985
1042
  retryPolicy: HttpRetryPolicy = NO_RETRY_POLICY,
1043
+ accountOptions?: Record<string, unknown>,
986
1044
  ): Promise<T> {
987
1045
  const { apiKey } = requirePrivateCredentials(credentials);
1046
+ const scope = this.rateLimitScope(
1047
+ method,
1048
+ "/papi/v1/listenKey",
1049
+ accountOptions,
1050
+ );
1051
+ await this.options.rateLimiter?.beforeRequest({ scope });
1052
+
988
1053
  const params = new URLSearchParams();
989
1054
  if (listenKey) {
990
1055
  params.set("listenKey", listenKey);
@@ -995,20 +1060,57 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
995
1060
  query ? `?${query}` : ""
996
1061
  }`;
997
1062
  const timeoutMs = this.options.httpTimeoutMs ?? DEFAULT_HTTP_TIMEOUT_MS;
998
- const response = await httpRequest<T>({
999
- fetchFn: this.options.fetchFn,
1000
- url,
1001
- method,
1002
- headers: {
1003
- "X-MBX-APIKEY": apiKey,
1004
- },
1005
- timeoutMs,
1006
- parseAs: "json",
1007
- emptyBody: "empty_object",
1008
- retryPolicy,
1009
- messages: getBinancePapiHttpMessages(timeoutMs),
1010
- });
1063
+ try {
1064
+ const response = await httpRequest<T>({
1065
+ fetchFn: this.options.fetchFn,
1066
+ url,
1067
+ method,
1068
+ headers: {
1069
+ "X-MBX-APIKEY": apiKey,
1070
+ },
1071
+ timeoutMs,
1072
+ parseAs: "json",
1073
+ emptyBody: "empty_object",
1074
+ retryPolicy,
1075
+ messages: getBinancePapiHttpMessages(timeoutMs),
1076
+ });
1077
+
1078
+ await this.options.rateLimiter?.afterResponse(
1079
+ { scope },
1080
+ {
1081
+ status: response.status,
1082
+ headers: response.headers,
1083
+ usage: parseBinanceRateLimitUsage(response.headers),
1084
+ },
1085
+ );
1086
+
1087
+ return response.body;
1088
+ } catch (error) {
1089
+ if (isTransportError(error)) {
1090
+ await this.options.rateLimiter?.onTransportError(
1091
+ { scope },
1092
+ {
1093
+ status: error.status,
1094
+ headers: error.headers,
1095
+ retryAfterMs: error.retryAfterMs,
1096
+ usage: parseBinanceRateLimitUsage(error.headers),
1097
+ },
1098
+ );
1099
+ }
1011
1100
 
1012
- return response.body;
1101
+ throw error;
1102
+ }
1103
+ }
1104
+
1105
+ private rateLimitScope(
1106
+ method: string,
1107
+ path: string,
1108
+ accountOptions?: Record<string, unknown>,
1109
+ ): RateLimitScope {
1110
+ return {
1111
+ venue: "binance",
1112
+ accountId: getStringOption(accountOptions, "accountId"),
1113
+ endpointKey: `${method} ${path}`,
1114
+ };
1013
1115
  }
1014
1116
  }
@@ -0,0 +1,47 @@
1
+ import type { RateLimitUsage } from "../../types/index.ts";
2
+
3
+ const USED_WEIGHT_PREFIX = "x-mbx-used-weight-";
4
+ const ORDER_COUNT_PREFIX = "x-mbx-order-count-";
5
+
6
+ export function parseBinanceRateLimitUsage(
7
+ headers: Headers,
8
+ ): RateLimitUsage | undefined {
9
+ const weight: Record<string, number> = {};
10
+ const orderCount: Record<string, number> = {};
11
+
12
+ for (const name of headers.keys()) {
13
+ const normalizedName = name.toLowerCase();
14
+ if (normalizedName.startsWith(USED_WEIGHT_PREFIX)) {
15
+ const interval = normalizedName.slice(USED_WEIGHT_PREFIX.length);
16
+ const value = parseHeaderNumber(headers.get(name));
17
+ if (interval && value !== undefined) {
18
+ weight[interval] = value;
19
+ }
20
+ continue;
21
+ }
22
+
23
+ if (normalizedName.startsWith(ORDER_COUNT_PREFIX)) {
24
+ const interval = normalizedName.slice(ORDER_COUNT_PREFIX.length);
25
+ const value = parseHeaderNumber(headers.get(name));
26
+ if (interval && value !== undefined) {
27
+ orderCount[interval] = value;
28
+ }
29
+ }
30
+ }
31
+
32
+ return Object.keys(weight).length > 0 || Object.keys(orderCount).length > 0
33
+ ? {
34
+ weight: Object.keys(weight).length > 0 ? weight : undefined,
35
+ orderCount: Object.keys(orderCount).length > 0 ? orderCount : undefined,
36
+ }
37
+ : undefined;
38
+ }
39
+
40
+ function parseHeaderNumber(value: string | null): number | undefined {
41
+ if (value === null) {
42
+ return undefined;
43
+ }
44
+
45
+ const parsed = Number(value);
46
+ return Number.isFinite(parsed) ? parsed : undefined;
47
+ }
@@ -595,7 +595,7 @@ export class PrivateSubscriptionCoordinator {
595
595
  juplendPollIntervalMs: this.juplendPollIntervalMs,
596
596
  now: () => this.context.now(),
597
597
  },
598
- account.options,
598
+ { ...account.options, accountId: account.accountId },
599
599
  );
600
600
 
601
601
  record.stream = stream;
@@ -719,7 +719,7 @@ export class PrivateSubscriptionCoordinator {
719
719
  try {
720
720
  const snapshots = await this.getAdapter(record.venue).bootstrapOpenOrders(
721
721
  account.credentials ?? {},
722
- account.options,
722
+ { ...account.options, accountId: account.accountId },
723
723
  );
724
724
  if (!record.ordersSubscribed) {
725
725
  return;
@@ -12,6 +12,7 @@ import type {
12
12
  import { AcexError, type AcexErrorCode } from "../errors.ts";
13
13
  import { AsyncEventBus } from "../internal/async-event-bus.ts";
14
14
  import { matchesHealthFilter } from "../internal/filters.ts";
15
+ import { ReactiveRateLimiter } from "../internal/rate-limiter.ts";
15
16
  import { AccountManagerImpl } from "../managers/account-manager.ts";
16
17
  import { MarketManagerImpl } from "../managers/market-manager.ts";
17
18
  import { OrderManagerImpl } from "../managers/order-manager.ts";
@@ -102,10 +103,14 @@ export class AcexClientImpl implements AcexClient, ClientContext {
102
103
  constructor(options: CreateClientOptions = {}) {
103
104
  activeClients.add(this);
104
105
 
105
- const marketAdapter = new BinanceMarketAdapter();
106
+ const rateLimiter = options.rateLimiter ?? new ReactiveRateLimiter();
107
+ const marketAdapter = new BinanceMarketAdapter({ rateLimiter });
106
108
  this.marketAdapters = new Map([[marketAdapter.venue, marketAdapter]]);
107
109
  const privateAdapters = [
108
- new BinancePrivateAdapter(),
110
+ new BinancePrivateAdapter({
111
+ signingClock: options.clock,
112
+ rateLimiter,
113
+ }),
109
114
  new JuplendPrivateAdapter(
110
115
  options.account?.juplend?.rpcUrl,
111
116
  options.account?.juplend?.jupApiKey,
@@ -339,7 +344,7 @@ export class AcexClientImpl implements AcexClient, ClientContext {
339
344
  return this.getPrivateAdapter(account.venue).createOrder(
340
345
  account.credentials ?? {},
341
346
  request,
342
- account.options,
347
+ { ...account.options, accountId: account.accountId },
343
348
  );
344
349
  }
345
350
 
@@ -354,7 +359,7 @@ export class AcexClientImpl implements AcexClient, ClientContext {
354
359
  return this.getPrivateAdapter(account.venue).cancelOrder(
355
360
  account.credentials ?? {},
356
361
  request,
357
- account.options,
362
+ { ...account.options, accountId: account.accountId },
358
363
  );
359
364
  }
360
365
 
@@ -367,7 +372,7 @@ export class AcexClientImpl implements AcexClient, ClientContext {
367
372
  return this.getPrivateAdapter(account.venue).cancelAllOrders(
368
373
  account.credentials ?? {},
369
374
  request,
370
- account.options,
375
+ { ...account.options, accountId: account.accountId },
371
376
  );
372
377
  }
373
378
 
@@ -0,0 +1,181 @@
1
+ import type {
2
+ RateLimiter,
3
+ RateLimitRequestContext,
4
+ RateLimitResponseContext,
5
+ RateLimitScope,
6
+ RateLimitSnapshot,
7
+ RateLimitTransportErrorContext,
8
+ RateLimitUsage,
9
+ } from "../types/index.ts";
10
+
11
+ interface ReactiveRateLimiterOptions {
12
+ readonly now?: () => number;
13
+ readonly sleep?: (ms: number) => Promise<void>;
14
+ readonly defaultRateLimitMs?: number;
15
+ readonly defaultBanMs?: number;
16
+ }
17
+
18
+ interface RateLimitState {
19
+ usage?: RateLimitUsage;
20
+ blockedUntil?: number;
21
+ retryAfterMs?: number;
22
+ state: RateLimitSnapshot["state"];
23
+ updatedAt?: number;
24
+ }
25
+
26
+ const DEFAULT_RATE_LIMIT_MS = 0;
27
+ const DEFAULT_BAN_MS = 60_000;
28
+
29
+ export class ReactiveRateLimiter implements RateLimiter {
30
+ private readonly now: () => number;
31
+ private readonly sleep: (ms: number) => Promise<void>;
32
+ private readonly defaultRateLimitMs: number;
33
+ private readonly defaultBanMs: number;
34
+ private readonly states = new Map<string, RateLimitState>();
35
+
36
+ constructor(options: ReactiveRateLimiterOptions = {}) {
37
+ this.now = options.now ?? Date.now;
38
+ this.sleep = options.sleep ?? defaultSleep;
39
+ this.defaultRateLimitMs =
40
+ options.defaultRateLimitMs ?? DEFAULT_RATE_LIMIT_MS;
41
+ this.defaultBanMs = options.defaultBanMs ?? DEFAULT_BAN_MS;
42
+ }
43
+
44
+ async beforeRequest(ctx: RateLimitRequestContext): Promise<void> {
45
+ const snapshot = this.getSnapshot(ctx.scope);
46
+ if (!snapshot?.blockedUntil || snapshot.blockedUntil <= this.now()) {
47
+ return;
48
+ }
49
+
50
+ await this.sleep(Math.max(0, snapshot.blockedUntil - this.now()));
51
+ }
52
+
53
+ afterResponse(
54
+ ctx: RateLimitRequestContext,
55
+ response: RateLimitResponseContext,
56
+ ): void {
57
+ if (response.usage) {
58
+ const existing = this.getState(ctx.scope);
59
+ const hasActiveBlock =
60
+ existing?.blockedUntil !== undefined &&
61
+ existing.blockedUntil > this.now();
62
+ this.updateState(ctx.scope, {
63
+ usage: cloneUsage(response.usage),
64
+ state: hasActiveBlock ? existing.state : "ok",
65
+ });
66
+ }
67
+ }
68
+
69
+ onTransportError(
70
+ ctx: RateLimitRequestContext,
71
+ error: RateLimitTransportErrorContext,
72
+ ): void {
73
+ if (error.usage) {
74
+ this.updateState(ctx.scope, {
75
+ usage: cloneUsage(error.usage),
76
+ });
77
+ }
78
+
79
+ if (error.status !== 429 && error.status !== 418) {
80
+ return;
81
+ }
82
+
83
+ const now = this.now();
84
+ const isBan = error.status === 418;
85
+ const retryAfterMs =
86
+ error.retryAfterMs ??
87
+ (isBan ? this.defaultBanMs : this.defaultRateLimitMs);
88
+ const blockedUntil =
89
+ retryAfterMs > 0
90
+ ? now + retryAfterMs
91
+ : this.getState(ctx.scope)?.blockedUntil;
92
+
93
+ this.updateState(ctx.scope, {
94
+ blockedUntil,
95
+ retryAfterMs,
96
+ state: isBan ? "banned" : "rate_limited",
97
+ });
98
+ }
99
+
100
+ getSnapshot(scope: RateLimitScope): RateLimitSnapshot | undefined {
101
+ const state = this.getState(scope);
102
+ if (!state) {
103
+ return undefined;
104
+ }
105
+
106
+ const now = this.now();
107
+ const blockedUntil =
108
+ state.blockedUntil !== undefined && state.blockedUntil > now
109
+ ? state.blockedUntil
110
+ : undefined;
111
+ const runtimeState =
112
+ blockedUntil === undefined && state.state !== "ok" ? "ok" : state.state;
113
+
114
+ return {
115
+ scope: { ...scope },
116
+ usage: state.usage ? cloneUsage(state.usage) : undefined,
117
+ blockedUntil,
118
+ retryAfterMs: blockedUntil ? state.retryAfterMs : undefined,
119
+ state: runtimeState,
120
+ updatedAt: state.updatedAt,
121
+ };
122
+ }
123
+
124
+ private getState(scope: RateLimitScope): RateLimitState | undefined {
125
+ return this.states.get(scopeKey(scope));
126
+ }
127
+
128
+ private updateState(
129
+ scope: RateLimitScope,
130
+ patch: Partial<RateLimitState>,
131
+ ): void {
132
+ const existing = this.getState(scope);
133
+ const nextBlockedUntil = maxOptional(
134
+ existing?.blockedUntil,
135
+ patch.blockedUntil,
136
+ );
137
+ const nextState =
138
+ patch.state ??
139
+ (nextBlockedUntil !== undefined
140
+ ? (existing?.state ?? "ok")
141
+ : existing?.state);
142
+
143
+ this.states.set(scopeKey(scope), {
144
+ usage: patch.usage ?? existing?.usage,
145
+ blockedUntil: nextBlockedUntil,
146
+ retryAfterMs: patch.retryAfterMs ?? existing?.retryAfterMs,
147
+ state: nextState ?? "ok",
148
+ updatedAt: this.now(),
149
+ });
150
+ }
151
+ }
152
+
153
+ function scopeKey(scope: RateLimitScope): string {
154
+ return [scope.venue, scope.accountId ?? "", scope.endpointKey].join("\0");
155
+ }
156
+
157
+ function cloneUsage(usage: RateLimitUsage): RateLimitUsage {
158
+ return {
159
+ weight: usage.weight ? { ...usage.weight } : undefined,
160
+ orderCount: usage.orderCount ? { ...usage.orderCount } : undefined,
161
+ };
162
+ }
163
+
164
+ function maxOptional(
165
+ left: number | undefined,
166
+ right: number | undefined,
167
+ ): number | undefined {
168
+ if (left === undefined) {
169
+ return right;
170
+ }
171
+ if (right === undefined) {
172
+ return left;
173
+ }
174
+ return Math.max(left, right);
175
+ }
176
+
177
+ function defaultSleep(ms: number): Promise<void> {
178
+ return new Promise((resolve) => {
179
+ setTimeout(resolve, ms);
180
+ });
181
+ }
@@ -24,6 +24,63 @@ export interface Logger {
24
24
  error(msg: string, context?: Record<string, unknown>): void;
25
25
  }
26
26
 
27
+ export interface TimeProvider {
28
+ /** Millisecond timestamp used for outbound request/signing timestamps. */
29
+ now(): number;
30
+ }
31
+
32
+ export interface RateLimitScope {
33
+ venue: Venue;
34
+ accountId?: string;
35
+ endpointKey: string;
36
+ }
37
+
38
+ export interface RateLimitUsage {
39
+ /** Exchange request-weight usage by interval key, e.g. "1m". */
40
+ weight?: Record<string, number>;
41
+ /** Exchange order-count usage by interval key, separate from request weight. */
42
+ orderCount?: Record<string, number>;
43
+ }
44
+
45
+ export interface RateLimitRequestContext {
46
+ scope: RateLimitScope;
47
+ }
48
+
49
+ export interface RateLimitResponseContext {
50
+ status: number;
51
+ headers?: Headers;
52
+ usage?: RateLimitUsage;
53
+ }
54
+
55
+ export interface RateLimitTransportErrorContext {
56
+ status?: number;
57
+ headers?: Headers;
58
+ retryAfterMs?: number;
59
+ usage?: RateLimitUsage;
60
+ }
61
+
62
+ export interface RateLimitSnapshot {
63
+ scope: RateLimitScope;
64
+ usage?: RateLimitUsage;
65
+ blockedUntil?: number;
66
+ retryAfterMs?: number;
67
+ state: "ok" | "rate_limited" | "banned";
68
+ updatedAt?: number;
69
+ }
70
+
71
+ export interface RateLimiter {
72
+ beforeRequest(ctx: RateLimitRequestContext): Promise<void> | void;
73
+ afterResponse(
74
+ ctx: RateLimitRequestContext,
75
+ response: RateLimitResponseContext,
76
+ ): Promise<void> | void;
77
+ onTransportError(
78
+ ctx: RateLimitRequestContext,
79
+ error: RateLimitTransportErrorContext,
80
+ ): Promise<void> | void;
81
+ getSnapshot(scope: RateLimitScope): RateLimitSnapshot | undefined;
82
+ }
83
+
27
84
  export interface MarketRuntimeOptions {
28
85
  l1InitialMessageTimeoutMs?: number;
29
86
  l1StaleAfterMs?: number;
@@ -48,6 +105,9 @@ export interface AccountRuntimeOptions {
48
105
 
49
106
  export interface CreateClientOptions {
50
107
  sandbox?: boolean;
108
+ /** Request/signing clock; local receivedAt/freshness clocks stay independent. */
109
+ clock?: TimeProvider;
110
+ rateLimiter?: RateLimiter;
51
111
  logger?: Logger;
52
112
  logLevel?: LogLevel;
53
113
  market?: MarketRuntimeOptions;