@imbingox/acex 0.4.0-beta.14 → 0.4.0-beta.16
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/CHANGELOG.md +12 -0
- package/docs/api.md +32 -6
- package/package.json +1 -1
- package/src/adapters/binance/error-codes.ts +53 -0
- package/src/adapters/binance/private-adapter.ts +6 -2
- package/src/adapters/types.ts +2 -0
- package/src/client/context.ts +27 -0
- package/src/client/private-subscription-coordinator.ts +113 -5
- package/src/client/runtime.ts +9 -0
- package/src/errors.ts +17 -1
- package/src/index.ts +2 -1
- package/src/managers/order/data-status.ts +9 -0
- package/src/managers/order/model.ts +3 -0
- package/src/managers/order/snapshot.ts +1 -0
- package/src/managers/order-manager.ts +309 -27
- package/src/types/order.ts +2 -1
- package/src/types/shared.ts +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @imbingox/acex
|
|
2
2
|
|
|
3
|
+
## 0.4.0-beta.16
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- bdaf9ea: 订单生命周期增加 confirmed-missing 收尾与 pending claim TTL:`OrderStatus` 新增 `unknown` 终态,open 订单在 reconcile 单笔回查连续确认不存在后会移入 closed;`CreateClientOptions.order` 新增 `missingOrderEvictionThreshold` 与 `pendingClaimTtlMs`,用于配置幽灵 open 订单驱逐阈值和 `createOrder` timeout claim 回查 TTL。
|
|
8
|
+
|
|
9
|
+
## 0.4.0-beta.15
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 3f6dcb8: 新增 `AcexError.details.venueError.reason`、订单命令错误的 `details.orderState`,并导出 `isOrderStateUnknown()`,方便调用方用稳定语义区分交易所拒单、限流、余额不足和订单状态未知场景。
|
|
14
|
+
|
|
3
15
|
## 0.4.0-beta.14
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/docs/api.md
CHANGED
|
@@ -442,7 +442,7 @@ interface OrderManager {
|
|
|
442
442
|
|
|
443
443
|
### 7.3 本地缓存与查询
|
|
444
444
|
|
|
445
|
-
- OrderManager 内部按 open / closed 分层缓存订单。**closed(filled / canceled / rejected / expired)订单按 symbol 各保留最近 N 个**,`N = CreateClientOptions.order.maxClosedOrdersPerSymbol`(默认 500,非正或非整数回退默认),超限按 FIFO 裁剪最旧;**open 订单不受此上限限制**。`getOpenOrders()` 查询复杂度与历史终态订单数量无关。
|
|
445
|
+
- OrderManager 内部按 open / closed 分层缓存订单。**closed(filled / canceled / rejected / expired / unknown)订单按 symbol 各保留最近 N 个**,`N = CreateClientOptions.order.maxClosedOrdersPerSymbol`(默认 500,非正或非整数回退默认),超限按 FIFO 裁剪最旧;**open 订单不受此上限限制**。`getOpenOrders()` 查询复杂度与历史终态订单数量无关。
|
|
446
446
|
- `getOrder(input)` 需带 `orderId` 或 `clientOrderId`(否则返回 `undefined`),`symbol` 可选:
|
|
447
447
|
- **精确查单推荐传 `symbol + orderId`**(O(1) 精确索引、唯一命中)。
|
|
448
448
|
- 仅 `clientOrderId` 查询可命中 open 与未被裁剪的 closed;当 `clientOrderId` 唯一(你自定义的或 SDK 生成的 `acex-*`)时可精确命中,但同一 `clientOrderId` 命中多笔时返回**最新一笔**(精确定位历史某一笔请用 `symbol + orderId`)。
|
|
@@ -450,7 +450,7 @@ interface OrderManager {
|
|
|
450
450
|
- 同时给 `orderId` 与 `clientOrderId` 时,两者都匹配才命中。
|
|
451
451
|
- 已超出保留上限被裁剪的 closed 订单将查不到(返回 `undefined`)。
|
|
452
452
|
|
|
453
|
-
Order 事件用于消费订单状态变化和 open orders 快照校准。Binance private reconcile 会先用 `/papi/v1/um/openOrders` 校验当前 open set;本地 open order 从 open set 消失时,SDK 会优先查询单笔订单终态并发布 `order.filled` / `order.canceled`
|
|
453
|
+
Order 事件用于消费订单状态变化和 open orders 快照校准。Binance private reconcile 会先用 `/papi/v1/um/openOrders` 校验当前 open set;本地 open order 从 open set 消失时,SDK 会优先查询单笔订单终态并发布 `order.filled` / `order.canceled` 等事件。若单笔查询连续确认订单不存在(默认 3 次,`CreateClientOptions.order.missingOrderEvictionThreshold` 可配置),SDK 会把该订单终态化为 `status: "unknown"`、移出 open 缓存并发布一次 runtime error;网络/超时/限流等 transport 错误不会计入该阈值。`createOrder()` 超时保留的 pending claim 会在 reconcile 周期里按 `CreateClientOptions.order.pendingClaimTtlMs`(默认 90s)过期回查:查到订单则正常入库,确认不存在则清理 claim 并发布 runtime error;无 `fetchOrder` 能力的 venue 会保守保留 claim。
|
|
454
454
|
|
|
455
455
|
```ts
|
|
456
456
|
for await (const event of client.order.events.updates({
|
|
@@ -499,7 +499,8 @@ type OrderStatus =
|
|
|
499
499
|
| "filled"
|
|
500
500
|
| "canceled"
|
|
501
501
|
| "rejected"
|
|
502
|
-
| "expired"
|
|
502
|
+
| "expired"
|
|
503
|
+
| "unknown";
|
|
503
504
|
|
|
504
505
|
type PrivateRuntimeReason =
|
|
505
506
|
| "credentials_missing"
|
|
@@ -597,6 +598,8 @@ interface CreateClientOptions {
|
|
|
597
598
|
};
|
|
598
599
|
order?: {
|
|
599
600
|
maxClosedOrdersPerSymbol?: number;
|
|
601
|
+
missingOrderEvictionThreshold?: number;
|
|
602
|
+
pendingClaimTtlMs?: number;
|
|
600
603
|
};
|
|
601
604
|
}
|
|
602
605
|
|
|
@@ -918,20 +921,43 @@ type OrderEvent =
|
|
|
918
921
|
可预期错误统一抛 `AcexError`:
|
|
919
922
|
|
|
920
923
|
```ts
|
|
921
|
-
import { AcexError } from "@imbingox/acex";
|
|
924
|
+
import { AcexError, isOrderStateUnknown } from "@imbingox/acex";
|
|
922
925
|
|
|
923
926
|
try {
|
|
924
|
-
await client.
|
|
927
|
+
await client.order.createOrder({
|
|
928
|
+
accountId: "main-binance",
|
|
929
|
+
symbol: "BTC/USDT:USDT",
|
|
930
|
+
side: "buy",
|
|
931
|
+
type: "limit",
|
|
932
|
+
price: "101000",
|
|
933
|
+
amount: "0.01",
|
|
934
|
+
postOnly: true,
|
|
935
|
+
});
|
|
925
936
|
} catch (error) {
|
|
926
937
|
if (error instanceof AcexError) {
|
|
927
938
|
console.log(error.code);
|
|
928
939
|
console.log(error.details?.venueError?.code);
|
|
940
|
+
console.log(error.details?.venueError?.reason);
|
|
941
|
+
console.log(error.details?.orderState);
|
|
929
942
|
console.log(error.details?.transport?.status);
|
|
943
|
+
console.log(isOrderStateUnknown(error));
|
|
930
944
|
}
|
|
931
945
|
}
|
|
932
946
|
```
|
|
933
947
|
|
|
934
|
-
`details.venueError` 是读取交易所结构化拒绝原因的首选字段;`details.transport` 保存已脱敏的 HTTP / transport 诊断信息;`cause` 保留底层错误链。
|
|
948
|
+
`details.venueError` 是读取交易所结构化拒绝原因的首选字段;`details.venueError.reason` 是 SDK 归一后的稳定原因,原始 `code/message` 会继续保留。`details.orderState` 只在订单命令错误中填写:`not_placed` 表示 SDK 判定订单未落地,`unknown` 表示请求可能已经到达交易所,应由调用方后续查询或对账确认。`details.transport` 保存已脱敏的 HTTP / transport 诊断信息;`cause` 保留底层错误链。
|
|
949
|
+
|
|
950
|
+
归一错误原因:
|
|
951
|
+
|
|
952
|
+
| `VenueErrorReason` | 典型含义 |
|
|
953
|
+
|---|---|
|
|
954
|
+
| `insufficient_balance` | 余额或保证金不足 |
|
|
955
|
+
| `would_take` | Post Only / maker-only 订单会吃单而被拒 |
|
|
956
|
+
| `order_not_found` | 订单不存在、已不在可撤订单簿或超过交易所可查询范围 |
|
|
957
|
+
| `filter_violation` | 价格、数量、精度、最小名义金额或订单数量限制不满足 |
|
|
958
|
+
| `rate_limited` | 请求权重、订单频率或账户排队被限流 |
|
|
959
|
+
| `timestamp_out_of_sync` | 请求时间戳或 `recvWindow` 与交易所时间不匹配 |
|
|
960
|
+
| `unknown` | 交易所原始码未归入稳定语义,调用方仍可读取原始 `code/message` |
|
|
935
961
|
|
|
936
962
|
完整错误码:
|
|
937
963
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { VenueErrorReason } from "../../errors.ts";
|
|
2
|
+
|
|
3
|
+
const BINANCE_RATE_LIMITED_CODES = new Set([
|
|
4
|
+
"-1003",
|
|
5
|
+
"-1008",
|
|
6
|
+
"-1015",
|
|
7
|
+
"-5041",
|
|
8
|
+
]);
|
|
9
|
+
const BINANCE_TIMESTAMP_OUT_OF_SYNC_CODES = new Set(["-1021", "-5028"]);
|
|
10
|
+
const BINANCE_ORDER_NOT_FOUND_CODES = new Set(["-2011", "-2013"]);
|
|
11
|
+
const BINANCE_INSUFFICIENT_BALANCE_CODES = new Set(["-2018", "-2019"]);
|
|
12
|
+
const BINANCE_WOULD_TAKE_CODES = new Set(["-5022"]);
|
|
13
|
+
const BINANCE_FILTER_VIOLATION_CODES = new Set([
|
|
14
|
+
"-4131",
|
|
15
|
+
"-2025",
|
|
16
|
+
"-1111",
|
|
17
|
+
"-4002",
|
|
18
|
+
"-4004",
|
|
19
|
+
"-4005",
|
|
20
|
+
"-4013",
|
|
21
|
+
"-4014",
|
|
22
|
+
"-4016",
|
|
23
|
+
"-4023",
|
|
24
|
+
"-4024",
|
|
25
|
+
"-4029",
|
|
26
|
+
"-4030",
|
|
27
|
+
"-4164",
|
|
28
|
+
"-4183",
|
|
29
|
+
"-4184",
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
export function normalizeBinanceErrorCode(code: string): VenueErrorReason {
|
|
33
|
+
if (BINANCE_RATE_LIMITED_CODES.has(code)) {
|
|
34
|
+
return "rate_limited";
|
|
35
|
+
}
|
|
36
|
+
if (BINANCE_TIMESTAMP_OUT_OF_SYNC_CODES.has(code)) {
|
|
37
|
+
return "timestamp_out_of_sync";
|
|
38
|
+
}
|
|
39
|
+
if (BINANCE_ORDER_NOT_FOUND_CODES.has(code)) {
|
|
40
|
+
return "order_not_found";
|
|
41
|
+
}
|
|
42
|
+
if (BINANCE_INSUFFICIENT_BALANCE_CODES.has(code)) {
|
|
43
|
+
return "insufficient_balance";
|
|
44
|
+
}
|
|
45
|
+
if (BINANCE_WOULD_TAKE_CODES.has(code)) {
|
|
46
|
+
return "would_take";
|
|
47
|
+
}
|
|
48
|
+
if (BINANCE_FILTER_VIOLATION_CODES.has(code)) {
|
|
49
|
+
return "filter_violation";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return "unknown";
|
|
53
|
+
}
|
|
@@ -33,6 +33,7 @@ import type {
|
|
|
33
33
|
RawRiskUpdate,
|
|
34
34
|
StreamHandle,
|
|
35
35
|
} from "../types.ts";
|
|
36
|
+
import { normalizeBinanceErrorCode } from "./error-codes.ts";
|
|
36
37
|
import { parseBinanceRateLimitUsage } from "./rate-limit.ts";
|
|
37
38
|
|
|
38
39
|
type TimerHandle = ReturnType<typeof setInterval>;
|
|
@@ -183,7 +184,6 @@ const LISTEN_KEY_KEEPALIVE_RETRY_POLICY: HttpRetryPolicy = {
|
|
|
183
184
|
idempotent: true,
|
|
184
185
|
maxAttempts: 3,
|
|
185
186
|
};
|
|
186
|
-
const BINANCE_ORDER_NOT_FOUND_CODES = new Set(["-2011", "-2013"]);
|
|
187
187
|
function getBinancePapiHttpMessages(timeoutMs: number): HttpClientMessages {
|
|
188
188
|
return {
|
|
189
189
|
http: ({ status, statusText, url, rawBody }) =>
|
|
@@ -628,7 +628,7 @@ function isBinanceOrderNotFound(error: unknown): boolean {
|
|
|
628
628
|
|
|
629
629
|
try {
|
|
630
630
|
const parsed = JSON.parse(rawBody) as { code?: unknown };
|
|
631
|
-
return
|
|
631
|
+
return normalizeBinanceErrorCode(`${parsed.code}`) === "order_not_found";
|
|
632
632
|
} catch {
|
|
633
633
|
return false;
|
|
634
634
|
}
|
|
@@ -676,6 +676,10 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
|
|
|
676
676
|
} = {},
|
|
677
677
|
) {}
|
|
678
678
|
|
|
679
|
+
normalizeVenueErrorCode(code: string) {
|
|
680
|
+
return normalizeBinanceErrorCode(code);
|
|
681
|
+
}
|
|
682
|
+
|
|
679
683
|
async bootstrapAccount(
|
|
680
684
|
credentials: AccountCredentials,
|
|
681
685
|
accountOptions?: Record<string, unknown>,
|
package/src/adapters/types.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { VenueErrorReason } from "../errors.ts";
|
|
1
2
|
import type {
|
|
2
3
|
AccountCredentials,
|
|
3
4
|
CreateOrderType,
|
|
@@ -233,6 +234,7 @@ export interface PrivateUserDataAdapter {
|
|
|
233
234
|
readonly notes: string[];
|
|
234
235
|
readonly accountCapabilities: VenueAccountCapabilities;
|
|
235
236
|
readonly orderCapabilities: VenueOrderCapabilities;
|
|
237
|
+
normalizeVenueErrorCode?(code: string): VenueErrorReason;
|
|
236
238
|
bootstrapAccount(
|
|
237
239
|
credentials: AccountCredentials,
|
|
238
240
|
accountOptions?: Record<string, unknown>,
|
package/src/client/context.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
RawOpenOrdersSnapshot,
|
|
5
5
|
RawOrderUpdate,
|
|
6
6
|
} from "../adapters/types.ts";
|
|
7
|
+
import type { VenueErrorReason } from "../errors.ts";
|
|
7
8
|
import type {
|
|
8
9
|
AccountCredentials,
|
|
9
10
|
AcexInternalError,
|
|
@@ -30,6 +31,10 @@ export interface ClientContext {
|
|
|
30
31
|
assertStarted(): void;
|
|
31
32
|
getRegisteredAccount(accountId: string): RegisteredAccountRecord;
|
|
32
33
|
getPrivateOrderCapabilities(venue: Venue): VenueOrderCapabilities | undefined;
|
|
34
|
+
normalizeVenueErrorCode(
|
|
35
|
+
venue: Venue,
|
|
36
|
+
code: string,
|
|
37
|
+
): VenueErrorReason | undefined;
|
|
33
38
|
ensurePrivateCredentials(accountId: string): void;
|
|
34
39
|
subscribePrivateAccountFeed(accountId: string): Promise<void>;
|
|
35
40
|
unsubscribePrivateAccountFeed(accountId: string): void;
|
|
@@ -68,6 +73,13 @@ export interface PrivateSubscriptionState {
|
|
|
68
73
|
lastReadyAt?: number;
|
|
69
74
|
}
|
|
70
75
|
|
|
76
|
+
export interface ExpiredPendingOrderClaim {
|
|
77
|
+
venueClientOrderId: string;
|
|
78
|
+
localOrderId: string;
|
|
79
|
+
symbol: string;
|
|
80
|
+
claimedAt: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
71
83
|
export interface PrivateAccountDataConsumer {
|
|
72
84
|
onPrivateAccountPending(accountId: string, venue: Venue): void;
|
|
73
85
|
onPrivateAccountBootstrap(
|
|
@@ -114,7 +126,22 @@ export interface PrivateOrderDataConsumer {
|
|
|
114
126
|
update: RawOrderUpdate,
|
|
115
127
|
options?: { requestStartedAt?: number; preserveStatus?: boolean },
|
|
116
128
|
): void;
|
|
129
|
+
onPrivateOrderConfirmedMissing(
|
|
130
|
+
accountId: string,
|
|
131
|
+
venue: Venue,
|
|
132
|
+
order: OrderSnapshot,
|
|
133
|
+
): void;
|
|
117
134
|
getPrivateOpenOrders(accountId: string): OrderSnapshot[];
|
|
135
|
+
getExpiredPrivateOrderClaims(
|
|
136
|
+
accountId: string,
|
|
137
|
+
now: number,
|
|
138
|
+
ttlMs: number,
|
|
139
|
+
): ExpiredPendingOrderClaim[];
|
|
140
|
+
onPrivateOrderClaimNotFound(
|
|
141
|
+
accountId: string,
|
|
142
|
+
venue: Venue,
|
|
143
|
+
claim: ExpiredPendingOrderClaim,
|
|
144
|
+
): void;
|
|
118
145
|
onPrivateOrderStreamState(
|
|
119
146
|
accountId: string,
|
|
120
147
|
venue: Venue,
|
|
@@ -12,12 +12,14 @@ import {
|
|
|
12
12
|
import { isTransportError } from "../internal/http-client.ts";
|
|
13
13
|
import type {
|
|
14
14
|
AccountRuntimeOptions,
|
|
15
|
+
OrderRuntimeOptions,
|
|
15
16
|
OrderSnapshot,
|
|
16
17
|
PrivateRuntimeReason,
|
|
17
18
|
Venue,
|
|
18
19
|
} from "../types/index.ts";
|
|
19
20
|
import type {
|
|
20
21
|
ClientContext,
|
|
22
|
+
ExpiredPendingOrderClaim,
|
|
21
23
|
PrivateAccountDataConsumer,
|
|
22
24
|
PrivateOrderDataConsumer,
|
|
23
25
|
RegisteredAccountRecord,
|
|
@@ -50,6 +52,7 @@ const DEFAULT_LISTEN_KEY_KEEPALIVE_MS = 30 * 60 * 1_000;
|
|
|
50
52
|
const DEFAULT_PRIVATE_STREAM_STALE_AFTER_MS = 65 * 60_000;
|
|
51
53
|
const DEFAULT_BINANCE_RISK_POLL_INTERVAL_MS = 5_000;
|
|
52
54
|
const DEFAULT_BINANCE_PRIVATE_RECONCILE_INTERVAL_MS = 60_000;
|
|
55
|
+
const DEFAULT_PENDING_CLAIM_TTL_MS = 90_000;
|
|
53
56
|
const MAX_ORDER_TERMINAL_BACKFILLS_PER_RECONCILE = 20;
|
|
54
57
|
const MAX_ORDER_TERMINAL_BACKFILL_CONCURRENCY = 4;
|
|
55
58
|
|
|
@@ -94,6 +97,7 @@ export class PrivateSubscriptionCoordinator {
|
|
|
94
97
|
private readonly binancePrivateStreamStaleAfterMs: number;
|
|
95
98
|
private readonly binanceRiskPollIntervalMs: number;
|
|
96
99
|
private readonly binancePrivateReconcileIntervalMs: number | undefined;
|
|
100
|
+
private readonly pendingClaimTtlMs: number;
|
|
97
101
|
private readonly records = new Map<string, PrivateSubscriptionRecord>();
|
|
98
102
|
|
|
99
103
|
constructor(
|
|
@@ -102,6 +106,7 @@ export class PrivateSubscriptionCoordinator {
|
|
|
102
106
|
accountConsumer: PrivateAccountDataConsumer,
|
|
103
107
|
orderConsumer: PrivateOrderDataConsumer,
|
|
104
108
|
options: AccountRuntimeOptions = {},
|
|
109
|
+
orderOptions: OrderRuntimeOptions = {},
|
|
105
110
|
) {
|
|
106
111
|
this.context = context;
|
|
107
112
|
this.adapters = new Map(
|
|
@@ -130,6 +135,10 @@ export class PrivateSubscriptionCoordinator {
|
|
|
130
135
|
options.binance?.privateReconcileIntervalMs,
|
|
131
136
|
DEFAULT_BINANCE_PRIVATE_RECONCILE_INTERVAL_MS,
|
|
132
137
|
);
|
|
138
|
+
this.pendingClaimTtlMs = normalizePositiveInterval(
|
|
139
|
+
orderOptions.pendingClaimTtlMs,
|
|
140
|
+
DEFAULT_PENDING_CLAIM_TTL_MS,
|
|
141
|
+
);
|
|
133
142
|
}
|
|
134
143
|
|
|
135
144
|
async subscribeAccountFeed(accountId: string): Promise<void> {
|
|
@@ -940,6 +949,12 @@ export class PrivateSubscriptionCoordinator {
|
|
|
940
949
|
orderGeneration,
|
|
941
950
|
disappeared,
|
|
942
951
|
);
|
|
952
|
+
await this.reconcileExpiredPendingOrderClaims(
|
|
953
|
+
record,
|
|
954
|
+
account,
|
|
955
|
+
generation,
|
|
956
|
+
orderGeneration,
|
|
957
|
+
);
|
|
943
958
|
} catch (error) {
|
|
944
959
|
if (
|
|
945
960
|
!this.shouldContinueOrderBootstrap(record, generation, orderGeneration)
|
|
@@ -1054,11 +1069,104 @@ export class PrivateSubscriptionCoordinator {
|
|
|
1054
1069
|
}
|
|
1055
1070
|
|
|
1056
1071
|
if (!update) {
|
|
1057
|
-
this.
|
|
1058
|
-
record,
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1072
|
+
this.orderConsumer.onPrivateOrderConfirmedMissing(
|
|
1073
|
+
record.accountId,
|
|
1074
|
+
record.venue,
|
|
1075
|
+
order,
|
|
1076
|
+
);
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
this.orderConsumer.onPrivateOrderUpdate(
|
|
1081
|
+
record.accountId,
|
|
1082
|
+
record.venue,
|
|
1083
|
+
update,
|
|
1084
|
+
{
|
|
1085
|
+
requestStartedAt,
|
|
1086
|
+
},
|
|
1087
|
+
);
|
|
1088
|
+
} catch (error) {
|
|
1089
|
+
if (
|
|
1090
|
+
!this.shouldContinueOrderBootstrap(record, generation, orderGeneration)
|
|
1091
|
+
) {
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
this.handleOrderReconcileError(record, error);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
private async reconcileExpiredPendingOrderClaims(
|
|
1100
|
+
record: PrivateSubscriptionRecord,
|
|
1101
|
+
account: RegisteredAccountRecord,
|
|
1102
|
+
generation: number,
|
|
1103
|
+
orderGeneration: number,
|
|
1104
|
+
): Promise<void> {
|
|
1105
|
+
const adapter = this.getAdapter(record.venue);
|
|
1106
|
+
if (
|
|
1107
|
+
!this.shouldContinueOrderBootstrap(record, generation, orderGeneration)
|
|
1108
|
+
) {
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
if (!adapter.fetchOrder) {
|
|
1113
|
+
// Pending createOrder claims can only be retired after the venue confirms
|
|
1114
|
+
// the clientOrderId is absent. Adapters without fetchOrder keep claims.
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
const expiredClaims = this.orderConsumer.getExpiredPrivateOrderClaims(
|
|
1119
|
+
record.accountId,
|
|
1120
|
+
this.context.now(),
|
|
1121
|
+
this.pendingClaimTtlMs,
|
|
1122
|
+
);
|
|
1123
|
+
for (const claim of expiredClaims) {
|
|
1124
|
+
await this.reconcileExpiredPendingOrderClaim(
|
|
1125
|
+
record,
|
|
1126
|
+
account,
|
|
1127
|
+
generation,
|
|
1128
|
+
orderGeneration,
|
|
1129
|
+
claim,
|
|
1130
|
+
);
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
private async reconcileExpiredPendingOrderClaim(
|
|
1135
|
+
record: PrivateSubscriptionRecord,
|
|
1136
|
+
account: RegisteredAccountRecord,
|
|
1137
|
+
generation: number,
|
|
1138
|
+
orderGeneration: number,
|
|
1139
|
+
claim: ExpiredPendingOrderClaim,
|
|
1140
|
+
): Promise<void> {
|
|
1141
|
+
const adapter = this.getAdapter(record.venue);
|
|
1142
|
+
if (
|
|
1143
|
+
!adapter.fetchOrder ||
|
|
1144
|
+
!this.shouldContinueOrderBootstrap(record, generation, orderGeneration)
|
|
1145
|
+
) {
|
|
1146
|
+
return;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
const requestStartedAt = this.context.now();
|
|
1150
|
+
try {
|
|
1151
|
+
const update = await adapter.fetchOrder(
|
|
1152
|
+
account.credentials ?? {},
|
|
1153
|
+
{
|
|
1154
|
+
symbol: claim.symbol,
|
|
1155
|
+
clientOrderId: claim.venueClientOrderId,
|
|
1156
|
+
},
|
|
1157
|
+
{ ...account.options, accountId: account.accountId },
|
|
1158
|
+
);
|
|
1159
|
+
if (
|
|
1160
|
+
!this.shouldContinueOrderBootstrap(record, generation, orderGeneration)
|
|
1161
|
+
) {
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
if (!update) {
|
|
1166
|
+
this.orderConsumer.onPrivateOrderClaimNotFound(
|
|
1167
|
+
record.accountId,
|
|
1168
|
+
record.venue,
|
|
1169
|
+
claim,
|
|
1062
1170
|
);
|
|
1063
1171
|
return;
|
|
1064
1172
|
}
|
package/src/client/runtime.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
AcexError,
|
|
14
14
|
type AcexErrorCode,
|
|
15
15
|
buildAcexErrorDetails,
|
|
16
|
+
type VenueErrorReason,
|
|
16
17
|
} from "../errors.ts";
|
|
17
18
|
import { AsyncEventBus } from "../internal/async-event-bus.ts";
|
|
18
19
|
import { matchesHealthFilter } from "../internal/filters.ts";
|
|
@@ -142,6 +143,7 @@ export class AcexClientImpl implements AcexClient, ClientContext {
|
|
|
142
143
|
this.accountManager as PrivateAccountDataConsumer,
|
|
143
144
|
this.orderManager as PrivateOrderDataConsumer,
|
|
144
145
|
options.account,
|
|
146
|
+
options.order,
|
|
145
147
|
);
|
|
146
148
|
|
|
147
149
|
this.market = this.marketManager;
|
|
@@ -312,6 +314,13 @@ export class AcexClientImpl implements AcexClient, ClientContext {
|
|
|
312
314
|
return this.privateAdapters.get(venue)?.orderCapabilities;
|
|
313
315
|
}
|
|
314
316
|
|
|
317
|
+
normalizeVenueErrorCode(
|
|
318
|
+
venue: Venue,
|
|
319
|
+
code: string,
|
|
320
|
+
): VenueErrorReason | undefined {
|
|
321
|
+
return this.privateAdapters.get(venue)?.normalizeVenueErrorCode?.(code);
|
|
322
|
+
}
|
|
323
|
+
|
|
315
324
|
ensurePrivateCredentials(accountId: string): void {
|
|
316
325
|
const account = this.getRegisteredAccount(accountId);
|
|
317
326
|
if (
|
package/src/errors.ts
CHANGED
|
@@ -27,9 +27,19 @@ export type AcexErrorTransportKind =
|
|
|
27
27
|
| "rate_limited"
|
|
28
28
|
| "parse";
|
|
29
29
|
|
|
30
|
+
export type VenueErrorReason =
|
|
31
|
+
| "insufficient_balance"
|
|
32
|
+
| "would_take"
|
|
33
|
+
| "order_not_found"
|
|
34
|
+
| "filter_violation"
|
|
35
|
+
| "rate_limited"
|
|
36
|
+
| "timestamp_out_of_sync"
|
|
37
|
+
| "unknown";
|
|
38
|
+
|
|
30
39
|
export interface AcexVenueErrorDetails {
|
|
31
40
|
readonly code?: string;
|
|
32
41
|
readonly message?: string;
|
|
42
|
+
readonly reason?: VenueErrorReason;
|
|
33
43
|
}
|
|
34
44
|
|
|
35
45
|
export interface AcexErrorTransportDetails {
|
|
@@ -49,6 +59,7 @@ export interface AcexErrorDetails {
|
|
|
49
59
|
readonly symbol?: string;
|
|
50
60
|
readonly venueError?: AcexVenueErrorDetails;
|
|
51
61
|
readonly transport?: AcexErrorTransportDetails;
|
|
62
|
+
readonly orderState?: "not_placed" | "unknown";
|
|
52
63
|
}
|
|
53
64
|
|
|
54
65
|
export interface AcexErrorOptions {
|
|
@@ -91,6 +102,10 @@ export function buildAcexErrorDetails(
|
|
|
91
102
|
return hasDetails(details) ? details : undefined;
|
|
92
103
|
}
|
|
93
104
|
|
|
105
|
+
export function isOrderStateUnknown(error: unknown): boolean {
|
|
106
|
+
return error instanceof AcexError && error.details?.orderState === "unknown";
|
|
107
|
+
}
|
|
108
|
+
|
|
94
109
|
export function formatAcexErrorMessage(
|
|
95
110
|
message: string,
|
|
96
111
|
details?: AcexErrorDetails,
|
|
@@ -166,7 +181,8 @@ function hasDetails(details: AcexErrorDetails): boolean {
|
|
|
166
181
|
details.accountId ||
|
|
167
182
|
details.symbol ||
|
|
168
183
|
details.venueError ||
|
|
169
|
-
details.transport
|
|
184
|
+
details.transport ||
|
|
185
|
+
details.orderState,
|
|
170
186
|
);
|
|
171
187
|
}
|
|
172
188
|
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ export type {
|
|
|
7
7
|
AcexErrorTransportDetails,
|
|
8
8
|
AcexErrorTransportKind,
|
|
9
9
|
AcexVenueErrorDetails,
|
|
10
|
+
VenueErrorReason,
|
|
10
11
|
} from "./errors.ts";
|
|
11
|
-
export { AcexError } from "./errors.ts";
|
|
12
|
+
export { AcexError, isOrderStateUnknown } from "./errors.ts";
|
|
12
13
|
export * from "./types/index.ts";
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { OrderDataStatus, Venue } from "../../types/index.ts";
|
|
2
2
|
|
|
3
3
|
export const DEFAULT_MAX_CLOSED_ORDERS_PER_SYMBOL = 500;
|
|
4
|
+
export const DEFAULT_MISSING_ORDER_EVICTION_THRESHOLD = 3;
|
|
4
5
|
|
|
5
6
|
export function cloneOrderStatus(status: OrderDataStatus): OrderDataStatus {
|
|
6
7
|
return { ...status };
|
|
@@ -28,6 +29,14 @@ export function normalizeMaxClosedOrdersPerSymbol(
|
|
|
28
29
|
: DEFAULT_MAX_CLOSED_ORDERS_PER_SYMBOL;
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
export function normalizeMissingOrderEvictionThreshold(
|
|
33
|
+
value: number | undefined,
|
|
34
|
+
): number {
|
|
35
|
+
return value !== undefined && Number.isInteger(value) && value > 0
|
|
36
|
+
? value
|
|
37
|
+
: DEFAULT_MISSING_ORDER_EVICTION_THRESHOLD;
|
|
38
|
+
}
|
|
39
|
+
|
|
31
40
|
export function successfulStatus(
|
|
32
41
|
status: OrderDataStatus,
|
|
33
42
|
options: {
|
|
@@ -15,6 +15,7 @@ export interface OrderRecord {
|
|
|
15
15
|
orderIdOnlyIndex: Map<string, Set<string>>;
|
|
16
16
|
clientOrderIdIndex: Map<string, Set<string>>;
|
|
17
17
|
pendingClientOrderIdIndex: Map<string, PendingOrderClaim>;
|
|
18
|
+
missingOrderConfirmations: Map<string, number>;
|
|
18
19
|
status: OrderDataStatus;
|
|
19
20
|
}
|
|
20
21
|
|
|
@@ -29,8 +30,10 @@ export interface OrderLocation {
|
|
|
29
30
|
export interface PendingOrderClaim {
|
|
30
31
|
localOrderId: string;
|
|
31
32
|
symbol: string;
|
|
33
|
+
claimedAt: number;
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
export interface OrderManagerOptions {
|
|
35
37
|
maxClosedOrdersPerSymbol?: number;
|
|
38
|
+
missingOrderEvictionThreshold?: number;
|
|
36
39
|
}
|
|
@@ -5,11 +5,13 @@ import type {
|
|
|
5
5
|
import type {
|
|
6
6
|
AccountAwareManager,
|
|
7
7
|
ClientContext,
|
|
8
|
+
ExpiredPendingOrderClaim,
|
|
8
9
|
HealthReporter,
|
|
9
10
|
ManagerLifecycle,
|
|
10
11
|
PrivateOrderDataConsumer,
|
|
11
12
|
PrivateSubscriptionState,
|
|
12
13
|
} from "../client/context.ts";
|
|
14
|
+
import type { AcexErrorDetails, AcexErrorTransportKind } from "../errors.ts";
|
|
13
15
|
import {
|
|
14
16
|
AcexError,
|
|
15
17
|
buildAcexErrorDetails,
|
|
@@ -42,6 +44,7 @@ import {
|
|
|
42
44
|
cloneOrderStatus,
|
|
43
45
|
createOrderDataStatus,
|
|
44
46
|
normalizeMaxClosedOrdersPerSymbol,
|
|
47
|
+
normalizeMissingOrderEvictionThreshold,
|
|
45
48
|
successfulStatus,
|
|
46
49
|
} from "./order/data-status.ts";
|
|
47
50
|
import {
|
|
@@ -60,6 +63,7 @@ import { createSnapshot, isOpenOrder } from "./order/snapshot.ts";
|
|
|
60
63
|
import {
|
|
61
64
|
getAllSnapshots,
|
|
62
65
|
getExistingSnapshot,
|
|
66
|
+
getExistingSnapshotLocation,
|
|
63
67
|
getLocalOrderIdForVenueOrderId,
|
|
64
68
|
getLocationByLocalOrderId,
|
|
65
69
|
getOpenOrderSnapshots,
|
|
@@ -74,6 +78,15 @@ import {
|
|
|
74
78
|
setSnapshot,
|
|
75
79
|
} from "./order/store.ts";
|
|
76
80
|
|
|
81
|
+
type OrderCommandErrorCode =
|
|
82
|
+
| "ORDER_CANCEL_ALL_FAILED"
|
|
83
|
+
| "ORDER_CANCEL_FAILED"
|
|
84
|
+
| "ORDER_CREATE_FAILED";
|
|
85
|
+
|
|
86
|
+
type OrderErrorCode = OrderCommandErrorCode | "ORDER_INPUT_INVALID";
|
|
87
|
+
|
|
88
|
+
type OrderCommandOrderState = NonNullable<AcexErrorDetails["orderState"]>;
|
|
89
|
+
|
|
77
90
|
export class OrderManagerImpl
|
|
78
91
|
implements
|
|
79
92
|
OrderManager,
|
|
@@ -86,6 +99,7 @@ export class OrderManagerImpl
|
|
|
86
99
|
|
|
87
100
|
private readonly context: ClientContext;
|
|
88
101
|
private readonly maxClosedOrdersPerSymbol: number;
|
|
102
|
+
private readonly missingOrderEvictionThreshold: number;
|
|
89
103
|
private readonly orderBus = new AsyncEventBus<OrderEvent>();
|
|
90
104
|
private readonly orderStatusBus =
|
|
91
105
|
new AsyncEventBus<OrderStatusChangedEvent>();
|
|
@@ -97,6 +111,9 @@ export class OrderManagerImpl
|
|
|
97
111
|
this.maxClosedOrdersPerSymbol = normalizeMaxClosedOrdersPerSymbol(
|
|
98
112
|
options.maxClosedOrdersPerSymbol,
|
|
99
113
|
);
|
|
114
|
+
this.missingOrderEvictionThreshold = normalizeMissingOrderEvictionThreshold(
|
|
115
|
+
options.missingOrderEvictionThreshold,
|
|
116
|
+
);
|
|
100
117
|
|
|
101
118
|
this.events = {
|
|
102
119
|
status: (filter) =>
|
|
@@ -484,6 +501,9 @@ export class OrderManagerImpl
|
|
|
484
501
|
openSetKeys.add(lookupKey);
|
|
485
502
|
}
|
|
486
503
|
const current = getExistingSnapshot(record, update);
|
|
504
|
+
if (current) {
|
|
505
|
+
this.clearMissingOrderConfirmationsForUpdate(record, current);
|
|
506
|
+
}
|
|
487
507
|
const nextSnapshot = this.applyUpdateToRecord(
|
|
488
508
|
record,
|
|
489
509
|
accountId,
|
|
@@ -555,6 +575,36 @@ export class OrderManagerImpl
|
|
|
555
575
|
return this.getOpenOrders(accountId);
|
|
556
576
|
}
|
|
557
577
|
|
|
578
|
+
getExpiredPrivateOrderClaims(
|
|
579
|
+
accountId: string,
|
|
580
|
+
now: number,
|
|
581
|
+
ttlMs: number,
|
|
582
|
+
): ExpiredPendingOrderClaim[] {
|
|
583
|
+
const record = this.records.get(accountId);
|
|
584
|
+
if (!record || ttlMs <= 0) {
|
|
585
|
+
return [];
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const expired: ExpiredPendingOrderClaim[] = [];
|
|
589
|
+
for (const [
|
|
590
|
+
venueClientOrderId,
|
|
591
|
+
claim,
|
|
592
|
+
] of record.pendingClientOrderIdIndex) {
|
|
593
|
+
if (now - claim.claimedAt < ttlMs) {
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
expired.push({
|
|
598
|
+
venueClientOrderId,
|
|
599
|
+
localOrderId: claim.localOrderId,
|
|
600
|
+
symbol: claim.symbol,
|
|
601
|
+
claimedAt: claim.claimedAt,
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
return expired;
|
|
606
|
+
}
|
|
607
|
+
|
|
558
608
|
onPrivateOrderUpdate(
|
|
559
609
|
accountId: string,
|
|
560
610
|
venue: Venue,
|
|
@@ -580,32 +630,129 @@ export class OrderManagerImpl
|
|
|
580
630
|
return;
|
|
581
631
|
}
|
|
582
632
|
|
|
583
|
-
|
|
584
|
-
snapshot.status === "filled"
|
|
585
|
-
? "order.filled"
|
|
586
|
-
: snapshot.status === "rejected"
|
|
587
|
-
? "order.rejected"
|
|
588
|
-
: snapshot.status === "canceled" || snapshot.status === "expired"
|
|
589
|
-
? "order.canceled"
|
|
590
|
-
: "order.updated";
|
|
633
|
+
this.publishOrderEvent(accountId, venue, snapshot);
|
|
591
634
|
|
|
592
|
-
|
|
593
|
-
|
|
635
|
+
record.status = successfulStatus(record.status, {
|
|
636
|
+
preserveStatus: options.preserveStatus,
|
|
637
|
+
lastReceivedAt: snapshot.receivedAt,
|
|
638
|
+
lastReadyAt: snapshot.updatedAt,
|
|
639
|
+
});
|
|
640
|
+
this.publishStatus(record);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
onPrivateOrderConfirmedMissing(
|
|
644
|
+
accountId: string,
|
|
645
|
+
venue: Venue,
|
|
646
|
+
order: OrderSnapshot,
|
|
647
|
+
): void {
|
|
648
|
+
const record = this.getOrCreateRecord(accountId, venue);
|
|
649
|
+
if (!record.subscribed) {
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
const location = getExistingSnapshotLocation(record, order);
|
|
654
|
+
if (!location || location.table !== "open") {
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const current = getSnapshotAtLocation(record, location);
|
|
659
|
+
if (!current || !isOpenOrder(current)) {
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const confirmations =
|
|
664
|
+
(record.missingOrderConfirmations.get(location.localOrderId) ?? 0) + 1;
|
|
665
|
+
if (confirmations < this.missingOrderEvictionThreshold) {
|
|
666
|
+
record.missingOrderConfirmations.set(
|
|
667
|
+
location.localOrderId,
|
|
668
|
+
confirmations,
|
|
669
|
+
);
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const receivedAt = this.context.now();
|
|
674
|
+
const snapshot = createSnapshot(
|
|
594
675
|
accountId,
|
|
595
676
|
venue,
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
677
|
+
{
|
|
678
|
+
orderId: current.orderId,
|
|
679
|
+
clientOrderId: current.clientOrderId,
|
|
680
|
+
symbol: current.symbol,
|
|
681
|
+
side: current.side,
|
|
682
|
+
type: current.type,
|
|
683
|
+
status: "unknown",
|
|
684
|
+
price: current.price,
|
|
685
|
+
triggerPrice: current.triggerPrice,
|
|
686
|
+
amount: current.amount,
|
|
687
|
+
filled: current.filled,
|
|
688
|
+
remaining: current.remaining,
|
|
689
|
+
reduceOnly: current.reduceOnly,
|
|
690
|
+
positionSide: current.positionSide,
|
|
691
|
+
avgFillPrice: current.avgFillPrice,
|
|
692
|
+
receivedAt,
|
|
693
|
+
},
|
|
694
|
+
current,
|
|
695
|
+
);
|
|
696
|
+
|
|
697
|
+
if (
|
|
698
|
+
!this.writeSnapshot(record, location.localOrderId, snapshot, location)
|
|
699
|
+
) {
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
600
702
|
|
|
703
|
+
this.context.publishRuntimeError(
|
|
704
|
+
"order",
|
|
705
|
+
new Error(
|
|
706
|
+
`Evicted ${venue} open order after ${confirmations} confirmed missing checks`,
|
|
707
|
+
),
|
|
708
|
+
{
|
|
709
|
+
accountId,
|
|
710
|
+
venue,
|
|
711
|
+
symbol: current.symbol,
|
|
712
|
+
},
|
|
713
|
+
);
|
|
714
|
+
this.publishOrderEvent(accountId, venue, snapshot);
|
|
601
715
|
record.status = successfulStatus(record.status, {
|
|
602
|
-
preserveStatus: options.preserveStatus,
|
|
603
716
|
lastReceivedAt: snapshot.receivedAt,
|
|
604
717
|
lastReadyAt: snapshot.updatedAt,
|
|
605
718
|
});
|
|
606
719
|
this.publishStatus(record);
|
|
607
720
|
}
|
|
608
721
|
|
|
722
|
+
onPrivateOrderClaimNotFound(
|
|
723
|
+
accountId: string,
|
|
724
|
+
venue: Venue,
|
|
725
|
+
claim: ExpiredPendingOrderClaim,
|
|
726
|
+
): void {
|
|
727
|
+
const record = this.records.get(accountId);
|
|
728
|
+
if (!record) {
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const pending = record.pendingClientOrderIdIndex.get(
|
|
733
|
+
claim.venueClientOrderId,
|
|
734
|
+
);
|
|
735
|
+
if (
|
|
736
|
+
pending?.localOrderId !== claim.localOrderId ||
|
|
737
|
+
pending.symbol !== claim.symbol
|
|
738
|
+
) {
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
record.pendingClientOrderIdIndex.delete(claim.venueClientOrderId);
|
|
743
|
+
this.context.publishRuntimeError(
|
|
744
|
+
"order",
|
|
745
|
+
new Error(
|
|
746
|
+
`createOrder timed out and the order was not found on the venue: ${claim.venueClientOrderId}`,
|
|
747
|
+
),
|
|
748
|
+
{
|
|
749
|
+
accountId,
|
|
750
|
+
venue,
|
|
751
|
+
symbol: claim.symbol,
|
|
752
|
+
},
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
|
|
609
756
|
onPrivateOrderStreamState(
|
|
610
757
|
accountId: string,
|
|
611
758
|
venue: Venue,
|
|
@@ -660,6 +807,7 @@ export class OrderManagerImpl
|
|
|
660
807
|
orderIdOnlyIndex: new Map(),
|
|
661
808
|
clientOrderIdIndex: new Map(),
|
|
662
809
|
pendingClientOrderIdIndex: new Map(),
|
|
810
|
+
missingOrderConfirmations: new Map(),
|
|
663
811
|
status: createOrderDataStatus(accountId, venue, "inactive"),
|
|
664
812
|
};
|
|
665
813
|
|
|
@@ -708,9 +856,51 @@ export class OrderManagerImpl
|
|
|
708
856
|
|
|
709
857
|
this.warnSystemClientOrderIdOnlyClaim(record, snapshot);
|
|
710
858
|
this.warnProvisionalTerminalOrder(record, snapshot);
|
|
859
|
+
this.clearMissingOrderConfirmations(record, localOrderId);
|
|
711
860
|
return true;
|
|
712
861
|
}
|
|
713
862
|
|
|
863
|
+
private clearMissingOrderConfirmations(
|
|
864
|
+
record: OrderRecord,
|
|
865
|
+
localOrderId: string,
|
|
866
|
+
): void {
|
|
867
|
+
record.missingOrderConfirmations.delete(localOrderId);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
private clearMissingOrderConfirmationsForUpdate(
|
|
871
|
+
record: OrderRecord,
|
|
872
|
+
update: { symbol: string; orderId?: string; clientOrderId?: string },
|
|
873
|
+
): void {
|
|
874
|
+
const location = getExistingSnapshotLocation(record, update);
|
|
875
|
+
if (location) {
|
|
876
|
+
this.clearMissingOrderConfirmations(record, location.localOrderId);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
private publishOrderEvent(
|
|
881
|
+
accountId: string,
|
|
882
|
+
venue: Venue,
|
|
883
|
+
snapshot: OrderSnapshot,
|
|
884
|
+
): void {
|
|
885
|
+
const eventType =
|
|
886
|
+
snapshot.status === "filled"
|
|
887
|
+
? "order.filled"
|
|
888
|
+
: snapshot.status === "rejected"
|
|
889
|
+
? "order.rejected"
|
|
890
|
+
: isOpenOrder(snapshot)
|
|
891
|
+
? "order.updated"
|
|
892
|
+
: "order.canceled";
|
|
893
|
+
|
|
894
|
+
this.orderBus.publish({
|
|
895
|
+
type: eventType,
|
|
896
|
+
accountId,
|
|
897
|
+
venue,
|
|
898
|
+
symbol: snapshot.symbol,
|
|
899
|
+
snapshot,
|
|
900
|
+
ts: this.context.now(),
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
|
|
714
904
|
private warnDroppedUnkeyedTerminalOrder(
|
|
715
905
|
record: OrderRecord,
|
|
716
906
|
snapshot: OrderSnapshot,
|
|
@@ -790,6 +980,9 @@ export class OrderManagerImpl
|
|
|
790
980
|
options: { requestStartedAt?: number; preserveStatus?: boolean } = {},
|
|
791
981
|
): OrderSnapshot | undefined {
|
|
792
982
|
const resolution = this.resolveLocalOrderIdForUpdate(record, update);
|
|
983
|
+
if (resolution.localOrderId) {
|
|
984
|
+
this.clearMissingOrderConfirmations(record, resolution.localOrderId);
|
|
985
|
+
}
|
|
793
986
|
const localOrderId = resolution.localOrderId ?? this.generateLocalOrderId();
|
|
794
987
|
const previousLocation = getLocationByLocalOrderId(record, localOrderId);
|
|
795
988
|
const previous = previousLocation
|
|
@@ -864,6 +1057,7 @@ export class OrderManagerImpl
|
|
|
864
1057
|
record.pendingClientOrderIdIndex.set(venueClientOrderId, {
|
|
865
1058
|
localOrderId,
|
|
866
1059
|
symbol,
|
|
1060
|
+
claimedAt: this.context.now(),
|
|
867
1061
|
});
|
|
868
1062
|
}
|
|
869
1063
|
|
|
@@ -945,6 +1139,9 @@ export class OrderManagerImpl
|
|
|
945
1139
|
update,
|
|
946
1140
|
options.localOrderId,
|
|
947
1141
|
);
|
|
1142
|
+
if (resolution.localOrderId) {
|
|
1143
|
+
this.clearMissingOrderConfirmations(record, resolution.localOrderId);
|
|
1144
|
+
}
|
|
948
1145
|
const localOrderId = resolution.localOrderId ?? this.generateLocalOrderId();
|
|
949
1146
|
const previousLocation = getLocationByLocalOrderId(record, localOrderId);
|
|
950
1147
|
const previous = previousLocation
|
|
@@ -990,12 +1187,7 @@ export class OrderManagerImpl
|
|
|
990
1187
|
}
|
|
991
1188
|
|
|
992
1189
|
private createError(
|
|
993
|
-
code:
|
|
994
|
-
| "VENUE_NOT_SUPPORTED"
|
|
995
|
-
| "ORDER_CANCEL_ALL_FAILED"
|
|
996
|
-
| "ORDER_CANCEL_FAILED"
|
|
997
|
-
| "ORDER_CREATE_FAILED"
|
|
998
|
-
| "ORDER_INPUT_INVALID",
|
|
1190
|
+
code: "VENUE_NOT_SUPPORTED" | OrderCommandErrorCode | "ORDER_INPUT_INVALID",
|
|
999
1191
|
message: string,
|
|
1000
1192
|
metadata: {
|
|
1001
1193
|
accountId: string;
|
|
@@ -1003,17 +1195,14 @@ export class OrderManagerImpl
|
|
|
1003
1195
|
symbol?: string;
|
|
1004
1196
|
},
|
|
1005
1197
|
): AcexError {
|
|
1006
|
-
const details =
|
|
1198
|
+
const details = this.buildOrderErrorDetails(code, metadata);
|
|
1007
1199
|
const error = new AcexError(code, message, { details });
|
|
1008
1200
|
this.context.publishRuntimeError("order", error, metadata);
|
|
1009
1201
|
return error;
|
|
1010
1202
|
}
|
|
1011
1203
|
|
|
1012
1204
|
private wrapCommandError(
|
|
1013
|
-
code:
|
|
1014
|
-
| "ORDER_CANCEL_ALL_FAILED"
|
|
1015
|
-
| "ORDER_CANCEL_FAILED"
|
|
1016
|
-
| "ORDER_CREATE_FAILED",
|
|
1205
|
+
code: OrderCommandErrorCode,
|
|
1017
1206
|
message: string,
|
|
1018
1207
|
error: unknown,
|
|
1019
1208
|
metadata: {
|
|
@@ -1031,10 +1220,103 @@ export class OrderManagerImpl
|
|
|
1031
1220
|
error instanceof Error ? error : new Error(message),
|
|
1032
1221
|
metadata,
|
|
1033
1222
|
);
|
|
1034
|
-
const details =
|
|
1223
|
+
const details = this.buildOrderErrorDetails(code, metadata, error);
|
|
1035
1224
|
return new AcexError(code, formatAcexErrorMessage(message, details), {
|
|
1036
1225
|
cause: error,
|
|
1037
1226
|
details,
|
|
1038
1227
|
});
|
|
1039
1228
|
}
|
|
1229
|
+
|
|
1230
|
+
private buildOrderErrorDetails(
|
|
1231
|
+
code: "VENUE_NOT_SUPPORTED" | OrderErrorCode,
|
|
1232
|
+
metadata: {
|
|
1233
|
+
accountId: string;
|
|
1234
|
+
venue: Venue;
|
|
1235
|
+
symbol?: string;
|
|
1236
|
+
},
|
|
1237
|
+
error?: unknown,
|
|
1238
|
+
): AcexErrorDetails | undefined {
|
|
1239
|
+
const details = buildAcexErrorDetails(metadata, error);
|
|
1240
|
+
if (!details || !isOrderErrorCode(code)) {
|
|
1241
|
+
return details;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
const detailsWithReason = this.addVenueErrorReason(metadata.venue, details);
|
|
1245
|
+
return {
|
|
1246
|
+
...detailsWithReason,
|
|
1247
|
+
orderState: getOrderState(code, detailsWithReason),
|
|
1248
|
+
};
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
private addVenueErrorReason(
|
|
1252
|
+
venue: Venue,
|
|
1253
|
+
details: AcexErrorDetails,
|
|
1254
|
+
): AcexErrorDetails {
|
|
1255
|
+
const venueErrorCode = details.venueError?.code;
|
|
1256
|
+
if (!venueErrorCode) {
|
|
1257
|
+
return details;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
const reason = this.context.normalizeVenueErrorCode(venue, venueErrorCode);
|
|
1261
|
+
if (!reason) {
|
|
1262
|
+
return details;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
return {
|
|
1266
|
+
...details,
|
|
1267
|
+
venueError: {
|
|
1268
|
+
...details.venueError,
|
|
1269
|
+
reason,
|
|
1270
|
+
},
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
function isOrderErrorCode(
|
|
1276
|
+
code: "VENUE_NOT_SUPPORTED" | OrderErrorCode,
|
|
1277
|
+
): code is OrderErrorCode {
|
|
1278
|
+
return (
|
|
1279
|
+
code === "ORDER_INPUT_INVALID" ||
|
|
1280
|
+
code === "ORDER_CREATE_FAILED" ||
|
|
1281
|
+
code === "ORDER_CANCEL_FAILED" ||
|
|
1282
|
+
code === "ORDER_CANCEL_ALL_FAILED"
|
|
1283
|
+
);
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
function getOrderState(
|
|
1287
|
+
code: OrderErrorCode,
|
|
1288
|
+
details: AcexErrorDetails,
|
|
1289
|
+
): OrderCommandOrderState {
|
|
1290
|
+
if (code === "ORDER_INPUT_INVALID") {
|
|
1291
|
+
return "not_placed";
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
const transport = details.transport;
|
|
1295
|
+
if (!transport) {
|
|
1296
|
+
return details.venueError ? "not_placed" : "unknown";
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
if (isUnknownOrderTransportKind(transport.kind)) {
|
|
1300
|
+
return "unknown";
|
|
1301
|
+
}
|
|
1302
|
+
if (transport.kind === "rate_limited") {
|
|
1303
|
+
return "not_placed";
|
|
1304
|
+
}
|
|
1305
|
+
if (transport.status !== undefined && transport.status >= 500) {
|
|
1306
|
+
return "unknown";
|
|
1307
|
+
}
|
|
1308
|
+
if (details.venueError) {
|
|
1309
|
+
return "not_placed";
|
|
1310
|
+
}
|
|
1311
|
+
if (transport.status !== undefined && transport.status < 500) {
|
|
1312
|
+
return "not_placed";
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
return "unknown";
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
function isUnknownOrderTransportKind(
|
|
1319
|
+
kind: AcexErrorTransportKind | undefined,
|
|
1320
|
+
): boolean {
|
|
1321
|
+
return kind === "timeout" || kind === "network" || kind === "parse";
|
|
1040
1322
|
}
|
package/src/types/order.ts
CHANGED
package/src/types/shared.ts
CHANGED
|
@@ -107,6 +107,8 @@ export interface AccountRuntimeOptions {
|
|
|
107
107
|
|
|
108
108
|
export interface OrderRuntimeOptions {
|
|
109
109
|
maxClosedOrdersPerSymbol?: number;
|
|
110
|
+
missingOrderEvictionThreshold?: number;
|
|
111
|
+
pendingClaimTtlMs?: number;
|
|
110
112
|
}
|
|
111
113
|
|
|
112
114
|
export interface CreateClientOptions {
|