@imbingox/acex 0.4.0-beta.3 → 0.4.0-beta.5

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.3",
3
+ "version": "0.4.0-beta.5",
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,14 @@ 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,
12
15
  TimeProvider,
13
16
  VenueAccountCapabilities,
14
17
  VenueOrderCapabilities,
@@ -28,6 +31,7 @@ import type {
28
31
  RawRiskUpdate,
29
32
  StreamHandle,
30
33
  } from "../types.ts";
34
+ import { parseBinanceRateLimitUsage } from "./rate-limit.ts";
31
35
 
32
36
  type TimerHandle = ReturnType<typeof setInterval>;
33
37
  type SignedRequestMethod = "GET" | "POST" | "DELETE";
@@ -205,6 +209,14 @@ function getNumberOption(
205
209
  : undefined;
206
210
  }
207
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
+
208
220
  function signQuery(query: string, secret: string): string {
209
221
  return createHmac("sha256", secret).update(query).digest("hex");
210
222
  }
@@ -590,6 +602,7 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
590
602
  readonly fetchFn?: FetchLike;
591
603
  readonly httpTimeoutMs?: number;
592
604
  readonly signingClock?: TimeProvider;
605
+ readonly rateLimiter?: RateLimiter;
593
606
  } = {},
594
607
  ) {}
595
608
 
@@ -786,7 +799,7 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
786
799
  credentials: AccountCredentials,
787
800
  callbacks: PrivateStreamCallbacks,
788
801
  options: PrivateStreamOptions,
789
- _accountOptions?: Record<string, unknown>,
802
+ accountOptions?: Record<string, unknown>,
790
803
  ): StreamHandle {
791
804
  let closed = false;
792
805
  let listenKey: string | undefined;
@@ -808,17 +821,19 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
808
821
 
809
822
  const key = listenKey;
810
823
  listenKey = undefined;
811
- void this.closeUserDataStream(credentials, key).catch((error) => {
812
- callbacks.onError(
813
- error instanceof Error
814
- ? error
815
- : new Error("Failed to close Binance PAPI listenKey"),
816
- );
817
- });
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
+ );
818
833
  };
819
834
 
820
835
  const ready = (async () => {
821
- listenKey = await this.startUserDataStream(credentials);
836
+ listenKey = await this.startUserDataStream(credentials, accountOptions);
822
837
  if (closed) {
823
838
  closeListenKey();
824
839
  return;
@@ -829,15 +844,17 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
829
844
  return;
830
845
  }
831
846
 
832
- void this.keepAliveUserDataStream(credentials, listenKey).catch(
833
- (error) => {
834
- callbacks.onError(
835
- error instanceof Error
836
- ? error
837
- : new Error("Failed to keep Binance PAPI listenKey alive"),
838
- );
839
- },
840
- );
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
+ });
841
858
  }, options.listenKeyKeepAliveMs);
842
859
 
843
860
  websocket = createManagedWebSocket<BinancePrivateMessage>({
@@ -905,6 +922,9 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
905
922
  retryPolicy?: HttpRetryPolicy,
906
923
  ): Promise<T> {
907
924
  const { apiKey, secret } = requirePrivateCredentials(credentials);
925
+ const scope = this.rateLimitScope(method, path, accountOptions);
926
+ await this.options.rateLimiter?.beforeRequest({ scope });
927
+
908
928
  const params = new URLSearchParams();
909
929
  for (const [key, value] of Object.entries(queryParams ?? {})) {
910
930
  if (value !== undefined) {
@@ -927,31 +947,58 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
927
947
 
928
948
  const url = `${BINANCE_PAPI_REST_BASE_URL}${path}?${params.toString()}`;
929
949
  const timeoutMs = this.options.httpTimeoutMs ?? DEFAULT_HTTP_TIMEOUT_MS;
930
- const response = await httpRequest<T>({
931
- fetchFn: this.options.fetchFn,
932
- url,
933
- method,
934
- headers: {
935
- "X-MBX-APIKEY": apiKey,
936
- },
937
- timeoutMs,
938
- parseAs: "json",
939
- emptyBody: "empty_object",
940
- retryPolicy: retryPolicy ?? NO_RETRY_POLICY,
941
- messages: getBinancePapiHttpMessages(timeoutMs),
942
- });
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
+ });
943
964
 
944
- return response.body;
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
+ }
987
+
988
+ throw error;
989
+ }
945
990
  }
