@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,573 @@
1
+ // BracketReconciler — periodic drift check between the local bracket ledger
2
+ // and the exchange state. Runs every 60s by default.
3
+ //
4
+ // Reconciliation matrix (key = {position exists, SL on exchange, TP on exchange}):
5
+ //
6
+ // pos:Y sl:Y tp:Y → healthy, no-op
7
+ // pos:Y sl:Y tp:N → partial (TP missing) — ALERT (manual cancel or trigger race)
8
+ // pos:Y sl:N tp:Y → partial (SL missing) — ALERT (position unprotected!)
9
+ // pos:Y sl:N tp:N → brackets gone, position alive → ALERT (unprotected)
10
+ // pos:N sl:Y tp:Y → position closed, brackets orphaned → AUTO-CANCEL, mark ledger
11
+ // pos:N sl:Y tp:N → position closed, one orphan → AUTO-CANCEL, mark ledger
12
+ // pos:N sl:N tp:Y → position closed, one orphan → AUTO-CANCEL, mark ledger
13
+ // pos:N sl:N tp:N → position closed cleanly → mark ledger (triggered or cancelled)
14
+ //
15
+ // Unknown orphans (bracket-tagged clientOrderIds on exchange with no ledger
16
+ // entry) are auto-cancelled — they represent either a crash before ledger
17
+ // persist or a leftover from a previous plugin install.
18
+ import { EventEmitter } from 'node:events';
19
+ import { normalizeBracketSymbol } from './bracket-ledger.js';
20
+ import { parseBracketCid } from './bracket-id.js';
21
+ import { logger, formatError } from '../logger.js';
22
+ const TAG = 'bracket-reconciler';
23
+ const DEFAULT_INTERVAL_MS = 60_000;
24
+ /** Grace window for the unknown-bracket orphan sweep (2026-05-16 SOL
25
+ * incident). A bracket-tagged `rc-*` order with no active ledger row is
26
+ * NOT necessarily junk: in the seconds right after an entry fill the
27
+ * post-entry auto-attach places SL/TP on the exchange while (a) the ledger
28
+ * row is still being persisted and (b) the reconciler's positions snapshot
29
+ * has not yet propagated the brand-new position — so BOTH the "active
30
+ * ledger entry" check AND the "live position exists" guard miss it, and the
31
+ * sweep cancels the freshly-placed protection (prod: SOL bracket cancelled
32
+ * ~8 s after entry, naked spiral). A bracket the reconciler is seeing for
33
+ * the first time, or whose exchange order is younger than this window, is
34
+ * too young to be a crash/old-plugin leftover (those are minutes-to-hours
35
+ * old) — delaying their cleanup by a minute is harmless; cancelling a
36
+ * live position's only protection is not. Env-overridable; clamp 10s–10m. */
37
+ const ORPHAN_GRACE_MS = (() => {
38
+ const raw = Number(process.env.RC_BRACKET_ORPHAN_GRACE_MS);
39
+ return Number.isFinite(raw) && raw >= 10_000 && raw <= 600_000 ? raw : 60_000;
40
+ })();
41
+ /** Max multiplier applied to interval under sustained back-pressure (e.g.
42
+ * binance 429 storm where `getOpenOrders` keeps throwing). 5× → 5 min cap.
43
+ * Matches the upper end of Binance's documented per-IP rate-limit window. */
44
+ const MAX_BACKOFF_MULTIPLIER = 5;
45
+ /** Error-message substrings that indicate transient exchange unavailability
46
+ * (rate-limit or null-collapse from `LiveAdapter.getOpenOrders`). When
47
+ * matched, the reconciler doubles its tick interval (up to the cap) so it
48
+ * stops piling onto an already-throttled IP. The match is intentionally
49
+ * loose — better to over-back-off on a misclassified network error than
50
+ * to keep hammering during a real 429. */
51
+ const TRANSIENT_UNAVAILABLE_MARKERS = [
52
+ '429',
53
+ 'Too Many Requests',
54
+ 'fetchOpenOrders returned null',
55
+ 'ETIMEDOUT',
56
+ 'ECONNRESET',
57
+ 'getaddrinfo',
58
+ ];
59
+ export class BracketReconciler extends EventEmitter {
60
+ adapter;
61
+ ledger;
62
+ manager;
63
+ api;
64
+ baseIntervalMs;
65
+ now;
66
+ handle = null;
67
+ /** Distinct from `handle` because we need to know "did stop() get called
68
+ * while a pass was in flight" — at that moment `handle` is still the
69
+ * in-flight setTimeout token but `started` has flipped to false. The
70
+ * finally-block consults `started` (not `handle`) to decide whether to
71
+ * schedule another tick. */
72
+ started = false;
73
+ running = false;
74
+ /** Current multiplier applied to baseIntervalMs. Doubles on each
75
+ * transient-unavailable error, capped at MAX_BACKOFF_MULTIPLIER. Resets to
76
+ * 1 on a successful pass. Persisted across pass invocations as the
77
+ * observed back-pressure state. */
78
+ backoffMultiplier = 1;
79
+ /** bracketId → epoch-ms the orphan sweep first observed it as an
80
+ * unmatched (no-active-ledger-row) bracket. Drives the ORPHAN_GRACE_MS
81
+ * youth guard: a bracket seen for the first time is, by construction,
82
+ * ≤ one pass old from our side — never cancel it that pass. Pruned each
83
+ * pass to bracketIds still unmatched on the exchange (bounded). */
84
+ firstSeenOrphanAt = new Map();
85
+ constructor(adapter, ledger, manager, api, opts = {}) {
86
+ super();
87
+ this.adapter = adapter;
88
+ this.ledger = ledger;
89
+ this.manager = manager;
90
+ this.api = api;
91
+ this.baseIntervalMs = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
92
+ this.now = opts.now ?? Date.now;
93
+ }
94
+ /** Begin periodic reconciliation. Safe to call if already started. */
95
+ start() {
96
+ if (this.started)
97
+ return;
98
+ this.started = true;
99
+ this.scheduleNextPass();
100
+ logger.info(TAG, `Started (interval=${this.baseIntervalMs}ms)`);
101
+ }
102
+ stop() {
103
+ if (!this.started)
104
+ return;
105
+ this.started = false;
106
+ if (this.handle) {
107
+ clearTimeout(this.handle);
108
+ this.handle = null;
109
+ }
110
+ logger.info(TAG, 'Stopped');
111
+ }
112
+ /** Self-rescheduling tick. Replaces the old `setInterval` because the
113
+ * next interval is dynamic — we slow down under back-pressure, speed back
114
+ * up after a clean pass. The old approach kept hitting Binance at full
115
+ * 60 s cadence during a 429 storm, amplifying it. */
116
+ scheduleNextPass() {
117
+ if (!this.started)
118
+ return;
119
+ const delay = this.baseIntervalMs * this.backoffMultiplier;
120
+ this.handle = setTimeout(() => {
121
+ this.handle = null;
122
+ void this.runPass()
123
+ .then(() => {
124
+ if (this.backoffMultiplier > 1) {
125
+ logger.info(TAG, `clean pass — backoff multiplier reset (was ${this.backoffMultiplier}×)`);
126
+ this.backoffMultiplier = 1;
127
+ }
128
+ })
129
+ .catch((err) => {
130
+ const msg = formatError(err);
131
+ logger.error(TAG, `reconcile pass failed: ${msg}`);
132
+ const transient = TRANSIENT_UNAVAILABLE_MARKERS.some((m) => msg.includes(m));
133
+ if (transient) {
134
+ const prev = this.backoffMultiplier;
135
+ this.backoffMultiplier = Math.min(prev * 2, MAX_BACKOFF_MULTIPLIER);
136
+ if (this.backoffMultiplier !== prev) {
137
+ logger.warn(TAG, `transient exchange unavailability — backing off ` +
138
+ `${prev}× → ${this.backoffMultiplier}× (next pass in ` +
139
+ `${(this.baseIntervalMs * this.backoffMultiplier) / 1000}s)`);
140
+ }
141
+ }
142
+ })
143
+ .finally(() => {
144
+ // Schedule next tick unless stop() was called while this pass was
145
+ // in flight. `started` is the authoritative signal — `handle` was
146
+ // cleared at top of this callback above.
147
+ if (this.started)
148
+ this.scheduleNextPass();
149
+ });
150
+ }, delay);
151
+ }
152
+ /** One reconciliation pass. Exposed for unit tests + startup recovery. */
153
+ async runPass() {
154
+ if (this.running) {
155
+ // Skip re-entrant pass — last one still in flight
156
+ return { startedAt: new Date(this.now()).toISOString(), drifts: [], positionsChecked: 0, ordersChecked: 0 };
157
+ }
158
+ this.running = true;
159
+ const startedAt = new Date(this.now()).toISOString();
160
+ const drifts = [];
161
+ try {
162
+ const [positionsRaw, openOrders] = await Promise.all([
163
+ this.adapter.getPositionsOrNull
164
+ ? this.adapter.getPositionsOrNull()
165
+ : this.adapter.getPositions(),
166
+ this.adapter.getOpenOrders(),
167
+ ]);
168
+ // `null` = positions fetch FAILED (429 / weight-paced / transient) —
169
+ // state UNKNOWN, NOT "flat". Under this snapshot we must take ZERO
170
+ // destructive action: an empty/failed positions list must never read as
171
+ // "no live position → safe to cancel these orphan brackets". 2026-05-15
172
+ // XRP strip loop: positions kept collapsing, the Section-2 orphan sweep
173
+ // cancelled the live position's bracket legs every pass.
174
+ const positionsUntrusted = positionsRaw === null;
175
+ const positions = positionsRaw ?? [];
176
+ // Key by normalized symbol so `entry.symbol` (canonical un-suffixed
177
+ // form stored by BracketLedger) matches CCXT position.symbol (which
178
+ // arrives suffixed as `BTC/USDT:USDT`). Without this, every active
179
+ // bracket entry would look "position gone" and the reconciler would
180
+ // auto-cancel live protection. See bracket-ledger.ts
181
+ // `normalizeBracketSymbol` for the rationale.
182
+ const positionBySymbol = new Map();
183
+ for (const p of positions)
184
+ positionBySymbol.set(normalizeBracketSymbol(p.symbol), p);
185
+ // ROOT-CAUSE FIX 2026-05-16 (belt-and-suspenders to the attach-time
186
+ // disable): proactively neutralise Binance's per-symbol
187
+ // countdownCancelAll dead-man's-switch on every symbol that currently
188
+ // has a live position. An externally-armed countdown (nothing in our
189
+ // stack arms it — verified) purges ALL orders for the symbol
190
+ // server-side ~tens of seconds after they're placed; that was the
191
+ // months-long naked-position spiral. Disabling it each pass means a
192
+ // re-armed switch is cleared within one reconciler cycle instead of
193
+ // silently stripping protection. Best-effort: never let it break the
194
+ // pass; the method itself is idempotent + swallows its own errors.
195
+ if (this.api.disableCountdownCancelAll) {
196
+ for (const sym of new Set(positions.map(p => p.symbol))) {
197
+ try {
198
+ await this.api.disableCountdownCancelAll(sym);
199
+ }
200
+ catch (err) {
201
+ logger.warn(TAG, `periodic disableCountdownCancelAll(${sym}) ignored: ${formatError(err)}`);
202
+ }
203
+ }
204
+ }
205
+ // Bucket open orders by bracketId + role
206
+ const ordersByBracket = new Map();
207
+ for (const o of openOrders) {
208
+ const cid = o.clientOrderId;
209
+ if (!cid)
210
+ continue;
211
+ const parsed = parseBracketCid(cid);
212
+ if (!parsed)
213
+ continue;
214
+ const bucket = ordersByBracket.get(parsed.bracketId) ?? {};
215
+ bucket[parsed.role] = o;
216
+ ordersByBracket.set(parsed.bracketId, bucket);
217
+ }
218
+ // ---- 1. Walk active ledger entries ----
219
+ for (const entry of this.ledger.getActive()) {
220
+ const onExchange = ordersByBracket.get(entry.bracketId) ?? {};
221
+ const position = positionBySymbol.get(entry.symbol);
222
+ const slExpected = entry.stopPrice !== undefined;
223
+ const tpExpected = entry.targetPrice !== undefined;
224
+ const slLive = !!onExchange.stop;
225
+ const tpLive = !!onExchange.target;
226
+ if (position) {
227
+ // Position alive — brackets SHOULD be present as expected
228
+ const missingSl = slExpected && !slLive;
229
+ const missingTp = tpExpected && !tpLive;
230
+ if (missingSl || missingTp) {
231
+ drifts.push({
232
+ kind: 'position_unprotected',
233
+ symbol: entry.symbol,
234
+ bracketId: entry.bracketId,
235
+ details: `missing: ${[missingSl && 'stop', missingTp && 'target'].filter(Boolean).join('+')}`,
236
+ ts: new Date(this.now()).toISOString(),
237
+ });
238
+ }
239
+ else {
240
+ drifts.push({
241
+ kind: 'healthy',
242
+ symbol: entry.symbol,
243
+ bracketId: entry.bracketId,
244
+ ts: new Date(this.now()).toISOString(),
245
+ });
246
+ }
247
+ }
248
+ else if (positionsUntrusted) {
249
+ // Positions fetch FAILED — "position gone" is unverifiable. Never
250
+ // mark the ledger terminal or cancel on an untrusted snapshot;
251
+ // brackets (if any) stay, ledger stays, re-evaluate next pass.
252
+ drifts.push({
253
+ kind: 'untrusted_snapshot_skip',
254
+ symbol: entry.symbol,
255
+ bracketId: entry.bracketId,
256
+ details: `positions fetch returned null (429 / weight-paced / transient) — ` +
257
+ `cannot verify ${entry.symbol} is gone. NO ledger mutation, NO cancel. ` +
258
+ `Re-evaluating next pass.`,
259
+ ts: new Date(this.now()).toISOString(),
260
+ });
261
+ logger.warn(TAG, `Untrusted positions snapshot — skipping "position gone" handling for ` +
262
+ `${entry.symbol} (bracketId=${entry.bracketId}); NOT mutating ledger or cancelling.`);
263
+ }
264
+ else {
265
+ // Position gone (per getPositions) — but is that trustworthy?
266
+ //
267
+ // 2026-05-15 bracket-spiral root cause: `getPositions()` collapses a
268
+ // 429 / transient `fetchPositions` failure to `[]` (live-adapter.ts
269
+ // `positions ?? []`), unlike `getOpenOrders()` which throws on null
270
+ // since the 2026-05-14 fix. `fetchPositions` and `fetchOpenOrders`
271
+ // are independent HTTP calls with independent rate-limit windows —
272
+ // under a 429 storm one succeeds while the other fails. When
273
+ // positions 429-collapses to empty but openOrders SUCCEEDS and shows
274
+ // the bracket legs live, this branch fired `cancelBrackets` and
275
+ // stripped protection off a still-open live position. The position
276
+ // then "reappeared" next snapshot, now naked. Confirmed via prod
277
+ // ALGO_UPDATE sniffer: SL+TP placed, then CANCELED ~46-60s later
278
+ // (= one reconciler cycle) with no Binance reason — i.e. an explicit
279
+ // cancelOrder from us, not an exchange-side action.
280
+ //
281
+ // The discriminator: Binance auto-cancels reduce-only STOP/TP orders
282
+ // within ~10s of a position reaching zero. So if `getOpenOrders`
283
+ // (trustworthy — it throws rather than lying on 429) STILL shows the
284
+ // bracket legs live, the position almost certainly still exists and
285
+ // the empty positions list is a failed fetch. Live brackets + zero
286
+ // positions is a logical contradiction, not a closed position.
287
+ // Refuse the destructive cancel; let the next pass (Patch C backs
288
+ // the cadence off under this exact pressure) re-evaluate with good
289
+ // data. Cost of waiting: ~nothing (Binance auto-cancels genuine
290
+ // reduce-only orphans anyway). Cost of acting on the bad snapshot:
291
+ // a naked live position. See
292
+ // `memory/feedback_getopenorders_null_collapse_naked_loop.md`.
293
+ if (slLive || tpLive) {
294
+ drifts.push({
295
+ kind: 'untrusted_snapshot_skip',
296
+ symbol: entry.symbol,
297
+ bracketId: entry.bracketId,
298
+ details: `getPositions() returned no position for ${entry.symbol} but ` +
299
+ `getOpenOrders() shows live ${[slLive && 'stop', tpLive && 'target'].filter(Boolean).join('+')} ` +
300
+ `(bracketId=${entry.bracketId}). Live reduce-only brackets cannot ` +
301
+ `coexist with a closed position on Binance — treating the empty ` +
302
+ `positions snapshot as a failed fetch and SKIPPING cancel. ` +
303
+ `Re-evaluating next pass.`,
304
+ ts: new Date(this.now()).toISOString(),
305
+ });
306
+ logger.warn(TAG, `REFUSING destructive cancel for ${entry.symbol}: positions=empty but ` +
307
+ `brackets live on exchange (bracketId=${entry.bracketId}). ` +
308
+ `Bad positions snapshot — NOT cancelling live protection.`);
309
+ }
310
+ else {
311
+ // Position gone, no brackets open — clean terminal state.
312
+ // We don't know which side triggered (SL vs TP) without fill history,
313
+ // so log as 'cancelled' unless ledger already resolved via the
314
+ // trigger-handler path (future wiring).
315
+ drifts.push({
316
+ kind: 'terminal_clean',
317
+ symbol: entry.symbol,
318
+ bracketId: entry.bracketId,
319
+ ts: new Date(this.now()).toISOString(),
320
+ });
321
+ try {
322
+ this.ledger.markState(entry.symbol, 'cancelled', { closeReason: 'cancelled_auto' });
323
+ }
324
+ catch { /* already terminal */ }
325
+ }
326
+ }
327
+ }
328
+ // ---- 2. Walk exchange orders for unknown-bracket orphans ----
329
+ const activeBracketIds = new Set(this.ledger.getActive().map(e => e.bracketId));
330
+ // Prune the first-seen map to bracketIds still present + unmatched on
331
+ // the exchange this pass (resolved/gone ones can't spiral). Keeps it
332
+ // bounded and means a bracketId that disappears then reappears is
333
+ // correctly treated as young again.
334
+ for (const id of [...this.firstSeenOrphanAt.keys()]) {
335
+ if (!ordersByBracket.has(id) || activeBracketIds.has(id)) {
336
+ this.firstSeenOrphanAt.delete(id);
337
+ }
338
+ }
339
+ for (const [bracketId, bucket] of ordersByBracket) {
340
+ if (activeBracketIds.has(bracketId))
341
+ continue; // already handled
342
+ // Bracket-tagged cids on exchange with no ACTIVE ledger entry.
343
+ // Possible causes: crashed before ledger persist; leftover from old
344
+ // plugin; OR — the 2026-05-16 SOL incident — a bracket just placed
345
+ // by the post-entry auto-attach whose ledger row / position snapshot
346
+ // hasn't propagated yet. NOT always safe to cancel; gated below.
347
+ const samples = [bucket.stop, bucket.target, bucket.entry].filter(Boolean);
348
+ if (samples.length === 0)
349
+ continue;
350
+ const symbol = samples[0].symbol;
351
+ // Record the first pass that ever saw this bracketId unmatched, so
352
+ // the grace clock (below) starts at true first sighting regardless
353
+ // of which guard fires this pass. Pruned above once it resolves.
354
+ const nowMs = this.now();
355
+ if (!this.firstSeenOrphanAt.has(bracketId)) {
356
+ this.firstSeenOrphanAt.set(bracketId, nowMs);
357
+ }
358
+ // ---- PRIMARY POSITIVE-CONFIRMATION GATE (2026-05-16 SOL) --------
359
+ // The authoritative, zero-weight source of per-leg liveness is the
360
+ // trusted user-data WS ALGO_UPDATE map — the SAME signal
361
+ // audit_bracket_protection used to (correctly) report SOL's stop
362
+ // leg LIVE at 08:36:10.167, 271 ms before this sweep cancelled it
363
+ // off a stale REST positions snapshot. Ask it FIRST. If any of the
364
+ // exact orders we are about to cancel is positively WS-confirmed
365
+ // live, it is reduce-only protection on a real position (Binance
366
+ // auto-cancels true reduce-only orphans within ~10 s, so a still-
367
+ // live leg ⇒ a position exists) — NEVER cancel it, regardless of
368
+ // ledger/snapshot state. Absence from `liveCids` is NOT proof a leg
369
+ // is dead (never-recorded ≠ cancelled — same contract as
370
+ // resolveBracketLegLiveness): the WS can only POSITIVELY protect
371
+ // here; it never licenses a cancel. When the WS has no usable
372
+ // opinion (untrusted/silent) we fall through to the snapshot guards
373
+ // and the grace backstop unchanged.
374
+ const wsLive = this.adapter.findTrustedLiveBracketCidsForSymbol?.(symbol);
375
+ if (wsLive?.trusted && wsLive.liveCids.length > 0) {
376
+ const liveSet = new Set(wsLive.liveCids);
377
+ const liveLegCids = samples
378
+ .map(o => o.clientOrderId)
379
+ .filter((c) => !!c && liveSet.has(c));
380
+ if (liveLegCids.length > 0) {
381
+ drifts.push({
382
+ kind: 'unknown_orphan_skip_ws_live',
383
+ symbol,
384
+ bracketId,
385
+ details: `no active ledger row for bracketId=${bracketId} but the trusted ` +
386
+ `user-data WS POSITIVELY confirms leg(s) [${liveLegCids.join(', ')}] ` +
387
+ `LIVE for ${symbol} — authoritative reduce-only protection on a ` +
388
+ `real position. REFUSING to cancel (zero-weight positive proof, ` +
389
+ `independent of the unreliable ledger row / stale positions ` +
390
+ `snapshot). Investigate why the ledger row is missing.`,
391
+ ts: new Date(nowMs).toISOString(),
392
+ });
393
+ logger.warn(TAG, `REFUSING orphan cancel for ${symbol} (bracketId=${bracketId}): ` +
394
+ `trusted WS confirms leg(s) [${liveLegCids.join(', ')}] LIVE — ` +
395
+ `these are protecting a live position. Keeping them. Ledger row ` +
396
+ `missing/non-active for an open position — root cause to fix.`);
397
+ continue;
398
+ }
399
+ }
400
+ // CRITICAL money-safety guard (2026-05-15 XRP strip loop). A
401
+ // bracket-tagged order with no ACTIVE ledger entry is NOT
402
+ // automatically a safe orphan: if a live position exists for this
403
+ // symbol, these legs are the ONLY exchange protection on a real open
404
+ // position whose ledger row we lost (stale/terminal/restart/the
405
+ // ledger-goes-non-active root cause under investigation). Cancelling
406
+ // them strips a live position naked; attach re-adds, the ledger goes
407
+ // stale again, next pass strips again → infinite naked loop. Losing
408
+ // the ledger row must NEVER strip exchange protection off a live
409
+ // position. Only a TRUE orphan — positions snapshot TRUSTED *and* no
410
+ // live position for the symbol — is safe to cancel.
411
+ if (positionsUntrusted) {
412
+ drifts.push({
413
+ kind: 'untrusted_snapshot_skip',
414
+ symbol,
415
+ bracketId,
416
+ details: `unknown-bracket cids on exchange for ${symbol} but positions ` +
417
+ `snapshot is untrusted (null fetch) — cannot prove these are true ` +
418
+ `orphans vs protection on a live position. SKIPPING cancel.`,
419
+ ts: new Date(this.now()).toISOString(),
420
+ });
421
+ logger.warn(TAG, `REFUSING orphan cancel for ${symbol} (bracketId=${bracketId}): ` +
422
+ `positions snapshot untrusted — NOT cancelling, could be live protection.`);
423
+ continue;
424
+ }
425
+ if (positionBySymbol.has(normalizeBracketSymbol(symbol))) {
426
+ drifts.push({
427
+ kind: 'unknown_orphan_skip_live_position',
428
+ symbol,
429
+ bracketId,
430
+ details: `no active ledger entry for bracketId=${bracketId} but a LIVE ` +
431
+ `position exists for ${symbol} — these bracket legs are protecting ` +
432
+ `it (lost/stale ledger row). REFUSING to cancel; brackets stay. ` +
433
+ `Investigate why the ledger row went non-active.`,
434
+ ts: new Date(this.now()).toISOString(),
435
+ });
436
+ logger.warn(TAG, `REFUSING orphan cancel for ${symbol} (bracketId=${bracketId}): ` +
437
+ `live position present — these legs are its protection. Keeping them. ` +
438
+ `Ledger row lost/non-active for an open position — root cause to investigate.`);
439
+ continue;
440
+ }
441
+ // ---- GRACE GUARD (TERTIARY backstop, 2026-05-16 SOL incident) ----
442
+ // Reached only when: the trusted-WS positive gate above had NO
443
+ // usable opinion (WS untrusted/silent, or no live record for these
444
+ // cids) AND trusted snapshot AND no live position for the symbol AND
445
+ // no active ledger row. The authoritative fix is the WS gate above;
446
+ // this remains purely as a safety net for the WS-blind path. Same
447
+ // false-positive the SOL spiral exploited — a bracket placed seconds
448
+ // ago by the post-entry auto-attach, before the ledger row persisted
449
+ // and before the brand-new position propagated into the positions
450
+ // snapshot. A genuine crash/old-plugin orphan is minutes-to-hours
451
+ // old; a real leftover is never seconds old. Two independent youth
452
+ // signals; if EITHER says "young", REFUSE the cancel (positive-
453
+ // safety: only act when positively proven stale, never on a maybe):
454
+ // - reconciler first-seen: first unmatched sighting ⇒ ≤ one pass
455
+ // old from our side ⇒ young by construction.
456
+ // - exchange order age: youngest plausible sample timestamp (algo
457
+ // orders may omit it — then unused, never assumed old).
458
+ const seenAgeMs = nowMs - (this.firstSeenOrphanAt.get(bracketId) ?? nowMs);
459
+ let exchangeAgeMs = Number.POSITIVE_INFINITY;
460
+ for (const o of samples) {
461
+ const ts = o.timestamp;
462
+ if (typeof ts === 'number' && ts > 0 && ts <= nowMs + 5_000) {
463
+ exchangeAgeMs = Math.min(exchangeAgeMs, nowMs - ts);
464
+ }
465
+ }
466
+ const apparentAgeMs = Math.min(seenAgeMs, exchangeAgeMs);
467
+ if (apparentAgeMs < ORPHAN_GRACE_MS) {
468
+ drifts.push({
469
+ kind: 'unknown_orphan_grace_skip',
470
+ symbol,
471
+ bracketId,
472
+ details: `bracket-tagged cids for ${symbol} have no active ledger row and ` +
473
+ `no live position in the snapshot, but are only ~${Math.round(apparentAgeMs / 1000)}s old (grace ${Math.round(ORPHAN_GRACE_MS / 1000)}s) — too ` +
474
+ `young to be a crash/old-plugin leftover; treating as a freshly-` +
475
+ `attached bracket racing ledger-persist + position propagation. ` +
476
+ `NOT cancelling this pass.`,
477
+ ts: new Date(nowMs).toISOString(),
478
+ });
479
+ logger.warn(TAG, `REFUSING orphan cancel for ${symbol} (bracketId=${bracketId}): ` +
480
+ `only ~${Math.round(apparentAgeMs / 1000)}s old (< ${Math.round(ORPHAN_GRACE_MS / 1000)}s grace) — freshly-placed bracket, ledger/position not yet ` +
481
+ `propagated. Keeping it; re-evaluate next pass.`);
482
+ continue;
483
+ }
484
+ // ---- AUTHORITATIVE POSITIVE-TERMINAL GATE — THE Section-2 root fix
485
+ // (2026-05-16 SOL recurring naked-position spiral) ------------------
486
+ // Reaching here means: WS positive-live gate had no opinion, the
487
+ // positions snapshot is non-null and does NOT contain this symbol,
488
+ // and the orphan is past the grace window. PRE-FIX that conjunction
489
+ // alone licensed the cancel — Section-2 inferred "orphan" purely
490
+ // from the symbol's ABSENCE in the positions snapshot. That
491
+ // inference is structurally unsound for rc-* legs: every rc-*-{s,t}
492
+ // is a reduce-only STOP/TP, and Binance auto-cancels a reduce-only
493
+ // order within ~10 s of the position reaching zero. A reduce-only
494
+ // rc-* leg still LIVE on the exchange well past the grace window
495
+ // (60 s ≫ 10 s) is therefore POSITIVE PROOF the position is still
496
+ // OPEN and the empty / weight-paced / eventually-consistent
497
+ // positions snapshot is a bad read — the exact null≠empty hole
498
+ // Section-1 already refuses (the "live brackets + empty positions =
499
+ // logical contradiction" branch above) and the precise mechanism
500
+ // every prior guard (WS / live-position / grace) was routed around
501
+ // because each only narrowed, never removed, the
502
+ // positions-absence→cancel path.
503
+ //
504
+ // The invariant, applied symmetrically here: ONLY positive per-leg
505
+ // terminal proof — NEVER positions-absence — may license a
506
+ // destructive bracket cancel. resolveBracketLegLiveness is the same
507
+ // authoritative signal classifyLedgerVsExchange uses (trusted-WS →
508
+ // weight-1 REST → 'unknown' floor). ALL legs positively 'terminal'
509
+ // ⇒ the legs really are dead ⇒ the cancel is a harmless idempotent
510
+ // cleanup (Binance had likely auto-cancelled them already). ANY leg
511
+ // 'live' or 'unknown' ⇒ REFUSE and re-evaluate next pass; a genuine
512
+ // crash orphan stays caught (Binance reduce-only auto-cancel, or a
513
+ // later pass once a leg goes positively terminal / positions read
514
+ // trustworthy). The resolver is optional only for narrow unit mocks;
515
+ // the real LiveAdapter always implements it, so the production path
516
+ // is unconditionally gated and the spiral is structurally impossible.
517
+ if (this.adapter.resolveBracketLegLiveness) {
518
+ const cids = samples
519
+ .map(o => o.clientOrderId)
520
+ .filter((c) => !!c);
521
+ const verdicts = await Promise.all(cids.map(c => this.adapter.resolveBracketLegLiveness(c)));
522
+ const allTerminal = verdicts.length > 0 && verdicts.every(v => v === 'terminal');
523
+ if (!allTerminal) {
524
+ drifts.push({
525
+ kind: 'unknown_orphan_skip_legs_not_terminal',
526
+ symbol,
527
+ bracketId,
528
+ details: `no ledger row + positions snapshot reports ${symbol} flat, but ` +
529
+ `the authoritative per-leg resolver did NOT positively confirm ` +
530
+ `ALL legs terminal (verdicts=${JSON.stringify(verdicts)}). A ` +
531
+ `reduce-only rc-* leg still live past the ${Math.round(ORPHAN_GRACE_MS / 1000)}s grace implies an OPEN position (Binance auto-cancels ` +
532
+ `reduce-only orphans in ~10s) — the empty positions snapshot ` +
533
+ `is a bad read. REFUSING the cancel; only positive terminal ` +
534
+ `proof licenses it. Re-evaluating next pass.`,
535
+ ts: new Date(this.now()).toISOString(),
536
+ });
537
+ logger.warn(TAG, `REFUSING orphan cancel for ${symbol} (bracketId=${bracketId}): ` +
538
+ `per-leg resolver verdicts=${JSON.stringify(verdicts)} (not all ` +
539
+ `'terminal'). A live reduce-only rc-* leg past grace means the ` +
540
+ `position is OPEN — positions-absence is NOT proof of ` +
541
+ `orphan-hood. Keeping protection. (Section-2 positive-terminal ` +
542
+ `gate — the 2026-05-16 SOL spiral root fix.)`);
543
+ continue;
544
+ }
545
+ }
546
+ drifts.push({
547
+ kind: 'unknown_orphan',
548
+ symbol,
549
+ bracketId,
550
+ details: `no ledger entry for bracketId=${bracketId}, no live position for ${symbol}, all legs POSITIVELY terminal, ~${Math.round((nowMs - (this.firstSeenOrphanAt.get(bracketId) ?? nowMs)) / 1000)}s observed (> grace); cancelling ${samples.length} orphan order(s)`,
551
+ ts: new Date(this.now()).toISOString(),
552
+ });
553
+ for (const o of samples) {
554
+ if (!o.clientOrderId)
555
+ continue;
556
+ try {
557
+ await this.api.cancelOrderByClientId(o.clientOrderId, o.symbol);
558
+ }
559
+ catch (err) {
560
+ logger.warn(TAG, `orphan cancel failed for cid ${o.clientOrderId}: ${formatError(err)}`);
561
+ }
562
+ }
563
+ }
564
+ // Emit every drift so listeners (telemetry, admin dashboard) see them all.
565
+ for (const d of drifts)
566
+ this.emit('drift', d);
567
+ return { startedAt, drifts, positionsChecked: positions.length, ordersChecked: openOrders.length };
568
+ }
569
+ finally {
570
+ this.running = false;
571
+ }
572
+ }
573
+ }