@reefclaw/openclaw-plugin 0.1.0

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.
Files changed (371) hide show
  1. package/.gitignore +8 -0
  2. package/audit/mode-transition-audit.d.ts +11 -0
  3. package/audit/mode-transition-audit.js +29 -0
  4. package/balance-utils.d.ts +36 -0
  5. package/balance-utils.js +98 -0
  6. package/bridge/bridge.d.ts +109 -0
  7. package/bridge/bridge.js +1036 -0
  8. package/bridge/config.d.ts +43 -0
  9. package/bridge/config.js +176 -0
  10. package/bridge/connector.d.ts +52 -0
  11. package/bridge/connector.js +293 -0
  12. package/bridge/event-replay-buffer.d.ts +43 -0
  13. package/bridge/event-replay-buffer.js +109 -0
  14. package/bridge/gateway/event-parser.d.ts +209 -0
  15. package/bridge/gateway/event-parser.js +793 -0
  16. package/bridge/gateway/gateway-config.d.ts +39 -0
  17. package/bridge/gateway/gateway-config.js +86 -0
  18. package/bridge/gateway/gateway-http-client.d.ts +50 -0
  19. package/bridge/gateway/gateway-http-client.js +165 -0
  20. package/bridge/gateway/gateway-ws-client.d.ts +116 -0
  21. package/bridge/gateway/gateway-ws-client.js +417 -0
  22. package/bridge/gateway/poller.d.ts +146 -0
  23. package/bridge/gateway/poller.js +505 -0
  24. package/bridge/gateway/tool-discovery.d.ts +25 -0
  25. package/bridge/gateway/tool-discovery.js +198 -0
  26. package/bridge/index.d.ts +2 -0
  27. package/bridge/index.js +253 -0
  28. package/bridge/logger.d.ts +2 -0
  29. package/bridge/logger.js +2 -0
  30. package/bridge/provider.d.ts +156 -0
  31. package/bridge/provider.js +2 -0
  32. package/bridge/providers/emergency-commands.d.ts +54 -0
  33. package/bridge/providers/emergency-commands.js +235 -0
  34. package/bridge/providers/gateway.d.ts +322 -0
  35. package/bridge/providers/gateway.js +2302 -0
  36. package/bridge/providers/mock.d.ts +37 -0
  37. package/bridge/providers/mock.js +385 -0
  38. package/bridge/providers/onboarding-commands.d.ts +83 -0
  39. package/bridge/providers/onboarding-commands.js +213 -0
  40. package/bridge/providers/risk-calculator.d.ts +96 -0
  41. package/bridge/providers/risk-calculator.js +369 -0
  42. package/bridge/setup.d.ts +32 -0
  43. package/bridge/setup.js +226 -0
  44. package/bridge/types.d.ts +584 -0
  45. package/bridge/types.js +50 -0
  46. package/bridge/utils/reconnect.d.ts +6 -0
  47. package/bridge/utils/reconnect.js +6 -0
  48. package/bridge/utils/skill-signing.d.ts +51 -0
  49. package/bridge/utils/skill-signing.js +138 -0
  50. package/bridge/utils/skill-version.d.ts +17 -0
  51. package/bridge/utils/skill-version.js +74 -0
  52. package/ccxt/binance-ban-gate.d.ts +47 -0
  53. package/ccxt/binance-ban-gate.js +409 -0
  54. package/ccxt/binance-private.d.ts +325 -0
  55. package/ccxt/binance-private.js +1415 -0
  56. package/ccxt/binance-public.d.ts +18 -0
  57. package/ccxt/binance-public.js +147 -0
  58. package/config/agent-config-client.d.ts +55 -0
  59. package/config/agent-config-client.js +145 -0
  60. package/config/agent-config-poller.d.ts +25 -0
  61. package/config/agent-config-poller.js +100 -0
  62. package/config/brackets-config.d.ts +22 -0
  63. package/config/brackets-config.js +58 -0
  64. package/config/gate-store.d.ts +18 -0
  65. package/config/gate-store.js +61 -0
  66. package/config/plugin-config-io.d.ts +181 -0
  67. package/config/plugin-config-io.js +84 -0
  68. package/config/position-review-config.d.ts +35 -0
  69. package/config/position-review-config.js +105 -0
  70. package/config/tool-gate.d.ts +53 -0
  71. package/config/tool-gate.js +125 -0
  72. package/config/user-data-stream-config.d.ts +85 -0
  73. package/config/user-data-stream-config.js +224 -0
  74. package/connector-supervisor.d.ts +36 -0
  75. package/connector-supervisor.js +149 -0
  76. package/exchange-adapter.d.ts +49 -0
  77. package/exchange-adapter.js +4 -0
  78. package/index.d.ts +30 -0
  79. package/index.js +2030 -0
  80. package/ingest/pending-entry-metadata.d.ts +52 -0
  81. package/ingest/pending-entry-metadata.js +182 -0
  82. package/ingest/position-auto-capture.d.ts +98 -0
  83. package/ingest/position-auto-capture.js +394 -0
  84. package/ingest/position-decisions-client.d.ts +318 -0
  85. package/ingest/position-decisions-client.js +296 -0
  86. package/ingest/reconcile-db-vs-exchange.d.ts +13 -0
  87. package/ingest/reconcile-db-vs-exchange.js +114 -0
  88. package/ingest/reconciler-cleanup.d.ts +37 -0
  89. package/ingest/reconciler-cleanup.js +147 -0
  90. package/ingest/rest-gap-filler.d.ts +191 -0
  91. package/ingest/rest-gap-filler.js +565 -0
  92. package/ingest/touched-symbols-store.d.ts +25 -0
  93. package/ingest/touched-symbols-store.js +96 -0
  94. package/ingest/trade-store-client.d.ts +40 -0
  95. package/ingest/trade-store-client.js +125 -0
  96. package/ingest/ws-ingest.d.ts +43 -0
  97. package/ingest/ws-ingest.js +126 -0
  98. package/learning/setup-family.d.ts +21 -0
  99. package/learning/setup-family.js +103 -0
  100. package/lifecycle/install-signal-handlers.d.ts +33 -0
  101. package/lifecycle/install-signal-handlers.js +112 -0
  102. package/lifecycle/shutdown-coordinator.d.ts +43 -0
  103. package/lifecycle/shutdown-coordinator.js +131 -0
  104. package/live/bracket-id.d.ts +18 -0
  105. package/live/bracket-id.js +81 -0
  106. package/live/bracket-ledger.d.ts +54 -0
  107. package/live/bracket-ledger.js +267 -0
  108. package/live/bracket-manager.d.ts +82 -0
  109. package/live/bracket-manager.js +478 -0
  110. package/live/bracket-params.d.ts +22 -0
  111. package/live/bracket-params.js +124 -0
  112. package/live/bracket-reconciler.d.ts +95 -0
  113. package/live/bracket-reconciler.js +573 -0
  114. package/live/bracket-types.d.ts +102 -0
  115. package/live/bracket-types.js +8 -0
  116. package/live/deposit-tracker.d.ts +62 -0
  117. package/live/deposit-tracker.js +97 -0
  118. package/live/emergency-controls.d.ts +32 -0
  119. package/live/emergency-controls.js +226 -0
  120. package/live/exchange-errors.d.ts +12 -0
  121. package/live/exchange-errors.js +130 -0
  122. package/live/exchange-info-cache.d.ts +35 -0
  123. package/live/exchange-info-cache.js +119 -0
  124. package/live/fact-subscriber.d.ts +78 -0
  125. package/live/fact-subscriber.js +182 -0
  126. package/live/intent-journal.d.ts +42 -0
  127. package/live/intent-journal.js +122 -0
  128. package/live/listen-key-manager.d.ts +70 -0
  129. package/live/listen-key-manager.js +169 -0
  130. package/live/live-adapter.d.ts +264 -0
  131. package/live/live-adapter.js +1665 -0
  132. package/live/live-balance-enricher.d.ts +32 -0
  133. package/live/live-balance-enricher.js +104 -0
  134. package/live/live-bracket-api.d.ts +13 -0
  135. package/live/live-bracket-api.js +20 -0
  136. package/live/live-state-store.d.ts +194 -0
  137. package/live/live-state-store.js +450 -0
  138. package/live/local-signal-service.d.ts +57 -0
  139. package/live/local-signal-service.js +146 -0
  140. package/live/local-strategy-evaluator.d.ts +62 -0
  141. package/live/local-strategy-evaluator.js +127 -0
  142. package/live/microstructure-assembler.d.ts +54 -0
  143. package/live/microstructure-assembler.js +148 -0
  144. package/live/order-poller.d.ts +29 -0
  145. package/live/order-poller.js +125 -0
  146. package/live/position-state-store.d.ts +83 -0
  147. package/live/position-state-store.js +237 -0
  148. package/live/proposal-decision-listener.d.ts +64 -0
  149. package/live/proposal-decision-listener.js +288 -0
  150. package/live/proposal-manager.d.ts +76 -0
  151. package/live/proposal-manager.js +140 -0
  152. package/live/rate-limiter.d.ts +47 -0
  153. package/live/rate-limiter.js +159 -0
  154. package/live/reconciler.d.ts +39 -0
  155. package/live/reconciler.js +175 -0
  156. package/live/setup-buckets.d.ts +7 -0
  157. package/live/setup-buckets.js +33 -0
  158. package/live/slippage-tracker.d.ts +45 -0
  159. package/live/slippage-tracker.js +78 -0
  160. package/live/stop-watcher.d.ts +34 -0
  161. package/live/stop-watcher.js +158 -0
  162. package/live/user-data-active-probe.d.ts +54 -0
  163. package/live/user-data-active-probe.js +180 -0
  164. package/live/user-data-stream-controller.d.ts +200 -0
  165. package/live/user-data-stream-controller.js +579 -0
  166. package/live/user-data-stream-ws.d.ts +22 -0
  167. package/live/user-data-stream-ws.js +63 -0
  168. package/live/user-data-stream.d.ts +243 -0
  169. package/live/user-data-stream.js +704 -0
  170. package/logger.d.ts +2 -0
  171. package/logger.js +2 -0
  172. package/mfe.d.ts +21 -0
  173. package/mfe.js +68 -0
  174. package/onboarding/mode-ladder.d.ts +1 -0
  175. package/onboarding/mode-ladder.js +3 -0
  176. package/onboarding/runtime.d.ts +71 -0
  177. package/onboarding/runtime.js +153 -0
  178. package/openclaw.plugin.json +94 -0
  179. package/package.json +27 -0
  180. package/paper-adapter.d.ts +24 -0
  181. package/paper-adapter.js +91 -0
  182. package/persistence/state-manager.d.ts +42 -0
  183. package/persistence/state-manager.js +164 -0
  184. package/pinned-plan.d.ts +9 -0
  185. package/pinned-plan.js +23 -0
  186. package/risk/pre-trade-check.d.ts +38 -0
  187. package/risk/pre-trade-check.js +345 -0
  188. package/risk/pre-trade-types.d.ts +60 -0
  189. package/risk/pre-trade-types.js +3 -0
  190. package/scripts/assemble.mjs +114 -0
  191. package/shadow/shadow-tracker.d.ts +36 -0
  192. package/shadow/shadow-tracker.js +151 -0
  193. package/shadow/types.d.ts +42 -0
  194. package/shadow/types.js +20 -0
  195. package/shared/indicators-extended.d.ts +52 -0
  196. package/shared/indicators-extended.js +291 -0
  197. package/shared/indicators.d.ts +15 -0
  198. package/shared/indicators.js +114 -0
  199. package/signals/conditions/registry.d.ts +16 -0
  200. package/signals/conditions/registry.js +1274 -0
  201. package/signals/conditions/types.d.ts +1 -0
  202. package/signals/conditions/types.js +4 -0
  203. package/signals/direction-rules.d.ts +3 -0
  204. package/signals/direction-rules.js +24 -0
  205. package/signals/entry-rules.d.ts +6 -0
  206. package/signals/entry-rules.js +33 -0
  207. package/signals/serialize-context.d.ts +4 -0
  208. package/signals/serialize-context.js +39 -0
  209. package/signals/stop-rules.d.ts +3 -0
  210. package/signals/stop-rules.js +48 -0
  211. package/signals/strategy-adapter.d.ts +14 -0
  212. package/signals/strategy-adapter.js +122 -0
  213. package/signals/types.d.ts +1 -0
  214. package/signals/types.js +8 -0
  215. package/simulator/exchange-simulator.d.ts +93 -0
  216. package/simulator/exchange-simulator.js +684 -0
  217. package/simulator/fill-engine.d.ts +53 -0
  218. package/simulator/fill-engine.js +276 -0
  219. package/simulator/paper-market-feed.d.ts +26 -0
  220. package/simulator/paper-market-feed.js +104 -0
  221. package/simulator/realistic-fills.d.ts +59 -0
  222. package/simulator/realistic-fills.js +175 -0
  223. package/simulator/types.d.ts +219 -0
  224. package/simulator/types.js +43 -0
  225. package/skills/reefclaw/SKILL.md +97 -0
  226. package/strategy/builtin-strategies.d.ts +2 -0
  227. package/strategy/builtin-strategies.js +109 -0
  228. package/strategy/condition-registry.d.ts +3 -0
  229. package/strategy/condition-registry.js +153 -0
  230. package/strategy/evaluator.d.ts +67 -0
  231. package/strategy/evaluator.js +93 -0
  232. package/tools/assessment-validation.d.ts +118 -0
  233. package/tools/assessment-validation.js +415 -0
  234. package/tools/attach-brackets.d.ts +34 -0
  235. package/tools/attach-brackets.js +363 -0
  236. package/tools/audit-bracket-protection.d.ts +49 -0
  237. package/tools/audit-bracket-protection.js +527 -0
  238. package/tools/cancel-all-orders.d.ts +7 -0
  239. package/tools/cancel-all-orders.js +5 -0
  240. package/tools/cancel-order.d.ts +10 -0
  241. package/tools/cancel-order.js +14 -0
  242. package/tools/check-position-health.d.ts +46 -0
  243. package/tools/check-position-health.js +194 -0
  244. package/tools/clear-exchange-credentials.d.ts +24 -0
  245. package/tools/clear-exchange-credentials.js +70 -0
  246. package/tools/close-position.d.ts +22 -0
  247. package/tools/close-position.js +449 -0
  248. package/tools/create-order.d.ts +54 -0
  249. package/tools/create-order.js +338 -0
  250. package/tools/exit-gate.d.ts +58 -0
  251. package/tools/exit-gate.js +162 -0
  252. package/tools/fetch-balance.d.ts +5 -0
  253. package/tools/fetch-balance.js +4 -0
  254. package/tools/fetch-ohlcv.d.ts +11 -0
  255. package/tools/fetch-ohlcv.js +8 -0
  256. package/tools/fetch-open-orders.d.ts +7 -0
  257. package/tools/fetch-open-orders.js +4 -0
  258. package/tools/fetch-positions.d.ts +7 -0
  259. package/tools/fetch-positions.js +4 -0
  260. package/tools/fetch-ticker.d.ts +11 -0
  261. package/tools/fetch-ticker.js +5 -0
  262. package/tools/get-agent-profile.d.ts +4 -0
  263. package/tools/get-agent-profile.js +6 -0
  264. package/tools/get-analytics.d.ts +6 -0
  265. package/tools/get-analytics.js +7 -0
  266. package/tools/get-backtest.d.ts +12 -0
  267. package/tools/get-backtest.js +91 -0
  268. package/tools/get-basis.d.ts +7 -0
  269. package/tools/get-basis.js +7 -0
  270. package/tools/get-bracket-config.d.ts +11 -0
  271. package/tools/get-bracket-config.js +24 -0
  272. package/tools/get-cascade-risk.d.ts +7 -0
  273. package/tools/get-cascade-risk.js +8 -0
  274. package/tools/get-crypto-metrics.d.ts +18 -0
  275. package/tools/get-crypto-metrics.js +45 -0
  276. package/tools/get-cvd.d.ts +6 -0
  277. package/tools/get-cvd.js +6 -0
  278. package/tools/get-divergences.d.ts +6 -0
  279. package/tools/get-divergences.js +6 -0
  280. package/tools/get-funding-context.d.ts +6 -0
  281. package/tools/get-funding-context.js +16 -0
  282. package/tools/get-liquidation-levels.d.ts +7 -0
  283. package/tools/get-liquidation-levels.js +7 -0
  284. package/tools/get-liquidation-pulse.d.ts +9 -0
  285. package/tools/get-liquidation-pulse.js +22 -0
  286. package/tools/get-market-breadth.d.ts +6 -0
  287. package/tools/get-market-breadth.js +8 -0
  288. package/tools/get-market-intel.d.ts +19 -0
  289. package/tools/get-market-intel.js +116 -0
  290. package/tools/get-market-structure.d.ts +47 -0
  291. package/tools/get-market-structure.js +198 -0
  292. package/tools/get-my-mined-patterns.d.ts +20 -0
  293. package/tools/get-my-mined-patterns.js +61 -0
  294. package/tools/get-my-proposed-learnings.d.ts +20 -0
  295. package/tools/get-my-proposed-learnings.js +55 -0
  296. package/tools/get-my-recent-reviews.d.ts +22 -0
  297. package/tools/get-my-recent-reviews.js +66 -0
  298. package/tools/get-orderbook.d.ts +21 -0
  299. package/tools/get-orderbook.js +32 -0
  300. package/tools/get-pattern-scan.d.ts +7 -0
  301. package/tools/get-pattern-scan.js +8 -0
  302. package/tools/get-regime.d.ts +6 -0
  303. package/tools/get-regime.js +7 -0
  304. package/tools/get-relevant-learnings.d.ts +21 -0
  305. package/tools/get-relevant-learnings.js +65 -0
  306. package/tools/get-resting-liquidity.d.ts +6 -0
  307. package/tools/get-resting-liquidity.js +11 -0
  308. package/tools/get-risk-scenario.d.ts +29 -0
  309. package/tools/get-risk-scenario.js +47 -0
  310. package/tools/get-risk-summary.d.ts +51 -0
  311. package/tools/get-risk-summary.js +118 -0
  312. package/tools/get-sentiment.d.ts +4 -0
  313. package/tools/get-sentiment.js +6 -0
  314. package/tools/get-session-review.d.ts +7 -0
  315. package/tools/get-session-review.js +8 -0
  316. package/tools/get-setup-detail.d.ts +7 -0
  317. package/tools/get-setup-detail.js +303 -0
  318. package/tools/get-signals.d.ts +15 -0
  319. package/tools/get-signals.js +54 -0
  320. package/tools/get-sizing.d.ts +6 -0
  321. package/tools/get-sizing.js +6 -0
  322. package/tools/get-trade-feedback.d.ts +7 -0
  323. package/tools/get-trade-feedback.js +8 -0
  324. package/tools/get-trade-flow.d.ts +7 -0
  325. package/tools/get-trade-flow.js +7 -0
  326. package/tools/get-volume-analysis.d.ts +21 -0
  327. package/tools/get-volume-analysis.js +74 -0
  328. package/tools/get-volume-profile.d.ts +7 -0
  329. package/tools/get-volume-profile.js +7 -0
  330. package/tools/helpers.d.ts +25 -0
  331. package/tools/helpers.js +38 -0
  332. package/tools/intel-api.d.ts +14 -0
  333. package/tools/intel-api.js +52 -0
  334. package/tools/intel-cache.d.ts +25 -0
  335. package/tools/intel-cache.js +133 -0
  336. package/tools/list-strategies.d.ts +7 -0
  337. package/tools/list-strategies.js +6 -0
  338. package/tools/modify-stop.d.ts +17 -0
  339. package/tools/modify-stop.js +81 -0
  340. package/tools/modify-target.d.ts +17 -0
  341. package/tools/modify-target.js +71 -0
  342. package/tools/propose-learning.d.ts +22 -0
  343. package/tools/propose-learning.js +65 -0
  344. package/tools/query-review-outcomes.d.ts +30 -0
  345. package/tools/query-review-outcomes.js +64 -0
  346. package/tools/query-trades.d.ts +21 -0
  347. package/tools/query-trades.js +37 -0
  348. package/tools/record-position-reviews.d.ts +38 -0
  349. package/tools/record-position-reviews.js +147 -0
  350. package/tools/save-strategy.d.ts +16 -0
  351. package/tools/save-strategy.js +46 -0
  352. package/tools/scan-pairs.d.ts +18 -0
  353. package/tools/scan-pairs.js +220 -0
  354. package/tools/score-setup.d.ts +31 -0
  355. package/tools/score-setup.js +268 -0
  356. package/tools/set-bracket-requirement.d.ts +18 -0
  357. package/tools/set-bracket-requirement.js +81 -0
  358. package/tools/set-exchange-credentials.d.ts +25 -0
  359. package/tools/set-exchange-credentials.js +80 -0
  360. package/tools/set-trading-mode.d.ts +26 -0
  361. package/tools/set-trading-mode.js +135 -0
  362. package/tools/test-exchange-credentials.d.ts +16 -0
  363. package/tools/test-exchange-credentials.js +100 -0
  364. package/tools/toggle-strategy.d.ts +8 -0
  365. package/tools/toggle-strategy.js +8 -0
  366. package/trading-params-cache.d.ts +26 -0
  367. package/trading-params-cache.js +52 -0
  368. package/types.d.ts +110 -0
  369. package/types.js +7 -0
  370. package/util/plugin-paths.d.ts +3 -0
  371. package/util/plugin-paths.js +15 -0
