@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,579 @@
1
+ // UserDataStreamController — owns the WS + store + snapshot lifecycle
2
+ // behind the `userDataStream.mode` feature flag.
3
+ //
4
+ // Responsibility separation (Phase 1/2):
5
+ // - `start()` mints a listenKey, opens the WS, takes a REST snapshot, seeds
6
+ // the store, and routes WS events into the store.
7
+ // - In `shadow` mode, a periodic 60s REST truth-check runs; diffs are logged
8
+ // but REST remains authoritative for `LiveAdapter` reads.
9
+ // - In `observe` / `enforce` (Phase 2–3, not yet activated on prod), the
10
+ // store becomes authoritative — but the wiring in LiveAdapter is what
11
+ // flips the read path. The controller just keeps the store fresh.
12
+ //
13
+ // The controller deliberately keeps NO business logic — it orchestrates the
14
+ // store + stream + REST client. Policy (when to auto-pause on repeated drift,
15
+ // when to alert) lives one level up in LiveAdapter + dashboards.
16
+ import { EventEmitter } from 'node:events';
17
+ import { UserDataStream } from './user-data-stream.js';
18
+ import { LiveStateStore } from './live-state-store.js';
19
+ import { defaultWebSocketFactory, userDataWsBaseUrl } from './user-data-stream-ws.js';
20
+ import { logger, formatError } from '../logger.js';
21
+ import { WsIngest } from '../ingest/ws-ingest.js';
22
+ import { RestGapFiller } from '../ingest/rest-gap-filler.js';
23
+ import { UserDataActiveProbe } from './user-data-active-probe.js';
24
+ /**
25
+ * Rewrite `balance.total[asset]` from CCXT's marginBalance-equivalent (wallet
26
+ * + unrealizedProfit for Binance Futures) to raw walletBalance only, so the
27
+ * REST snapshot aligns with what the WS ACCOUNT_UPDATE frames deliver. The
28
+ * raw walletBalance field lives under `balance.info.assets[i].walletBalance`
29
+ * — an array of per-asset records Binance Futures always returns alongside
30
+ * the top-level totals. Exported for unit testing.
31
+ *
32
+ * Falls back to the unmodified balance if `info.assets` is missing or
33
+ * malformed (e.g. mock responses in unit tests without the info envelope).
34
+ * Only `total` is rewritten; `free` and `used` are left untouched because
35
+ * the WS store computes them separately from position-lock state.
36
+ */
37
+ export function normalizeBinanceFuturesBalance(balance) {
38
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
39
+ const assets = balance?.info?.assets;
40
+ if (!Array.isArray(assets))
41
+ return balance;
42
+ const walletByAsset = {};
43
+ for (const a of assets) {
44
+ if (!a || typeof a.asset !== 'string')
45
+ continue;
46
+ const wb = parseFloat(String(a.walletBalance));
47
+ if (Number.isFinite(wb))
48
+ walletByAsset[a.asset] = wb;
49
+ }
50
+ if (Object.keys(walletByAsset).length === 0)
51
+ return balance;
52
+ const newTotal = { ...(balance.total ?? {}) };
53
+ for (const [asset, wb] of Object.entries(walletByAsset)) {
54
+ newTotal[asset] = wb;
55
+ }
56
+ return { ...balance, total: newTotal };
57
+ }
58
+ const TAG = 'user-data-stream-ctl';
59
+ /**
60
+ * Thrown by `buildSnapshot` when ANY of the three REST fetches returned
61
+ * `null` — i.e. the fetch FAILED (Binance 429 / weight-paced / transient
62
+ * network), state is UNKNOWN. It does NOT mean "the account is flat / has no
63
+ * orders". `LiveStateStore.applySnapshot` replaces positions + orders +
64
+ * balances WHOLESALE, so applying a snapshot built from a failed fetch would
65
+ * wipe still-live positions and bracket SL/TP from the store — which is the
66
+ * AUTHORITATIVE read path in observe/enforce mode — blinding the agent into
67
+ * the documented phantom-naked spiral.
68
+ *
69
+ * Subclass of `Error` so the existing `.catch()` sites absorb it unchanged;
70
+ * callers detect the type to skip the apply, leave the store intact, and let
71
+ * WS deltas + the next reconnect/resync repair state. Same null≠empty rule as
72
+ * `getOpenOrders` / `getPositions` / `closePosition` / the bracket reconciler.
73
+ */
74
+ export class UntrustedSnapshotError extends Error {
75
+ constructor(message) {
76
+ super(message);
77
+ this.name = 'UntrustedSnapshotError';
78
+ }
79
+ }
80
+ const REAL_CTL_TIMERS = {
81
+ setInterval: (fn, ms) => setInterval(fn, ms),
82
+ clearInterval: (h) => clearInterval(h),
83
+ now: () => Date.now(),
84
+ };
85
+ export class UserDataStreamController extends EventEmitter {
86
+ mode;
87
+ api;
88
+ stream;
89
+ store;
90
+ truthCheckIntervalMs;
91
+ staleStoreThresholdMs;
92
+ timers;
93
+ truthCheckTimer = null;
94
+ algoRefreshTimer = null;
95
+ reconnectRateTimer = null;
96
+ algoRefreshMs;
97
+ wsIngest;
98
+ restGapFiller;
99
+ activeProbe;
100
+ /** Count of stale-forced + other reconnects in the current hourly window.
101
+ * Checked every hour; a spike above RECONNECT_RATE_ALERT_THRESHOLD logs
102
+ * a WARN so operator can spot Binance-side trouble or network flaps. */
103
+ reconnectCountThisHour = 0;
104
+ running = false;
105
+ snapshotInFlight = false;
106
+ /** Serialises snapshot applies so WS events don't race with a snapshot
107
+ * we're in the middle of fetching. WS events that arrive during a
108
+ * snapshot are buffered here and drained after apply. */
109
+ bufferedDuringSnapshot = [];
110
+ consecutiveMajorDrifts = 0;
111
+ constructor(api, options) {
112
+ super();
113
+ this.api = api;
114
+ this.mode = options.mode;
115
+ this.store = options.store ?? new LiveStateStore();
116
+ this.truthCheckIntervalMs = options.truthCheckIntervalMs ?? 60_000;
117
+ this.algoRefreshMs = options.tunables.algoRefreshMs;
118
+ // Reuse the dead-socket watchdog's calibrated threshold as the
119
+ // store-staleness bound for isStoreTrusted() — see that method.
120
+ this.staleStoreThresholdMs = options.tunables.staleEventWatchdogMs;
121
+ this.timers = {
122
+ setInterval: options.timers?.setInterval ?? REAL_CTL_TIMERS.setInterval,
123
+ clearInterval: options.timers?.clearInterval ?? REAL_CTL_TIMERS.clearInterval,
124
+ now: options.timers?.now ?? REAL_CTL_TIMERS.now,
125
+ };
126
+ this.stream = options.stream ?? new UserDataStream(api, this.buildStreamOptions(options));
127
+ this.attachStreamHandlers();
128
+ // Active probe — proactive REST check that catches "WS silently stopped
129
+ // delivering" before the staleness watchdog. Disabled when intervalMs=0.
130
+ // Construction is side-effect-free; subscription happens in start().
131
+ const probeIntervalMs = options.tunables.activeProbeIntervalMs;
132
+ this.activeProbe = probeIntervalMs > 0
133
+ ? new UserDataActiveProbe({
134
+ api: this.api,
135
+ stream: this.stream,
136
+ store: this.store,
137
+ intervalMs: probeIntervalMs,
138
+ })
139
+ : null;
140
+ // WsIngest is constructed eagerly when a tradeIngest config is supplied,
141
+ // but only start()-ed inside the controller's start() — and only when
142
+ // mode≠'off'. Construction is side-effect-free; subscription happens on
143
+ // start(). See TRADE_AUDIT_TRAIL_PLAN.md Phase 1.
144
+ this.wsIngest = options.tradeIngest
145
+ ? new WsIngest({
146
+ stream: this.stream,
147
+ client: options.tradeIngest.client,
148
+ userId: options.tradeIngest.userId,
149
+ exchange: options.tradeIngest.exchange,
150
+ autoCapture: options.autoCapture,
151
+ })
152
+ : null;
153
+ // REST gap-filler — Phase 2 of TRADE_AUDIT_TRAIL_PLAN. Runs alongside
154
+ // WsIngest whenever dbWrite is active. On every reconnect (stale-forced
155
+ // or otherwise), queries /fapi/v1/userTrades for every known symbol
156
+ // and POSTs any missing fills with source='rest_reconcile'. The webapp
157
+ // ingest endpoint dedupes on (exchange, exchangeTradeId), so WS-already-
158
+ // delivered fills stay recorded as source='ws' (ON CONFLICT DO NOTHING,
159
+ // not last-writer-wins — see shared/src/fills.ts). Required for mode
160
+ // advancement to observe/enforce: in those modes WS is authoritative
161
+ // for the audit trail, and fills during reconnect blind windows (5-20s)
162
+ // would otherwise be silently missing from the trades table.
163
+ this.restGapFiller = options.tradeIngest
164
+ ? new RestGapFiller({
165
+ api: this.api,
166
+ stream: this.stream,
167
+ store: this.store,
168
+ client: options.tradeIngest.client,
169
+ userId: options.tradeIngest.userId,
170
+ exchange: options.tradeIngest.exchange,
171
+ touchedSymbolsStore: options.tradeIngest.touchedSymbolsStore,
172
+ now: () => this.timers.now(),
173
+ })
174
+ : null;
175
+ }
176
+ getStore() {
177
+ return this.store;
178
+ }
179
+ getStream() {
180
+ return this.stream;
181
+ }
182
+ /** True when the in-memory store is safe to serve as the authoritative
183
+ * source for positions/orders reads. Two independent conditions, BOTH
184
+ * required — each guards a distinct failure in the null≠empty class
185
+ * (same class as the getOpenOrders / getPositions / closePosition /
186
+ * bracket-reconciler / UntrustedSnapshotError rules):
187
+ *
188
+ * (1) **Seeded.** A trusted REST snapshot has run at least once
189
+ * (`lastSnapshotAt !== null`). False during startup before the
190
+ * `connected` handler's initial snapshot lands and the gap after
191
+ * `reset()`. An unseeded `[]` is indistinguishable from "no
192
+ * positions/orders" and would trip naked-bracket / orphan-cancel
193
+ * paths against real exchange state.
194
+ *
195
+ * (2) **Fresh.** We've received SOME incoming frame within
196
+ * `staleStoreThresholdMs` — the SAME calibrated threshold
197
+ * (`staleEventWatchdogMs`, default 6 min) and the SAME signal
198
+ * (`lastFrameAt`, any frame incl. Binance's 3-min server pings) the
199
+ * dead-socket watchdog itself uses to declare the socket dead.
200
+ * Beyond it the watchdog is already forcing a reconnect; serving a
201
+ * seeded-but-stale store as authoritative there would present stale
202
+ * state as truth on a destructive-action-gating read — the residual
203
+ * a reviewer flagged. Deliberately NOT a bare `isConnected()` check:
204
+ * a brief reconnect (listenKey rotation ~25 min, 1 s→60 s backoff)
205
+ * keeps `lastFrameAt` recent, so the common case is NOT pushed onto
206
+ * the weight-heavy / rate-limit-poisoned REST path — that would just
207
+ * trade one failure in this class for another. When the reconnect +
208
+ * resync completes, `lastFrameAt` refreshes and the store is trusted
209
+ * again. `lastFrameAt === null` (seeded but never a frame — an odd
210
+ * state) is conservatively treated as untrusted. */
211
+ isStoreTrusted() {
212
+ if (this.store.getHealth().lastSnapshotAt === null)
213
+ return false;
214
+ const { lastFrameAt } = this.stream.getHealth();
215
+ if (lastFrameAt === null)
216
+ return false;
217
+ return this.timers.now() - lastFrameAt <= this.staleStoreThresholdMs;
218
+ }
219
+ /** Start the stream + run the initial REST snapshot + seed the store.
220
+ * No-op if mode is 'off'. Throws if already running. */
221
+ async start() {
222
+ if (this.mode === 'off') {
223
+ logger.info(TAG, 'mode=off — controller not started');
224
+ return;
225
+ }
226
+ if (this.running) {
227
+ logger.warn(TAG, 'start() called while already running — ignored');
228
+ return;
229
+ }
230
+ this.running = true;
231
+ logger.info(TAG, `starting in mode=${this.mode}${this.wsIngest ? ' + dbWrite=on' : ''}`);
232
+ await this.stream.start();
233
+ // 'connected' handler will run the initial snapshot + seed.
234
+ if (this.mode === 'shadow' || this.mode === 'observe' || this.mode === 'enforce') {
235
+ this.startTruthCheckLoop();
236
+ this.startAlgoRefreshLoop();
237
+ this.startReconnectRateMonitor();
238
+ }
239
+ // Audit-trail WS ingest — opt-in via tradeIngest config. Subscribes to
240
+ // the stream's orderUpdate event, which is already wired for store apply.
241
+ // Both listeners coexist; the ingest one is filter+forward only, no
242
+ // mutation of the store.
243
+ if (this.wsIngest) {
244
+ this.wsIngest.start();
245
+ }
246
+ // REST gap-filler — closes the audit-trail hole opened by every WS
247
+ // reconnect blind window. Also opt-in via tradeIngest (same gate as
248
+ // WsIngest — either both run or neither, since they write to the same
249
+ // audit table with complementary provenance).
250
+ if (this.restGapFiller) {
251
+ this.restGapFiller.start();
252
+ }
253
+ // Active probe — proactive REST detector for silent-stream events. Runs in
254
+ // every mode that the stream itself runs in (shadow, observe, enforce);
255
+ // there is no audit-trail dependency. Independent of dbWrite.
256
+ if (this.activeProbe) {
257
+ this.activeProbe.start();
258
+ }
259
+ }
260
+ /** Graceful shutdown. */
261
+ async stop() {
262
+ if (!this.running)
263
+ return;
264
+ this.running = false;
265
+ this.stopTruthCheckLoop();
266
+ this.stopAlgoRefreshLoop();
267
+ this.stopReconnectRateMonitor();
268
+ if (this.wsIngest)
269
+ this.wsIngest.stop();
270
+ if (this.restGapFiller)
271
+ this.restGapFiller.stop();
272
+ if (this.activeProbe)
273
+ this.activeProbe.stop();
274
+ try {
275
+ await this.stream.stop();
276
+ }
277
+ catch (err) {
278
+ logger.warn(TAG, `stream.stop failed: ${formatError(err)}`);
279
+ }
280
+ logger.info(TAG, 'stopped');
281
+ }
282
+ /** Force a full snapshot + re-seed. Used by operator tools for manual resync. */
283
+ async forceResync(cause) {
284
+ logger.warn(TAG, `forced resync: ${cause}`);
285
+ await this.takeAndApplySnapshot();
286
+ this.emit('forcedResync', cause);
287
+ }
288
+ // ---- Internal ----
289
+ buildStreamOptions(options) {
290
+ return {
291
+ wsBaseUrl: userDataWsBaseUrl(options.testnet),
292
+ webSocketFactory: defaultWebSocketFactory,
293
+ listenKey: {
294
+ refreshIntervalMs: options.tunables.listenKeyRefreshMs,
295
+ },
296
+ reconnectBaseMs: options.tunables.reconnectBaseMs,
297
+ reconnectCapMs: options.tunables.reconnectCapMs,
298
+ // Single watchdog threshold — the per-position "active" specialisation
299
+ // was removed in 2026-04-25's ping-aware refactor. Empirical false-positive
300
+ // rate was 99% (203 fires/day vs 2 true positives) because the 90s active
301
+ // threshold was below Binance's 180s server-ping interval, and the
302
+ // watchdog only counted message frames as liveness. The active REST probe
303
+ // (60s `fetchMyTrades` per open-position symbol) is the canonical
304
+ // "events stopped arriving on a position" detector — it fires within ~60s
305
+ // of any missed fill and was already running alongside the broken watchdog.
306
+ staleEventWatchdogMs: options.tunables.staleEventWatchdogMs,
307
+ };
308
+ }
309
+ attachStreamHandlers() {
310
+ this.stream.on('connected', () => {
311
+ void this.takeAndApplySnapshot().catch((err) => {
312
+ if (err instanceof UntrustedSnapshotError) {
313
+ logger.warn(TAG, `initial snapshot skipped (store intact): ${err.message}`);
314
+ }
315
+ else {
316
+ logger.error(TAG, `initial snapshot failed: ${formatError(err)}`);
317
+ }
318
+ });
319
+ });
320
+ this.stream.on('resyncRequired', (cause) => {
321
+ void this.takeAndApplySnapshot().then(() => {
322
+ this.emit('forcedResync', cause);
323
+ }).catch((err) => {
324
+ if (err instanceof UntrustedSnapshotError) {
325
+ // Did NOT resync — do not emit forcedResync. WS deltas keep the
326
+ // store fresh; the next reconnect/resync retries.
327
+ logger.warn(TAG, `resync snapshot skipped (store intact, will retry): ${err.message}`);
328
+ }
329
+ else {
330
+ logger.error(TAG, `resync snapshot failed: ${formatError(err)}`);
331
+ }
332
+ });
333
+ });
334
+ this.stream.on('accountUpdate', (ev) => {
335
+ if (this.snapshotInFlight) {
336
+ this.bufferedDuringSnapshot.push(() => this.store.applyAccountUpdate(ev));
337
+ return;
338
+ }
339
+ this.store.applyAccountUpdate(ev);
340
+ });
341
+ this.stream.on('orderUpdate', (ev) => {
342
+ if (this.snapshotInFlight) {
343
+ this.bufferedDuringSnapshot.push(() => this.store.applyOrderUpdate(ev));
344
+ return;
345
+ }
346
+ this.store.applyOrderUpdate(ev);
347
+ });
348
+ this.stream.on('marginCall', (ev) => this.emit('marginCall', ev));
349
+ // ALGO_UPDATE pass-through. NOT buffered through snapshot apply: it is a
350
+ // terminal/lifecycle signal for conditional orders, not store state the
351
+ // snapshot would clobber, and acting on EXPIRED/TRIGGERED promptly is the
352
+ // whole point (it's how we learn a position closed without REST polling).
353
+ this.stream.on('algoUpdate', (ev) => this.emit('algoUpdate', ev));
354
+ this.stream.on('reconnecting', (attempt, delay) => this.emit('reconnecting', attempt, delay));
355
+ this.stream.on('disconnected', (reason, willReconnect) => {
356
+ logger.warn(TAG, `stream disconnected: ${reason} (willReconnect=${willReconnect})`);
357
+ });
358
+ this.stream.on('staleForcedReconnect', (idleMs) => {
359
+ logger.warn(TAG, `stream stale watchdog fired after ${idleMs}ms idle`);
360
+ });
361
+ // Every reconnect attempt bumps the hourly counter; the monitor below
362
+ // checks the counter each hour and WARNs if it exceeds the alert
363
+ // threshold. Expected baseline on a quiet account is ~3-4/hour
364
+ // (worst case listenKey rotation every 25 min + a few stale
365
+ // reconnects). Sustained 10+/hour means Binance-side trouble,
366
+ // network flap, or a regression — worth an operator look.
367
+ this.stream.on('reconnecting', () => {
368
+ this.reconnectCountThisHour++;
369
+ });
370
+ }
371
+ /** Fetch REST positions/balance/orders and normalise symbols so the store
372
+ * keys match WS events regardless of which write path landed first.
373
+ * CCXT returns `BTC/USDT:USDT`; our WS parser produces `BTC/USDT`. */
374
+ async buildSnapshot() {
375
+ const [positions, balance, openOrders] = await Promise.all([
376
+ this.api.fetchPositions(),
377
+ this.api.fetchBalance(),
378
+ this.api.fetchOpenOrders(),
379
+ ]);
380
+ // null = the underlying fetch FAILED (429 / weight-paced / transient),
381
+ // NOT "flat / no orders". applySnapshot() wipes positions+orders+balances
382
+ // wholesale, so a snapshot built from any failed fetch would strip live
383
+ // positions + bracket SL/TP from the (authoritative in observe/enforce)
384
+ // store. A WS reconnect storm correlates with rate-limit pressure, so this
385
+ // path is hot, not theoretical. Refuse to build an untrusted snapshot; the
386
+ // caller skips the apply and the store survives for WS deltas + the next
387
+ // reconnect/resync to repair. All three are treated as one trust unit
388
+ // because applySnapshot clears all three sections (a null balance would
389
+ // zero NAV/equity in enforce — KPI-must-equal-Binance).
390
+ if (positions === null || openOrders === null || balance === null) {
391
+ throw new UntrustedSnapshotError(`REST snapshot fetch failed (positions=${positions === null ? 'null' : 'ok'} ` +
392
+ `balance=${balance === null ? 'null' : 'ok'} ` +
393
+ `openOrders=${openOrders === null ? 'null' : 'ok'}) — refusing to apply a ` +
394
+ `store-wiping snapshot; store left intact, will retry next cycle`);
395
+ }
396
+ const normSymbol = (s) => s.replace(/:[A-Z]+$/, '');
397
+ return {
398
+ timestamp: this.timers.now(),
399
+ positions: positions.map(p => ({ ...p, symbol: normSymbol(p.symbol) })),
400
+ // Normalize REST balance.total from CCXT's marginBalance (= wallet + uPnl)
401
+ // to walletBalance-only, matching what WS ACCOUNT_UPDATE.B[].wb stores.
402
+ // Without this, any open position with accruing unrealized PnL causes
403
+ // persistent drift on the margin asset (BNFCR on prod as of 2026-04-21)
404
+ // — just above the 10 bps minor threshold, forcing resync every 3 cycles
405
+ // in shadow mode. See live-state-store.ts:336 for the WS-side wallet
406
+ // store that this normalisation aligns against.
407
+ balance: normalizeBinanceFuturesBalance(balance),
408
+ openOrders: openOrders.map(o => ({ ...o, symbol: normSymbol(o.symbol) })),
409
+ };
410
+ }
411
+ async takeAndApplySnapshot() {
412
+ if (this.snapshotInFlight) {
413
+ logger.info(TAG, 'snapshot already in flight — skipping');
414
+ return;
415
+ }
416
+ this.snapshotInFlight = true;
417
+ try {
418
+ const snap = await this.buildSnapshot();
419
+ this.store.applySnapshot(snap);
420
+ logger.info(TAG, `snapshot applied: ${snap.positions.length} positions, ${snap.openOrders.length} orders, balance=${snap.balance ? 'ok' : 'null'}`);
421
+ this.emit('snapshotApplied', snap);
422
+ }
423
+ finally {
424
+ this.snapshotInFlight = false;
425
+ // Drain any WS events that arrived during snapshot apply.
426
+ const buffered = this.bufferedDuringSnapshot;
427
+ this.bufferedDuringSnapshot = [];
428
+ for (const fn of buffered) {
429
+ try {
430
+ fn();
431
+ }
432
+ catch (err) {
433
+ logger.warn(TAG, `buffered event apply failed: ${formatError(err)}`);
434
+ }
435
+ }
436
+ }
437
+ }
438
+ startTruthCheckLoop() {
439
+ this.stopTruthCheckLoop();
440
+ this.truthCheckTimer = this.timers.setInterval(() => {
441
+ if (!this.running)
442
+ return;
443
+ void this.runTruthCheck().catch((err) => {
444
+ logger.warn(TAG, `truth check failed (will retry): ${formatError(err)}`);
445
+ });
446
+ }, this.truthCheckIntervalMs);
447
+ logger.info(TAG, `truth-check loop started (every ${this.truthCheckIntervalMs / 1000}s)`);
448
+ }
449
+ stopTruthCheckLoop() {
450
+ if (this.truthCheckTimer) {
451
+ this.timers.clearInterval(this.truthCheckTimer);
452
+ this.truthCheckTimer = null;
453
+ }
454
+ }
455
+ /** Start the algo-order refresh loop — REST-polls open orders and replaces
456
+ * the algo (bracket SL/TP) subset in the store. Fills the gap left by
457
+ * Binance's WS not emitting ORDER_TRADE_UPDATE for algo orders until they
458
+ * trigger. Disabled when algoRefreshMs === 0. */
459
+ startAlgoRefreshLoop() {
460
+ this.stopAlgoRefreshLoop();
461
+ if (this.algoRefreshMs <= 0) {
462
+ logger.info(TAG, 'algo-refresh loop disabled (algoRefreshMs=0)');
463
+ return;
464
+ }
465
+ this.algoRefreshTimer = this.timers.setInterval(() => {
466
+ if (!this.running)
467
+ return;
468
+ void this.runAlgoRefresh().catch((err) => {
469
+ logger.warn(TAG, `algo-refresh failed (will retry): ${formatError(err)}`);
470
+ });
471
+ }, this.algoRefreshMs);
472
+ logger.info(TAG, `algo-refresh loop started (every ${this.algoRefreshMs / 1000}s)`);
473
+ }
474
+ stopAlgoRefreshLoop() {
475
+ if (this.algoRefreshTimer) {
476
+ this.timers.clearInterval(this.algoRefreshTimer);
477
+ this.algoRefreshTimer = null;
478
+ }
479
+ }
480
+ /** Alert threshold — hourly reconnect count that logs a WARN. Set at
481
+ * 10/hour because the natural baseline on a quiet account (listenKey
482
+ * rotation every 25 min + occasional stale-watchdog firing) is
483
+ * 3-4/hour; anything consistently above that is unexpected. Does NOT
484
+ * auto-pause or take action — operator investigates when they see
485
+ * the log line land. */
486
+ static RECONNECT_RATE_ALERT_THRESHOLD = 10;
487
+ static RECONNECT_RATE_WINDOW_MS = 60 * 60_000;
488
+ /** Start the reconnect-rate monitor. Runs hourly; resets the counter
489
+ * after each check whether or not it alerted. */
490
+ startReconnectRateMonitor() {
491
+ this.stopReconnectRateMonitor();
492
+ this.reconnectRateTimer = this.timers.setInterval(() => {
493
+ if (!this.running)
494
+ return;
495
+ const count = this.reconnectCountThisHour;
496
+ this.reconnectCountThisHour = 0;
497
+ if (count >= UserDataStreamController.RECONNECT_RATE_ALERT_THRESHOLD) {
498
+ logger.warn(TAG, `HIGH reconnect rate: ${count} reconnects in the last hour ` +
499
+ `(threshold=${UserDataStreamController.RECONNECT_RATE_ALERT_THRESHOLD}). ` +
500
+ `Check Binance API status + network health; listenKey rotation is 25-min, ` +
501
+ `stale-watchdog is 15-min — baseline should be 3-4/hr.`);
502
+ }
503
+ else {
504
+ logger.info(TAG, `reconnect-rate OK: ${count}/hr`);
505
+ }
506
+ }, UserDataStreamController.RECONNECT_RATE_WINDOW_MS);
507
+ logger.info(TAG, 'reconnect-rate monitor started (hourly window)');
508
+ }
509
+ stopReconnectRateMonitor() {
510
+ if (this.reconnectRateTimer) {
511
+ this.timers.clearInterval(this.reconnectRateTimer);
512
+ this.reconnectRateTimer = null;
513
+ }
514
+ }
515
+ /** One pass of algo-order refresh: fetch open orders (merges regular + algo
516
+ * via BinancePrivateApi), filter to algo-bracket cids, and hand them to the
517
+ * store's replaceAlgoOrders. No mutation if WS isn't connected (we'd be
518
+ * polluting state the reconnect snapshot is about to replace anyway). */
519
+ async runAlgoRefresh() {
520
+ if (!this.stream.isConnected())
521
+ return;
522
+ if (this.snapshotInFlight)
523
+ return;
524
+ const open = await this.api.fetchOpenOrders();
525
+ if (open === null) {
526
+ // Fetch FAILED (429 / weight-paced / transient) — NOT "no open orders".
527
+ // replaceAlgoOrders([]) would strip EVERY live bracket SL/TP from the
528
+ // authoritative store until the next successful poll (≤algoRefreshMs
529
+ // naked window). Keep the existing algo orders; a trustworthy refresh
530
+ // replaces them. Same null≠empty rule as buildSnapshot above.
531
+ logger.warn(TAG, 'algo-refresh: fetchOpenOrders returned null (REST unavailable) — keeping existing algo orders, will retry');
532
+ return;
533
+ }
534
+ this.store.replaceAlgoOrders(open);
535
+ }
536
+ async runTruthCheck() {
537
+ if (!this.stream.isConnected()) {
538
+ // WS isn't up — skip; snapshot will happen on reconnect anyway.
539
+ return;
540
+ }
541
+ const snap = await this.buildSnapshot();
542
+ const drift = this.store.diffAgainstSnapshot(snap);
543
+ if (drift.severity === 'none') {
544
+ this.consecutiveMajorDrifts = 0;
545
+ // Spend the REST read we already paid for on a uPnl/mark refresh rather
546
+ // than discarding it. ACCOUNT_UPDATE is event-driven and carries no
547
+ // per-tick mark price, so without this the store's unrealizedPnl is
548
+ // frozen between fills/funding while the Binance app moves every tick
549
+ // (2026-05-16 "Unrealized lags/frozen"). Bounded, non-structural — see
550
+ // LiveStateStore.refreshDerivedMarkFields. Only on severity 'none':
551
+ // 'minor'/'major' mean structure is in question, so leave those to the
552
+ // existing paths (major already applies the full fresh snapshot).
553
+ this.store.refreshDerivedMarkFields(snap.positions);
554
+ logger.info(TAG, `truth-check OK (mode=${this.mode})`);
555
+ return;
556
+ }
557
+ this.emit('drift', drift);
558
+ if (drift.severity === 'minor') {
559
+ this.consecutiveMajorDrifts = 0;
560
+ logger.warn(TAG, `minor drift: ${drift.summary}`);
561
+ return;
562
+ }
563
+ // major
564
+ this.consecutiveMajorDrifts += 1;
565
+ logger.error(TAG, `MAJOR drift (#${this.consecutiveMajorDrifts}): ${drift.summary}`);
566
+ // In shadow mode: never mutate store from truth-check — REST is authoritative
567
+ // for reads anyway, so a drift is a signal that the WS pipeline is wrong.
568
+ // In observe/enforce: apply the snapshot so reads are correct. On 3rd
569
+ // consecutive major drift in any mode, force resync and emit the event.
570
+ if (this.mode !== 'shadow') {
571
+ this.store.applySnapshot(snap);
572
+ }
573
+ if (this.consecutiveMajorDrifts >= 3) {
574
+ logger.error(TAG, `${this.consecutiveMajorDrifts} consecutive major drifts — emitting forcedResync`);
575
+ this.emit('forcedResync', 'consecutive_major_drifts');
576
+ this.consecutiveMajorDrifts = 0;
577
+ }
578
+ }
579
+ }
@@ -0,0 +1,22 @@
1
+ import WebSocket from 'ws';
2
+ import type { StreamWebSocket, WebSocketFactory } from './user-data-stream.js';
3
+ /** Default mainnet user-data WS base URL. Append `/<listenKey>` to connect.
4
+ * Binance decommissioned the legacy `wss://fstream.binance.com/ws` URL on
5
+ * 2026-04-23 (per developers.binance.com changelog 2026-04-02). The legacy
6
+ * URL still accepts WS connections and even sends server pings, but no
7
+ * longer routes ACCOUNT_UPDATE / ORDER_TRADE_UPDATE / ACCOUNT_CONFIG_UPDATE
8
+ * events. Migration was silent: events stopped arriving for this account on
9
+ * 2026-04-27 10:35 UTC. New URL adds the `/private` path segment per the
10
+ * current user-data-streams docs page. Verified via A/B test — set leverage
11
+ * fires ACCOUNT_CONFIG_UPDATE on /private/ws but NOT on /ws. */
12
+ export declare const MAINNET_USER_DATA_WS = "wss://fstream.binance.com/private/ws";
13
+ /** Testnet user-data WS base URL. Matches CCXT's testnet futures endpoint. */
14
+ export declare const TESTNET_USER_DATA_WS = "wss://stream.binancefuture.com/ws";
15
+ /** Build the user-data WS base URL for a given environment. */
16
+ export declare function userDataWsBaseUrl(testnet: boolean): string;
17
+ /** Adapter from the node `ws` library to our minimal StreamWebSocket shape. */
18
+ export declare function wrapWsClient(ws: WebSocket): StreamWebSocket;
19
+ /** Default factory. Opens a real WS using the `ws` library.
20
+ * perMessageDeflate=false matches Binance's recommendation for user-data streams —
21
+ * frames are tiny and JSON, compression adds latency without meaningful savings. */
22
+ export declare const defaultWebSocketFactory: WebSocketFactory;
@@ -0,0 +1,63 @@
1
+ // Default WebSocketFactory wrapping the `ws` library.
2
+ //
3
+ // Split out from user-data-stream.ts so that:
4
+ // 1. Unit tests import UserDataStream without loading the `ws` native addon.
5
+ // 2. The `ws`-specific glue (buffer handling, ping/pong) stays in one place.
6
+ // 3. Alternative factories (native WebSocket, debug proxies) can swap in.
7
+ import WebSocket from 'ws';
8
+ /** Default mainnet user-data WS base URL. Append `/<listenKey>` to connect.
9
+ * Binance decommissioned the legacy `wss://fstream.binance.com/ws` URL on
10
+ * 2026-04-23 (per developers.binance.com changelog 2026-04-02). The legacy
11
+ * URL still accepts WS connections and even sends server pings, but no
12
+ * longer routes ACCOUNT_UPDATE / ORDER_TRADE_UPDATE / ACCOUNT_CONFIG_UPDATE
13
+ * events. Migration was silent: events stopped arriving for this account on
14
+ * 2026-04-27 10:35 UTC. New URL adds the `/private` path segment per the
15
+ * current user-data-streams docs page. Verified via A/B test — set leverage
16
+ * fires ACCOUNT_CONFIG_UPDATE on /private/ws but NOT on /ws. */
17
+ export const MAINNET_USER_DATA_WS = 'wss://fstream.binance.com/private/ws';
18
+ /** Testnet user-data WS base URL. Matches CCXT's testnet futures endpoint. */
19
+ export const TESTNET_USER_DATA_WS = 'wss://stream.binancefuture.com/ws';
20
+ /** Build the user-data WS base URL for a given environment. */
21
+ export function userDataWsBaseUrl(testnet) {
22
+ return testnet ? TESTNET_USER_DATA_WS : MAINNET_USER_DATA_WS;
23
+ }
24
+ /** Adapter from the node `ws` library to our minimal StreamWebSocket shape. */
25
+ export function wrapWsClient(ws) {
26
+ const adapter = {
27
+ on(event, cb) {
28
+ // `ws` and StreamWebSocket agree on event names; just forward.
29
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
30
+ ws.on(event, cb);
31
+ },
32
+ close(code, reason) {
33
+ try {
34
+ ws.close(code, reason);
35
+ }
36
+ catch { /* already closed */ }
37
+ },
38
+ pong(data) {
39
+ try {
40
+ ws.pong(data);
41
+ }
42
+ catch { /* not connected */ }
43
+ },
44
+ terminate() {
45
+ try {
46
+ ws.terminate();
47
+ }
48
+ catch { /* already closed */ }
49
+ },
50
+ };
51
+ return adapter;
52
+ }
53
+ /** Default factory. Opens a real WS using the `ws` library.
54
+ * perMessageDeflate=false matches Binance's recommendation for user-data streams —
55
+ * frames are tiny and JSON, compression adds latency without meaningful savings. */
56
+ export const defaultWebSocketFactory = (url) => {
57
+ const ws = new WebSocket(url, {
58
+ perMessageDeflate: false,
59
+ // Binance is strict about the Origin header; wss clients default to empty.
60
+ handshakeTimeout: 15_000,
61
+ });
62
+ return wrapWsClient(ws);
63
+ };