946
991
 
947
992
  private async startUserDataStream(
948
993
  credentials: AccountCredentials,
994
+ accountOptions?: Record<string, unknown>,
949
995
  ): Promise<string> {
950
996
  const response = await this.userStreamRequest<BinanceListenKeyResponse>(
951
997
  "POST",
952
998
  credentials,
953
999
  undefined,
954
1000
  NO_RETRY_POLICY,
1001
+ accountOptions,
955
1002
  );
956
1003
  if (!response.listenKey) {
957
1004
  throw new Error("Binance PAPI did not return a listenKey");
@@ -963,24 +1010,28 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
963
1010
  private async keepAliveUserDataStream(
964
1011
  credentials: AccountCredentials,
965
1012
  listenKey: string,
1013
+ accountOptions?: Record<string, unknown>,
966
1014
  ): Promise<void> {
967
1015
  await this.userStreamRequest<Record<string, never>>(
968
1016
  "PUT",
969
1017
  credentials,
970
1018
  listenKey,
971
1019
  LISTEN_KEY_KEEPALIVE_RETRY_POLICY,
1020
+ accountOptions,
972
1021
  );
973
1022
  }
974
1023
 
975
1024
  private async closeUserDataStream(
976
1025
  credentials: AccountCredentials,
977
1026
  listenKey: string,
1027
+ accountOptions?: Record<string, unknown>,
978
1028
  ): Promise<void> {
979
1029
  await this.userStreamRequest<Record<string, never>>(
980
1030
  "DELETE",
981
1031
  credentials,
982
1032
  listenKey,
983
1033
  NO_RETRY_POLICY,
1034
+ accountOptions,
984
1035
  );
985
1036
  }
986
1037
 
@@ -989,8 +1040,16 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
989
1040
  credentials: AccountCredentials,
990
1041
  listenKey?: string,
991
1042
  retryPolicy: HttpRetryPolicy = NO_RETRY_POLICY,
1043
+ accountOptions?: Record<string, unknown>,
992
1044
  ): Promise<T> {
993
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
+
994
1053
  const params = new URLSearchParams();
995
1054
  if (listenKey) {
996
1055
  params.set("listenKey", listenKey);
@@ -1001,20 +1060,57 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
1001
1060
  query ? `?${query}` : ""
1002
1061
  }`;
1003
1062
  const timeoutMs = this.options.httpTimeoutMs ?? DEFAULT_HTTP_TIMEOUT_MS;
1004
- const response = await httpRequest<T>({
1005
- fetchFn: this.options.fetchFn,
1006
- url,
1007
- method,
1008
- headers: {
1009
- "X-MBX-APIKEY": apiKey,
1010
- },
1011
- timeoutMs,
1012
- parseAs: "json",
1013
- emptyBody: "empty_object",
1014
- retryPolicy,
1015
- messages: getBinancePapiHttpMessages(timeoutMs),
1016
- });
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
+ }
1017
1100
 
1018
- 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
+ };
1019
1115
  }
1020
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
+ }
@@ -699,6 +699,7 @@ export class JuplendPrivateAdapter implements PrivateUserDataAdapter {
699
699
  private readonly options: {
700
700
  readonly fetchFn?: FetchLike;
701
701
  readonly httpTimeoutMs?: number;
702
+ readonly pollIntervalMs?: number;
702
703
  } = {},
703
704
  ) {}
704
705
 
@@ -762,13 +763,13 @@ export class JuplendPrivateAdapter implements PrivateUserDataAdapter {
762
763
  createPrivateStream(
763
764
  credentials: AccountCredentials,
764
765
  callbacks: PrivateStreamCallbacks,
765
- options: PrivateStreamOptions,
766
+ _options: PrivateStreamOptions,
766
767
  accountOptions?: Record<string, unknown>,
767
768
  ): StreamHandle {
768
769
  let closed = false;
769
770
  let timer: ReturnType<typeof setTimeout> | undefined;
770
771
  const pollIntervalMs =
771
- options.juplendPollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
772
+ this.options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
772
773
 
773
774
  const poll = async (): Promise<void> => {
774
775
  try {
@@ -210,7 +210,6 @@ export interface PrivateStreamOptions {
210
210
  reconnectDelayMs: number;
211
211
  reconnectMaxDelayMs: number;
212
212
  listenKeyKeepAliveMs: number;
213
- juplendPollIntervalMs?: number;
214
213
  now?: () => number;
215
214
  }
216
215
 
@@ -13,6 +13,7 @@ import type {
13
13
  PrivateRuntimeReason,
14
14
  PrivateRuntimeStatus,
15
15
  Venue,
16
+ VenueOrderCapabilities,
16
17
  } from "../types/index.ts";
17
18
 
18
19
  export interface RegisteredAccountRecord {
@@ -26,6 +27,7 @@ export interface ClientContext {
26
27
  now(): number;
27
28
  assertStarted(): void;
28
29
  getRegisteredAccount(accountId: string): RegisteredAccountRecord;
30
+ getPrivateOrderCapabilities(venue: Venue): VenueOrderCapabilities | undefined;
29
31
  ensurePrivateCredentials(accountId: string): void;
30
32
  subscribePrivateAccountFeed(accountId: string): Promise<void>;
31
33
  unsubscribePrivateAccountFeed(accountId: string): void;
@@ -105,13 +107,11 @@ export interface PrivateOrderDataConsumer {
105
107
 
106
108
  export function hasPrivateCredentials(
107
109
  credentials?: AccountCredentials,
108
- venue?: Venue,
110
+ credentialsRequired = true,
109
111
  ): boolean {
110
- if (venue === "juplend") {
111
- return true;
112
- }
113
-
114
- return Boolean(credentials?.apiKey && credentials.secret);
112
+ return credentialsRequired
113
+ ? Boolean(credentials?.apiKey && credentials.secret)
114
+ : true;
115
115
  }
116
116
 
117
117
  export function mergeCredentials(
@@ -73,7 +73,6 @@ export class PrivateSubscriptionCoordinator {
73
73
  private readonly streamReconnectMaxDelayMs: number;
74
74
  private readonly listenKeyKeepAliveMs: number;
75
75
  private readonly binanceRiskPollIntervalMs: number;
76
- private readonly juplendPollIntervalMs?: number;
77
76
  private readonly records = new Map<string, PrivateSubscriptionRecord>();
78
77
 
79
78
  constructor(
@@ -102,7 +101,6 @@ export class PrivateSubscriptionCoordinator {
102
101
  options.binance?.riskPollIntervalMs,
103
102
  DEFAULT_BINANCE_RISK_POLL_INTERVAL_MS,
104
103
  );
105
- this.juplendPollIntervalMs = options.juplend?.pollIntervalMs;
106
104
  }
107
105
 
108
106
  async subscribeAccountFeed(accountId: string): Promise<void> {
@@ -115,7 +113,8 @@ export class PrivateSubscriptionCoordinator {
115
113
  }
116
114
 
117
115
  try {
118
- if (record.venue === "juplend") {
116
+ const adapter = this.getAdapter(record.venue);
117
+ if (adapter.accountCapabilities.updates === "polling") {
119
118
  await this.bootstrapAccount(record, account);
120
119
  await this.ensureStream(record, account);
121
120
  } else {
@@ -233,7 +232,11 @@ export class PrivateSubscriptionCoordinator {
233
232
  this.stopAccountRefreshPolling(record);
234
233
 
235
234
  try {
236
- if (record.venue === "juplend" && record.accountSubscribed) {
235
+ const adapter = this.getAdapter(record.venue);
236
+ if (
237
+ adapter.accountCapabilities.updates === "polling" &&
238
+ record.accountSubscribed
239
+ ) {
237
240
  await this.bootstrapAccount(record, account);
238
241
  await this.ensureStream(record, account);
239
242
  } else {
@@ -318,7 +321,7 @@ export class PrivateSubscriptionCoordinator {
318
321
 
319
322
  private ensureAccountRefreshPolling(record: PrivateSubscriptionRecord): void {
320
323
  if (
321
- record.venue !== "binance" ||
324
+ typeof this.getAdapter(record.venue).refreshAccount !== "function" ||
322
325
  !record.accountSubscribed ||
323
326
  record.accountRefreshTimer ||
324
327
  record.accountRefreshInFlight
@@ -339,7 +342,10 @@ export class PrivateSubscriptionCoordinator {
339
342
  }
340
343
 
341
344
  private scheduleAccountRefreshPoll(record: PrivateSubscriptionRecord): void {
342
- if (record.venue !== "binance" || !record.accountSubscribed) {
345
+ if (
346
+ typeof this.getAdapter(record.venue).refreshAccount !== "function" ||
347
+ !record.accountSubscribed
348
+ ) {
343
349
  return;
344
350
  }
345
351
 
@@ -348,7 +354,7 @@ export class PrivateSubscriptionCoordinator {
348
354
  record.accountRefreshTimer = undefined;
349
355
  if (
350
356
  generation !== record.accountRefreshGeneration ||
351
- record.venue !== "binance" ||
357
+ typeof this.getAdapter(record.venue).refreshAccount !== "function" ||
352
358
  !record.accountSubscribed
353
359
  ) {
354
360
  return;
@@ -374,7 +380,10 @@ export class PrivateSubscriptionCoordinator {
374
380
  }
375
381
 
376
382
  record.accountRefreshInFlight = undefined;
377
- if (record.accountSubscribed && record.venue === "binance") {
383
+ if (
384
+ record.accountSubscribed &&
385
+ typeof this.getAdapter(record.venue).refreshAccount === "function"
386
+ ) {
378
387
  this.scheduleAccountRefreshPoll(record);
379
388
  }
380
389
  });
@@ -488,15 +497,15 @@ export class PrivateSubscriptionCoordinator {
488
497
  record: PrivateSubscriptionRecord,
489
498
  account: RegisteredAccountRecord,
490
499
  ): Promise<void> {
500
+ const adapter = this.getAdapter(record.venue);
491
501
  const credentials = account.credentials;
492
- if (!credentials && record.venue !== "juplend") {
502
+ if (adapter.accountCapabilities.credentialsRequired && !credentials) {
493
503
  throw new AcexError(
494
504
  "CREDENTIALS_MISSING",
495
505
  `Account credentials are required for private subscriptions: ${account.accountId}`,
496
506
  );
497
507
  }
498
508
 
499
- const adapter = this.getAdapter(record.venue);
500
509
  const stream = adapter.createPrivateStream(
501
510
  credentials ?? {},
502
511
  {
@@ -592,10 +601,9 @@ export class PrivateSubscriptionCoordinator {
592
601
  reconnectDelayMs: this.streamReconnectDelayMs,
593
602
  reconnectMaxDelayMs: this.streamReconnectMaxDelayMs,
594
603
  listenKeyKeepAliveMs: this.listenKeyKeepAliveMs,
595
- juplendPollIntervalMs: this.juplendPollIntervalMs,
596
604
  now: () => this.context.now(),
597
605
  },
598
- account.options,
606
+ { ...account.options, accountId: account.accountId },
599
607
  );
600
608
 
601
609
  record.stream = stream;
@@ -664,7 +672,8 @@ export class PrivateSubscriptionCoordinator {
664
672
  account: RegisteredAccountRecord,
665
673
  ): Promise<void> {
666
674
  try {
667
- const bootstrap = await this.getAdapter(record.venue).bootstrapAccount(
675
+ const adapter = this.getAdapter(record.venue);
676
+ const bootstrap = await adapter.bootstrapAccount(
668
677
  account.credentials ?? {},
669
678
  { ...account.options, accountId: account.accountId },
670
679
  );
@@ -700,7 +709,10 @@ export class PrivateSubscriptionCoordinator {
700
709
  ready: false,
701
710
  reason: transportReason(
702
711
  error,
703
- record.venue === "juplend" ? "http_failed" : "auth_failed",
712
+ this.getAdapter(record.venue).accountCapabilities
713
+ .credentialsRequired
714
+ ? "auth_failed"
715
+ : "http_failed",
704
716
  ),
705
717
  },
706
718
  );
@@ -719,7 +731,7 @@ export class PrivateSubscriptionCoordinator {
719
731
  try {
720
732
  const snapshots = await this.getAdapter(record.venue).bootstrapOpenOrders(
721
733
  account.credentials ?? {},
722
- account.options,
734
+ { ...account.options, accountId: account.accountId },
723
735
  );
724
736
  if (!record.ordersSubscribed) {
725
737
  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";
@@ -37,6 +38,7 @@ import type {
37
38
  StopOptions,
38
39
  Venue,
39
40
  VenueCapabilities,
41
+ VenueOrderCapabilities,
40
42
  } from "../types/index.ts";
41
43
  import {
42
44
  type ClientContext,
@@ -102,15 +104,20 @@ export class AcexClientImpl implements AcexClient, ClientContext {
102
104
  constructor(options: CreateClientOptions = {}) {
103
105
  activeClients.add(this);
104
106
 
105
- const marketAdapter = new BinanceMarketAdapter();
107
+ const rateLimiter = options.rateLimiter ?? new ReactiveRateLimiter();
108
+ const marketAdapter = new BinanceMarketAdapter({ rateLimiter });
106
109
  this.marketAdapters = new Map([[marketAdapter.venue, marketAdapter]]);
107
110
  const privateAdapters = [
108
111
  new BinancePrivateAdapter({
109
112
  signingClock: options.clock,
113
+ rateLimiter,
110
114
  }),
111
115
  new JuplendPrivateAdapter(
112
116
  options.account?.juplend?.rpcUrl,
113
117
  options.account?.juplend?.jupApiKey,
118
+ {
119
+ pollIntervalMs: options.account?.juplend?.pollIntervalMs,
120
+ },
114
121
  ),
115
122
  ];
116
123
  this.privateAdapters = new Map(
@@ -295,9 +302,20 @@ export class AcexClientImpl implements AcexClient, ClientContext {
295
302
  return account;
296
303
  }
297
304
 
305
+ getPrivateOrderCapabilities(
306
+ venue: Venue,
307
+ ): VenueOrderCapabilities | undefined {
308
+ return this.privateAdapters.get(venue)?.orderCapabilities;
309
+ }
310
+
298
311
  ensurePrivateCredentials(accountId: string): void {
299
312
  const account = this.getRegisteredAccount(accountId);
300
- if (hasPrivateCredentials(account.credentials, account.venue)) {
313
+ if (
314
+ hasPrivateCredentials(
315
+ account.credentials,
316
+ this.getPrivateCredentialsRequired(account.venue),
317
+ )
318
+ ) {
301
319
  return;
302
320
  }
303
321
 
@@ -341,7 +359,7 @@ export class AcexClientImpl implements AcexClient, ClientContext {
341
359
  return this.getPrivateAdapter(account.venue).createOrder(
342
360
  account.credentials ?? {},
343
361
  request,
344
- account.options,
362
+ { ...account.options, accountId: account.accountId },
345
363
  );
346
364
  }
347
365
 
@@ -356,7 +374,7 @@ export class AcexClientImpl implements AcexClient, ClientContext {
356
374
  return this.getPrivateAdapter(account.venue).cancelOrder(
357
375
  account.credentials ?? {},
358
376
  request,
359
- account.options,
377
+ { ...account.options, accountId: account.accountId },
360
378
  );
361
379
  }
362
380
 
@@ -369,7 +387,7 @@ export class AcexClientImpl implements AcexClient, ClientContext {
369
387
  return this.getPrivateAdapter(account.venue).cancelAllOrders(
370
388
  account.credentials ?? {},
371
389
  request,
372
- account.options,
390
+ { ...account.options, accountId: account.accountId },
373
391
  );
374
392
  }
375
393
 
@@ -426,7 +444,7 @@ export class AcexClientImpl implements AcexClient, ClientContext {
426
444
  private getPrivateCommandAccount(accountId: string): RegisteredAccountRecord {
427
445
  const account = this.getRegisteredAccount(accountId);
428
446
  const adapter = this.getPrivateAdapter(account.venue);
429
- if (adapter.venue === "juplend") {
447
+ if (!adapter.orderCapabilities.supported) {
430
448
  throw this.createError(
431
449
  "VENUE_NOT_SUPPORTED",
432
450
  `Venue does not support private order commands: ${account.venue}`,
@@ -434,7 +452,12 @@ export class AcexClientImpl implements AcexClient, ClientContext {
434
452
  );
435
453
  }
436
454
 
437
- if (!hasPrivateCredentials(account.credentials, account.venue)) {
455
+ if (
456
+ !hasPrivateCredentials(
457
+ account.credentials,
458
+ adapter.accountCapabilities.credentialsRequired,
459
+ )
460
+ ) {
438
461
  throw this.createError(
439
462
  "CREDENTIALS_MISSING",
440
463
  `Account credentials are required for private order commands: ${accountId}`,
@@ -445,6 +468,13 @@ export class AcexClientImpl implements AcexClient, ClientContext {
445
468
  return account;
446
469
  }
447
470
 
471
+ private getPrivateCredentialsRequired(venue: Venue): boolean {
472
+ return (
473
+ this.privateAdapters.get(venue)?.accountCapabilities
474
+ .credentialsRequired ?? true
475
+ );
476
+ }
477
+
448
478
  private getPrivateAdapter(venue: Venue): PrivateUserDataAdapter {
449
479
  const adapter = this.privateAdapters.get(venue);
450
480
  if (!adapter) {
@@ -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
+ }
@@ -102,7 +102,10 @@ export class OrderManagerImpl
102
102
  async subscribeOrders(input: SubscribeOrdersInput): Promise<void> {
103
103
  this.context.assertStarted();
104
104
  const account = this.context.getRegisteredAccount(input.accountId);
105
- if (account.venue === "juplend") {
105
+ if (
106
+ this.context.getPrivateOrderCapabilities(account.venue)?.updates ===
107
+ "unsupported"
108
+ ) {
106
109
  throw this.createError(
107
110
  "VENUE_NOT_SUPPORTED",
108
111
  `Venue does not support private order subscriptions: ${account.venue}`,
@@ -29,6 +29,58 @@ export interface TimeProvider {
29
29
  now(): number;
30
30
  }
31
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
+
32
84
  export interface MarketRuntimeOptions {
33
85
  l1InitialMessageTimeoutMs?: number;
34
86
  l1StaleAfterMs?: number;
@@ -55,6 +107,7 @@ export interface CreateClientOptions {
55
107
  sandbox?: boolean;
56
108
  /** Request/signing clock; local receivedAt/freshness clocks stay independent. */
57
109
  clock?: TimeProvider;
110
+ rateLimiter?: RateLimiter;
58
111
  logger?: Logger;
59
112
  logLevel?: LogLevel;
60
113
  market?: MarketRuntimeOptions;