@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 +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/adapters/juplend/private-adapter.ts +3 -2
- package/src/adapters/types.ts +0 -1
- package/src/client/context.ts +6 -6
- package/src/client/private-subscription-coordinator.ts +27 -15
- package/src/client/runtime.ts +37 -7
- package/src/internal/rate-limiter.ts +181 -0
- package/src/managers/order-manager.ts +4 -1
- 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
|
+
}
|
|
@@ -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
|
-
|
|
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.
|
|
772
|
+
this.options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
772
773
|
|
|
773
774
|
const poll = async (): Promise<void> => {
|
|
774
775
|
try {
|
package/src/adapters/types.ts
CHANGED
package/src/client/context.ts
CHANGED
|
@@ -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
|
-
|
|
110
|
+
credentialsRequired = true,
|
|
109
111
|
): boolean {
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 !== "
|
|
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 (
|
|
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 !== "
|
|
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 (
|
|
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 (
|
|
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
|
|
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
|
|
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;
|
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";
|
|
@@ -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
|
|
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 (
|
|
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.
|
|
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 (
|
|
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 (
|
|
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}`,
|
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;
|