@imbingox/acex 0.4.0-beta.12 → 0.4.0-beta.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/api.md CHANGED
@@ -100,7 +100,7 @@ const risk = client.account.getRiskSnapshot("main-binance");
100
100
  const openOrders = client.order.getOpenOrders("main-binance");
101
101
  ```
102
102
 
103
- Binance 账户能力当前面向 PAPI UM。账户风险字段会由私有 WS 事件和 `/papi/v1/account` + `/papi/v1/um/positionRisk` REST refresh 共同维护;默认每 60s 还会用 `/papi/v1/balance`、`/papi/v1/account`、`/papi/v1/um/positionRisk` 和订单 REST 接口做 private reconcile。Binance 全账户 `/papi/v1/um/openOrders` 不带 symbol 时 request weight 较高,默认 60s 是保守值。
103
+ Binance 账户能力当前面向 PAPI UM。账户风险字段会由私有 WS 事件和 `/papi/v1/account` + `/papi/v1/um/positionRisk` REST refresh 共同维护;默认每 60s 还会用 `/papi/v1/balance`、`/papi/v1/account`、`/papi/v1/um/positionRisk` 和订单 REST 接口做 private reconcile。Binance 全账户 `/papi/v1/um/openOrders` 不带 symbol 时 request weight 较高,默认 60s 是保守值。读取余额、仓位或风险数据时必须订阅 `client.account.subscribeAccount()`;`client.order.subscribeOrders()` 只维护订单缓存,即使底层复用同一条 private WS,也不会维护 account 仓位缓存。
104
104
 
105
105
  ### 2.4 注册 Juplend 只读账户
106
106
 
@@ -242,6 +242,7 @@ const client = createClient({
242
242
  binance: {
243
243
  riskPollIntervalMs: 5_000,
244
244
  privateReconcileIntervalMs: 60_000,
245
+ privateStreamStaleAfterMs: 65 * 60_000,
245
246
  },
246
247
  juplend: {
247
248
  pollIntervalMs: 30_000,
@@ -392,7 +393,7 @@ interface AccountManager {
392
393
 
393
394
  `AccountSnapshot.balances` 是 `Record<string, BalanceSnapshot>`,数组视图用 `getBalances()`。
394
395
 
395
- Binance account update 是 REST bootstrap + WS 增量 + REST risk refresh + private reconcile 的组合。risk refresh 是增量语义,不会因 REST 缺失项删除本地 position;private reconcile 是全量校准语义,会清理 REST 全量余额/仓位中缺失或归零的本地记录。Juplend 每次 poll 都是全量快照,成功 poll 会替换 balances / positions / risk,用于清理已关闭或不再匹配的 position。
396
+ Binance account update 是 REST bootstrap + WS 增量 + REST risk refresh + private reconcile 的组合。WS `ACCOUNT_UPDATE` 会更新发生变化的余额和仓位;`/papi/v1/account` + `/papi/v1/um/positionRisk` refresh 用于校准风险字段和 mark-to-market 仓位字段。risk refresh 是增量语义,不会因 REST 缺失项删除本地 position;private reconcile 是全量校准语义,会清理 REST 全量余额/仓位中缺失或归零的本地记录。Juplend 每次 poll 都是全量快照,成功 poll 会替换 balances / positions / risk,用于清理已关闭或不再匹配的 position。
396
397
 
397
398
  Account 事件用于消费余额、仓位、风险或全量快照替换:
398
399
 
@@ -586,6 +587,7 @@ interface CreateClientOptions {
586
587
  binance?: {
587
588
  riskPollIntervalMs?: number;
588
589
  privateReconcileIntervalMs?: number;
590
+ privateStreamStaleAfterMs?: number;
589
591
  };
590
592
  juplend?: {
591
593
  pollIntervalMs?: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imbingox/acex",
3
- "version": "0.4.0-beta.12",
3
+ "version": "0.4.0-beta.13",
4
4
  "description": "Multi-exchange trading SDK for market data, account, and order management",
5
5
  "repository": {
6
6
  "type": "git",
@@ -39,6 +39,7 @@
39
39
  "test:live:order": "bun run scripts/live-order-smoke.ts",
40
40
  "test:live:order:smoke": "bun run scripts/live-order-smoke.ts --duration 10",
41
41
  "test:live:order:soak": "bun run scripts/live-order-smoke.ts --duration 60 --disconnect-after 5",
42
+ "test:live:order:listen-key": "bun run scripts/live-order-smoke.ts --duration 60 --expire-listen-key-after 5",
42
43
  "version-packages": "changeset version && files=\"package.json\"; if [ -f .changeset/pre.json ]; then files=\"$files .changeset/pre.json\"; fi; if [ -f CHANGELOG.md ]; then files=\"$files CHANGELOG.md\"; fi; biome check --write $files",
43
44
  "test:unit": "bun test tests/unit",
44
45
  "test:integration": "bun test --max-concurrency=1 tests/integration",
@@ -155,9 +155,16 @@ interface BinanceOrderTradeUpdateMessage {
155
155
  o?: BinanceOrderTradeUpdatePayload;
156
156
  }
157
157
 
158
+ interface BinanceListenKeyExpiredMessage {
159
+ e?: string;
160
+ E?: number;
161
+ listenKey?: string;
162
+ }
163
+
158
164
  type BinancePrivateMessage =
159
165
  | BinanceAccountUpdateMessage
160
- | BinanceOrderTradeUpdateMessage;
166
+ | BinanceOrderTradeUpdateMessage
167
+ | BinanceListenKeyExpiredMessage;
161
168
 
162
169
  const BINANCE_PAPI_REST_BASE_URL = "https://papi.binance.com";
163
170
  const BINANCE_PAPI_WS_BASE_URL = "wss://fstream.binance.com/pm/ws";
@@ -225,6 +232,10 @@ function getStringOption(
225
232
  return typeof value === "string" && value.length > 0 ? value : undefined;
226
233
  }
227
234
 
235
+ function toError(value: unknown, fallback: string): Error {
236
+ return value instanceof Error ? value : new Error(fallback);
237
+ }
238
+
228
239
  function signQuery(query: string, secret: string): string {
229
240
  return createHmac("sha256", secret).update(query).digest("hex");
230
241
  }
@@ -534,7 +545,9 @@ function mapAccountUpdatePosition(
534
545
 
535
546
  function parsePrivateMessage(data: string): BinancePrivateMessage | undefined {
536
547
  const parsed = JSON.parse(data) as BinancePrivateMessage;
537
- return parsed.e === "ACCOUNT_UPDATE" || parsed.e === "ORDER_TRADE_UPDATE"
548
+ return parsed.e === "ACCOUNT_UPDATE" ||
549
+ parsed.e === "ORDER_TRADE_UPDATE" ||
550
+ parsed.e === "listenKeyExpired"
538
551
  ? parsed
539
552
  : undefined;
540
553
  }
@@ -545,6 +558,12 @@ function isAccountUpdateMessage(
545
558
  return message.e === "ACCOUNT_UPDATE";
546
559
  }
547
560
 
561
+ function isListenKeyExpiredMessage(
562
+ message: BinancePrivateMessage,
563
+ ): message is BinanceListenKeyExpiredMessage {
564
+ return message.e === "listenKeyExpired";
565
+ }
566
+
548
567
  function mapAccountUpdate(
549
568
  message: BinanceAccountUpdateMessage,
550
569
  receivedAt: number,
@@ -920,75 +939,158 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
920
939
  options: PrivateStreamOptions,
921
940
  accountOptions?: Record<string, unknown>,
922
941
  ): StreamHandle {
942
+ interface PrivateStreamSession {
943
+ readonly listenKey: string;
944
+ websocket?: StreamHandle;
945
+ keepAliveTimer?: TimerHandle;
946
+ stopped: boolean;
947
+ }
948
+
949
+ type RecoveryReason =
950
+ | "heartbeat_timeout"
951
+ | "keepalive_failed"
952
+ | "listen_key_expired";
953
+
923
954
  let closed = false;
924
- let listenKey: string | undefined;
925
- let keepAliveTimer: TimerHandle | undefined;
926
- let websocket: StreamHandle | undefined;
955
+ let activeSession: PrivateStreamSession | undefined;
956
+ let recoveryInFlight: Promise<void> | undefined;
957
+ let recoveryRetryTimer: ReturnType<typeof setTimeout> | undefined;
927
958
  let openedOnce = false;
928
959
 
929
- const clearKeepAlive = () => {
930
- if (keepAliveTimer) {
931
- clearInterval(keepAliveTimer);
932
- keepAliveTimer = undefined;
960
+ const clearRecoveryRetry = () => {
961
+ if (recoveryRetryTimer) {
962
+ clearTimeout(recoveryRetryTimer);
963
+ recoveryRetryTimer = undefined;
933
964
  }
934
965
  };
935
966
 
936
- const closeListenKey = () => {
937
- if (!listenKey) {
967
+ const closeListenKey = (listenKey: string) => {
968
+ void this.closeUserDataStream(
969
+ credentials,
970
+ listenKey,
971
+ accountOptions,
972
+ ).catch((error) => {
973
+ if (!closed) {
974
+ callbacks.onError(
975
+ toError(error, "Failed to close Binance PAPI listenKey"),
976
+ );
977
+ }
978
+ });
979
+ };
980
+
981
+ const closeSession = (
982
+ session: PrivateStreamSession | undefined,
983
+ shouldCloseListenKey: boolean,
984
+ ) => {
985
+ if (!session || session.stopped) {
938
986
  return;
939
987
  }
940
988
 
941
- const key = listenKey;
942
- listenKey = undefined;
943
- void this.closeUserDataStream(credentials, key, accountOptions).catch(
944
- (error) => {
945
- callbacks.onError(
946
- error instanceof Error
947
- ? error
948
- : new Error("Failed to close Binance PAPI listenKey"),
949
- );
950
- },
951
- );
989
+ session.stopped = true;
990
+ if (session.keepAliveTimer) {
991
+ clearInterval(session.keepAliveTimer);
992
+ session.keepAliveTimer = undefined;
993
+ }
994
+ session.websocket?.close();
995
+ session.websocket = undefined;
996
+ if (shouldCloseListenKey) {
997
+ closeListenKey(session.listenKey);
998
+ }
952
999
  };
953
1000
 
954
- const ready = (async () => {
955
- listenKey = await this.startUserDataStream(credentials, accountOptions);
1001
+ const activateSession = (nextSession: PrivateStreamSession) => {
956
1002
  if (closed) {
957
- closeListenKey();
1003
+ closeSession(nextSession, true);
958
1004
  return;
959
1005
  }
960
1006
 
961
- keepAliveTimer = setInterval(() => {
962
- if (!listenKey) {
1007
+ const previousSession = activeSession;
1008
+ activeSession = nextSession;
1009
+ closeSession(previousSession, true);
1010
+
1011
+ if (openedOnce) {
1012
+ callbacks.onReconnected();
1013
+ } else {
1014
+ openedOnce = true;
1015
+ }
1016
+ };
1017
+
1018
+ const scheduleRecoveryRetry = (reason: RecoveryReason) => {
1019
+ if (closed || recoveryRetryTimer) {
1020
+ return;
1021
+ }
1022
+
1023
+ recoveryRetryTimer = setTimeout(() => {
1024
+ recoveryRetryTimer = undefined;
1025
+ recoverPrivateStream(reason);
1026
+ }, options.reconnectDelayMs);
1027
+ };
1028
+
1029
+ const createSession = async (): Promise<
1030
+ PrivateStreamSession | undefined
1031
+ > => {
1032
+ const listenKey = await this.startUserDataStream(
1033
+ credentials,
1034
+ accountOptions,
1035
+ );
1036
+ if (closed) {
1037
+ closeListenKey(listenKey);
1038
+ return undefined;
1039
+ }
1040
+
1041
+ const nextSession: PrivateStreamSession = {
1042
+ listenKey,
1043
+ stopped: false,
1044
+ };
1045
+
1046
+ nextSession.keepAliveTimer = setInterval(() => {
1047
+ if (closed || activeSession !== nextSession) {
963
1048
  return;
964
1049
  }
965
1050
 
966
1051
  void this.keepAliveUserDataStream(
967
1052
  credentials,
968
- listenKey,
1053
+ nextSession.listenKey,
969
1054
  accountOptions,
970
1055
  ).catch((error) => {
1056
+ if (closed || activeSession !== nextSession) {
1057
+ return;
1058
+ }
1059
+
971
1060
  callbacks.onError(
972
- error instanceof Error
973
- ? error
974
- : new Error("Failed to keep Binance PAPI listenKey alive"),
1061
+ toError(error, "Failed to keep Binance PAPI listenKey alive"),
975
1062
  );
1063
+ recoverPrivateStream("keepalive_failed");
976
1064
  });
977
1065
  }, options.listenKeyKeepAliveMs);
978
1066
 
979
- websocket = createManagedWebSocket<BinancePrivateMessage>({
1067
+ nextSession.websocket = createManagedWebSocket<BinancePrivateMessage>({
980
1068
  url: `${BINANCE_PAPI_WS_BASE_URL}/${listenKey}`,
981
1069
  initialMessageTimeoutMs: options.openTimeoutMs,
982
1070
  readyWhen: "open",
983
1071
  now: options.now,
984
1072
  parseMessage: parsePrivateMessage,
985
1073
  onOpen() {
1074
+ if (closed || activeSession !== nextSession) {
1075
+ return;
1076
+ }
1077
+
986
1078
  if (openedOnce) {
987
1079
  callbacks.onReconnected();
1080
+ } else {
1081
+ openedOnce = true;
988
1082
  }
989
- openedOnce = true;
990
1083
  },
991
1084
  onMessage(message, receivedAt) {
1085
+ if (closed || activeSession !== nextSession) {
1086
+ return;
1087
+ }
1088
+
1089
+ if (isListenKeyExpiredMessage(message)) {
1090
+ recoverPrivateStream("listen_key_expired");
1091
+ return;
1092
+ }
1093
+
992
1094
  if (isAccountUpdateMessage(message)) {
993
1095
  callbacks.onAccountUpdate(mapAccountUpdate(message, receivedAt));
994
1096
  return;
@@ -1000,13 +1102,31 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
1000
1102
  }
1001
1103
  },
1002
1104
  onUnexpectedClose() {
1105
+ if (closed || activeSession !== nextSession) {
1106
+ return;
1107
+ }
1108
+
1003
1109
  callbacks.onDisconnected();
1004
1110
  },
1005
1111
  onError() {
1112
+ if (closed || activeSession !== nextSession) {
1113
+ return;
1114
+ }
1115
+
1006
1116
  callbacks.onError(
1007
1117
  new Error("WebSocket error for Binance PAPI private stream"),
1008
1118
  );
1009
1119
  },
1120
+ messageWatchdog: {
1121
+ staleAfterMs: options.staleAfterMs,
1122
+ onStale() {
1123
+ if (closed || activeSession !== nextSession) {
1124
+ return;
1125
+ }
1126
+
1127
+ recoverPrivateStream("heartbeat_timeout");
1128
+ },
1129
+ },
1010
1130
  reconnect: {
1011
1131
  initialDelayMs: options.reconnectDelayMs,
1012
1132
  maxDelayMs: options.reconnectMaxDelayMs,
@@ -1014,7 +1134,60 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
1014
1134
  },
1015
1135
  });
1016
1136
 
1017
- await websocket.ready;
1137
+ try {
1138
+ await nextSession.websocket.ready;
1139
+ } catch (error) {
1140
+ closeSession(nextSession, true);
1141
+ throw error;
1142
+ }
1143
+
1144
+ return nextSession;
1145
+ };
1146
+
1147
+ const recoverPrivateStream = (reason: RecoveryReason) => {
1148
+ if (closed || recoveryInFlight) {
1149
+ return;
1150
+ }
1151
+
1152
+ clearRecoveryRetry();
1153
+ if (reason === "heartbeat_timeout") {
1154
+ callbacks.onFreshnessChange("stale", "heartbeat_timeout");
1155
+ } else {
1156
+ callbacks.onDisconnected();
1157
+ }
1158
+
1159
+ const recovery = (async () => {
1160
+ const previousSession = activeSession;
1161
+ activeSession = undefined;
1162
+ closeSession(previousSession, true);
1163
+
1164
+ try {
1165
+ const nextSession = await createSession();
1166
+ if (nextSession) {
1167
+ activateSession(nextSession);
1168
+ }
1169
+ } catch (error) {
1170
+ if (!closed) {
1171
+ callbacks.onError(
1172
+ toError(error, "Failed to rebuild Binance PAPI private stream"),
1173
+ );
1174
+ scheduleRecoveryRetry(reason);
1175
+ }
1176
+ }
1177
+ })().finally(() => {
1178
+ if (recoveryInFlight === recovery) {
1179
+ recoveryInFlight = undefined;
1180
+ }
1181
+ });
1182
+
1183
+ recoveryInFlight = recovery;
1184
+ };
1185
+
1186
+ const ready = (async () => {
1187
+ const initialSession = await createSession();
1188
+ if (initialSession) {
1189
+ activateSession(initialSession);
1190
+ }
1018
1191
  })();
1019
1192
 
1020
1193
  return {
@@ -1025,9 +1198,9 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
1025
1198
  }
1026
1199
 
1027
1200
  closed = true;
1028
- clearKeepAlive();
1029
- websocket?.close();
1030
- closeListenKey();
1201
+ clearRecoveryRetry();
1202
+ closeSession(activeSession, true);
1203
+ activeSession = undefined;
1031
1204
  },
1032
1205
  };
1033
1206
  }
@@ -212,6 +212,7 @@ export interface PrivateStreamCallbacks {
212
212
  onAccountSnapshot(snapshot: RawAccountBootstrap): void;
213
213
  onAccountUpdate(update: RawAccountUpdate): void;
214
214
  onOrderUpdate(update: RawOrderUpdate): void;
215
+ onFreshnessChange(freshness: "stale", reason: "heartbeat_timeout"): void;
215
216
  onDisconnected(): void;
216
217
  onReconnected(): void;
217
218
  onError(error: Error): void;
@@ -222,6 +223,7 @@ export interface PrivateStreamOptions {
222
223
  reconnectDelayMs: number;
223
224
  reconnectMaxDelayMs: number;
224
225
  listenKeyKeepAliveMs: number;
226
+ staleAfterMs: number;
225
227
  now?: () => number;
226
228
  }
227
229
 
@@ -47,6 +47,7 @@ const DEFAULT_STREAM_OPEN_TIMEOUT_MS = 15_000;
47
47
  const DEFAULT_STREAM_RECONNECT_DELAY_MS = 1_000;
48
48
  const DEFAULT_STREAM_RECONNECT_MAX_DELAY_MS = 10_000;
49
49
  const DEFAULT_LISTEN_KEY_KEEPALIVE_MS = 30 * 60 * 1_000;
50
+ const DEFAULT_PRIVATE_STREAM_STALE_AFTER_MS = 65 * 60_000;
50
51
  const DEFAULT_BINANCE_RISK_POLL_INTERVAL_MS = 5_000;
51
52
  const DEFAULT_BINANCE_PRIVATE_RECONCILE_INTERVAL_MS = 60_000;
52
53
  const MAX_ORDER_TERMINAL_BACKFILLS_PER_RECONCILE = 20;
@@ -90,6 +91,7 @@ export class PrivateSubscriptionCoordinator {
90
91
  private readonly streamReconnectDelayMs: number;
91
92
  private readonly streamReconnectMaxDelayMs: number;
92
93
  private readonly listenKeyKeepAliveMs: number;
94
+ private readonly binancePrivateStreamStaleAfterMs: number;
93
95
  private readonly binanceRiskPollIntervalMs: number;
94
96
  private readonly binancePrivateReconcileIntervalMs: number | undefined;
95
97
  private readonly records = new Map<string, PrivateSubscriptionRecord>();
@@ -116,6 +118,10 @@ export class PrivateSubscriptionCoordinator {
116
118
  DEFAULT_STREAM_RECONNECT_MAX_DELAY_MS;
117
119
  this.listenKeyKeepAliveMs =
118
120
  options.listenKeyKeepAliveMs ?? DEFAULT_LISTEN_KEY_KEEPALIVE_MS;
121
+ this.binancePrivateStreamStaleAfterMs = normalizePositiveInterval(
122
+ options.binance?.privateStreamStaleAfterMs,
123
+ DEFAULT_PRIVATE_STREAM_STALE_AFTER_MS,
124
+ );
119
125
  this.binanceRiskPollIntervalMs = normalizePositiveInterval(
120
126
  options.binance?.riskPollIntervalMs,
121
127
  DEFAULT_BINANCE_RISK_POLL_INTERVAL_MS,
@@ -1180,6 +1186,30 @@ export class PrivateSubscriptionCoordinator {
1180
1186
  update,
1181
1187
  );
1182
1188
  },
1189
+ onFreshnessChange: (_freshness, reason) => {
1190
+ if (record.accountSubscribed) {
1191
+ this.accountConsumer.onPrivateAccountStreamState(
1192
+ record.accountId,
1193
+ record.venue,
1194
+ {
1195
+ runtimeStatus: "reconnecting",
1196
+ ready: record.accountReady,
1197
+ reason,
1198
+ },
1199
+ );
1200
+ }
1201
+ if (record.ordersSubscribed) {
1202
+ this.orderConsumer.onPrivateOrderStreamState(
1203
+ record.accountId,
1204
+ record.venue,
1205
+ {
1206
+ runtimeStatus: "reconnecting",
1207
+ ready: record.orderReady,
1208
+ reason,
1209
+ },
1210
+ );
1211
+ }
1212
+ },
1183
1213
  onDisconnected: () => {
1184
1214
  if (record.accountSubscribed) {
1185
1215
  this.accountConsumer.onPrivateAccountStreamState(
@@ -1236,6 +1266,7 @@ export class PrivateSubscriptionCoordinator {
1236
1266
  reconnectDelayMs: this.streamReconnectDelayMs,
1237
1267
  reconnectMaxDelayMs: this.streamReconnectMaxDelayMs,
1238
1268
  listenKeyKeepAliveMs: this.listenKeyKeepAliveMs,
1269
+ staleAfterMs: this.binancePrivateStreamStaleAfterMs,
1239
1270
  now: () => this.context.now(),
1240
1271
  },
1241
1272
  { ...account.options, accountId: account.accountId },
@@ -28,6 +28,17 @@ export function shouldApplyWatermarkedUpdate(
28
28
  const graceMs = options.graceMs ?? CROSS_CLOCK_WATERMARK_GRACE_MS;
29
29
  const requestStartedAt = options.requestStartedAt;
30
30
 
31
+ if (options.source === "command" && requestStartedAt !== undefined) {
32
+ const hasMissingExchangeTs =
33
+ current.exchangeTs === undefined || incoming.exchangeTs === undefined;
34
+ if (hasMissingExchangeTs) {
35
+ return (
36
+ current.receivedAt <= requestStartedAt &&
37
+ incoming.receivedAt >= current.receivedAt
38
+ );
39
+ }
40
+ }
41
+
31
42
  if (
32
43
  options.source === "rest" &&
33
44
  requestStartedAt !== undefined &&
@@ -0,0 +1,61 @@
1
+ import type { OrderDataStatus, Venue } from "../../types/index.ts";
2
+
3
+ export const DEFAULT_MAX_CLOSED_ORDERS_PER_SYMBOL = 500;
4
+
5
+ export function cloneOrderStatus(status: OrderDataStatus): OrderDataStatus {
6
+ return { ...status };
7
+ }
8
+
9
+ export function createOrderDataStatus(
10
+ accountId: string,
11
+ venue: Venue,
12
+ activity: "active" | "inactive",
13
+ ): OrderDataStatus {
14
+ return {
15
+ accountId,
16
+ venue,
17
+ activity,
18
+ ready: false,
19
+ runtimeStatus: activity === "active" ? "bootstrap_pending" : "stopped",
20
+ };
21
+ }
22
+
23
+ export function normalizeMaxClosedOrdersPerSymbol(
24
+ value: number | undefined,
25
+ ): number {
26
+ return value !== undefined && Number.isInteger(value) && value > 0
27
+ ? value
28
+ : DEFAULT_MAX_CLOSED_ORDERS_PER_SYMBOL;
29
+ }
30
+
31
+ export function successfulStatus(
32
+ status: OrderDataStatus,
33
+ options: {
34
+ ready?: boolean;
35
+ lastReceivedAt?: number;
36
+ lastReadyAt?: number;
37
+ preserveStatus?: boolean;
38
+ },
39
+ ): OrderDataStatus {
40
+ const preservesStreamState =
41
+ options.preserveStatus &&
42
+ (status.runtimeStatus === "reconnecting" ||
43
+ status.reason === "ws_disconnected" ||
44
+ status.reason === "heartbeat_timeout");
45
+ const ready = options.ready ?? true;
46
+
47
+ return {
48
+ ...status,
49
+ activity: "active",
50
+ ready,
51
+ runtimeStatus: preservesStreamState ? status.runtimeStatus : "healthy",
52
+ reason: preservesStreamState ? status.reason : undefined,
53
+ lastReceivedAt: options.lastReceivedAt ?? status.lastReceivedAt,
54
+ lastReadyAt: ready
55
+ ? (options.lastReadyAt ??
56
+ (options.preserveStatus ? status.lastReadyAt : undefined) ??
57
+ Date.now())
58
+ : status.lastReadyAt,
59
+ inactiveSince: undefined,
60
+ };
61
+ }
@@ -0,0 +1,77 @@
1
+ import type { OrderSnapshot } from "../../types/index.ts";
2
+
3
+ export const SDK_CLIENT_ORDER_ID_PREFIX = "acex-";
4
+ export const VENUE_CLIENT_ORDER_ID_PATTERN = /^[.A-Z:/a-z0-9_-]{1,32}$/;
5
+
6
+ const SYSTEM_CLIENT_ORDER_ID_PATTERNS = [
7
+ /^adl_autoclose$/,
8
+ /^autoclose-/,
9
+ /^settlement_autoclose-/,
10
+ ];
11
+
12
+ export function getOrderLookupKeys(input: {
13
+ symbol: string;
14
+ orderId?: string;
15
+ clientOrderId?: string;
16
+ }): string[] {
17
+ const keys: string[] = [];
18
+ if (input.orderId) {
19
+ keys.push(`symbol:${input.symbol}:order:${input.orderId}`);
20
+ }
21
+
22
+ if (input.clientOrderId) {
23
+ keys.push(`symbol:${input.symbol}:client:${input.clientOrderId}`);
24
+ }
25
+
26
+ return keys;
27
+ }
28
+
29
+ export function shouldMatchOrderQuery(
30
+ candidate: OrderSnapshot,
31
+ input: { symbol?: string; orderId?: string; clientOrderId?: string },
32
+ ): boolean {
33
+ if (input.symbol && candidate.symbol !== input.symbol) {
34
+ return false;
35
+ }
36
+
37
+ if (input.orderId && candidate.orderId !== input.orderId) {
38
+ return false;
39
+ }
40
+
41
+ if (input.clientOrderId && candidate.clientOrderId !== input.clientOrderId) {
42
+ return false;
43
+ }
44
+
45
+ return Boolean(input.orderId || input.clientOrderId);
46
+ }
47
+
48
+ export function shouldMatchStoredOrderIdentity(
49
+ candidate: OrderSnapshot,
50
+ input: { symbol: string; orderId?: string; clientOrderId?: string },
51
+ ): boolean {
52
+ if (candidate.symbol !== input.symbol) {
53
+ return false;
54
+ }
55
+
56
+ if (candidate.orderId && input.orderId) {
57
+ return candidate.orderId === input.orderId;
58
+ }
59
+
60
+ // clientOrderId is only a temporary identity for an order that does not yet
61
+ // have an orderId. A candidate that already carries an orderId (including an
62
+ // old order sitting in closed that reused this clientOrderId) must not be
63
+ // merged by a cid-only update; otherwise the stale orderId would be carried
64
+ // forward and pollute closed. When the orderId is later filled in, the
65
+ // candidate still lacks an orderId and matches normally.
66
+ return Boolean(
67
+ input.clientOrderId &&
68
+ candidate.clientOrderId === input.clientOrderId &&
69
+ !candidate.orderId,
70
+ );
71
+ }
72
+
73
+ export function isSystemClientOrderId(clientOrderId: string): boolean {
74
+ return SYSTEM_CLIENT_ORDER_ID_PATTERNS.some((pattern) =>
75
+ pattern.test(clientOrderId),
76
+ );
77
+ }
@@ -0,0 +1,36 @@
1
+ import type {
2
+ OrderDataStatus,
3
+ OrderSnapshot,
4
+ Venue,
5
+ } from "../../types/index.ts";
6
+
7
+ export interface OrderRecord {
8
+ accountId: string;
9
+ venue: Venue;
10
+ subscribed: boolean;
11
+ openOrders: Map<string, Map<string, OrderSnapshot>>;
12
+ closedOrders: Map<string, Map<string, OrderSnapshot>>;
13
+ localOrderLocations: Map<string, OrderLocation>;
14
+ orderIdIndex: Map<string, Map<string, string>>;
15
+ orderIdOnlyIndex: Map<string, Set<string>>;
16
+ clientOrderIdIndex: Map<string, Set<string>>;
17
+ pendingClientOrderIdIndex: Map<string, PendingOrderClaim>;
18
+ status: OrderDataStatus;
19
+ }
20
+
21
+ export type OrderTable = "open" | "closed";
22
+
23
+ export interface OrderLocation {
24
+ table: OrderTable;
25
+ symbol: string;
26
+ localOrderId: string;
27
+ }
28
+
29
+ export interface PendingOrderClaim {
30
+ localOrderId: string;
31
+ symbol: string;
32
+ }
33
+
34
+ export interface OrderManagerOptions {
35
+ maxClosedOrdersPerSymbol?: number;
36
+ }