@@ -0,0 +1,704 @@
1
+ // UserDataStream — the authenticated Binance Futures user-data WebSocket.
2
+ //
3
+ // What it does:
4
+ // 1. Mints a listenKey (via ListenKeyManager) and opens a WebSocket to
5
+ // `wss://fstream.binance.com/private/ws/<listenKey>` (testnet: `stream.binancefuture.com`).
6
+ // 2. Parses incoming frames into typed events (ACCOUNT_UPDATE,
7
+ // ORDER_TRADE_UPDATE, ALGO_UPDATE, MARGIN_CALL, ACCOUNT_CONFIG_UPDATE,
8
+ // listenKeyExpired).
9
+ // 3. Emits those events for the parent (typically LiveStateStore +
10
+ // LiveAdapter) to apply.
11
+ // 4. Handles reconnects with exponential backoff + jitter, staleness
12
+ // watchdog, graduated alert thresholds, and listenKey rotation.
13
+ //
14
+ // What it does NOT do:
15
+ // - No state management. The store (`LiveStateStore`) applies events.
16
+ // - No REST snapshot logic. The parent calls its own REST on
17
+ // `'reconnect'` / `'connected'` so the store can re-seed.
18
+ // - No 60s reconciler. That lives separately (see PositionReconciler).
19
+ //
20
+ // Testability:
21
+ // - `webSocketFactory` is injectable → tests use a mock WebSocket.
22
+ // - `timers` is injectable → fake-timer tests drive backoff + watchdog.
23
+ // - `listenKeyManager` is injectable → tests don't hit the network.
24
+ //
25
+ // Rule of thumb: this class is a thin, well-logged translator from Binance
26
+ // framing to our domain events. All business logic belongs upstream.
27
+ import { EventEmitter } from 'node:events';
28
+ import { ListenKeyManager } from './listen-key-manager.js';
29
+ import { logger, formatError } from '../logger.js';
30
+ const TAG = 'user-data-stream';
31
+ const REAL_TIMERS = {
32
+ setTimeout: (fn, ms) => setTimeout(fn, ms),
33
+ clearTimeout: (h) => clearTimeout(h),
34
+ setInterval: (fn, ms) => setInterval(fn, ms),
35
+ clearInterval: (h) => clearInterval(h),
36
+ now: () => Date.now(),
37
+ random: () => Math.random(),
38
+ };
39
+ // ---- The class ----
40
+ export class UserDataStream extends EventEmitter {
41
+ api;
42
+ opts;
43
+ webSocketFactory;
44
+ timers;
45
+ listenKeyManager;
46
+ translateSymbol;
47
+ ws = null;
48
+ status = 'stopped';
49
+ reconnectTimer = null;
50
+ watchdogInterval = null;
51
+ lastEventAt = null;
52
+ lastFrameAt = null;
53
+ lastConnectedAt = null;
54
+ consecutiveReconnects = 0;
55
+ totalReconnects = 0;
56
+ stopRequested = false;
57
+ firstConnect = true;
58
+ /** First-N raw ALGO_UPDATE payloads logged at INFO so operators can verify
59
+ * the parser against real Binance traffic without scraping the WS at
60
+ * TRACE. Capped to avoid log volume; resets per process restart. */
61
+ algoUpdateRawLogged = 0;
62
+ static ALGO_UPDATE_RAW_LOG_CAP = 20;
63
+ constructor(api, options) {
64
+ super();
65
+ this.api = api;
66
+ this.opts = {
67
+ wsBaseUrl: options.wsBaseUrl,
68
+ reconnectBaseMs: options.reconnectBaseMs ?? 1_000,
69
+ reconnectCapMs: options.reconnectCapMs ?? 60_000,
70
+ reconnectJitterPct: options.reconnectJitterPct ?? 0.2,
71
+ staleEventWatchdogMs: options.staleEventWatchdogMs ?? 6 * 60_000,
72
+ warnThreshold: options.warnThreshold ?? 3,
73
+ errorThreshold: options.errorThreshold ?? 10,
74
+ fatalThreshold: options.fatalThreshold ?? 30,
75
+ };
76
+ this.webSocketFactory = options.webSocketFactory;
77
+ this.timers = {
78
+ setTimeout: options.timers?.setTimeout ?? REAL_TIMERS.setTimeout,
79
+ clearTimeout: options.timers?.clearTimeout ?? REAL_TIMERS.clearTimeout,
80
+ setInterval: options.timers?.setInterval ?? REAL_TIMERS.setInterval,
81
+ clearInterval: options.timers?.clearInterval ?? REAL_TIMERS.clearInterval,
82
+ now: options.timers?.now ?? REAL_TIMERS.now,
83
+ random: options.timers?.random ?? REAL_TIMERS.random,
84
+ };
85
+ const makeListenKeyMgr = options.listenKeyManagerFactory
86
+ ?? ((a, o) => new ListenKeyManager(a, o));
87
+ this.listenKeyManager = makeListenKeyMgr(api, options.listenKey);
88
+ this.translateSymbol = options.symbolTranslator
89
+ ?? ((id) => api.symbolFromMarketId(id));
90
+ this.listenKeyManager.on('keyKeepAliveFailed', (reason) => {
91
+ logger.warn(TAG, `listenKey keep-alive failed — forcing reconnect: ${reason}`);
92
+ // Forcing a reconnect will re-mint a fresh key as part of start().
93
+ this.forceReconnect('listen_key_keepalive_failed');
94
+ });
95
+ }
96
+ /** Start the stream. Idempotent — returns immediately if already running. */
97
+ async start() {
98
+ if (this.status !== 'stopped') {
99
+ logger.warn(TAG, `start() called while status=${this.status} — ignored`);
100
+ return;
101
+ }
102
+ this.stopRequested = false;
103
+ this.consecutiveReconnects = 0;
104
+ this.firstConnect = true;
105
+ await this.openConnection();
106
+ this.startWatchdog();
107
+ }
108
+ /** Graceful stop. Closes WS + stops listenKey. Idempotent. */
109
+ async stop() {
110
+ this.stopRequested = true;
111
+ this.clearReconnectTimer();
112
+ this.stopWatchdog();
113
+ if (this.ws) {
114
+ try {
115
+ this.ws.close(1000, 'client shutdown');
116
+ }
117
+ catch { /* best-effort */ }
118
+ this.ws = null;
119
+ }
120
+ try {
121
+ await this.listenKeyManager.stop();
122
+ }
123
+ catch (err) {
124
+ logger.warn(TAG, `listenKey stop failed (non-fatal): ${formatError(err)}`);
125
+ }
126
+ this.setStatus('stopped');
127
+ }
128
+ /** Current health snapshot for observability. */
129
+ getHealth() {
130
+ return {
131
+ status: this.status,
132
+ currentListenKey: this.listenKeyManager.getKey(),
133
+ lastEventAt: this.lastEventAt,
134
+ lastFrameAt: this.lastFrameAt,
135
+ lastConnectedAt: this.lastConnectedAt,
136
+ consecutiveReconnectAttempts: this.consecutiveReconnects,
137
+ totalReconnects: this.totalReconnects,
138
+ };
139
+ }
140
+ /** Whether the stream is currently open. Convenience for tests + callers. */
141
+ isConnected() {
142
+ return this.status === 'open';
143
+ }
144
+ // ---- Internal: connection lifecycle ----
145
+ async openConnection() {
146
+ if (this.stopRequested)
147
+ return;
148
+ this.setStatus('connecting');
149
+ let key;
150
+ try {
151
+ key = await this.listenKeyManager.start();
152
+ }
153
+ catch (err) {
154
+ logger.error(TAG, `listenKey mint failed: ${formatError(err)}`);
155
+ this.scheduleReconnect('listen_key_mint_failed');
156
+ return;
157
+ }
158
+ const url = `${this.opts.wsBaseUrl}/${key}`;
159
+ logger.info(TAG, `Opening WS to ${this.opts.wsBaseUrl}/<listenKey> (hash=${hash8(key)})`);
160
+ let socket;
161
+ try {
162
+ socket = this.webSocketFactory(url);
163
+ }
164
+ catch (err) {
165
+ logger.error(TAG, `webSocketFactory threw: ${formatError(err)}`);
166
+ this.scheduleReconnect('ws_factory_failed');
167
+ return;
168
+ }
169
+ this.ws = socket;
170
+ this.attachWsHandlers(socket);
171
+ }
172
+ attachWsHandlers(socket) {
173
+ socket.on('open', () => {
174
+ if (this.ws !== socket)
175
+ return; // stale
176
+ this.lastConnectedAt = this.timers.now();
177
+ this.lastEventAt = this.lastConnectedAt;
178
+ this.lastFrameAt = this.lastConnectedAt;
179
+ this.consecutiveReconnects = 0;
180
+ this.setStatus('open');
181
+ logger.info(TAG, 'WS connected');
182
+ this.emit('connected');
183
+ if (!this.firstConnect) {
184
+ this.emit('resyncRequired', 'reconnect');
185
+ }
186
+ this.firstConnect = false;
187
+ });
188
+ socket.on('message', (data) => {
189
+ if (this.ws !== socket)
190
+ return;
191
+ const now = this.timers.now();
192
+ this.lastEventAt = now; // data event — drives the active probe's freshness check
193
+ this.lastFrameAt = now; // any frame — drives the dead-socket watchdog
194
+ this.handleFrame(data);
195
+ });
196
+ socket.on('ping', (payload) => {
197
+ if (this.ws !== socket)
198
+ return;
199
+ // Server ping = liveness signal even on a fully-idle account. Binance
200
+ // documents these every 3 min on the user-data stream, so counting them
201
+ // toward `lastFrameAt` keeps the watchdog quiet between data frames.
202
+ // Note: `lastEventAt` is intentionally NOT updated — pings carry no data
203
+ // so the active REST probe still sees them as silence (correct, the probe
204
+ // is checking for missed FILLS not missed liveness).
205
+ this.lastFrameAt = this.timers.now();
206
+ // Most `ws` clients pong automatically; in case a custom factory doesn't,
207
+ // reply here. Binance closes the socket if no pong arrives within 10 min.
208
+ try {
209
+ socket.pong?.(payload);
210
+ }
211
+ catch { /* best-effort */ }
212
+ });
213
+ socket.on('pong', () => {
214
+ if (this.ws !== socket)
215
+ return;
216
+ // Server-sent pong (in response to a client-initiated ping if any). Same
217
+ // liveness role as `ping` above. Kept for observability symmetry.
218
+ this.lastFrameAt = this.timers.now();
219
+ });
220
+ socket.on('error', (err) => {
221
+ if (this.ws !== socket)
222
+ return;
223
+ logger.warn(TAG, `WS error: ${formatError(err)}`);
224
+ // 'close' will follow. Don't schedule reconnect here to avoid double-firing.
225
+ });
226
+ socket.on('close', (code, reason) => {
227
+ if (this.ws !== socket)
228
+ return;
229
+ let reasonStr;
230
+ if (reason == null)
231
+ reasonStr = '';
232
+ else if (typeof reason === 'string')
233
+ reasonStr = reason;
234
+ else
235
+ reasonStr = reason.toString('utf-8');
236
+ logger.warn(TAG, `WS closed code=${code ?? '?'} reason=${reasonStr || '(none)'}`);
237
+ this.ws = null;
238
+ // Stop the keep-alive loop WITHOUT DELETEing the key; the next connect
239
+ // re-POSTs. Binance's re-POST returns the SAME active key (doc-verified),
240
+ // so the previous un-awaited DELETE here could land AFTER the reconnect's
241
+ // POST and invalidate the key the fresh socket just authenticated with.
242
+ // The graceful path (stop() below / UserDataStream.stop()) still DELETEs —
243
+ // no reconnect follows there.
244
+ this.listenKeyManager.stopKeepAlive();
245
+ if (this.stopRequested) {
246
+ this.setStatus('stopped');
247
+ this.emit('disconnected', reasonStr || 'client stop', false);
248
+ return;
249
+ }
250
+ this.emit('disconnected', reasonStr || `code=${code ?? '?'}`, true);
251
+ this.scheduleReconnect(reasonStr || 'ws_close');
252
+ });
253
+ }
254
+ handleFrame(raw) {
255
+ let parsed;
256
+ try {
257
+ const text = typeof raw === 'string' ? raw : raw.toString('utf-8');
258
+ parsed = JSON.parse(text);
259
+ }
260
+ catch (err) {
261
+ logger.warn(TAG, `frame parse failed (ignored): ${formatError(err)}`);
262
+ return;
263
+ }
264
+ if (!parsed || typeof parsed !== 'object')
265
+ return;
266
+ const obj = parsed;
267
+ const eventType = typeof obj.e === 'string' ? obj.e : null;
268
+ if (!eventType) {
269
+ // Binance occasionally sends pong-like keepalive frames w/o 'e'. Swallow.
270
+ return;
271
+ }
272
+ try {
273
+ switch (eventType) {
274
+ case 'ACCOUNT_UPDATE': {
275
+ const ev = parseAccountUpdate(obj, this.translateSymbol);
276
+ if (ev)
277
+ this.emit('accountUpdate', ev);
278
+ break;
279
+ }
280
+ case 'ORDER_TRADE_UPDATE': {
281
+ const ev = parseOrderUpdate(obj, this.translateSymbol);
282
+ if (ev)
283
+ this.emit('orderUpdate', ev);
284
+ break;
285
+ }
286
+ case 'ALGO_UPDATE': {
287
+ // Binance fires this for STOP_MARKET / TAKE_PROFIT_MARKET /
288
+ // TRAILING_STOP_MARKET conditional orders. Pre-2026-05-15 we
289
+ // swallowed this at the default branch — that left the plugin
290
+ // blind to exchange-side bracket cancellation, which is the
291
+ // root cause of the bracket-spiral class of bug. We now parse +
292
+ // emit; the downstream listener (controller + LiveAdapter) is
293
+ // responsible for the action.
294
+ const ev = parseAlgoUpdate(obj, this.translateSymbol);
295
+ if (ev) {
296
+ if (this.algoUpdateRawLogged < UserDataStream.ALGO_UPDATE_RAW_LOG_CAP) {
297
+ // Capped to avoid flooding the journal under heavy bracket
298
+ // traffic. Operators read these first-N samples once after
299
+ // deploy to verify the parser matches Binance's actual shape.
300
+ logger.info(TAG, `ALGO_UPDATE raw (${this.algoUpdateRawLogged + 1}/` +
301
+ `${UserDataStream.ALGO_UPDATE_RAW_LOG_CAP}): ${JSON.stringify(obj)}`);
302
+ this.algoUpdateRawLogged++;
303
+ }
304
+ this.emit('algoUpdate', ev);
305
+ }
306
+ break;
307
+ }
308
+ case 'TRADE_LITE': {
309
+ // Binance's newer lightweight fill notification (late-2024 addition).
310
+ // Same information as ORDER_TRADE_UPDATE but sent earlier for lower
311
+ // fill-latency. ORDER_TRADE_UPDATE follows with the canonical state
312
+ // transition, so we deliberately don't double-apply — TRADE_LITE is
313
+ // informational here. Log at debug-ish level (info) so operators
314
+ // can confirm Binance is sending it.
315
+ logger.info(TAG, 'TRADE_LITE received (canonical state follows in ORDER_TRADE_UPDATE)');
316
+ break;
317
+ }
318
+ case 'MARGIN_CALL': {
319
+ const ev = parseMarginCall(obj, this.translateSymbol);
320
+ if (ev)
321
+ this.emit('marginCall', ev);
322
+ break;
323
+ }
324
+ case 'ACCOUNT_CONFIG_UPDATE': {
325
+ const ev = parseAccountConfigUpdate(obj, this.translateSymbol);
326
+ if (ev)
327
+ this.emit('accountConfigUpdate', ev);
328
+ break;
329
+ }
330
+ case 'listenKeyExpired': {
331
+ logger.warn(TAG, 'listenKey expired notice from Binance — forcing reconnect');
332
+ this.emit('listenKeyExpired');
333
+ this.forceReconnect('listen_key_expired');
334
+ break;
335
+ }
336
+ default:
337
+ // Unknown event type. Binance adds new types over time; swallow at
338
+ // WARN so we notice but don't crash.
339
+ logger.warn(TAG, `unknown event type=${eventType} — swallowed`);
340
+ }
341
+ }
342
+ catch (err) {
343
+ logger.warn(TAG, `event handler for ${eventType} threw: ${formatError(err)}`);
344
+ }
345
+ }
346
+ // ---- Internal: reconnect + watchdog ----
347
+ scheduleReconnect(reason) {
348
+ this.clearReconnectTimer();
349
+ if (this.stopRequested)
350
+ return;
351
+ this.consecutiveReconnects += 1;
352
+ this.totalReconnects += 1;
353
+ // Graduated alerts — easier to tune alerting later.
354
+ if (this.consecutiveReconnects >= this.opts.fatalThreshold) {
355
+ logger.error(TAG, `RECONNECT-FATAL attempt=${this.consecutiveReconnects} reason=${reason}`);
356
+ }
357
+ else if (this.consecutiveReconnects >= this.opts.errorThreshold) {
358
+ logger.error(TAG, `RECONNECT-ERROR attempt=${this.consecutiveReconnects} reason=${reason}`);
359
+ }
360
+ else if (this.consecutiveReconnects >= this.opts.warnThreshold) {
361
+ logger.warn(TAG, `RECONNECT-WARN attempt=${this.consecutiveReconnects} reason=${reason}`);
362
+ }
363
+ const delay = this.computeBackoffMs(this.consecutiveReconnects);
364
+ logger.info(TAG, `reconnect attempt=${this.consecutiveReconnects} delayMs=${delay} reason=${reason}`);
365
+ this.setStatus('reconnecting');
366
+ this.emit('reconnecting', this.consecutiveReconnects, delay);
367
+ this.reconnectTimer = this.timers.setTimeout(() => {
368
+ this.reconnectTimer = null;
369
+ void this.openConnection();
370
+ }, delay);
371
+ }
372
+ computeBackoffMs(attempt) {
373
+ // Exponential: base * 2^(attempt-1). Capped. Then ± jitterPct.
374
+ const exp = this.opts.reconnectBaseMs * 2 ** Math.min(attempt - 1, 20);
375
+ const capped = Math.min(exp, this.opts.reconnectCapMs);
376
+ const jitterRange = capped * this.opts.reconnectJitterPct;
377
+ const jitter = (this.timers.random() * 2 - 1) * jitterRange;
378
+ return Math.max(0, Math.round(capped + jitter));
379
+ }
380
+ clearReconnectTimer() {
381
+ if (this.reconnectTimer) {
382
+ this.timers.clearTimeout(this.reconnectTimer);
383
+ this.reconnectTimer = null;
384
+ }
385
+ }
386
+ /** Force a reconnect now — used when the server tells us the key expired
387
+ * or when the listenKey keep-alive loop gives up.
388
+ *
389
+ * Invariant: the close handler is responsible for clearing `this.ws`,
390
+ * stopping the listenKey loop, and scheduling the reconnect. We only
391
+ * trigger close() here and let the handler run. If there's no socket to
392
+ * close (manager dropped out-of-band), we schedule directly. */
393
+ forceReconnect(reason) {
394
+ if (this.stopRequested)
395
+ return;
396
+ if (this.ws) {
397
+ // The socket is PRESUMED DEAD here (staleness watchdog fired, listenKey
398
+ // expired, keep-alive gave up, or the active REST probe asked for a
399
+ // resync). Prefer terminate() over close(): `ws.close(4001)` starts a
400
+ // closing HANDSHAKE and the `ws` library waits up to ~30s for the
401
+ // server's reciprocal close frame before it emits 'close' — on a truly
402
+ // dead TCP path that stalls the whole reconnect for the timeout.
403
+ // terminate() drops the socket immediately (emits 'close' with code
404
+ // 1006), so the reconnect fires right away. Same reasoning as the
405
+ // intel-side routed-WS watchdog (binance-routed-ws.ts:
406
+ // `ws.terminate()`). Fall back to close() when the socket doesn't
407
+ // expose terminate() (a browser/test socket without it).
408
+ const sock = this.ws;
409
+ try {
410
+ if (typeof sock.terminate === 'function')
411
+ sock.terminate();
412
+ else
413
+ sock.close(4001, reason);
414
+ }
415
+ catch { /* best-effort — the 'close' handler still runs cleanup once */ }
416
+ // Do NOT null this.ws here — the close handler needs it to pass the
417
+ // `ws === socket` identity check and run cleanup exactly once.
418
+ return;
419
+ }
420
+ // No socket in hand — schedule reconnect directly.
421
+ if (this.status !== 'reconnecting') {
422
+ this.scheduleReconnect(reason);
423
+ }
424
+ }
425
+ startWatchdog() {
426
+ // Fire the check every 30s — a 6-min threshold doesn't need finer
427
+ // resolution, and a less chatty interval cuts log noise during the steady
428
+ // state where the watchdog should never fire.
429
+ this.stopWatchdog();
430
+ this.watchdogInterval = this.timers.setInterval(() => {
431
+ if (this.status !== 'open' || this.stopRequested)
432
+ return;
433
+ const now = this.timers.now();
434
+ // Compare against `lastFrameAt` — the most recent INCOMING FRAME of any
435
+ // kind, including server pings (every 3 min on the Binance user-data
436
+ // stream). The earlier design compared against `lastEventAt` (message
437
+ // frames only) and false-fired ~200×/day on idle accounts because
438
+ // Binance pushes no message frames between events on a quiet position.
439
+ const last = this.lastFrameAt ?? this.lastConnectedAt ?? now;
440
+ const idle = now - last;
441
+ if (idle > this.opts.staleEventWatchdogMs) {
442
+ logger.warn(TAG, `staleness watchdog fired: idleMs=${idle} > ${this.opts.staleEventWatchdogMs} (no frames — likely dead socket)`);
443
+ this.emit('staleForcedReconnect', idle);
444
+ this.forceReconnect('stale_event_watchdog');
445
+ }
446
+ }, 30_000);
447
+ }
448
+ /** External trigger for a reconnect — used by the active REST probe when
449
+ * it detects a fill that the WS missed. Same plumbing as the watchdog
450
+ * path; the close handler runs the reconnect schedule. */
451
+ requestReconnect(reason) {
452
+ this.forceReconnect(reason);
453
+ }
454
+ stopWatchdog() {
455
+ if (this.watchdogInterval) {
456
+ this.timers.clearInterval(this.watchdogInterval);
457
+ this.watchdogInterval = null;
458
+ }
459
+ }
460
+ setStatus(next) {
461
+ this.status = next;
462
+ }
463
+ }
464
+ // ---- Frame parsers ----
465
+ function num(v) {
466
+ if (typeof v === 'number' && Number.isFinite(v))
467
+ return v;
468
+ if (typeof v === 'string') {
469
+ const n = Number(v);
470
+ if (Number.isFinite(n))
471
+ return n;
472
+ }
473
+ return 0;
474
+ }
475
+ function str(v) {
476
+ return typeof v === 'string' ? v : String(v ?? '');
477
+ }
478
+ function optStr(v) {
479
+ if (v == null)
480
+ return undefined;
481
+ return typeof v === 'string' ? v : String(v);
482
+ }
483
+ const IDENTITY = (s) => s;
484
+ export function parseAccountUpdate(obj, translateSymbol = IDENTITY) {
485
+ const a = obj.a;
486
+ if (!a)
487
+ return null;
488
+ const positionsRaw = Array.isArray(a.P) ? a.P : [];
489
+ const balancesRaw = Array.isArray(a.B) ? a.B : [];
490
+ const positions = positionsRaw.map((p) => {
491
+ const qtySigned = num(p.pa);
492
+ const positionSide = str(p.ps);
493
+ // In one-way mode, side is derived from the sign of pa.
494
+ // In hedge mode, ps is LONG or SHORT directly.
495
+ let side;
496
+ if (positionSide === 'LONG')
497
+ side = qtySigned > 0 ? 'long' : 'flat';
498
+ else if (positionSide === 'SHORT')
499
+ side = qtySigned < 0 ? 'short' : 'flat';
500
+ else {
501
+ if (qtySigned > 0)
502
+ side = 'long';
503
+ else if (qtySigned < 0)
504
+ side = 'short';
505
+ else
506
+ side = 'flat';
507
+ }
508
+ const mtRaw = str(p.mt).toLowerCase();
509
+ const marginType = mtRaw === 'isolated' ? 'isolated' : 'cross';
510
+ return {
511
+ symbol: translateSymbol(str(p.s)),
512
+ side,
513
+ quantity: Math.abs(qtySigned),
514
+ entryPrice: num(p.ep),
515
+ unrealizedPnl: num(p.up),
516
+ marginType,
517
+ positionSide,
518
+ };
519
+ });
520
+ const balances = balancesRaw.map((b) => ({
521
+ asset: str(b.a),
522
+ walletBalance: num(b.wb),
523
+ crossWallet: num(b.cw),
524
+ balanceChange: num(b.bc),
525
+ }));
526
+ return {
527
+ eventTime: num(obj.E),
528
+ transactionTime: num(obj.T),
529
+ reason: str(a.m),
530
+ positions,
531
+ balances,
532
+ };
533
+ }
534
+ export function parseOrderUpdate(obj, translateSymbol = IDENTITY) {
535
+ const o = obj.o;
536
+ if (!o)
537
+ return null;
538
+ const orderStatus = str(o.X);
539
+ let status;
540
+ switch (orderStatus) {
541
+ case 'NEW':
542
+ case 'PARTIALLY_FILLED':
543
+ status = 'open';
544
+ break;
545
+ case 'FILLED':
546
+ status = 'closed';
547
+ break;
548
+ case 'CANCELED':
549
+ case 'EXPIRED':
550
+ case 'REJECTED':
551
+ case 'EXPIRED_IN_MATCH':
552
+ status = 'canceled';
553
+ break;
554
+ default:
555
+ status = 'open';
556
+ }
557
+ const typeRaw = str(o.o).toUpperCase();
558
+ let type;
559
+ switch (typeRaw) {
560
+ case 'LIMIT':
561
+ type = 'limit';
562
+ break;
563
+ case 'MARKET':
564
+ type = 'market';
565
+ break;
566
+ case 'STOP_MARKET':
567
+ type = 'stop_market';
568
+ break;
569
+ case 'TAKE_PROFIT_MARKET':
570
+ type = 'take_profit_market';
571
+ break;
572
+ default: type = 'other';
573
+ }
574
+ const sideRaw = str(o.S).toUpperCase();
575
+ const side = sideRaw === 'SELL' ? 'sell' : 'buy';
576
+ const origPrice = o.p != null && str(o.p) !== '' ? num(o.p) : null;
577
+ const avgPrice = o.ap != null && num(o.ap) > 0 ? num(o.ap) : null;
578
+ const lastFillPrice = o.L != null && num(o.L) > 0 ? num(o.L) : null;
579
+ // Audit-trail fields. `o.t` is Binance's stable trade id, present only on
580
+ // executionType='TRADE' (fill events); for NEW / CANCELED / EXPIRED it's
581
+ // either absent or 0. We store null in those cases so callers don't
582
+ // accidentally upsert with a meaningless id.
583
+ const tradeIdRaw = o.t;
584
+ const tradeId = tradeIdRaw != null && str(tradeIdRaw) !== '' && str(tradeIdRaw) !== '0'
585
+ ? str(tradeIdRaw)
586
+ : null;
587
+ return {
588
+ eventTime: num(obj.E),
589
+ transactionTime: num(obj.T),
590
+ symbol: translateSymbol(str(o.s)),
591
+ orderId: str(o.i),
592
+ clientOrderId: optStr(o.c),
593
+ side,
594
+ type,
595
+ status,
596
+ executionType: str(o.x),
597
+ origQuantity: num(o.q),
598
+ cumulativeFilled: num(o.z),
599
+ lastFilledQty: num(o.l),
600
+ avgPrice,
601
+ origPrice,
602
+ lastFillPrice,
603
+ commission: num(o.n),
604
+ commissionAsset: str(o.N),
605
+ timeInForce: str(o.f),
606
+ tradeId,
607
+ reduceOnly: o.R === true,
608
+ maker: o.m === true,
609
+ realizedPnl: num(o.rp),
610
+ };
611
+ }
612
+ export function parseMarginCall(obj, translateSymbol = IDENTITY) {
613
+ const positionsRaw = Array.isArray(obj.p) ? obj.p : [];
614
+ return {
615
+ eventTime: num(obj.E),
616
+ crossWallet: num(obj.cw),
617
+ positions: positionsRaw.map((p) => ({
618
+ symbol: translateSymbol(str(p.s)),
619
+ positionSide: str(p.ps),
620
+ positionAmt: num(p.pa),
621
+ marginType: str(p.mt).toLowerCase() === 'isolated' ? 'isolated' : 'cross',
622
+ isolatedWallet: num(p.iw),
623
+ markPrice: num(p.mp),
624
+ unrealizedPnl: num(p.up),
625
+ maintMargin: num(p.mm),
626
+ })),
627
+ };
628
+ }
629
+ export function parseAlgoUpdate(obj, translateSymbol = IDENTITY) {
630
+ // Field shape verified 2026-05-15 against the authoritative Binance doc
631
+ // (developers.binance.com → USD-M Futures → User Data Streams → Event
632
+ // Algo Order Update) AND a raw prod ALGO_UPDATE capture. The earlier
633
+ // implementation guessed `ao`/`a` wrapper + `c`/`sp`/`as`/`r` keys — ALL
634
+ // wrong; it parsed nothing. Binance wraps the algo-order detail under `o`:
635
+ // { "e":"ALGO_UPDATE","T","E","o":{ caid, aid, at, o, s, S, ps, f, q,
636
+ // X, ai, ap, aq, act, tp, p, V, wt, pm, cp, pP, R, tt, gtd, rm } }
637
+ // where (doc-exact):
638
+ // caid = Client Algo Id aid = Algo Id s = Symbol
639
+ // S = Side o = Order Type X = Algo Status
640
+ // tp = Trigger Price q = Quantity R = Reduce Only
641
+ // f = Time In Force V = STP Mode rm = Failure Reason
642
+ // ai = Order Id (matching-engine order id, NOT the algo id)
643
+ const o = obj.o;
644
+ if (!o)
645
+ return null;
646
+ const rawSym = optStr(o.s);
647
+ if (!rawSym)
648
+ return null;
649
+ const sideRaw = str(o.S).toUpperCase();
650
+ const side = sideRaw === 'SELL' ? 'sell' : 'buy';
651
+ // Algo Status `X`: NEW / TRIGGERING / TRIGGERED / FINISHED / CANCELED /
652
+ // REJECTED / EXPIRED. Semantics that matter downstream (doc-confirmed):
653
+ // CANCELED = MANUALLY canceled (an explicit cancelOrder from us)
654
+ // EXPIRED = SYSTEM-canceled (e.g. GTE_GTC conditional + position closed
655
+ // → Binance auto-cancels the surviving sibling). EXPECTED.
656
+ const statusRaw = str(o.X).toUpperCase();
657
+ // Trigger price is `tp` (doc-exact). `p` is the (limit) order price, 0 for
658
+ // market-type triggers.
659
+ const trigRaw = o.tp;
660
+ const triggerPrice = trigRaw != null && num(trigRaw) > 0 ? num(trigRaw) : null;
661
+ return {
662
+ eventTime: num(obj.E),
663
+ transactionTime: num(obj.T),
664
+ symbol: translateSymbol(rawSym),
665
+ algoId: str(o.aid ?? ''),
666
+ clientAlgoId: optStr(o.caid) ?? null,
667
+ side,
668
+ orderType: str(o.o ?? '').toUpperCase(),
669
+ status: statusRaw,
670
+ triggerPrice,
671
+ quantity: num(o.q),
672
+ reduceOnly: o.R === true,
673
+ // `rm` = Failure Reason (doc-exact). Present on REJECTED/EXPIRED.
674
+ reason: optStr(o.rm),
675
+ raw: obj,
676
+ };
677
+ }
678
+ export function parseAccountConfigUpdate(obj, translateSymbol = IDENTITY) {
679
+ const ac = obj.ac; // leverage
680
+ const ai = obj.ai; // multi-asset mode
681
+ if (ac) {
682
+ const rawSym = optStr(ac.s);
683
+ return {
684
+ eventTime: num(obj.E),
685
+ kind: 'leverage',
686
+ symbol: rawSym ? translateSymbol(rawSym) : undefined,
687
+ leverage: num(ac.l),
688
+ };
689
+ }
690
+ if (ai) {
691
+ return {
692
+ eventTime: num(obj.E),
693
+ kind: 'multi_asset_mode',
694
+ multiAssetMode: Boolean(ai.j),
695
+ };
696
+ }
697
+ return {
698
+ eventTime: num(obj.E),
699
+ kind: 'other',
700
+ };
701
+ }
702
+ function hash8(key) {
703
+ return key.length > 8 ? key.slice(0, 8) : '(short)';
704
+ }