@imbingox/acex 0.4.0-beta.11 → 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 +4 -2
- package/package.json +2 -1
- package/src/adapters/binance/private-adapter.ts +253 -43
- package/src/adapters/types.ts +2 -0
- package/src/client/private-subscription-coordinator.ts +31 -0
- package/src/internal/watermark.ts +11 -0
- package/src/managers/order/data-status.ts +61 -0
- package/src/managers/order/identity.ts +77 -0
- package/src/managers/order/model.ts +36 -0
- package/src/managers/order/snapshot.ts +87 -0
- package/src/managers/order/store.ts +486 -0
- package/src/managers/order-manager.ts +168 -720
- package/src/types/shared.ts +1 -0
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.
|
|
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",
|
|
@@ -93,6 +93,11 @@ interface BinancePapiOpenOrder {
|
|
|
93
93
|
time?: number;
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
interface BinancePapiCancelAllResponse {
|
|
97
|
+
code?: number | string;
|
|
98
|
+
msg?: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
96
101
|
interface BinanceListenKeyResponse {
|
|
97
102
|
listenKey?: string;
|
|
98
103
|
}
|
|
@@ -150,9 +155,16 @@ interface BinanceOrderTradeUpdateMessage {
|
|
|
150
155
|
o?: BinanceOrderTradeUpdatePayload;
|
|
151
156
|
}
|
|
152
157
|
|
|
158
|
+
interface BinanceListenKeyExpiredMessage {
|
|
159
|
+
e?: string;
|
|
160
|
+
E?: number;
|
|
161
|
+
listenKey?: string;
|
|
162
|
+
}
|
|
163
|
+
|
|
153
164
|
type BinancePrivateMessage =
|
|
154
165
|
| BinanceAccountUpdateMessage
|
|
155
|
-
| BinanceOrderTradeUpdateMessage
|
|
166
|
+
| BinanceOrderTradeUpdateMessage
|
|
167
|
+
| BinanceListenKeyExpiredMessage;
|
|
156
168
|
|
|
157
169
|
const BINANCE_PAPI_REST_BASE_URL = "https://papi.binance.com";
|
|
158
170
|
const BINANCE_PAPI_WS_BASE_URL = "wss://fstream.binance.com/pm/ws";
|
|
@@ -220,6 +232,10 @@ function getStringOption(
|
|
|
220
232
|
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
221
233
|
}
|
|
222
234
|
|
|
235
|
+
function toError(value: unknown, fallback: string): Error {
|
|
236
|
+
return value instanceof Error ? value : new Error(fallback);
|
|
237
|
+
}
|
|
238
|
+
|
|
223
239
|
function signQuery(query: string, secret: string): string {
|
|
224
240
|
return createHmac("sha256", secret).update(query).digest("hex");
|
|
225
241
|
}
|
|
@@ -529,7 +545,9 @@ function mapAccountUpdatePosition(
|
|
|
529
545
|
|
|
530
546
|
function parsePrivateMessage(data: string): BinancePrivateMessage | undefined {
|
|
531
547
|
const parsed = JSON.parse(data) as BinancePrivateMessage;
|
|
532
|
-
return parsed.e === "ACCOUNT_UPDATE" ||
|
|
548
|
+
return parsed.e === "ACCOUNT_UPDATE" ||
|
|
549
|
+
parsed.e === "ORDER_TRADE_UPDATE" ||
|
|
550
|
+
parsed.e === "listenKeyExpired"
|
|
533
551
|
? parsed
|
|
534
552
|
: undefined;
|
|
535
553
|
}
|
|
@@ -540,6 +558,12 @@ function isAccountUpdateMessage(
|
|
|
540
558
|
return message.e === "ACCOUNT_UPDATE";
|
|
541
559
|
}
|
|
542
560
|
|
|
561
|
+
function isListenKeyExpiredMessage(
|
|
562
|
+
message: BinancePrivateMessage,
|
|
563
|
+
): message is BinanceListenKeyExpiredMessage {
|
|
564
|
+
return message.e === "listenKeyExpired";
|
|
565
|
+
}
|
|
566
|
+
|
|
543
567
|
function mapAccountUpdate(
|
|
544
568
|
message: BinanceAccountUpdateMessage,
|
|
545
569
|
receivedAt: number,
|
|
@@ -859,21 +883,53 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
|
|
|
859
883
|
request: CancelAllOrdersRequest,
|
|
860
884
|
accountOptions?: Record<string, unknown>,
|
|
861
885
|
): Promise<RawOrderUpdate[]> {
|
|
862
|
-
const
|
|
863
|
-
const
|
|
886
|
+
const symbol = encodeUmSymbol(request.symbol);
|
|
887
|
+
const openOrders = await this.signedRequest<BinancePapiOpenOrder[]>(
|
|
888
|
+
"GET",
|
|
889
|
+
"/papi/v1/um/openOrders",
|
|
890
|
+
credentials,
|
|
891
|
+
accountOptions,
|
|
892
|
+
{
|
|
893
|
+
symbol,
|
|
894
|
+
},
|
|
895
|
+
SAFE_READ_RETRY_POLICY,
|
|
896
|
+
);
|
|
897
|
+
|
|
898
|
+
// Venue responds {code,msg}; returned updates are synthesized from the
|
|
899
|
+
// pre-fetch. Orders that fill between fetch and cancel are corrected by
|
|
900
|
+
// the WS terminal event / reconcile.
|
|
901
|
+
const response = await this.signedRequest<BinancePapiCancelAllResponse>(
|
|
864
902
|
"DELETE",
|
|
865
903
|
"/papi/v1/um/allOpenOrders",
|
|
866
904
|
credentials,
|
|
867
905
|
accountOptions,
|
|
868
906
|
{
|
|
869
|
-
symbol
|
|
907
|
+
symbol,
|
|
870
908
|
},
|
|
871
909
|
NO_RETRY_POLICY,
|
|
872
910
|
);
|
|
873
911
|
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
912
|
+
if (response.code !== undefined && `${response.code}` !== "200") {
|
|
913
|
+
throw new Error(
|
|
914
|
+
`Binance PAPI cancelAllOrders failed: code=${response.code}, msg=${
|
|
915
|
+
response.msg ?? ""
|
|
916
|
+
}`,
|
|
917
|
+
);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
const receivedAt = Date.now();
|
|
921
|
+
return openOrders.flatMap((order) => {
|
|
922
|
+
const mapped = mapOpenOrder(order, receivedAt);
|
|
923
|
+
return mapped
|
|
924
|
+
? [
|
|
925
|
+
{
|
|
926
|
+
...mapped,
|
|
927
|
+
status: "canceled",
|
|
928
|
+
exchangeTs: undefined,
|
|
929
|
+
receivedAt,
|
|
930
|
+
},
|
|
931
|
+
]
|
|
932
|
+
: [];
|
|
877
933
|
});
|
|
878
934
|
}
|
|
879
935
|
|
|
@@ -883,75 +939,158 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
|
|
|
883
939
|
options: PrivateStreamOptions,
|
|
884
940
|
accountOptions?: Record<string, unknown>,
|
|
885
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
|
+
|
|
886
954
|
let closed = false;
|
|
887
|
-
let
|
|
888
|
-
let
|
|
889
|
-
let
|
|
955
|
+
let activeSession: PrivateStreamSession | undefined;
|
|
956
|
+
let recoveryInFlight: Promise<void> | undefined;
|
|
957
|
+
let recoveryRetryTimer: ReturnType<typeof setTimeout> | undefined;
|
|
890
958
|
let openedOnce = false;
|
|
891
959
|
|
|
892
|
-
const
|
|
893
|
-
if (
|
|
894
|
-
|
|
895
|
-
|
|
960
|
+
const clearRecoveryRetry = () => {
|
|
961
|
+
if (recoveryRetryTimer) {
|
|
962
|
+
clearTimeout(recoveryRetryTimer);
|
|
963
|
+
recoveryRetryTimer = undefined;
|
|
896
964
|
}
|
|
897
965
|
};
|
|
898
966
|
|
|
899
|
-
const closeListenKey = () => {
|
|
900
|
-
|
|
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) {
|
|
901
986
|
return;
|
|
902
987
|
}
|
|
903
988
|
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
);
|
|
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
|
+
}
|
|
915
999
|
};
|
|
916
1000
|
|
|
917
|
-
const
|
|
918
|
-
listenKey = await this.startUserDataStream(credentials, accountOptions);
|
|
1001
|
+
const activateSession = (nextSession: PrivateStreamSession) => {
|
|
919
1002
|
if (closed) {
|
|
920
|
-
|
|
1003
|
+
closeSession(nextSession, true);
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
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) {
|
|
921
1020
|
return;
|
|
922
1021
|
}
|
|
923
1022
|
|
|
924
|
-
|
|
925
|
-
|
|
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) {
|
|
926
1048
|
return;
|
|
927
1049
|
}
|
|
928
1050
|
|
|
929
1051
|
void this.keepAliveUserDataStream(
|
|
930
1052
|
credentials,
|
|
931
|
-
listenKey,
|
|
1053
|
+
nextSession.listenKey,
|
|
932
1054
|
accountOptions,
|
|
933
1055
|
).catch((error) => {
|
|
1056
|
+
if (closed || activeSession !== nextSession) {
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
934
1060
|
callbacks.onError(
|
|
935
|
-
error
|
|
936
|
-
? error
|
|
937
|
-
: new Error("Failed to keep Binance PAPI listenKey alive"),
|
|
1061
|
+
toError(error, "Failed to keep Binance PAPI listenKey alive"),
|
|
938
1062
|
);
|
|
1063
|
+
recoverPrivateStream("keepalive_failed");
|
|
939
1064
|
});
|
|
940
1065
|
}, options.listenKeyKeepAliveMs);
|
|
941
1066
|
|
|
942
|
-
websocket = createManagedWebSocket<BinancePrivateMessage>({
|
|
1067
|
+
nextSession.websocket = createManagedWebSocket<BinancePrivateMessage>({
|
|
943
1068
|
url: `${BINANCE_PAPI_WS_BASE_URL}/${listenKey}`,
|
|
944
1069
|
initialMessageTimeoutMs: options.openTimeoutMs,
|
|
945
1070
|
readyWhen: "open",
|
|
946
1071
|
now: options.now,
|
|
947
1072
|
parseMessage: parsePrivateMessage,
|
|
948
1073
|
onOpen() {
|
|
1074
|
+
if (closed || activeSession !== nextSession) {
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
949
1078
|
if (openedOnce) {
|
|
950
1079
|
callbacks.onReconnected();
|
|
1080
|
+
} else {
|
|
1081
|
+
openedOnce = true;
|
|
951
1082
|
}
|
|
952
|
-
openedOnce = true;
|
|
953
1083
|
},
|
|
954
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
|
+
|
|
955
1094
|
if (isAccountUpdateMessage(message)) {
|
|
956
1095
|
callbacks.onAccountUpdate(mapAccountUpdate(message, receivedAt));
|
|
957
1096
|
return;
|
|
@@ -963,13 +1102,31 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
|
|
|
963
1102
|
}
|
|
964
1103
|
},
|
|
965
1104
|
onUnexpectedClose() {
|
|
1105
|
+
if (closed || activeSession !== nextSession) {
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
966
1109
|
callbacks.onDisconnected();
|
|
967
1110
|
},
|
|
968
1111
|
onError() {
|
|
1112
|
+
if (closed || activeSession !== nextSession) {
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
969
1116
|
callbacks.onError(
|
|
970
1117
|
new Error("WebSocket error for Binance PAPI private stream"),
|
|
971
1118
|
);
|
|
972
1119
|
},
|
|
1120
|
+
messageWatchdog: {
|
|
1121
|
+
staleAfterMs: options.staleAfterMs,
|
|
1122
|
+
onStale() {
|
|
1123
|
+
if (closed || activeSession !== nextSession) {
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
recoverPrivateStream("heartbeat_timeout");
|
|
1128
|
+
},
|
|
1129
|
+
},
|
|
973
1130
|
reconnect: {
|
|
974
1131
|
initialDelayMs: options.reconnectDelayMs,
|
|
975
1132
|
maxDelayMs: options.reconnectMaxDelayMs,
|
|
@@ -977,7 +1134,60 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
|
|
|
977
1134
|
},
|
|
978
1135
|
});
|
|
979
1136
|
|
|
980
|
-
|
|
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
|
+
}
|
|
981
1191
|
})();
|
|
982
1192
|
|
|
983
1193
|
return {
|
|
@@ -988,9 +1198,9 @@ export class BinancePrivateAdapter implements PrivateUserDataAdapter {
|
|
|
988
1198
|
}
|
|
989
1199
|
|
|
990
1200
|
closed = true;
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
1201
|
+
clearRecoveryRetry();
|
|
1202
|
+
closeSession(activeSession, true);
|
|
1203
|
+
activeSession = undefined;
|
|
994
1204
|
},
|
|
995
1205
|
};
|
|
996
1206
|
}
|
package/src/adapters/types.ts
CHANGED
|
@@ -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
|
+
}
|