@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 +2 -0
- package/package.json +1 -1
- package/src/adapters/binance/adapter.ts +10 -1
- package/src/adapters/binance/market-catalog.ts +63 -15
- package/src/adapters/binance/private-adapter.ts +149 -47
- package/src/adapters/binance/rate-limit.ts +47 -0
- package/src/client/private-subscription-coordinator.ts +2 -2
- package/src/client/runtime.ts +10 -5
- package/src/internal/rate-limiter.ts +181 -0
- package/src/types/shared.ts +60 -0
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,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 {
|
|
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
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
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(
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
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(
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
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
|
-
`${
|
|
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
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
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
|
-
|
|
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
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
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
|
-
|
|
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;
|
package/src/client/runtime.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/src/types/shared.ts
CHANGED
|
@@ -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;
|