@imbingox/acex 0.3.1-beta.0 → 0.4.0-beta.10

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.
@@ -0,0 +1,106 @@
1
+ import {
2
+ type HttpClientMessages,
3
+ httpRequest,
4
+ isTransportError,
5
+ } from "../../internal/http-client.ts";
6
+ import type {
7
+ RateLimiter,
8
+ RateLimitScope,
9
+ VenueServerTime,
10
+ } from "../../types/index.ts";
11
+ import { parseBinanceRateLimitUsage } from "./rate-limit.ts";
12
+
13
+ type FetchLike = (
14
+ input: string | URL | Request,
15
+ init?: RequestInit,
16
+ ) => Promise<Response>;
17
+
18
+ interface BinanceServerTimeResponse {
19
+ serverTime?: unknown;
20
+ }
21
+
22
+ export interface FetchBinanceServerTimeOptions {
23
+ readonly rateLimiter?: RateLimiter;
24
+ readonly fetchFn?: FetchLike;
25
+ readonly now?: () => number;
26
+ readonly monotonicNow?: () => number;
27
+ }
28
+
29
+ const BINANCE_USDM_SERVER_TIME_URL = "https://fapi.binance.com/fapi/v1/time";
30
+ const DEFAULT_HTTP_TIMEOUT_MS = 10_000;
31
+ const BINANCE_SERVER_TIME_HTTP_MESSAGES: HttpClientMessages = {
32
+ http: ({ status, statusText }) =>
33
+ `Binance server time request failed: ${status} ${statusText ?? ""}`,
34
+ };
35
+
36
+ export async function fetchBinanceServerTime(
37
+ options: FetchBinanceServerTimeOptions = {},
38
+ ): Promise<VenueServerTime> {
39
+ const fetchFn = options.fetchFn ?? fetch;
40
+ const now = options.now ?? Date.now;
41
+ const monotonicNow = options.monotonicNow ?? (() => performance.now());
42
+ const scope: RateLimitScope = {
43
+ venue: "binance",
44
+ endpointKey: "GET /fapi/v1/time",
45
+ };
46
+
47
+ await options.rateLimiter?.beforeRequest({ scope });
48
+
49
+ const requestSentAt = now();
50
+ const startMono = monotonicNow();
51
+
52
+ try {
53
+ const response = await httpRequest<BinanceServerTimeResponse>({
54
+ fetchFn,
55
+ url: BINANCE_USDM_SERVER_TIME_URL,
56
+ timeoutMs: DEFAULT_HTTP_TIMEOUT_MS,
57
+ parseAs: "json",
58
+ jsonParseMode: "response",
59
+ retryPolicy: {
60
+ idempotent: true,
61
+ maxAttempts: 1,
62
+ },
63
+ messages: BINANCE_SERVER_TIME_HTTP_MESSAGES,
64
+ });
65
+ const responseReceivedAt = now();
66
+ const endMono = monotonicNow();
67
+
68
+ await options.rateLimiter?.afterResponse(
69
+ { scope },
70
+ {
71
+ status: response.status,
72
+ headers: response.headers,
73
+ usage: parseBinanceRateLimitUsage(response.headers),
74
+ },
75
+ );
76
+
77
+ const { serverTime } = response.body;
78
+ if (typeof serverTime !== "number" || !Number.isFinite(serverTime)) {
79
+ throw new Error(
80
+ "Binance server time response missing numeric serverTime",
81
+ );
82
+ }
83
+
84
+ return {
85
+ serverTime,
86
+ requestSentAt,
87
+ responseReceivedAt,
88
+ roundTripMs: endMono - startMono,
89
+ estimatedOffsetMs: serverTime - (requestSentAt + responseReceivedAt) / 2,
90
+ };
91
+ } catch (error) {
92
+ if (isTransportError(error)) {
93
+ await options.rateLimiter?.onTransportError(
94
+ { scope },
95
+ {
96
+ status: error.status,
97
+ headers: error.headers,
98
+ retryAfterMs: error.retryAfterMs,
99
+ usage: parseBinanceRateLimitUsage(error.headers),
100
+ },
101
+ );
102
+ }
103
+
104
+ throw error;
105
+ }
106
+ }
@@ -1,5 +1,8 @@
1
1
  import BigNumber from "bignumber.js";
