@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 +35 -0
- package/package.json +1 -1
- package/src/adapters/binance/adapter.ts +9 -0
- package/src/adapters/binance/server-time.ts +106 -0
- package/src/adapters/juplend/private-adapter.ts +3 -11
- package/src/adapters/types.ts +2 -0
- package/src/client/private-subscription-coordinator.ts +44 -13
- package/src/client/runtime.ts +8 -2
- package/src/client/venue-capabilities.ts +1 -0
- package/src/errors.ts +156 -2
- package/src/index.ts +8 -1
- package/src/managers/market-manager.ts +79 -5
- package/src/managers/order-manager.ts +12 -3
- package/src/types/client.ts +1 -0
- package/src/types/market.ts +14 -0
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
|
@@ -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
|
|
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
|
|
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
|
|
758
|
-
"VENUE_NOT_SUPPORTED",
|
|
750
|
+
throw new Error(
|
|
759
751
|
"Juplend is read-only and does not support cancelAllOrders",
|
|
760
752
|
);
|
|
761
753
|
}
|
package/src/adapters/types.ts
CHANGED
|
@@ -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 {
|
|
6
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/client/runtime.ts
CHANGED
|
@@ -9,7 +9,11 @@ import type {
|
|
|
9
9
|
PrivateUserDataAdapter,
|
|
10
10
|
RawOrderUpdate,
|
|
11
11
|
} from "../adapters/types.ts";
|
|
12
|
-
import {
|
|
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(),
|
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(
|
|
23
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
691
|
+
const details = buildAcexErrorDetails(metadata, error);
|
|
692
|
+
return new AcexError(code, formatAcexErrorMessage(message, details), {
|
|
693
|
+
cause: error,
|
|
694
|
+
details,
|
|
695
|
+
});
|
|
687
696
|
}
|
|
688
697
|
}
|
package/src/types/client.ts
CHANGED
|
@@ -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[];
|
package/src/types/market.ts
CHANGED
|
@@ -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>;
|