@imbingox/acex 0.4.0-beta.6 → 0.4.0-beta.8

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 CHANGED
@@ -1458,8 +1458,30 @@ interface AcexInternalError {
1458
1458
  ts: number;
1459
1459
  }
1460
1460
 
1461
+ interface AcexErrorDetails {
1462
+ venue?: Venue;
1463
+ accountId?: string;
1464
+ symbol?: string;
1465
+ venueError?: {
1466
+ code?: string;
1467
+ message?: string;
1468
+ };
1469
+ transport?: {
1470
+ kind?: "timeout" | "http" | "network" | "rate_limited" | "parse";
1471
+ status?: number;
1472
+ statusText?: string;
1473
+ retryAfterMs?: number;
1474
+ retryable?: boolean;
1475
+ attempts?: number;
1476
+ rawBody?: string;
1477
+ url?: string;
1478
+ };
1479
+ }
1480
+
1461
1481
  class AcexError extends Error {
1462
1482
  readonly code: AcexErrorCode;
1483
+ readonly details?: AcexErrorDetails;
1484
+ readonly cause?: unknown;
1463
1485
  }
1464
1486
  ```
1465
1487
 
@@ -1475,10 +1497,23 @@ try {
1475
1497
  } catch (err) {
1476
1498
  if (err instanceof AcexError) {
1477
1499
  console.log(err.code, err.message);
1500
+ console.log(err.details?.venueError?.code);
1501
+ console.log(err.details?.venueError?.message);
1478
1502
  }
1479
1503
  }
1480
1504
  ```
1481
1505
 
1506
+ `details.venueError` 是下游读取交易所结构化拒绝原因的首选字段,例如 Binance 返回 `{ "code": -2010, "msg": "Order would immediately trigger." }` 时会映射为:
1507
+
1508
+ ```ts
1509
+ {
1510
+ code: "-2010",
1511
+ message: "Order would immediately trigger.",
1512
+ }
1513
+ ```
1514
+
1515
+ `details.transport` 保留已脱敏的 HTTP/transport 诊断信息,例如 `kind`、`status`、`retryAfterMs`、`attempts`、`rawBody`、`url`。`rawBody` 和 `url` 只用于排障兜底,不建议作为业务分支首选字段。market stream 首包超时时,`MARKET_STREAM_TIMEOUT` 会带 `details.venue` / `details.symbol` 和底层 `cause`,通常不填 `details.venueError`。`cause` 保留底层错误链,用于高级调试。
1516
+
1482
1517
  完整错误码列表:
1483
1518
 
1484
1519
  | Code | 典型场景 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imbingox/acex",
3
- "version": "0.4.0-beta.6",
3
+ "version": "0.4.0-beta.8",
4
4
  "description": "Multi-exchange trading SDK for market data, account, and order management",