2
- import { AcexError } from "../../errors.ts";
2
+ import {
3
+ type HttpClientMessages,
4
+ httpRequest,
5
+ } from "../../internal/http-client.ts";
3
6
  import type {
4
7
  AccountCredentials,
5
8
  VenueAccountCapabilities,
@@ -66,6 +69,8 @@ interface JuplendPriceApiEntry {
66
69
  decimals?: number | string;
67
70
  }
68
71
 
72
+ type FetchLike = typeof fetch;
73
+
69
74
  interface JuplendTokenSearchEntry {
70
75
  id?: string;
71
76
  address?: string;
@@ -86,6 +91,13 @@ const DEFAULT_HTTP_TIMEOUT_MS = 10_000;
86
91
  // not mint-atomic token amounts.
87
92
  const POSITION_AMOUNT_SCALE_DECIMALS = 9;
88
93
  const VAULT_CACHE_TTL_MS = 60 * 60 * 1_000;
94
+ function getJuplendHttpMessages(timeoutMs: number): HttpClientMessages {
95
+ return {
96
+ http: ({ status, statusText }) => `Juplend HTTP ${status}: ${statusText}`,
97
+ timeout: () => `Juplend fetch timeout after ${timeoutMs}ms`,
98
+ aborted: () => "Juplend fetch aborted",
99
+ };
100
+ }
89
101
 
90
102
  interface JuplendVaultEnrichmentCacheEntry {
91
103
  loadedAt: number;
@@ -260,52 +272,30 @@ function buildRisk(input: {
260
272
  };
261
273
  }
262
274
 
263
- async function readJson<T>(url: string, init?: RequestInit): Promise<T> {
264
- const controller = new AbortController();
265
- const upstreamSignal = init?.signal;
266
- let timedOut = false;
267
- const onUpstreamAbort = () => {
268
- controller.abort();
269
- };
270
-
271
- if (upstreamSignal?.aborted) {
272
- controller.abort();
273
- } else if (upstreamSignal) {
274
- upstreamSignal.addEventListener("abort", onUpstreamAbort, { once: true });
275
- }
276
-
277
- const timeout = setTimeout(() => {
278
- timedOut = true;
279
- controller.abort();
280
- }, DEFAULT_HTTP_TIMEOUT_MS);
281
-
282
- try {
283
- const response = await fetch(url, {
284
- ...init,
285
- signal: controller.signal,
286
- });
287
- if (!response.ok) {
288
- throw new Error(
289
- `Juplend HTTP ${response.status}: ${response.statusText}`,
290
- );
291
- }
275
+ async function requestJuplendJson<T>(
276
+ url: string,
277
+ init: RequestInit | undefined,
278
+ fetchFn: FetchLike | undefined,
279
+ timeoutMs: number,
280
+ ): Promise<T> {
281
+ const response = await httpRequest<T>({
282
+ fetchFn,
283
+ url,
284
+ method: init?.method,
285
+ headers: init?.headers,
286
+ body: init?.body,
287
+ signal: init?.signal ?? undefined,
288
+ timeoutMs,
289
+ parseAs: "json",
290
+ jsonParseMode: "response",
291
+ retryPolicy: {
292
+ idempotent: true,
293
+ maxAttempts: 3,
294
+ },
295
+ messages: getJuplendHttpMessages(timeoutMs),
296
+ });
292
297
 
293
- return (await response.json()) as T;
294
- } catch (error) {
295
- if (error instanceof Error && error.name === "AbortError") {
296
- throw new Error(
297
- timedOut
298
- ? `Juplend fetch timeout after ${DEFAULT_HTTP_TIMEOUT_MS}ms`
299
- : "Juplend fetch aborted",
300
- );
301
- }
302
- throw error;
303
- } finally {
304
- clearTimeout(timeout);
305
- if (upstreamSignal) {
306
- upstreamSignal.removeEventListener("abort", onUpstreamAbort);
307
- }
308
- }
298
+ return response.body;
309
299
  }
310
300
 
311
301
  function getJupApiKey(explicitApiKey?: string): string | undefined {
@@ -326,12 +316,19 @@ function withBaseUrl(baseUrl: string, path: string): string {
326
316
 
327
317
  async function loadVaultMetadataFromLiteApi(
328
318
  apiKey?: string,
319
+ fetchFn?: FetchLike,
320
+ timeoutMs = DEFAULT_HTTP_TIMEOUT_MS,
329
321
  ): Promise<Map<string, JuplendVaultMetadata>> {
330
- const response = await readJson<
322
+ const response = await requestJuplendJson<
331
323
  JuplendVaultMetadata[] | { data?: JuplendVaultMetadata[] }
332
- >(withBaseUrl(JUP_LITE_API_BASE_URL, LEND_VAULTS_PATH), {
333
- headers: buildApiHeaders(apiKey),
334
- });
324
+ >(
325
+ withBaseUrl(JUP_LITE_API_BASE_URL, LEND_VAULTS_PATH),
326
+ {
327
+ headers: buildApiHeaders(apiKey),
328
+ },
329
+ fetchFn,
330
+ timeoutMs,
331
+ );
335
332
  const rawVaults = Array.isArray(response) ? response : response.data;
336
333
  const vaults = new Map<string, JuplendVaultMetadata>();
337
334
 
@@ -348,17 +345,21 @@ async function loadVaultMetadataFromLiteApi(
348
345
  async function loadTokenSearchMap(
349
346
  mintAddresses: string[],
350
347
  apiKey?: string,
348
+ fetchFn?: FetchLike,
349
+ timeoutMs = DEFAULT_HTTP_TIMEOUT_MS,
351
350
  ): Promise<Map<string, JuplendTokenMetadata>> {
352
351
  if (mintAddresses.length === 0) {
353
352
  return new Map();
354
353
  }
355
354
 
356
355
  const query = encodeURIComponent(mintAddresses.join(","));
357
- const response = await readJson<JuplendTokenSearchEntry[]>(
356
+ const response = await requestJuplendJson<JuplendTokenSearchEntry[]>(
358
357
  `${withBaseUrl(JUP_API_BASE_URL, TOKENS_SEARCH_PATH)}?query=${query}`,
359
358
  {
360
359
  headers: buildApiHeaders(apiKey),
361
360
  },
361
+ fetchFn,
362
+ timeoutMs,
362
363
  );
363
364
 
364
365
  const tokens = new Map<string, JuplendTokenMetadata>();
@@ -385,17 +386,23 @@ async function loadTokenSearchMap(
385
386
  async function loadPriceMap(
386
387
  mintAddresses: string[],
387
388
  apiKey?: string,
389
+ fetchFn?: FetchLike,
390
+ timeoutMs = DEFAULT_HTTP_TIMEOUT_MS,
388
391
  ): Promise<Map<string, JuplendPriceApiEntry>> {
389
392
  if (mintAddresses.length === 0) {
390
393
  return new Map();
391
394
  }
392
395
 
393
396
  const ids = encodeURIComponent(mintAddresses.join(","));
394
- const response = await readJson<Record<string, JuplendPriceApiEntry>>(
397
+ const response = await requestJuplendJson<
398
+ Record<string, JuplendPriceApiEntry>
399
+ >(
395
400
  `${withBaseUrl(JUP_API_BASE_URL, PRICE_V3_PATH)}?ids=${ids}`,
396
401
  {
397
402
  headers: buildApiHeaders(apiKey),
398
403
  },
404
+ fetchFn,
405
+ timeoutMs,
399
406
  );
400
407
 
401
408
  return new Map(Object.entries(response ?? {}));
@@ -436,6 +443,8 @@ function mergeTokenMetadata(
436
443
  async function enrichVaultsWithJupApi(input: {
437
444
  apiKey?: string;
438
445
  baseVaults: Map<string, JuplendVaultMetadata>;
446
+ fetchFn?: FetchLike;
447
+ timeoutMs: number;
439
448
  }): Promise<Map<string, JuplendVaultMetadata>> {
440
449
  const mintAddresses = new Set<string>();
441
450
  for (const vault of input.baseVaults.values()) {
@@ -450,8 +459,18 @@ async function enrichVaultsWithJupApi(input: {
450
459
  }
451
460
 
452
461
  const [tokenMap, priceMap] = await Promise.all([
453
- loadTokenSearchMap([...mintAddresses], input.apiKey),
454
- loadPriceMap([...mintAddresses], input.apiKey),
462
+ loadTokenSearchMap(
463
+ [...mintAddresses],
464
+ input.apiKey,
465
+ input.fetchFn,
466
+ input.timeoutMs,
467
+ ),
468
+ loadPriceMap(
469
+ [...mintAddresses],
470
+ input.apiKey,
471
+ input.fetchFn,
472
+ input.timeoutMs,
473
+ ),
455
474
  ]);
456
475
 
457
476
  const enriched = new Map<string, JuplendVaultMetadata>();
@@ -480,6 +499,8 @@ async function enrichVaultsWithJupApi(input: {
480
499
  async function loadVaults(
481
500
  now: number,
482
501
  apiKey?: string,
502
+ fetchFn?: FetchLike,
503
+ timeoutMs = DEFAULT_HTTP_TIMEOUT_MS,
483
504
  ): Promise<Map<string, JuplendVaultMetadata>> {
484
505
  const cacheKey = getEnrichmentCacheKey(apiKey);
485
506
  const cached = enrichmentCache.get(cacheKey);
@@ -492,7 +513,11 @@ async function loadVaults(
492
513
  const inflight = enrichmentCachePromise.get(cacheKey);
493
514
  if (!inflight) {
494
515
  const nextPromise = (async () => {
495
- const baseVaults = await loadVaultMetadataFromLiteApi(apiKey);
516
+ const baseVaults = await loadVaultMetadataFromLiteApi(
517
+ apiKey,
518
+ fetchFn,
519
+ timeoutMs,
520
+ );
496
521
  if (!apiKey) {
497
522
  enrichmentCache.set(cacheKey, {
498
523
  loadedAt: now,
@@ -506,6 +531,8 @@ async function loadVaults(
506
531
  const enrichedVaults = await enrichVaultsWithJupApi({
507
532
  apiKey,
508
533
  baseVaults,
534
+ fetchFn,
535
+ timeoutMs,
509
536
  });
510
537
  enrichmentCache.set(cacheKey, {
511
538
  loadedAt: now,
@@ -545,9 +572,11 @@ async function mapAccount(
545
572
  receivedAt: number,
546
573
  rpcUrl: string | undefined,
547
574
  jupApiKey: string | undefined,
575
+ fetchFn: FetchLike | undefined,
576
+ timeoutMs: number,
548
577
  ): Promise<JuplendMappedAccount> {
549
578
  const [vaults, positionResult] = await Promise.all([
550
- loadVaults(receivedAt, jupApiKey),
579
+ loadVaults(receivedAt, jupApiKey, fetchFn, timeoutMs),
551
580
  readJuplendPositions({
552
581
  walletAddress: accountOptions.walletAddress,
553
582
  vaultId: accountOptions.vaultId,
@@ -666,6 +695,11 @@ export class JuplendPrivateAdapter implements PrivateUserDataAdapter {
666
695
  constructor(
667
696
  private readonly rpcUrl?: string,
668
697
  private readonly jupApiKey?: string,
698
+ private readonly options: {
699
+ readonly fetchFn?: FetchLike;
700
+ readonly httpTimeoutMs?: number;
701
+ readonly pollIntervalMs?: number;
702
+ } = {},
669
703
  ) {}
670
704
 
671
705
  async bootstrapAccount(
@@ -679,6 +713,8 @@ export class JuplendPrivateAdapter implements PrivateUserDataAdapter {
679
713
  receivedAt,
680
714
  this.rpcUrl,
681
715
  getJupApiKey(this.jupApiKey),
716
+ this.options.fetchFn,
717
+ this.options.httpTimeoutMs ?? DEFAULT_HTTP_TIMEOUT_MS,
682
718
  );
683
719
 
684
720
  return {
@@ -697,28 +733,21 @@ export class JuplendPrivateAdapter implements PrivateUserDataAdapter {
697
733
  _credentials: AccountCredentials,
698
734
  _request: CreateOrderRequest,
699
735
  ): Promise<RawOrderUpdate> {
700
- throw new AcexError(
701
- "VENUE_NOT_SUPPORTED",
702
- "Juplend is read-only and does not support createOrder",
703
- );
736
+ throw new Error("Juplend is read-only and does not support createOrder");
704
737
  }
705
738
 
706
739
  cancelOrder(
707
740
  _credentials: AccountCredentials,
708
741
  _request: CancelOrderRequest,
709
742
  ): Promise<RawOrderUpdate> {
710
- throw new AcexError(
711
- "VENUE_NOT_SUPPORTED",
712
- "Juplend is read-only and does not support cancelOrder",
713
- );
743
+ throw new Error("Juplend is read-only and does not support cancelOrder");
714
744
  }
715
745
 
716
746
  cancelAllOrders(
717
747
  _credentials: AccountCredentials,
718
748
  _request: CancelAllOrdersRequest,
719
749
  ): Promise<RawOrderUpdate[]> {
720
- throw new AcexError(
721
- "VENUE_NOT_SUPPORTED",
750
+ throw new Error(
722
751
  "Juplend is read-only and does not support cancelAllOrders",
723
752
  );
724
753
  }
@@ -726,13 +755,13 @@ export class JuplendPrivateAdapter implements PrivateUserDataAdapter {
726
755
  createPrivateStream(
727
756
  credentials: AccountCredentials,
728
757
  callbacks: PrivateStreamCallbacks,
729
- options: PrivateStreamOptions,
758
+ _options: PrivateStreamOptions,
730
759
  accountOptions?: Record<string, unknown>,
731
760
  ): StreamHandle {
732
761
  let closed = false;
733
762
  let timer: ReturnType<typeof setTimeout> | undefined;
734
763
  const pollIntervalMs =
735
- options.juplendPollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
764
+ this.options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
736
765
 
737
766
  const poll = async (): Promise<void> => {
738
767
  try {
@@ -9,6 +9,7 @@ import type {
9
9
  VenueAccountCapabilities,
10
10
  VenueMarketCapabilities,
11
11
  VenueOrderCapabilities,
12
+ VenueServerTime,
12
13
  } from "../types/index.ts";
13
14
 
14
15
  export interface StreamHandle {
@@ -74,6 +75,7 @@ export interface MarketAdapter {
74
75
  readonly venue: Venue;
75
76
  readonly marketCapabilities: VenueMarketCapabilities;
76
77
  loadMarkets(): Promise<MarketDefinition[]>;
78
+ fetchServerTime?(): Promise<VenueServerTime>;
77
79
  createL1BookStream(
78
80
  market: MarketDefinition,
79
81
  callbacks: L1BookStreamCallbacks,
@@ -174,6 +176,16 @@ export interface RawOrderUpdate {
174
176
  receivedAt: number;
175
177
  }
176
178
 
179
+ export interface RawOpenOrdersSnapshot {
180
+ orders: RawOrderUpdate[];
181
+ snapshotReceivedAt: number;
182
+ snapshotExchangeTs?: number;
183
+ }
184
+
185
+ export type FetchOrderRequest =
186
+ | { symbol: string; orderId: string; clientOrderId?: string }
187
+ | { symbol: string; clientOrderId: string; orderId?: string };
188
+
177
189
  export interface CreateOrderRequest {
178
190
  symbol: string;
179
191
  side: OrderSide;
@@ -210,7 +222,6 @@ export interface PrivateStreamOptions {
210
222
  reconnectDelayMs: number;
211
223
  reconnectMaxDelayMs: number;
212
224
  listenKeyKeepAliveMs: number;
213
- juplendPollIntervalMs?: number;
214
225
  now?: () => number;
215
226
  }
216
227
 
@@ -228,10 +239,23 @@ export interface PrivateUserDataAdapter {
228
239
  credentials: AccountCredentials,
229
240
  accountOptions?: Record<string, unknown>,
230
241
  ): Promise<RawAccountUpdate>;
242
+ reconcileAccount?(
243
+ credentials: AccountCredentials,
244
+ accountOptions?: Record<string, unknown>,
245
+ ): Promise<RawAccountBootstrap>;
231
246
  bootstrapOpenOrders(
232
247
  credentials: AccountCredentials,
233
248
  accountOptions?: Record<string, unknown>,
234
249
  ): Promise<RawOrderUpdate[]>;
250
+ fetchOpenOrders?(
251
+ credentials: AccountCredentials,
252
+ accountOptions?: Record<string, unknown>,
253
+ ): Promise<RawOpenOrdersSnapshot>;
254
+ fetchOrder?(
255
+ credentials: AccountCredentials,
256
+ request: FetchOrderRequest,
257
+ accountOptions?: Record<string, unknown>,
258
+ ): Promise<RawOrderUpdate | undefined>;
235
259
  createOrder(
236
260
  credentials: AccountCredentials,
237
261
  request: CreateOrderRequest,
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  RawAccountBootstrap,
3
3
  RawAccountUpdate,
4
+ RawOpenOrdersSnapshot,
4
5
  RawOrderUpdate,
5
6
  } from "../adapters/types.ts";
6
7
  import type {
@@ -10,9 +11,11 @@ import type {
10
11
  CancelOrderInput,
11
12
  CreateOrderInput,
12
13
  HealthEvent,
14
+ OrderSnapshot,
13
15
  PrivateRuntimeReason,
14
16
  PrivateRuntimeStatus,
15
17
  Venue,
18
+ VenueOrderCapabilities,
16
19
  } from "../types/index.ts";
17
20
 
18
21
  export interface RegisteredAccountRecord {
@@ -26,6 +29,7 @@ export interface ClientContext {
26
29
  now(): number;
27
30
  assertStarted(): void;
28
31
  getRegisteredAccount(accountId: string): RegisteredAccountRecord;
32
+ getPrivateOrderCapabilities(venue: Venue): VenueOrderCapabilities | undefined;
29
33
  ensurePrivateCredentials(accountId: string): void;
30
34
  subscribePrivateAccountFeed(accountId: string): Promise<void>;
31
35
  unsubscribePrivateAccountFeed(accountId: string): void;
@@ -75,7 +79,13 @@ export interface PrivateAccountDataConsumer {
75
79
  accountId: string,
76
80
  venue: Venue,
77
81
  update: RawAccountUpdate,
78
- options?: { preserveStatus?: boolean },
82
+ options?: { preserveStatus?: boolean; requestStartedAt?: number },
83
+ ): void;
84
+ onPrivateAccountReconcile(
85
+ accountId: string,
86
+ venue: Venue,
87
+ snapshot: RawAccountBootstrap,
88
+ options: { requestStartedAt: number; preserveStatus?: boolean },
79
89
  ): void;
80
90
  onPrivateAccountStreamState(
81
91
  accountId: string,
@@ -89,13 +99,22 @@ export interface PrivateOrderDataConsumer {
89
99
  onPrivateOrderBootstrap(
90
100
  accountId: string,
91
101
  venue: Venue,
92
- snapshots: RawOrderUpdate[],
93
- ): void;
102
+ snapshot: RawOpenOrdersSnapshot,
103
+ options: { requestStartedAt: number; preserveStatus?: boolean },
104
+ ): OrderSnapshot[];
105
+ onPrivateOrderReconcile(
106
+ accountId: string,
107
+ venue: Venue,
108
+ snapshot: RawOpenOrdersSnapshot,
109
+ options: { requestStartedAt: number; preserveStatus?: boolean },
110
+ ): OrderSnapshot[];
94
111
  onPrivateOrderUpdate(
95
112
  accountId: string,
96
113
  venue: Venue,
97
114
  update: RawOrderUpdate,
115
+ options?: { requestStartedAt?: number; preserveStatus?: boolean },
98
116
  ): void;
117
+ getPrivateOpenOrders(accountId: string): OrderSnapshot[];
99
118
  onPrivateOrderStreamState(
100
119
  accountId: string,
101
120
  venue: Venue,
@@ -105,13 +124,11 @@ export interface PrivateOrderDataConsumer {
105
124
 
106
125
  export function hasPrivateCredentials(
107
126
  credentials?: AccountCredentials,
108
- venue?: Venue,
127
+ credentialsRequired = true,
109
128
  ): boolean {
110
- if (venue === "juplend") {
111
- return true;
112
- }
113
-
114
- return Boolean(credentials?.apiKey && credentials.secret);
129
+ return credentialsRequired
130
+ ? Boolean(credentials?.apiKey && credentials.secret)
131
+ : true;
115
132
  }
116
133
 
117
134
  export function mergeCredentials(