@imbingox/acex 0.4.0-beta.3 → 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 +142 -46
- package/src/adapters/binance/rate-limit.ts +47 -0
- package/src/client/private-subscription-coordinator.ts +2 -2
- package/src/client/runtime.ts +7 -4
- package/src/internal/rate-limiter.ts +181 -0
- package/src/types/shared.ts +53 -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,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
|
-
|
|
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(
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
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(
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
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
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -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,11 +103,13 @@ 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
110
|
new BinancePrivateAdapter({
|
|
109
111
|
signingClock: options.clock,
|
|
112
|
+
rateLimiter,
|
|
110
113
|
}),
|
|
111
114
|
new JuplendPrivateAdapter(
|
|
112
115
|
options.account?.juplend?.rpcUrl,
|
|
@@ -341,7 +344,7 @@ export class AcexClientImpl implements AcexClient, ClientContext {
|
|
|
341
344
|
return this.getPrivateAdapter(account.venue).createOrder(
|
|
342
345
|
account.credentials ?? {},
|
|
343
346
|
request,
|
|
344
|
-
account.options,
|
|
347
|
+
{ ...account.options, accountId: account.accountId },
|
|
345
348
|
);
|
|
346
349
|
}
|
|
347
350
|
|
|
@@ -356,7 +359,7 @@ export class AcexClientImpl implements AcexClient, ClientContext {
|
|
|
356
359
|
return this.getPrivateAdapter(account.venue).cancelOrder(
|
|
357
360
|
account.credentials ?? {},
|
|
358
361
|
request,
|
|
359
|
-
account.options,
|
|
362
|
+
{ ...account.options, accountId: account.accountId },
|
|
360
363
|
);
|
|
361
364
|
}
|
|
362
365
|
|
|
@@ -369,7 +372,7 @@ export class AcexClientImpl implements AcexClient, ClientContext {
|
|
|
369
372
|
return this.getPrivateAdapter(account.venue).cancelAllOrders(
|
|
370
373
|
account.credentials ?? {},
|
|
371
374
|
request,
|
|
372
|
-
account.options,
|
|
375
|
+
{ ...account.options, accountId: account.accountId },
|
|
373
376
|
);
|
|
374
377
|
}
|
|
375
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
|
@@ -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;
|