@imbingox/acex 0.4.0-beta.15 → 0.4.0-beta.17
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 +38 -8
- package/package.json +1 -1
- package/src/client/context.ts +22 -0
- package/src/client/private-subscription-coordinator.ts +113 -5
- package/src/client/runtime.ts +43 -5
- package/src/errors.ts +1 -0
- package/src/internal/async-event-bus.ts +75 -3
- package/src/managers/account-manager.ts +43 -16
- package/src/managers/market-manager.ts +111 -7
- 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 +244 -31
- package/src/types/account.ts +9 -2
- package/src/types/client.ts +8 -2
- package/src/types/market.ts +19 -4
- package/src/types/order.ts +11 -3
- package/src/types/shared.ts +15 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @imbingox/acex
|
|
2
2
|
|
|
3
|
+
## 0.4.0-beta.17
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 35b8163: 事件流新增 `conflate` / `buffer` 与 `maxBuffer` 订阅选项:L1 Book 与 Funding Rate 默认改为 latest-wins,慢消费者只保留同一 `venue:symbol` 的最新事件;market status 事件按 activity/ready/freshness/reason 去重发布;buffer 溢出会丢弃最旧事件并通过 `EVENT_BUFFER_OVERFLOW` runtime error 告警。
|
|
8
|
+
|
|
9
|
+
## 0.4.0-beta.16
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- bdaf9ea: 订单生命周期增加 confirmed-missing 收尾与 pending claim TTL:`OrderStatus` 新增 `unknown` 终态,open 订单在 reconcile 单笔回查连续确认不存在后会移入 closed;`CreateClientOptions.order` 新增 `missingOrderEvictionThreshold` 与 `pendingClaimTtlMs`,用于配置幽灵 open 订单驱逐阈值和 `createOrder` timeout claim 回查 TTL。
|
|
14
|
+
|
|
3
15
|
## 0.4.0-beta.15
|
|
4
16
|
|
|
5
17
|
### Minor Changes
|
package/docs/api.md
CHANGED
|
@@ -372,6 +372,26 @@ console.log(time.serverTime, time.roundTripMs, time.estimatedOffsetMs);
|
|
|
372
372
|
|
|
373
373
|
Funding Rate 当前通过 Binance mark price websocket 更新,仅支持永续合约(`MarketDefinition.type === "swap"`,包括 Binance TradFi Perps)。spot 或 future 订阅会抛 `MARKET_FUNDING_RATE_UNSUPPORTED`。
|
|
374
374
|
|
|
375
|
+
### 5.5 事件流 options
|
|
376
|
+
|
|
377
|
+
Market 事件流支持可选第二参:
|
|
378
|
+
|
|
379
|
+
```ts
|
|
380
|
+
type EventStreamOptions = {
|
|
381
|
+
mode?: "conflate" | "buffer";
|
|
382
|
+
maxBuffer?: number;
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
client.market.events.l1BookUpdates(
|
|
386
|
+
{ venue: "binance", symbol: "BTC/USDT:USDT" },
|
|
387
|
+
{ mode: "buffer", maxBuffer: 50_000 },
|
|
388
|
+
);
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
`l1BookUpdates()` 与 `fundingRateUpdates()` 默认使用 `conflate`,同一 `venue:symbol` 慢消费者只保留最新事件,适合策略热路径。需要录制每个 tick 时显式传 `{ mode: "buffer" }`。`market.events.all()` 与 `market.events.status()` 默认使用 `buffer`;显式传 `{ mode: "conflate" }` 时,`all()` 按 `type:venue:symbol` 合并,`status()` 按 `venue:symbol` 合并。
|
|
392
|
+
|
|
393
|
+
`buffer` 模式默认每个订阅者最多积压 `10_000` 条事件,超过后丢弃最旧事件。每次积压 episode 只会向 `client.events.errors()` 发布一次 `EVENT_BUFFER_OVERFLOW` runtime error,事件 metadata 包含 `stream` 与 `maxBuffer`;队列排空后再次溢出会再次告警。`conflate` 模式天然有界,不使用 `maxBuffer`。
|
|
394
|
+
|
|
375
395
|
## 6. AccountManager
|
|
376
396
|
|
|
377
397
|
```ts
|
|
@@ -400,7 +420,7 @@ Account 事件用于消费余额、仓位、风险或全量快照替换:
|
|
|
400
420
|
```ts
|
|
401
421
|
for await (const event of client.account.events.updates({
|
|
402
422
|
accountId: "main-binance",
|
|
403
|
-
})) {
|
|
423
|
+
}, { maxBuffer: 20_000 })) {
|
|
404
424
|
if (event.type === "risk.updated") {
|
|
405
425
|
console.log(event.snapshot.riskRatio);
|
|
406
426
|
}
|
|
@@ -408,6 +428,8 @@ for await (const event of client.account.events.updates({
|
|
|
408
428
|
}
|
|
409
429
|
```
|
|
410
430
|
|
|
431
|
+
Account 事件流只支持 `{ maxBuffer?: number }`,不提供 conflate;余额、仓位、风险和状态事件默认按 buffer 语义保留顺序。
|
|
432
|
+
|
|
411
433
|
## 7. OrderManager
|
|
412
434
|
|
|
413
435
|
```ts
|
|
@@ -442,7 +464,7 @@ interface OrderManager {
|
|
|
442
464
|
|
|
443
465
|
### 7.3 本地缓存与查询
|
|
444
466
|
|
|
445
|
-
- OrderManager 内部按 open / closed 分层缓存订单。**closed(filled / canceled / rejected / expired)订单按 symbol 各保留最近 N 个**,`N = CreateClientOptions.order.maxClosedOrdersPerSymbol`(默认 500,非正或非整数回退默认),超限按 FIFO 裁剪最旧;**open 订单不受此上限限制**。`getOpenOrders()` 查询复杂度与历史终态订单数量无关。
|
|
467
|
+
- OrderManager 内部按 open / closed 分层缓存订单。**closed(filled / canceled / rejected / expired / unknown)订单按 symbol 各保留最近 N 个**,`N = CreateClientOptions.order.maxClosedOrdersPerSymbol`(默认 500,非正或非整数回退默认),超限按 FIFO 裁剪最旧;**open 订单不受此上限限制**。`getOpenOrders()` 查询复杂度与历史终态订单数量无关。
|
|
446
468
|
- `getOrder(input)` 需带 `orderId` 或 `clientOrderId`(否则返回 `undefined`),`symbol` 可选:
|
|
447
469
|
- **精确查单推荐传 `symbol + orderId`**(O(1) 精确索引、唯一命中)。
|
|
448
470
|
- 仅 `clientOrderId` 查询可命中 open 与未被裁剪的 closed;当 `clientOrderId` 唯一(你自定义的或 SDK 生成的 `acex-*`)时可精确命中,但同一 `clientOrderId` 命中多笔时返回**最新一笔**(精确定位历史某一笔请用 `symbol + orderId`)。
|
|
@@ -450,13 +472,13 @@ interface OrderManager {
|
|
|
450
472
|
- 同时给 `orderId` 与 `clientOrderId` 时,两者都匹配才命中。
|
|
451
473
|
- 已超出保留上限被裁剪的 closed 订单将查不到(返回 `undefined`)。
|
|
452
474
|
|
|
453
|
-
Order 事件用于消费订单状态变化和 open orders 快照校准。Binance private reconcile 会先用 `/papi/v1/um/openOrders` 校验当前 open set;本地 open order 从 open set 消失时,SDK 会优先查询单笔订单终态并发布 `order.filled` / `order.canceled`
|
|
475
|
+
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
476
|
|
|
455
477
|
```ts
|
|
456
478
|
for await (const event of client.order.events.updates({
|
|
457
479
|
accountId: "main-binance",
|
|
458
480
|
symbol: "BTC/USDT:USDT",
|
|
459
|
-
})) {
|
|
481
|
+
}, { maxBuffer: 20_000 })) {
|
|
460
482
|
if (event.type === "order.filled") {
|
|
461
483
|
console.log(event.snapshot.filled);
|
|
462
484
|
}
|
|
@@ -464,23 +486,28 @@ for await (const event of client.order.events.updates({
|
|
|
464
486
|
}
|
|
465
487
|
```
|
|
466
488
|
|
|
489
|
+
Order 事件流只支持 `{ maxBuffer?: number }`,不提供 conflate;订单中间状态和错误恢复信号默认按 buffer 语义保留顺序。
|
|
490
|
+
|
|
467
491
|
## 8. 健康与错误事件
|
|
468
492
|
|
|
469
493
|
```ts
|
|
470
494
|
const health = client.getHealth();
|
|
471
495
|
|
|
472
|
-
for await (const event of client.events.health(
|
|
496
|
+
for await (const event of client.events.health(
|
|
497
|
+
{ venue: "binance" },
|
|
498
|
+
{ maxBuffer: 20_000 },
|
|
499
|
+
)) {
|
|
473
500
|
console.log(event.type);
|
|
474
501
|
break;
|
|
475
502
|
}
|
|
476
503
|
|
|
477
|
-
for await (const error of client.events.errors()) {
|
|
504
|
+
for await (const error of client.events.errors({ maxBuffer: 20_000 })) {
|
|
478
505
|
console.error(error.source, error.error);
|
|
479
506
|
break;
|
|
480
507
|
}
|
|
481
508
|
```
|
|
482
509
|
|
|
483
|
-
`getHealth()` 聚合 client、market、account、order 的当前状态。`events.health(filter)` 只返回满足 filter 的事件;如果事件没有 filter
|
|
510
|
+
`getHealth()` 聚合 client、market、account、order 的当前状态。`events.health(filter, options?)` 只返回满足 filter 的事件;如果事件没有 filter 请求的字段,会被过滤掉。`events.health()` 与 `events.errors()` 只支持 `{ maxBuffer?: number }`,默认 buffer 上限同样是 `10_000`;`errors()` 自身溢出时只丢弃最旧错误事件,不再发布新的 overflow 错误,避免递归。
|
|
484
511
|
|
|
485
512
|
## 9. 数据类型速查
|
|
486
513
|
|
|
@@ -499,7 +526,8 @@ type OrderStatus =
|
|
|
499
526
|
| "filled"
|
|
500
527
|
| "canceled"
|
|
501
528
|
| "rejected"
|
|
502
|
-
| "expired"
|
|
529
|
+
| "expired"
|
|
530
|
+
| "unknown";
|
|
503
531
|
|
|
504
532
|
type PrivateRuntimeReason =
|
|
505
533
|
| "credentials_missing"
|
|
@@ -597,6 +625,8 @@ interface CreateClientOptions {
|
|
|
597
625
|
};
|
|
598
626
|
order?: {
|
|
599
627
|
maxClosedOrdersPerSymbol?: number;
|
|
628
|
+
missingOrderEvictionThreshold?: number;
|
|
629
|
+
pendingClaimTtlMs?: number;
|
|
600
630
|
};
|
|
601
631
|
}
|
|
602
632
|
|
package/package.json
CHANGED
package/src/client/context.ts
CHANGED
|
@@ -73,6 +73,13 @@ export interface PrivateSubscriptionState {
|
|
|
73
73
|
lastReadyAt?: number;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
export interface ExpiredPendingOrderClaim {
|
|
77
|
+
venueClientOrderId: string;
|
|
78
|
+
localOrderId: string;
|
|
79
|
+
symbol: string;
|
|
80
|
+
claimedAt: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
76
83
|
export interface PrivateAccountDataConsumer {
|
|
77
84
|
onPrivateAccountPending(accountId: string, venue: Venue): void;
|
|
78
85
|
onPrivateAccountBootstrap(
|
|
@@ -119,7 +126,22 @@ export interface PrivateOrderDataConsumer {
|
|
|
119
126
|
update: RawOrderUpdate,
|
|
120
127
|
options?: { requestStartedAt?: number; preserveStatus?: boolean },
|
|
121
128
|
): void;
|
|
129
|
+
onPrivateOrderConfirmedMissing(
|
|
130
|
+
accountId: string,
|
|
131
|
+
venue: Venue,
|
|
132
|
+
order: OrderSnapshot,
|
|
133
|
+
): void;
|
|
122
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;
|
|
123
145
|
onPrivateOrderStreamState(
|
|
124
146
|
accountId: string,
|
|
125
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
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
buildAcexErrorDetails,
|
|
16
16
|
type VenueErrorReason,
|
|
17
17
|
} from "../errors.ts";
|
|
18
|
+
import type { AsyncEventBusOverflowInfo } from "../internal/async-event-bus.ts";
|
|
18
19
|
import { AsyncEventBus } from "../internal/async-event-bus.ts";
|
|
19
20
|
import { matchesHealthFilter } from "../internal/filters.ts";
|
|
20
21
|
import { ReactiveRateLimiter } from "../internal/rate-limiter.ts";
|
|
@@ -26,6 +27,7 @@ import type {
|
|
|
26
27
|
AccountManager,
|
|
27
28
|
AcexClient,
|
|
28
29
|
AcexInternalError,
|
|
30
|
+
BufferedEventStreamOptions,
|
|
29
31
|
CancelAllOrdersInput,
|
|
30
32
|
CancelOrderInput,
|
|
31
33
|
ClientEventStreams,
|
|
@@ -75,14 +77,30 @@ class ClientEventStreamsImpl implements ClientEventStreams {
|
|
|
75
77
|
constructor(
|
|
76
78
|
private readonly healthBus: AsyncEventBus<HealthEvent>,
|
|
77
79
|
private readonly errorBus: AsyncEventBus<AcexInternalError>,
|
|
80
|
+
private readonly onHealthOverflow: (
|
|
81
|
+
info: AsyncEventBusOverflowInfo,
|
|
82
|
+
) => void,
|
|
78
83
|
) {}
|
|
79
84
|
|
|
80
|
-
errors(
|
|
81
|
-
|
|
85
|
+
errors(
|
|
86
|
+
options?: BufferedEventStreamOptions,
|
|
87
|
+
): AsyncIterable<AcexInternalError> {
|
|
88
|
+
return this.errorBus.stream(() => true, {
|
|
89
|
+
maxBuffer: options?.maxBuffer,
|
|
90
|
+
});
|
|
82
91
|
}
|
|
83
92
|
|
|
84
|
-
health(
|
|
85
|
-
|
|
93
|
+
health(
|
|
94
|
+
filter?: HealthEventFilter,
|
|
95
|
+
options?: BufferedEventStreamOptions,
|
|
96
|
+
): AsyncIterable<HealthEvent> {
|
|
97
|
+
return this.healthBus.stream(
|
|
98
|
+
(event) => matchesHealthFilter(event, filter),
|
|
99
|
+
{
|
|
100
|
+
maxBuffer: options?.maxBuffer,
|
|
101
|
+
onOverflow: this.onHealthOverflow,
|
|
102
|
+
},
|
|
103
|
+
);
|
|
86
104
|
}
|
|
87
105
|
}
|
|
88
106
|
|
|
@@ -143,12 +161,17 @@ export class AcexClientImpl implements AcexClient, ClientContext {
|
|
|
143
161
|
this.accountManager as PrivateAccountDataConsumer,
|
|
144
162
|
this.orderManager as PrivateOrderDataConsumer,
|
|
145
163
|
options.account,
|
|
164
|
+
options.order,
|
|
146
165
|
);
|
|
147
166
|
|
|
148
167
|
this.market = this.marketManager;
|
|
149
168
|
this.account = this.accountManager;
|
|
150
169
|
this.order = this.orderManager;
|
|
151
|
-
this.events = new ClientEventStreamsImpl(
|
|
170
|
+
this.events = new ClientEventStreamsImpl(
|
|
171
|
+
this.healthBus,
|
|
172
|
+
this.errorBus,
|
|
173
|
+
this.createOverflowHandler("client.health"),
|
|
174
|
+
);
|
|
152
175
|
}
|
|
153
176
|
|
|
154
177
|
// --- AcexClient public API ---
|
|
@@ -420,6 +443,21 @@ export class AcexClientImpl implements AcexClient, ClientContext {
|
|
|
420
443
|
this.healthBus.publish(event);
|
|
421
444
|
}
|
|
422
445
|
|
|
446
|
+
private createOverflowHandler(
|
|
447
|
+
stream: string,
|
|
448
|
+
): (info: AsyncEventBusOverflowInfo) => void {
|
|
449
|
+
return ({ maxBuffer }) => {
|
|
450
|
+
const error = new AcexError(
|
|
451
|
+
"EVENT_BUFFER_OVERFLOW",
|
|
452
|
+
`Event stream buffer overflow: ${stream}`,
|
|
453
|
+
);
|
|
454
|
+
this.publishRuntimeError("runtime", error, {
|
|
455
|
+
stream,
|
|
456
|
+
maxBuffer,
|
|
457
|
+
});
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
423
461
|
// --- Private ---
|
|
424
462
|
|
|
425
463
|
private setClientStatus(status: ClientStatus): void {
|
package/src/errors.ts
CHANGED
|
@@ -1,10 +1,25 @@
|
|
|
1
1
|
type EventPredicate<T> = (event: T) => boolean;
|
|
2
2
|
|
|
3
|
+
export type AsyncEventBusStreamMode = "buffer" | "conflate";
|
|
4
|
+
|
|
5
|
+
export interface AsyncEventBusOverflowInfo {
|
|
6
|
+
maxBuffer: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface AsyncEventBusStreamOptions<T> {
|
|
10
|
+
mode?: AsyncEventBusStreamMode;
|
|
11
|
+
maxBuffer?: number;
|
|
12
|
+
conflateKey?: (event: T) => string;
|
|
13
|
+
onOverflow?: (info: AsyncEventBusOverflowInfo) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
3
16
|
interface BusListener<T> {
|
|
4
17
|
close(): void;
|
|
5
18
|
dispatch(event: T): void;
|
|
6
19
|
}
|
|
7
20
|
|
|
21
|
+
const DEFAULT_MAX_BUFFER = 10_000;
|
|
22
|
+
|
|
8
23
|
function doneResult<T>(): IteratorResult<T> {
|
|
9
24
|
return { done: true, value: undefined as T };
|
|
10
25
|
}
|
|
@@ -20,11 +35,67 @@ export class AsyncEventBus<T> {
|
|
|
20
35
|
|
|
21
36
|
stream<U extends T = T>(
|
|
22
37
|
filter: ((event: T) => event is U) | EventPredicate<T> = () => true,
|
|
38
|
+
options: AsyncEventBusStreamOptions<U> = {},
|
|
23
39
|
): AsyncIterable<U> {
|
|
24
40
|
let closed = false;
|
|
25
|
-
const
|
|
41
|
+
const mode = options.mode ?? "buffer";
|
|
42
|
+
const maxBuffer = options.maxBuffer ?? DEFAULT_MAX_BUFFER;
|
|
43
|
+
const bufferQueue: U[] = [];
|
|
44
|
+
const conflateQueue =
|
|
45
|
+
mode === "conflate" ? new Map<string, U>() : undefined;
|
|
46
|
+
let overflowNotified = false;
|
|
26
47
|
let pendingResolve: ((result: IteratorResult<U>) => void) | undefined;
|
|
27
48
|
|
|
49
|
+
if (mode === "conflate" && !options.conflateKey) {
|
|
50
|
+
throw new Error("AsyncEventBus conflate mode requires conflateKey");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const resetOverflowIfDrained = () => {
|
|
54
|
+
if (bufferQueue.length === 0) {
|
|
55
|
+
overflowNotified = false;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const enqueue = (event: U) => {
|
|
60
|
+
if (conflateQueue) {
|
|
61
|
+
const key = options.conflateKey?.(event);
|
|
62
|
+
if (key === undefined) {
|
|
63
|
+
throw new Error("AsyncEventBus conflate mode requires conflateKey");
|
|
64
|
+
}
|
|
65
|
+
conflateQueue.set(key, event);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
bufferQueue.push(event);
|
|
70
|
+
|
|
71
|
+
if (bufferQueue.length <= maxBuffer) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
bufferQueue.shift();
|
|
76
|
+
if (!overflowNotified) {
|
|
77
|
+
overflowNotified = true;
|
|
78
|
+
options.onOverflow?.({ maxBuffer });
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const dequeue = (): U | undefined => {
|
|
83
|
+
if (conflateQueue) {
|
|
84
|
+
const first = conflateQueue.entries().next();
|
|
85
|
+
if (first.done) {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const [key, event] = first.value;
|
|
90
|
+
conflateQueue.delete(key);
|
|
91
|
+
return event;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const event = bufferQueue.shift();
|
|
95
|
+
resetOverflowIfDrained();
|
|
96
|
+
return event;
|
|
97
|
+
};
|
|
98
|
+
|
|
28
99
|
const close = () => {
|
|
29
100
|
if (closed) {
|
|
30
101
|
return;
|
|
@@ -55,7 +126,7 @@ export class AsyncEventBus<T> {
|
|
|
55
126
|
return;
|
|
56
127
|
}
|
|
57
128
|
|
|
58
|
-
|
|
129
|
+
enqueue(typedEvent);
|
|
59
130
|
},
|
|
60
131
|
};
|
|
61
132
|
|
|
@@ -70,11 +141,12 @@ export class AsyncEventBus<T> {
|
|
|
70
141
|
return doneResult<U>();
|
|
71
142
|
}
|
|
72
143
|
|
|
73
|
-
const queued =
|
|
144
|
+
const queued = dequeue();
|
|
74
145
|
if (queued !== undefined) {
|
|
75
146
|
return { done: false, value: queued };
|
|
76
147
|
}
|
|
77
148
|
|
|
149
|
+
resetOverflowIfDrained();
|
|
78
150
|
return await new Promise<IteratorResult<U>>((resolve) => {
|
|
79
151
|
pendingResolve = resolve;
|
|
80
152
|
});
|
|
@@ -14,6 +14,8 @@ import type {
|
|
|
14
14
|
PrivateAccountDataConsumer,
|
|
15
15
|
PrivateSubscriptionState,
|
|
16
16
|
} from "../client/context.ts";
|
|
17
|
+
import { AcexError } from "../errors.ts";
|
|
18
|
+
import type { AsyncEventBusOverflowInfo } from "../internal/async-event-bus.ts";
|
|
17
19
|
import { AsyncEventBus } from "../internal/async-event-bus.ts";
|
|
18
20
|
import { toCanonical } from "../internal/decimal.ts";
|
|
19
21
|
import { matchesAccountFilter } from "../internal/filters.ts";
|
|
@@ -125,23 +127,33 @@ export class AccountManagerImpl
|
|
|
125
127
|
this.context = context;
|
|
126
128
|
|
|
127
129
|
this.events = {
|
|
128
|
-
status: (filter) =>
|
|
129
|
-
this.accountStatusBus.stream(
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
130
|
+
status: (filter, options) =>
|
|
131
|
+
this.accountStatusBus.stream(
|
|
132
|
+
(event) =>
|
|
133
|
+
matchesAccountFilter(
|
|
134
|
+
{ accountId: event.accountId, venue: event.venue },
|
|
135
|
+
filter,
|
|
136
|
+
),
|
|
137
|
+
{
|
|
138
|
+
maxBuffer: options?.maxBuffer,
|
|
139
|
+
onOverflow: this.createOverflowHandler("account.status"),
|
|
140
|
+
},
|
|
134
141
|
),
|
|
135
|
-
updates: (filter) =>
|
|
136
|
-
this.accountBus.stream(
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
142
|
+
updates: (filter, options) =>
|
|
143
|
+
this.accountBus.stream(
|
|
144
|
+
(event) =>
|
|
145
|
+
matchesAccountFilter(
|
|
146
|
+
{
|
|
147
|
+
accountId: event.accountId,
|
|
148
|
+
venue: event.venue,
|
|
149
|
+
symbol: "symbol" in event ? event.symbol : undefined,
|
|
150
|
+
},
|
|
151
|
+
filter,
|
|
152
|
+
),
|
|
153
|
+
{
|
|
154
|
+
maxBuffer: options?.maxBuffer,
|
|
155
|
+
onOverflow: this.createOverflowHandler("account.updates"),
|
|
156
|
+
},
|
|
145
157
|
),
|
|
146
158
|
};
|
|
147
159
|
}
|
|
@@ -872,4 +884,19 @@ export class AccountManagerImpl
|
|
|
872
884
|
this.accountStatusBus.publish(event);
|
|
873
885
|
this.context.publishHealthEvent(event);
|
|
874
886
|
}
|
|
887
|
+
|
|
888
|
+
private createOverflowHandler(
|
|
889
|
+
stream: string,
|
|
890
|
+
): (info: AsyncEventBusOverflowInfo) => void {
|
|
891
|
+
return ({ maxBuffer }) => {
|
|
892
|
+
const error = new AcexError(
|
|
893
|
+
"EVENT_BUFFER_OVERFLOW",
|
|
894
|
+
`Event stream buffer overflow: ${stream}`,
|
|
895
|
+
);
|
|
896
|
+
this.context.publishRuntimeError("account", error, {
|
|
897
|
+
stream,
|
|
898
|
+
maxBuffer,
|
|
899
|
+
});
|
|
900
|
+
};
|
|
901
|
+
}
|
|
875
902
|
}
|