@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.
@@ -9,9 +9,14 @@ import type {
9
9
  PrivateUserDataAdapter,
10
10
  RawOrderUpdate,
11
11
  } from "../adapters/types.ts";
12
- import { AcexError, type AcexErrorCode } from "../errors.ts";
12
+ import {
13
+ AcexError,
14
+ type AcexErrorCode,
15
+ buildAcexErrorDetails,
16
+ } from "../errors.ts";
13
17
  import { AsyncEventBus } from "../internal/async-event-bus.ts";
14
18
  import { matchesHealthFilter } from "../internal/filters.ts";
19
+ import { ReactiveRateLimiter } from "../internal/rate-limiter.ts";
15
20
  import { AccountManagerImpl } from "../managers/account-manager.ts";
16
21
  import { MarketManagerImpl } from "../managers/market-manager.ts";
17
22
  import { OrderManagerImpl } from "../managers/order-manager.ts";
@@ -37,6 +42,7 @@ import type {
37
42
  StopOptions,
38
43
  Venue,
39
44
  VenueCapabilities,
45
+ VenueOrderCapabilities,
40
46
  } from "../types/index.ts";
41
47
  import {
42
48
  type ClientContext,
@@ -102,13 +108,20 @@ export class AcexClientImpl implements AcexClient, ClientContext {
102
108
  constructor(options: CreateClientOptions = {}) {
103
109
  activeClients.add(this);
104
110
 
105
- const marketAdapter = new BinanceMarketAdapter();
111
+ const rateLimiter = options.rateLimiter ?? new ReactiveRateLimiter();
112
+ const marketAdapter = new BinanceMarketAdapter({ rateLimiter });
106
113
  this.marketAdapters = new Map([[marketAdapter.venue, marketAdapter]]);
107
114
  const privateAdapters = [
108
- new BinancePrivateAdapter(),
115
+ new BinancePrivateAdapter({
116
+ signingClock: options.clock,
117
+ rateLimiter,
118
+ }),
109
119
  new JuplendPrivateAdapter(
110
120
  options.account?.juplend?.rpcUrl,
111
121
  options.account?.juplend?.jupApiKey,
122
+ {
123
+ pollIntervalMs: options.account?.juplend?.pollIntervalMs,
124
+ },
112
125
  ),
113
126
  ];
114
127
  this.privateAdapters = new Map(
@@ -122,7 +135,7 @@ export class AcexClientImpl implements AcexClient, ClientContext {
122
135
  l1ReconnectMaxDelayMs: options.market?.l1ReconnectMaxDelayMs,
123
136
  });
124
137
  this.accountManager = new AccountManagerImpl(this);
125
- this.orderManager = new OrderManagerImpl(this);
138
+ this.orderManager = new OrderManagerImpl(this, options.order);
126
139
  this.privateCoordinator = new PrivateSubscriptionCoordinator(
127
140
  this,
128
141
  privateAdapters,
@@ -293,9 +306,20 @@ export class AcexClientImpl implements AcexClient, ClientContext {
293
306
  return account;
294
307
  }
295
308
 
309
+ getPrivateOrderCapabilities(
310
+ venue: Venue,
311
+ ): VenueOrderCapabilities | undefined {
312
+ return this.privateAdapters.get(venue)?.orderCapabilities;
313
+ }
314
+
296
315
  ensurePrivateCredentials(accountId: string): void {
297
316
  const account = this.getRegisteredAccount(accountId);
298
- if (hasPrivateCredentials(account.credentials, account.venue)) {
317
+ if (
318
+ hasPrivateCredentials(
319
+ account.credentials,
320
+ this.getPrivateCredentialsRequired(account.venue),
321
+ )
322
+ ) {
299
323
  return;
300
324
  }
301
325
 
@@ -339,7 +363,7 @@ export class AcexClientImpl implements AcexClient, ClientContext {
339
363
  return this.getPrivateAdapter(account.venue).createOrder(
340
364
  account.credentials ?? {},
341
365
  request,
342
- account.options,
366
+ { ...account.options, accountId: account.accountId },
343
367
  );
344
368
  }
345
369
 
@@ -354,7 +378,7 @@ export class AcexClientImpl implements AcexClient, ClientContext {
354
378
  return this.getPrivateAdapter(account.venue).cancelOrder(
355
379
  account.credentials ?? {},
356
380
  request,
357
- account.options,
381
+ { ...account.options, accountId: account.accountId },
358
382
  );
359
383
  }
360
384
 
@@ -367,7 +391,7 @@ export class AcexClientImpl implements AcexClient, ClientContext {
367
391
  return this.getPrivateAdapter(account.venue).cancelAllOrders(
368
392
  account.credentials ?? {},
369
393
  request,
370
- account.options,
394
+ { ...account.options, accountId: account.accountId },
371
395
  );
372
396
  }
373
397
 
@@ -411,7 +435,9 @@ export class AcexClientImpl implements AcexClient, ClientContext {
411
435
  message: string,
412
436
  metadata?: Omit<AcexInternalError, "error" | "source" | "ts">,
413
437
  ): AcexError {
414
- const error = new AcexError(code, message);
438
+ const error = new AcexError(code, message, {
439
+ details: buildAcexErrorDetails(metadata),
440
+ });
415
441
  this.errorBus.publish({
416
442
  source: "client",
417
443
  ts: this.now(),
@@ -424,7 +450,7 @@ export class AcexClientImpl implements AcexClient, ClientContext {
424
450
  private getPrivateCommandAccount(accountId: string): RegisteredAccountRecord {
425
451
  const account = this.getRegisteredAccount(accountId);
426
452
  const adapter = this.getPrivateAdapter(account.venue);
427
- if (adapter.venue === "juplend") {
453
+ if (!adapter.orderCapabilities.supported) {
428
454
  throw this.createError(
429
455
  "VENUE_NOT_SUPPORTED",
430
456
  `Venue does not support private order commands: ${account.venue}`,
@@ -432,7 +458,12 @@ export class AcexClientImpl implements AcexClient, ClientContext {
432
458
  );
433
459
  }
434
460
 
435
- if (!hasPrivateCredentials(account.credentials, account.venue)) {
461
+ if (
462
+ !hasPrivateCredentials(
463
+ account.credentials,
464
+ adapter.accountCapabilities.credentialsRequired,
465
+ )
466
+ ) {
436
467
  throw this.createError(
437
468
  "CREDENTIALS_MISSING",
438
469
  `Account credentials are required for private order commands: ${accountId}`,
@@ -443,6 +474,13 @@ export class AcexClientImpl implements AcexClient, ClientContext {
443
474
  return account;
444
475
  }
445
476
 
477
+ private getPrivateCredentialsRequired(venue: Venue): boolean {
478
+ return (
479
+ this.privateAdapters.get(venue)?.accountCapabilities
480
+ .credentialsRequired ?? true
481
+ );
482
+ }
483
+
446
484
  private getPrivateAdapter(venue: Venue): PrivateUserDataAdapter {
447
485
  const adapter = this.privateAdapters.get(venue);
448
486
  if (!adapter) {
@@ -42,6 +42,7 @@ const typeOnlyNotes = [
42
42
 
43
43
  const unsupportedMarket: VenueMarketCapabilities = {
44
44
  catalog: "unsupported",
45
+ serverTime: "unsupported",
45
46
  l1Book: "unsupported",
46
47
  fundingRate: "unsupported",
47
48
  marketTypes: [],
package/src/errors.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import { isTransportError } from "./internal/http-client.ts";
2
+ import type { Venue } from "./types/shared.ts";
3
+
1
4
  export type AcexErrorCode =
2
5
  | "ACCOUNT_ALREADY_EXISTS"
3
6
  | "ACCOUNT_BOOTSTRAP_FAILED"
@@ -6,6 +9,7 @@ export type AcexErrorCode =
6
9
  | "CREDENTIALS_MISSING"
7
10
  | "VENUE_NOT_SUPPORTED"
8
11
  | "MARKET_CATALOG_LOAD_FAILED"
12
+ | "MARKET_SERVER_TIME_FETCH_FAILED"
9
13
  | "MARKET_INACTIVE"
10
14
  | "MARKET_FUNDING_RATE_UNSUPPORTED"
11
15
  | "MARKET_NOT_FOUND"
@@ -16,12 +20,162 @@ export type AcexErrorCode =
16
20
  | "ORDER_CREATE_FAILED"
17
21
  | "ORDER_INPUT_INVALID";
18
22
 
23
+ export type AcexErrorTransportKind =
24
+ | "timeout"
25
+ | "http"
26
+ | "network"
27
+ | "rate_limited"
28
+ | "parse";
29
+
30
+ export interface AcexVenueErrorDetails {
31
+ readonly code?: string;
32
+ readonly message?: string;
33
+ }
34
+
35
+ export interface AcexErrorTransportDetails {
36
+ readonly kind?: AcexErrorTransportKind;
37
+ readonly status?: number;
38
+ readonly statusText?: string;
39
+ readonly retryAfterMs?: number;
40
+ readonly retryable?: boolean;
41
+ readonly attempts?: number;
42
+ readonly rawBody?: string;
43
+ readonly url?: string;
44
+ }
45
+
46
+ export interface AcexErrorDetails {
47
+ readonly venue?: Venue;
48
+ readonly accountId?: string;
49
+ readonly symbol?: string;
50
+ readonly venueError?: AcexVenueErrorDetails;
51
+ readonly transport?: AcexErrorTransportDetails;
52
+ }
53
+
54
+ export interface AcexErrorOptions {
55
+ readonly cause?: unknown;
56
+ readonly details?: AcexErrorDetails;
57
+ }
58
+
19
59
  export class AcexError extends Error {
20
60
  readonly code: AcexErrorCode;
61
+ readonly details?: AcexErrorDetails;
62
+ override readonly cause?: unknown;
21
63
 
22
- constructor(code: AcexErrorCode, message: string) {
23
- super(message);
64
+ constructor(
65
+ code: AcexErrorCode,
66
+ message: string,
67
+ options: AcexErrorOptions = {},
68
+ ) {
69
+ super(message, { cause: options.cause });
24
70
  this.name = "AcexError";
25
71
  this.code = code;
72
+ this.details = options.details;
73
+ this.cause = options.cause;
74
+ }
75
+ }
76
+
77
+ export function buildAcexErrorDetails(
78
+ context?: Pick<AcexErrorDetails, "venue" | "accountId" | "symbol">,
79
+ cause?: unknown,
80
+ ): AcexErrorDetails | undefined {
81
+ const transport = buildTransportDetails(cause);
82
+ const venueError = parseVenueErrorDetails(transport?.rawBody);
83
+ const details: AcexErrorDetails = {
84
+ venue: context?.venue,
85
+ accountId: context?.accountId,
86
+ symbol: context?.symbol,
87
+ venueError,
88
+ transport,
89
+ };
90
+
91
+ return hasDetails(details) ? details : undefined;
92
+ }
93
+
94
+ export function formatAcexErrorMessage(
95
+ message: string,
96
+ details?: AcexErrorDetails,
97
+ ): string {
98
+ const venueErrorMessage = details?.venueError?.message?.trim();
99
+ if (!venueErrorMessage) {
100
+ return message;
101
+ }
102
+
103
+ const venue = details?.venue;
104
+ const venueLabel = venue ? formatVenueLabel(venue) : "Exchange";
105
+ return `${message} (${venueLabel} rejected: ${venueErrorMessage})`;
106
+ }
107
+
108
+ function buildTransportDetails(
109
+ cause: unknown,
110
+ ): AcexErrorTransportDetails | undefined {
111
+ if (!isTransportError(cause)) {
112
+ return undefined;
113
+ }
114
+
115
+ return pruneUndefined({
116
+ kind: cause.kind,
117
+ status: cause.status,
118
+ statusText: cause.statusText,
119
+ retryAfterMs: cause.retryAfterMs,
120
+ retryable: cause.retryable,
121
+ attempts: cause.attempts,
122
+ rawBody: cause.rawBody,
123
+ url: cause.url,
124
+ });
125
+ }
126
+
127
+ function parseVenueErrorDetails(
128
+ rawBody: string | undefined,
129
+ ): AcexVenueErrorDetails | undefined {
130
+ if (!rawBody) {
131
+ return undefined;
26
132
  }
133
+
134
+ let parsed: unknown;
135
+ try {
136
+ parsed = JSON.parse(rawBody);
137
+ } catch {
138
+ return undefined;
139
+ }
140
+
141
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
142
+ return undefined;
143
+ }
144
+
145
+ const record = parsed as Record<string, unknown>;
146
+ const code = record.code;
147
+ const message = record.msg ?? record.message;
148
+
149
+ if (
150
+ (typeof code !== "string" && typeof code !== "number") ||
151
+ typeof message !== "string" ||
152
+ message.trim() === ""
153
+ ) {
154
+ return undefined;
155
+ }
156
+
157
+ return {
158
+ code: String(code),
159
+ message,
160
+ };
161
+ }
162
+
163
+ function hasDetails(details: AcexErrorDetails): boolean {
164
+ return Boolean(
165
+ details.venue ||
166
+ details.accountId ||
167
+ details.symbol ||
168
+ details.venueError ||
169
+ details.transport,
170
+ );
171
+ }
172
+
173
+ function formatVenueLabel(venue: Venue): string {
174
+ return `${venue.charAt(0).toUpperCase()}${venue.slice(1)}`;
175
+ }
176
+
177
+ function pruneUndefined<T extends Record<string, unknown>>(input: T): T {
178
+ return Object.fromEntries(
179
+ Object.entries(input).filter(([, value]) => value !== undefined),
180
+ ) as T;
27
181
  }
package/src/index.ts CHANGED
@@ -1,5 +1,12 @@
1
1
  export { BigNumber } from "bignumber.js";
2
2
  export { createClient } from "./client/create-client.ts";
3
- export type { AcexErrorCode } from "./errors.ts";
3
+ export type {
4
+ AcexErrorCode,
5
+ AcexErrorDetails,
6
+ AcexErrorOptions,
7
+ AcexErrorTransportDetails,
8
+ AcexErrorTransportKind,
9
+ AcexVenueErrorDetails,
10
+ } from "./errors.ts";
4
11
  export { AcexError } from "./errors.ts";
5
12
  export * from "./types/index.ts";
@@ -0,0 +1,19 @@
1
+ import BigNumber from "bignumber.js";
2
+ import type { DecimalInput } from "../types/index.ts";
3
+
4
+ /**
5
+ * Convert a decimal value to its canonical string form: full precision, no
6
+ * scientific notation, no trailing zeros.
7
+ *
8
+ * Throws on non-finite input (NaN / Infinity) so producers can never leak
9
+ * sentinel strings into public output fields. Call sites that legitimately
10
+ * accept non-finite input (e.g. order-input validation) must guard before
11
+ * calling this.
12
+ */
13
+ export function toCanonical(value: DecimalInput): string {
14
+ const bn = new BigNumber(value);
15
+ if (!bn.isFinite()) {
16
+ throw new RangeError(`invalid non-finite DecimalInput: ${bn.toString()}`);
17
+ }
18
+ return bn.toFixed();
19
+ }