5
5
  "repository": {
6
6
  "type": "git",
@@ -3,6 +3,7 @@ import type {
3
3
  MarketDefinition,
4
4
  RateLimiter,
5
5
  VenueMarketCapabilities,
6
+ VenueServerTime,
6
7
  } from "../../types/index.ts";
7
8
  import type {
8
9
  FundingRateStreamCallbacks,
@@ -16,6 +17,7 @@ import {
16
17
  type BinanceMarketDefinition,
17
18
  loadBinanceMarkets,
18
19
  } from "./market-catalog.ts";
20
+ import { fetchBinanceServerTime } from "./server-time.ts";
19
21
  import {
20
22
  type BinanceStreamDescriptor,
21
23
  type BinanceStreamMessage,
@@ -45,6 +47,7 @@ export class BinanceMarketAdapter implements MarketAdapter {
45
47
  readonly venue = "binance" as const;
46
48
  readonly marketCapabilities: VenueMarketCapabilities = {
47
49
  catalog: "supported",
50
+ serverTime: "supported",
48
51
  l1Book: "supported",
49
52
  fundingRate: "market_dependent",
50
53
  marketTypes: ["spot", "swap", "future"],
@@ -73,6 +76,12 @@ export class BinanceMarketAdapter implements MarketAdapter {
73
76
  return markets;
74
77
  }
75
78
 
79
+ async fetchServerTime(): Promise<VenueServerTime> {
80
+ return await fetchBinanceServerTime({
81
+ rateLimiter: this.options.rateLimiter,
82
+ });
83
+ }
84
+
76
85
  createL1BookStream(
77
86
  market: MarketDefinition,
78
87
  callbacks: L1BookStreamCallbacks,
@@ -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,4 @@
1
1
  import BigNumber from "bignumber.js";
2
- import { AcexError } from "../../errors.ts";
3
2
  import {
4
3
  type HttpClientMessages,
5
4
  httpRequest,
@@ -734,28 +733,21 @@ export class JuplendPrivateAdapter implements PrivateUserDataAdapter {
734
733
  _credentials: AccountCredentials,
735
734
  _request: CreateOrderRequest,
736
735
  ): Promise<RawOrderUpdate> {
737
- throw new AcexError(
738
- "VENUE_NOT_SUPPORTED",
739
- "Juplend is read-only and does not support createOrder",
740
- );
736
+ throw new Error("Juplend is read-only and does not support createOrder");
741
737
  }
742
738
 
743
739
  cancelOrder(
744
740
  _credentials: AccountCredentials,
745
741
  _request: CancelOrderRequest,
746
742
  ): Promise<RawOrderUpdate> {
747
- throw new AcexError(
748
- "VENUE_NOT_SUPPORTED",
749
- "Juplend is read-only and does not support cancelOrder",
750
- );
743
+ throw new Error("Juplend is read-only and does not support cancelOrder");
751
744
  }
752
745
 
753
746
  cancelAllOrders(
754
747
  _credentials: AccountCredentials,
755
748
  _request: CancelAllOrdersRequest,
756
749
  ): Promise<RawOrderUpdate[]> {
757
- throw new AcexError(
758
- "VENUE_NOT_SUPPORTED",
750
+ throw new Error(
759
751
  "Juplend is read-only and does not support cancelAllOrders",
760
752
  );
761
753
  }
@@ -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,
@@ -2,8 +2,12 @@ import type {
2
2
  PrivateUserDataAdapter,
3
3
  StreamHandle,
4
4
  } from "../adapters/types.ts";
5
- import { AcexError } from "../errors.ts";
6
- import { isTransportError, redactSecrets } from "../internal/http-client.ts";
5
+ import {
6
+ AcexError,
7
+ buildAcexErrorDetails,
8
+ formatAcexErrorMessage,
9
+ } from "../errors.ts";
10
+ import { isTransportError } from "../internal/http-client.ts";
7
11
  import type {
8
12
  AccountRuntimeOptions,
9
13
  PrivateRuntimeReason,
@@ -55,14 +59,6 @@ function transportReason(
55
59
  : fallback;
56
60
  }
57
61
 
58
- function bootstrapErrorDetail(error: unknown): string {
59
- if (!(error instanceof Error) || !error.message) {
60
- return "";
61
- }
62
-
63
- return ` (${redactSecrets(error.message)})`;
64
- }
65
-
66
62
  export class PrivateSubscriptionCoordinator {
67
63
  private readonly context: ClientContext;
68
64
  private readonly adapters: Map<Venue, PrivateUserDataAdapter>;
@@ -260,6 +256,7 @@ export class PrivateSubscriptionCoordinator {
260
256
  throw new AcexError(
261
257
  "VENUE_NOT_SUPPORTED",
262
258
  `Venue is not supported yet: ${account.venue}`,
259
+ { details: buildAcexErrorDetails({ venue: account.venue }) },
263
260
  );
264
261
  }
265
262
 
@@ -272,6 +269,7 @@ export class PrivateSubscriptionCoordinator {
272
269
  throw new AcexError(
273
270
  "VENUE_NOT_SUPPORTED",
274
271
  `Venue is not supported yet: ${venue}`,
272
+ { details: buildAcexErrorDetails({ venue }) },
275
273
  );
276
274
  }
277
275
 
@@ -503,6 +501,12 @@ export class PrivateSubscriptionCoordinator {
503
501
  throw new AcexError(
504
502
  "CREDENTIALS_MISSING",
505
503
  `Account credentials are required for private subscriptions: ${account.accountId}`,
504
+ {
505
+ details: buildAcexErrorDetails({
506
+ accountId: account.accountId,
507
+ venue: account.venue,
508
+ }),
509
+ },
506
510
  );
507
511
  }
508
512
 
@@ -716,10 +720,23 @@ export class PrivateSubscriptionCoordinator {
716
720
  ),
717
721
  },
718
722
  );
719
- const reason = bootstrapErrorDetail(error);
723
+ const details = buildAcexErrorDetails(
724
+ {
725
+ accountId: record.accountId,
726
+ venue: record.venue,
727
+ },
728
+ error,
729
+ );
720
730
  throw new AcexError(
721
731
  "ACCOUNT_BOOTSTRAP_FAILED",
722
- `Failed to bootstrap account data: ${record.accountId}${reason}`,
732
+ formatAcexErrorMessage(
733
+ `Failed to bootstrap account data: ${record.accountId}`,
734
+ details,
735
+ ),
736
+ {
737
+ cause: error,
738
+ details,
739
+ },
723
740
  );
724
741
  }
725
742
  }
@@ -766,9 +783,23 @@ export class PrivateSubscriptionCoordinator {
766
783
  reason: transportReason(error, "auth_failed"),
767
784
  },
768
785
  );
786
+ const details = buildAcexErrorDetails(
787
+ {
788
+ accountId: record.accountId,
789
+ venue: record.venue,
790
+ },
791
+ error,
792
+ );
769
793
  throw new AcexError(
770
794
  "ORDER_BOOTSTRAP_FAILED",
771
- `Failed to bootstrap order data: ${record.accountId}`,
795
+ formatAcexErrorMessage(
796
+ `Failed to bootstrap order data: ${record.accountId}`,
797
+ details,
798
+ ),
799
+ {
800
+ cause: error,
801
+ details,
802
+ },
772
803
  );
773
804
  }
774
805
  }
@@ -9,7 +9,11 @@ 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";
15
19
  import { ReactiveRateLimiter } from "../internal/rate-limiter.ts";
@@ -431,7 +435,9 @@ export class AcexClientImpl implements AcexClient, ClientContext {
431
435
  message: string,
432
436
  metadata?: Omit<AcexInternalError, "error" | "source" | "ts">,
433
437
  ): AcexError {
434
- const error = new AcexError(code, message);
438
+ const error = new AcexError(code, message, {
439
+ details: buildAcexErrorDetails(metadata),
440
+ });
435
441
  this.errorBus.publish({
436
442
  source: "client",
437
443
  ts: this.now(),
@@ -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";
@@ -14,7 +14,11 @@ import type {
14
14
  HealthReporter,
15
15
  ManagerLifecycle,
16
16
  } from "../client/context.ts";
17
- import { AcexError } from "../errors.ts";
17
+ import {
18
+ AcexError,
19
+ buildAcexErrorDetails,
20
+ formatAcexErrorMessage,
21
+ } from "../errors.ts";
18
22
  import { AsyncEventBus } from "../internal/async-event-bus.ts";
19
23
  import { toCanonical } from "../internal/decimal.ts";
20
24
  import { matchesMarketFilter } from "../internal/filters.ts";
@@ -38,6 +42,7 @@ import type {
38
42
  SubscribeL1BookInput,
39
43
  SubscriptionActivity,
40
44
  Venue,
45
+ VenueServerTime,
41
46
  } from "../types/index.ts";
42
47
 
43
48
  export interface MarketManagerOptions {
@@ -201,6 +206,24 @@ export class MarketManagerImpl
201
206
  return summaries;
202
207
  }
203
208
 
209
+ async fetchServerTime(venue: Venue): Promise<VenueServerTime> {
210
+ const adapter = this.getMarketAdapter(venue);
211
+ if (!adapter.fetchServerTime) {
212
+ throw this.createError(
213
+ "VENUE_NOT_SUPPORTED",
214
+ `Venue is not supported yet: ${venue}`,
215
+ { venue },
216
+ "client",
217
+ );
218
+ }
219
+
220
+ try {
221
+ return await adapter.fetchServerTime();
222
+ } catch (error) {
223
+ throw this.createServerTimeFetchError(venue, error);
224
+ }
225
+ }
226
+
204
227
  async subscribeL1Book(input: SubscribeL1BookInput): Promise<void> {
205
228
  this.context.assertStarted();
206
229
  const market = await this.resolveMarketDefinition(input);
@@ -587,9 +610,17 @@ export class MarketManagerImpl
587
610
  }
588
611
 
589
612
  private createCatalogLoadError(venue: Venue, error: unknown): AcexError {
613
+ const details = buildAcexErrorDetails({ venue }, error);
590
614
  const wrapped = new AcexError(
591
615
  "MARKET_CATALOG_LOAD_FAILED",
592
- `Failed to load market catalog from ${venue}`,
616
+ formatAcexErrorMessage(
617
+ `Failed to load market catalog from ${venue}`,
618
+ details,
619
+ ),
620
+ {
621
+ cause: error,
622
+ details,
623
+ },
593
624
  );
594
625
  this.context.publishRuntimeError(
595
626
  "adapter",
@@ -601,6 +632,29 @@ export class MarketManagerImpl
601
632
  return wrapped;
602
633
  }
603
634
 
635
+ private createServerTimeFetchError(venue: Venue, error: unknown): AcexError {
636
+ const details = buildAcexErrorDetails({ venue }, error);
637
+ const wrapped = new AcexError(
638
+ "MARKET_SERVER_TIME_FETCH_FAILED",
639
+ formatAcexErrorMessage(
640
+ `Failed to fetch server time from ${venue}`,
641
+ details,
642
+ ),
643
+ {
644
+ cause: error,
645
+ details,
646
+ },
647
+ );
648
+ this.context.publishRuntimeError(
649
+ "adapter",
650
+ error instanceof Error
651
+ ? error
652
+ : new Error("Unknown server time fetch failure"),
653
+ { venue },
654
+ );
655
+ return wrapped;
656
+ }
657
+
604
658
  private async resolveMarketDefinition(input: {
605
659
  venue: Venue;
606
660
  symbol: string;
@@ -724,11 +778,20 @@ export class MarketManagerImpl
724
778
 
725
779
  try {
726
780
  await record.l1BookStream.ready;
727
- } catch {
781
+ } catch (error) {
782
+ record.l1BookStream.close();
728
783
  record.l1BookStream = undefined;
784
+ const details = buildAcexErrorDetails(
785
+ { venue: market.venue, symbol: market.symbol },
786
+ error,
787
+ );
729
788
  const timeoutError = new AcexError(
730
789
  "MARKET_STREAM_TIMEOUT",
731
790
  `Timed out waiting for market data: ${market.symbol}`,
791
+ {
792
+ cause: error,
793
+ details,
794
+ },
732
795
  );
733
796
  this.context.publishRuntimeError("runtime", timeoutError, {
734
797
  venue: market.venue,
@@ -752,11 +815,20 @@ export class MarketManagerImpl
752
815
 
753
816
  try {
754
817
  await record.fundingRateStream.ready;
755
- } catch {
818
+ } catch (error) {
819
+ record.fundingRateStream.close();
756
820
  record.fundingRateStream = undefined;
821
+ const details = buildAcexErrorDetails(
822
+ { venue: market.venue, symbol: market.symbol },
823
+ error,
824
+ );
757
825
  const timeoutError = new AcexError(
758
826
  "MARKET_STREAM_TIMEOUT",
759
827
  `Timed out waiting for market data: ${market.symbol}`,
828
+ {
829
+ cause: error,
830
+ details,
831
+ },
760
832
  );
761
833
  this.context.publishRuntimeError("runtime", timeoutError, {
762
834
  venue: market.venue,
@@ -1155,7 +1227,9 @@ export class MarketManagerImpl
1155
1227
  metadata?: { venue?: Venue; symbol?: string },
1156
1228
  source: "market" | "client" = "market",
1157
1229
  ): AcexError {
1158
- const error = new AcexError(code, message);
1230
+ const error = new AcexError(code, message, {
1231
+ details: buildAcexErrorDetails(metadata),
1232
+ });
1159
1233
  this.context.publishRuntimeError(source, error, metadata);
1160
1234
  return error;
1161
1235
  }
@@ -8,7 +8,11 @@ import type {
8
8
  PrivateOrderDataConsumer,
9
9
  PrivateSubscriptionState,
10
10
  } from "../client/context.ts";
11
- import { AcexError } from "../errors.ts";
11
+ import {
12
+ AcexError,
13
+ buildAcexErrorDetails,
14
+ formatAcexErrorMessage,
15
+ } from "../errors.ts";
12
16
  import { AsyncEventBus } from "../internal/async-event-bus.ts";
13
17
  import { toCanonical } from "../internal/decimal.ts";
14
18
  import { matchesOrderFilter } from "../internal/filters.ts";
@@ -656,7 +660,8 @@ export class OrderManagerImpl
656
660
  symbol?: string;
657
661
  },
658
662
  ): AcexError {
659
- const error = new AcexError(code, message);
663
+ const details = buildAcexErrorDetails(metadata);
664
+ const error = new AcexError(code, message, { details });
660
665
  this.context.publishRuntimeError("order", error, metadata);
661
666
  return error;
662
667
  }
@@ -683,6 +688,10 @@ export class OrderManagerImpl
683
688
  error instanceof Error ? error : new Error(message),
684
689
  metadata,
685
690
  );
686
- return new AcexError(code, message);
691
+ const details = buildAcexErrorDetails(metadata, error);
692
+ return new AcexError(code, formatAcexErrorMessage(message, details), {
693
+ cause: error,
694
+ details,
695
+ });
687
696
  }
688
697
  }
@@ -83,6 +83,7 @@ export type OrderTimeInForceCapability = "gtc" | "post_only";
83
83
 
84
84
  export interface VenueMarketCapabilities {
85
85
  catalog: VenueCapabilitySupport;
86
+ serverTime: VenueCapabilitySupport;
86
87
  l1Book: VenueCapabilitySupport;
87
88
  fundingRate: FundingRateCapability;
88
89
  marketTypes: MarketType[];
@@ -36,6 +36,19 @@ export interface MarketCatalogReloadSummary {
36
36
  error?: AcexError;
37
37
  }
38
38
 
39
+ export interface VenueServerTime {
40
+ /** Exchange server time in epoch milliseconds. Binance currently measures the USDM cluster. */
41
+ serverTime: number;
42
+ /** Local wall-clock timestamp captured immediately before the HTTP request is sent. */
43
+ requestSentAt: number;
44
+ /** Local wall-clock timestamp captured immediately after the HTTP response is received. */
45
+ responseReceivedAt: number;
46
+ /** Round trip duration measured with a monotonic clock, in milliseconds. */
47
+ roundTripMs: number;
48
+ /** NTP-style offset estimate: serverTime - midpoint(requestSentAt, responseReceivedAt). */
49
+ estimatedOffsetMs: number;
50
+ }
51
+
39
52
  export interface MarketDataStatus {
40
53
  venue: Venue;
41
54
  symbol: string;
@@ -170,6 +183,7 @@ export interface MarketManager {
170
183
 
171
184
  loadMarkets(): Promise<void>;
172
185
  reloadMarkets(venue?: Venue): Promise<MarketCatalogReloadSummary[]>;
186
+ fetchServerTime(venue: Venue): Promise<VenueServerTime>;
173
187
  subscribeL1Book(input: SubscribeL1BookInput): Promise<void>;
174
188
  unsubscribeL1Book(input: SubscribeL1BookInput): Promise<void>;
175
189
  subscribeFundingRate(input: SubscribeFundingRateInput): Promise<void>;