@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,1415 @@
1
+ // Authenticated CCXT wrapper for Binance Futures.
2
+ // Used for shadow mode (read-only validation) and future live trading.
3
+ // API keys stay local — never transmitted to ReefClaw servers.
4
+ import { createRequire } from 'node:module';
5
+ import { logger, formatError } from '../logger.js';
6
+ import { assertNotBanned, noteBinanceError, noteSuccess, BinanceBannedError } from './binance-ban-gate.js';
7
+ import { parseBracketCid, isBracketCid } from '../live/bracket-id.js';
8
+ const TAG = 'binance-private';
9
+ // Load ccxt via CJS require — same pattern as binance-public.ts
10
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
11
+ let ccxtCjs;
12
+ try {
13
+ const _require = createRequire(import.meta.url);
14
+ ccxtCjs = _require('ccxt');
15
+ }
16
+ catch {
17
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
18
+ ccxtCjs = require('ccxt');
19
+ }
20
+ /** Reverse-lookup a raw Binance market id (e.g. "ADAUSDT") to the CCXT
21
+ * unified symbol (e.g. "ADA/USDT:USDT") so algo orders come back in the
22
+ * same format as regular orders. Callers that already have a unified
23
+ * symbol pass it as `explicit` and it is used verbatim.
24
+ *
25
+ * Primary path: CCXT's `safeMarket(id, undefined, undefined, 'swap')` —
26
+ * the blessed internal helper. It handles the single-vs-array shape of
27
+ * `markets_by_id` and, critically, disambiguates shared spot/futures ids
28
+ * (Binance shares `BTCUSDT` between both) via the 'swap' marketType hint
29
+ * so we always land on the USDT-M perpetual entry.
30
+ *
31
+ * Fallback path: direct `markets_by_id` lookup with a `linear: true`
32
+ * preference. Covers CCXT versions that don't expose safeMarket and tests
33
+ * that stub markets_by_id without the helper.
34
+ *
35
+ * On any miss or error: return the raw id. Visible degradation (audit
36
+ * reverts to false-negative) is preferable to silently dropping the order. */
37
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
38
+ function resolveUnifiedSymbol(ex, explicit, rawSymbol) {
39
+ if (explicit)
40
+ return explicit;
41
+ const raw = String(rawSymbol ?? '');
42
+ if (!raw)
43
+ return '';
44
+ try {
45
+ if (typeof ex?.safeMarket === 'function') {
46
+ // safeMarket(marketId, market?, delimiter?, marketType?) — see CCXT
47
+ // base/Exchange.ts. For an unknown id it returns a synthetic market
48
+ // { id: raw, symbol: raw, ... }; detect that by checking whether
49
+ // symbol differs from the raw id before accepting the result.
50
+ const market = ex.safeMarket(raw, undefined, undefined, 'swap');
51
+ if (typeof market?.symbol === 'string' && market.symbol !== raw) {
52
+ return market.symbol;
53
+ }
54
+ }
55
+ const entry = ex?.markets_by_id?.[raw];
56
+ if (!entry)
57
+ return raw;
58
+ if (Array.isArray(entry)) {
59
+ const linear = entry.find((m) => m?.linear === true && typeof m?.symbol === 'string');
60
+ if (linear?.symbol)
61
+ return linear.symbol;
62
+ const fallback = entry.find((m) => typeof m?.symbol === 'string');
63
+ return fallback?.symbol ?? raw;
64
+ }
65
+ return typeof entry?.symbol === 'string' ? entry.symbol : raw;
66
+ }
67
+ catch {
68
+ return raw;
69
+ }
70
+ }
71
+ export class BinancePrivateApi {
72
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
73
+ exchange;
74
+ testnet;
75
+ constructor(credentials) {
76
+ this.testnet = credentials.testnet ?? false;
77
+ const BinanceClass = ccxtCjs.binance ?? ccxtCjs.default?.binance;
78
+ if (!BinanceClass) {
79
+ throw new Error('CCXT binance class not found — check ccxt version');
80
+ }
81
+ this.exchange = new BinanceClass({
82
+ apiKey: credentials.apiKey,
83
+ secret: credentials.secret,
84
+ enableRateLimit: true,
85
+ options: {
86
+ defaultType: 'future',
87
+ warnOnFetchOpenOrdersWithoutSymbol: false,
88
+ // CCXT 4.5 deprecated Binance Futures testnet and throws NotSupported
89
+ // on signed write calls unless this flag is set. testnet.binancefuture.com
90
+ // still works on the Binance side; the check is CCXT-side only. See
91
+ // node_modules/ccxt/dist/cjs/src/binance.js line ~12225.
92
+ disableFuturesSandboxWarning: true,
93
+ // Auto-sync request timestamps to server time. Without this, even a
94
+ // 1s clock drift on the client trips Binance's recvWindow check with
95
+ // "Timestamp for this request was Nms ahead of the server's time."
96
+ // CCXT pings the server on the first signed call to learn the offset.
97
+ adjustForTimeDifference: true,
98
+ },
99
+ });
100
+ // Enable demo/testnet if configured
101
+ if (this.testnet) {
102
+ this.exchange.setSandboxMode(true);
103
+ }
104
+ // ---- WIDENED CANCEL TRACER (2026-05-16) ------------------------------
105
+ // The narrow chokepoint (fapiPrivateDeleteAlgoOrder + the
106
+ // cancelOrderByClientId ATTRIBUTION) is deployed and PROVEN working
107
+ // (it caught the one bracket_integrity flush with a full stack) yet is
108
+ // SILENT for the recurring ~20s rc-* bracket kills — while a clean
109
+ // NON-rc closePosition STOP+TP pair, identical in shape to the agent's,
110
+ // survived 3.6 min untouched on the SAME symbol (controlled prod test
111
+ // 2026-05-16). => the kill is keyed to the rc-* cid and BYPASSES every
112
+ // BinancePrivateApi cancel method, i.e. something cancels directly on
113
+ // the raw ccxt client. Wrap EVERY cancel surface on the raw instance so
114
+ // no caller (this class, the reconciler, the agent, a ccxt cross-call,
115
+ // anything holding `exchange`) can bypass it; log method + args + full
116
+ // caller stack. Pure observability — each wrapper calls straight through
117
+ // (same `this`, same return Promise), zero behaviour change. Remove
118
+ // once the proven source is fixed and soaked.
119
+ {
120
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
121
+ const ex = this.exchange;
122
+ const cancelSurfaces = [
123
+ 'cancelOrder', 'cancelOrders', 'cancelAllOrders', 'cancelOrdersForSymbols',
124
+ 'fapiPrivateDeleteOrder', 'fapiPrivateDeleteAllOpenOrders',
125
+ 'fapiPrivateDeleteAlgoOrder', 'fapiPrivateDeleteBatchOrders',
126
+ 'fapiPrivateDeleteAlgoFuturesOrder',
127
+ ];
128
+ let armed = 0;
129
+ for (const m of cancelSurfaces) {
130
+ const orig = ex[m];
131
+ if (typeof orig !== 'function')
132
+ continue;
133
+ ex[m] = (...args) => {
134
+ let argStr;
135
+ try {
136
+ argStr = JSON.stringify(args).slice(0, 600);
137
+ }
138
+ catch {
139
+ argStr = '(unserializable)';
140
+ }
141
+ const stack = new Error('wide-cancel-trace').stack ?? '(no stack)';
142
+ logger.warn(TAG, `WIDE-CANCEL-TRACE — raw ccxt ${m}(${argStr}) invoked. If this ` +
143
+ `carries an rc-*-{s,t} cid/algoId, THIS is the bracket ` +
144
+ `stripper (the narrow chokepoint never sees it). Caller ` +
145
+ `stack follows:\n${stack}`);
146
+ return orig.apply(ex, args);
147
+ };
148
+ armed++;
149
+ }
150
+ logger.info(TAG, `WIDE-CANCEL-TRACE armed on ${armed} raw cancel surface(s)`);
151
+ }
152
+ logger.info(TAG, `Binance private API initialized (${this.testnet ? 'testnet' : 'mainnet'})`);
153
+ }
154
+ /**
155
+ * Validate API key permissions by making test API calls.
156
+ * Best-effort — never throws.
157
+ */
158
+ async validatePermissions() {
159
+ const result = {
160
+ canReadBalance: false,
161
+ canReadPositions: false,
162
+ canTrade: false,
163
+ errors: [],
164
+ };
165
+ // Test: read balance. Route through the gate like every other read —
166
+ // a permission probe during an active ban would otherwise extend it,
167
+ // and a 429/418 here must arm the gate for the pollers.
168
+ try {
169
+ assertNotBanned('fetchBalance');
170
+ await this.exchange.fetchBalance();
171
+ noteSuccess();
172
+ result.canReadBalance = true;
173
+ }
174
+ catch (err) {
175
+ noteBinanceError(err);
176
+ result.errors.push(`Balance read failed: ${formatError(err)}`);
177
+ }
178
+ // Test: read positions (same gate routing as the balance probe above).
179
+ try {
180
+ assertNotBanned('fetchPositions');
181
+ await this.exchange.fetchPositions();
182
+ noteSuccess();
183
+ result.canReadPositions = true;
184
+ }
185
+ catch (err) {
186
+ noteBinanceError(err);
187
+ result.errors.push(`Position read failed: ${formatError(err)}`);
188
+ }
189
+ // Note: We DON'T test trade permission here (would require placing a real order).
190
+ // Trade permission is validated implicitly when micro-live/live mode attempts the first order.
191
+ // For now, if balance + positions work, the key is likely valid.
192
+ result.canTrade = result.canReadBalance; // Optimistic — actual test in live mode
193
+ return result;
194
+ }
195
+ /** Fetch real account balance. Returns null on error. */
196
+ async fetchBalance() {
197
+ try {
198
+ assertNotBanned('fetchBalance');
199
+ const raw = await this.exchange.fetchBalance();
200
+ noteSuccess();
201
+ const free = {};
202
+ const used = {};
203
+ const total = {};
204
+ const result = { free, used, total };
205
+ for (const currency of Object.keys(raw.total ?? {})) {
206
+ // Keep any NON-ZERO balance — negative included. Binance multi-asset
207
+ // margin (BNFCR, live on prod) legitimately reports negative wallet
208
+ // lines (cumulative realized losses); the old `> 0` filter silently
209
+ // dropped them, overstating NAV for any consumer of the filtered
210
+ // total/free maps. `!== 0` preserves the original skip-empty intent
211
+ // without the sign bias.
212
+ if ((raw.total[currency] ?? 0) !== 0 || (raw.free[currency] ?? 0) !== 0) {
213
+ free[currency] = raw.free[currency] ?? 0;
214
+ used[currency] = raw.used[currency] ?? 0;
215
+ total[currency] = raw.total[currency] ?? 0;
216
+ result[currency] = {
217
+ free: free[currency],
218
+ used: used[currency],
219
+ total: total[currency],
220
+ };
221
+ }
222
+ }
223
+ // Preserve CCXT's raw `info` field so downstream callers can read
224
+ // Binance-specific fields like info.assets[i].walletBalance for
225
+ // margin-aware balance normalisation (see
226
+ // UserDataStreamController.normalizeBinanceFuturesBalance). Without
227
+ // this, CCXT's fetchBalance.total returns marginBalance (wallet +
228
+ // uPnl) and WS store holds walletBalance only, causing persistent
229
+ // drift on any margin asset with open-position uPnl.
230
+ result.info = raw.info;
231
+ return result;
232
+ }
233
+ catch (err) {
234
+ noteBinanceError(err);
235
+ logger.error(TAG, `fetchBalance failed: ${formatError(err)}`);
236
+ return null;
237
+ }
238
+ }
239
+ /**
240
+ * Fetch account-transfer income entries (deposits/withdrawals into/out of the
241
+ * Futures wallet) between two timestamps. These surface as `incomeType=TRANSFER`
242
+ * on Binance's `/fapi/v1/income` endpoint; positive `income` = inflow to
243
+ * futures, negative = outflow. Used by DepositTracker to re-anchor
244
+ * sessionStartNav so capital flows never show up as Day P&L.
245
+ */
246
+ async fetchTransfers(startMs, endMs) {
247
+ try {
248
+ const params = {
249
+ incomeType: 'TRANSFER',
250
+ startTime: Math.max(0, Math.floor(startMs)),
251
+ endTime: Math.floor(endMs),
252
+ limit: 1000,
253
+ };
254
+ assertNotBanned('fetchTransfers');
255
+ const raw = await this.exchange.fapiPrivateGetIncome(params);
256
+ noteSuccess();
257
+ if (!Array.isArray(raw))
258
+ return [];
259
+ return raw.map((r) => ({
260
+ // tranId is Binance's idempotency key for the transfer; fall back to a
261
+ // time+amount composite if the exchange omits it (shouldn't happen).
262
+ tranId: String(r.tranId ?? `${r.time}-${r.income}-${r.asset}`),
263
+ time: Number(r.time ?? 0),
264
+ amount: Number(r.income ?? 0),
265
+ asset: String(r.asset ?? ''),
266
+ }));
267
+ }
268
+ catch (err) {
269
+ noteBinanceError(err);
270
+ logger.error(TAG, `fetchTransfers failed: ${formatError(err)}`);
271
+ throw err;
272
+ }
273
+ }
274
+ /**
275
+ * Sum trading-effect income (every incomeType EXCEPT TRANSFER) since
276
+ * `sinceMs` — i.e. net wallet change from trading. This matches Binance
277
+ * mobile app's "Today's Realized PNL" line (verified 2026-05-14).
278
+ *
279
+ * An earlier comment here claimed Binance's UI uses GROSS REALIZED_PNL
280
+ * only and that the gross/net values "no longer match" — that was based
281
+ * on a faulty assumption that propagated into the live-adapter and
282
+ * caused dashboard Realized to show $2.28 less negative than Binance's
283
+ * own UI. Reverted: gross is for debug only, net is what the user sees.
284
+ *
285
+ * Single REST call, weight=30. Returns `null` (NOT 0) on a failed/paced
286
+ * fetch — null≠empty: a non-answer must never collapse to a zero the
287
+ * caller would treat as real (2026-05-16 KPI incident). Unused in prod
288
+ * (kept for parity / ad-hoc reconciliation).
289
+ *
290
+ * Spec: https://developers.binance.com/docs/derivatives/usds-margined-futures/account/rest-api/Get-Income-History
291
+ */
292
+ async fetchRealizedPnlSince(sinceMs) {
293
+ const b = await this.fetchTodayIncomeBreakdown(sinceMs);
294
+ return b ? b.netNonTransfer : null;
295
+ }
296
+ /**
297
+ * One income-history call, two numbers:
298
+ * - `realizedPnlGross`: sum of `incomeType === 'REALIZED_PNL'` only.
299
+ * KEPT FOR DEBUG / RECONCILIATION ONLY — despite an earlier comment
300
+ * claiming this matches Binance's "Today's Realized PNL" UI line,
301
+ * the mobile app verification on 2026-05-14 showed Binance reports
302
+ * the NET figure (-$5.98 in the test) while gross was -$3.70. Do
303
+ * NOT surface this number in the dashboard Realized KPI.
304
+ * - `netNonTransfer`: sum of EVERY non-TRANSFER incomeType (REALIZED_PNL +
305
+ * COMMISSION + FUNDING_FEE + rebates). This equals today's net wallet
306
+ * change from trading and matches the Binance mobile app's
307
+ * "Today's Realized PNL" line. Used for BOTH the Realized KPI display
308
+ * AND the `sessionStartNav` anchor so Day P&L reflects total NAV
309
+ * change since UTC midnight.
310
+ *
311
+ * Both numbers refresh together. Caller throttles. Single page (limit=1000)
312
+ * — > 1000 income rows in a UTC day is unrealistic for a single account.
313
+ *
314
+ * Return contract (null≠empty — same rule as getOpenOrders /
315
+ * closePosition / the bracket reconciler):
316
+ * - `{realizedPnlGross, netNonTransfer}` on a successful parse, INCLUDING
317
+ * a genuine empty income history (`{0,0}` = Binance confirms no income
318
+ * today — a real zero the caller can anchor on: wallet − 0).
319
+ * - `null` on a failed/paced/garbled fetch (rate-limit, 418, network,
320
+ * non-array body). It is "today's income UNKNOWN", NOT "zero". The
321
+ * caller MUST keep its last good anchor. Coercing this to `{0,0}`
322
+ * pinned `sessionStartNav` to `wallet_now` and zeroed Day P&L — the
323
+ * 2026-05-16 KPI incident this contract exists to prevent.
324
+ *
325
+ * `opts.forced` selects the never-paced ban-gate context — pass it ONLY
326
+ * for the boot + UTC-day-rollover anchor (the values that actually change
327
+ * within a UTC day). Routine same-day refreshes leave it false so they
328
+ * stay cosmetic/sheddable; a shed there is now a harmless no-op because
329
+ * the caller keeps the (invariant) last good anchor.
330
+ *
331
+ * Spec: https://developers.binance.com/docs/derivatives/usds-margined-futures/account/rest-api/Get-Income-History
332
+ */
333
+ async fetchTodayIncomeBreakdown(sinceMs, opts) {
334
+ const context = opts?.forced
335
+ ? 'fetchTodayIncomeBreakdownForced'
336
+ : 'fetchTodayIncomeBreakdown';
337
+ try {
338
+ const params = {
339
+ startTime: Math.max(0, Math.floor(sinceMs)),
340
+ endTime: Date.now(),
341
+ limit: 1000,
342
+ };
343
+ assertNotBanned(context);
344
+ const raw = await this.exchange.fapiPrivateGetIncome(params);
345
+ noteSuccess();
346
+ // null≠empty: a non-array is a garbled/non-answer (NOT "no income
347
+ // today"), so it must NOT be coerced into a zero anchor.
348
+ if (!Array.isArray(raw))
349
+ return null;
350
+ let realizedPnlGross = 0;
351
+ let netNonTransfer = 0;
352
+ for (const r of raw) {
353
+ const row = r;
354
+ const type = String(row.incomeType ?? '');
355
+ if (type === 'TRANSFER')
356
+ continue;
357
+ const amt = Number(row.income ?? 0);
358
+ if (!Number.isFinite(amt))
359
+ continue;
360
+ netNonTransfer += amt;
361
+ if (type === 'REALIZED_PNL')
362
+ realizedPnlGross += amt;
363
+ }
364
+ // A genuine empty income history IS a confirmed zero — {0,0} here is
365
+ // correct (the caller anchors sessionStartNav = wallet − 0).
366
+ return { realizedPnlGross, netNonTransfer };
367
+ }
368
+ catch (err) {
369
+ noteBinanceError(err);
370
+ // null, NEVER {0,0}: a failed/paced fetch is "today's income UNKNOWN",
371
+ // not "zero". {0,0} pins sessionStartNav to wallet_now and zeroes
372
+ // Day P&L (2026-05-16 KPI incident). The caller keeps its last good
373
+ // anchor, which is invariant within a UTC day. Don't WARN-spam the
374
+ // EXPECTED paced/banned case — the ban gate already logs that once
375
+ // per window; only a genuine failure is notable here.
376
+ if (!(err instanceof BinanceBannedError)) {
377
+ logger.warn(TAG, `fetchTodayIncomeBreakdown failed: ${formatError(err)}`);
378
+ }
379
+ return null;
380
+ }
381
+ }
382
+ /**
383
+ * Discover symbols that had any trade-tagged income activity since `sinceMs`.
384
+ *
385
+ * /fapi/v1/income (no incomeType filter) returns up to 1000 entries spanning
386
+ * REALIZED_PNL, COMMISSION, FUNDING_FEE, etc. — every entry that originates
387
+ * from a trade carries a non-empty `symbol`. We collect the unique symbols
388
+ * and translate each raw Binance id ('BTCUSDT') into the CCXT-unified form
389
+ * ('BTC/USDT:USDT') via the same `resolveUnifiedSymbol` helper used elsewhere
390
+ * in this file, so callers can store / compare them under one canonical key.
391
+ *
392
+ * Used by RestGapFiller at startup to recover symbols traded during plugin
393
+ * downtime that aren't in the persisted touched-symbols cache (Codex
394
+ * finding 4 follow-up). Single REST call, weight=30 — well below the 2400/min
395
+ * budget. Single page; > 1000 income rows in the window is rare and would
396
+ * indicate a multi-hour outage that needs an operator-driven backfill anyway.
397
+ *
398
+ * Spec: https://developers.binance.com/docs/derivatives/usds-margined-futures/account/rest-api/Get-Income-History
399
+ */
400
+ async fetchRecentTradedSymbols(sinceMs) {
401
+ try {
402
+ const params = {
403
+ startTime: Math.max(0, Math.floor(sinceMs)),
404
+ endTime: Date.now(),
405
+ limit: 1000,
406
+ };
407
+ assertNotBanned('fetchRecentTradedSymbols');
408
+ const raw = await this.exchange.fapiPrivateGetIncome(params);
409
+ noteSuccess();
410
+ if (!Array.isArray(raw))
411
+ return [];
412
+ const out = new Set();
413
+ for (const r of raw) {
414
+ const sym = r?.symbol;
415
+ if (typeof sym !== 'string' || sym.length === 0)
416
+ continue;
417
+ const unified = resolveUnifiedSymbol(this.exchange, undefined, sym);
418
+ if (unified)
419
+ out.add(unified);
420
+ }
421
+ return [...out];
422
+ }
423
+ catch (err) {
424
+ noteBinanceError(err);
425
+ logger.error(TAG, `fetchRecentTradedSymbols failed: ${formatError(err)}`);
426
+ throw err;
427
+ }
428
+ }
429
+ /** Fetch real open positions. Returns null on error. */
430
+ async fetchPositions(symbol) {
431
+ try {
432
+ assertNotBanned('fetchPositions');
433
+ const symbols = symbol ? [symbol] : undefined;
434
+ const raw = await this.exchange.fetchPositions(symbols);
435
+ noteSuccess();
436
+ // Filter to positions with actual quantity
437
+ return raw
438
+ .filter((p) => {
439
+ const contracts = Number(p.contracts ?? 0);
440
+ return contracts > 0;
441
+ })
442
+ .map((p) => ({
443
+ symbol: String(p.symbol ?? ''),
444
+ side: String(p.side ?? 'long'),
445
+ contracts: Number(p.contracts ?? 0),
446
+ contractSize: Number(p.contractSize ?? 1),
447
+ entryPrice: Number(p.entryPrice ?? 0),
448
+ markPrice: Number(p.markPrice ?? 0),
449
+ notional: Number(p.notional ?? 0),
450
+ unrealizedPnl: Number(p.unrealizedPnl ?? 0),
451
+ percentage: Number(p.percentage ?? 0),
452
+ timestamp: Date.now(),
453
+ datetime: new Date().toISOString(),
454
+ }));
455
+ }
456
+ catch (err) {
457
+ noteBinanceError(err);
458
+ logger.error(TAG, `fetchPositions failed: ${formatError(err)}`);
459
+ return null;
460
+ }
461
+ }
462
+ /** Fetch real open orders — REGULAR + ALGO (conditional) orders merged.
463
+ *
464
+ * Binance Futures splits orders into two namespaces:
465
+ * - Regular orders (`/fapi/v1/openOrders`) — limit, market, etc.
466
+ * - Algo orders (`/fapi/v1/openAlgoOrders`) — STOP_MARKET,
467
+ * TAKE_PROFIT_MARKET, TRAILING_STOP_MARKET. CCXT submits these via
468
+ * `createOrder` but the unified `fetchOpenOrders` does NOT include them.
469
+ * The bracket-orders feature lives entirely in the algo bucket, so we
470
+ * fetch both and merge the results so downstream consumers (reconciler,
471
+ * dashboard) see a single list. Algo orders are normalised into the same
472
+ * CcxtOrder shape with `clientAlgoId` → `clientOrderId`. */
473
+ async fetchOpenOrders(symbol) {
474
+ try {
475
+ // This wrapper makes TWO Binance REST calls (regular /fapi/v1/openOrders
476
+ // + algo /fapi/v1/openAlgoOrders, both 1 scoped / 40 unscoped per
477
+ // developers.binance.com). Account the COMBINED cost up-front via a
478
+ // distinct context (CONTEXT_WEIGHT: fetchOpenOrders=2, …All=80) so the
479
+ // process-wide pacer doesn't undercount the single most expensive hot
480
+ // call ~16×. Gate once here (not in the algo sub-call) so a mid-wrapper
481
+ // pace can't return a regular-only partial that reads as "no brackets".
482
+ assertNotBanned(symbol ? 'fetchOpenOrders' : 'fetchOpenOrdersAll');
483
+ const raw = await this.exchange.fetchOpenOrders(symbol);
484
+ noteSuccess();
485
+ const regular = raw.map((o) => ({
486
+ id: String(o.id ?? ''),
487
+ symbol: String(o.symbol ?? ''),
488
+ side: String(o.side ?? 'buy'),
489
+ type: String(o.type ?? 'limit'),
490
+ status: String(o.status ?? 'open'),
491
+ amount: Number(o.amount ?? 0),
492
+ filled: Number(o.filled ?? 0),
493
+ remaining: Number(o.remaining ?? 0),
494
+ average: o.average != null ? Number(o.average) : null,
495
+ price: o.price != null ? Number(o.price) : null,
496
+ cost: Number(o.cost ?? 0),
497
+ fee: { cost: 0, currency: 'USDT' },
498
+ timestamp: Number(o.timestamp ?? Date.now()),
499
+ datetime: String(o.datetime ?? new Date().toISOString()),
500
+ timeInForce: String(o.timeInForce ?? 'GTC'),
501
+ clientOrderId: o.clientOrderId != null ? String(o.clientOrderId) : undefined,
502
+ // Preserve raw CCXT order shape (which preserves Binance's raw
503
+ // payload inside its own `info`). Downstream classifiers read
504
+ // `info.info?.orderType` / similar when they need authoritative
505
+ // order subtype beyond the narrow 'market'|'limit' typing above.
506
+ info: o.info != null && typeof o.info === 'object' ? o.info : undefined,
507
+ }));
508
+ // Merge algo/conditional orders (STOP_MARKET/TAKE_PROFIT_MARKET — our
509
+ // protective brackets) from the separate /fapi/v1/openAlgoOrders
510
+ // endpoint. fetchOpenAlgoOrders returns [] ONLY when the endpoint
511
+ // method is structurally absent (non-algo/paper key — a stable
512
+ // capability gap, safe to merge as empty); a real 429/network failure
513
+ // THROWS and is caught below → this whole call returns `null`
514
+ // ("exchange order state unknown — do not treat as zero brackets").
515
+ // A partial "regular-only" list that looks successful would make live
516
+ // SL/TP vanish from audit/reconciler → phantom-naked re-attach spiral.
517
+ const algo = await this.fetchOpenAlgoOrders(symbol);
518
+ return [...regular, ...algo];
519
+ }
520
+ catch (err) {
521
+ noteBinanceError(err);
522
+ logger.error(TAG, `fetchOpenOrders failed: ${formatError(err)}`);
523
+ return null;
524
+ }
525
+ }
526
+ /** Fetch open algo (conditional) orders. Used by fetchOpenOrders to merge
527
+ * STOP_MARKET / TAKE_PROFIT_MARKET orders into the unified list. Best-effort:
528
+ * returns [] on endpoint error so the caller degrades gracefully. */
529
+ async fetchOpenAlgoOrders(symbol) {
530
+ try {
531
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
532
+ const ex = this.exchange;
533
+ if (typeof ex.fapiPrivateGetOpenAlgoOrders !== 'function') {
534
+ return [];
535
+ }
536
+ // Binance API expects the bare symbol ("BTCUSDT"), CCXT passes "BTC/USDT"
537
+ // in the unified layer. Use marketId() to translate when a symbol is given.
538
+ const params = {};
539
+ if (symbol) {
540
+ try {
541
+ params.symbol = ex.marketId(symbol);
542
+ }
543
+ catch {
544
+ params.symbol = symbol.replace('/', '').split(':')[0];
545
+ }
546
+ }
547
+ const raw = await ex.fapiPrivateGetOpenAlgoOrders(params);
548
+ // A successful call to "open algo orders" returns an array (possibly
549
+ // empty). A non-array is an unexpected/garbled response — treat it as
550
+ // a FAILURE, not "zero algo orders". Returning [] here would make
551
+ // live SL/TP brackets silently vanish from the merged result while
552
+ // fetchOpenOrders still looks successful (non-null) — the exact
553
+ // partial-success-as-success trap the null contract forbids.
554
+ if (!Array.isArray(raw)) {
555
+ throw new Error('fetchOpenAlgoOrders: unexpected non-array response');
556
+ }
557
+ return raw.map((o) => {
558
+ // Translate algo side → CCXT side (Binance returns "SELL"/"BUY" uppercase)
559
+ const side = String(o.side ?? 'sell').toLowerCase();
560
+ const stopPrice = o.triggerPrice != null ? Number(o.triggerPrice) : null;
561
+ const createTime = o.createTime != null ? Number(o.createTime) : Date.now();
562
+ // Binance's algo endpoint returns raw market IDs (e.g. "ADAUSDT"). The
563
+ // regular (non-algo) fetchOpenOrders branch returns CCXT unified form
564
+ // (e.g. "ADA/USDT:USDT"). Portfolio-wide consumers that key orders by
565
+ // symbol (audit_bracket_protection, reconciler) see both in one list —
566
+ // if formats disagree, position↔order grouping silently loses matches.
567
+ // Before 2026-04-22 the no-explicit-symbol path fell back to `o.symbol`
568
+ // verbatim, producing 291 audit false-negatives in 24h on prod and the
569
+ // bracket_integrity death loop. Resolve the raw ID through CCXT's
570
+ // markets_by_id cache (populated by loadMarkets on startup).
571
+ const resolvedSymbol = resolveUnifiedSymbol(ex, symbol, o.symbol);
572
+ // CcxtOrder.type is narrow ('market'|'limit'); we preserve the original
573
+ // STOP_MARKET / TAKE_PROFIT_MARKET in `info` for consumers that care.
574
+ return {
575
+ id: String(o.algoId ?? ''),
576
+ symbol: resolvedSymbol,
577
+ side,
578
+ type: 'market',
579
+ status: String(o.algoStatus ?? 'open').toLowerCase() === 'new' ? 'open' : 'open',
580
+ amount: Number(o.quantity ?? 0),
581
+ filled: 0,
582
+ remaining: Number(o.quantity ?? 0),
583
+ average: null,
584
+ price: stopPrice,
585
+ cost: 0,
586
+ fee: { cost: 0, currency: 'USDT' },
587
+ timestamp: createTime,
588
+ datetime: new Date(createTime).toISOString(),
589
+ timeInForce: String(o.timeInForce ?? 'GTC'),
590
+ clientOrderId: o.clientAlgoId != null ? String(o.clientAlgoId) : undefined,
591
+ // Preserve the raw Binance algo response verbatim. Key fields
592
+ // consumers read:
593
+ // - `orderType`: 'STOP_MARKET' | 'TAKE_PROFIT_MARKET' | 'TRAILING_STOP_MARKET'
594
+ // - `algoType`: 'CONDITIONAL' (vs 'VP' / 'TWAP' on the algo/futures endpoint)
595
+ // - `triggerPrice`, `slTriggerPrice`, `tpTriggerPrice`: authoritative stop/tp levels
596
+ // Spec: https://developers.binance.com/docs/derivatives/usds-margined-futures/trade/rest-api/Current-All-Algo-Open-Orders
597
+ info: o,
598
+ };
599
+ });
600
+ }
601
+ catch (err) {
602
+ // CRITICAL: do NOT degrade a real fetch failure to []. ReefClaw's
603
+ // protective brackets ARE algo orders (STOP_MARKET/TAKE_PROFIT_MARKET
604
+ // on /fapi/v1/openAlgoOrders). A transient 429/network failure here,
605
+ // swallowed as [], merges into fetchOpenOrders as a SUCCESSFUL-looking
606
+ // (non-null) list with every SL/TP missing → audit/reconciler read
607
+ // "unprotected" → phantom-naked re-attach / bracket clobber. That is
608
+ // exactly the null-collapse class the getOpenOrders→null contract
609
+ // exists to prevent. Propagate: fetchOpenOrders' outer catch converts
610
+ // this to `null` ("exchange state unknown — skip this cycle"), which
611
+ // every consumer already handles safely. noteBinanceError is left to
612
+ // that single outer catch so the ban gate isn't armed twice.
613
+ logger.warn(TAG, `fetchOpenAlgoOrders failed (propagating as unknown, NOT empty): ${formatError(err)}`);
614
+ throw err instanceof Error ? err : new Error(String(err));
615
+ }
616
+ }
617
+ /** Query the live status of a SINGLE algo (conditional) order by its
618
+ * clientAlgoId via GET /fapi/v1/algoOrder (weight 1, doc-verified
619
+ * developers.binance.com/.../Query-Algo-Order, 2026-05-15).
620
+ *
621
+ * This is the authoritative per-order disambiguator for the bracket-spiral
622
+ * class. A broad /fapi/v1/openAlgoOrders empty result is NOT proof a
623
+ * specific bracket leg is gone — Binance returns a *successful* [] under
624
+ * weight pressure / eventual-consistency. This call asks Binance a DIRECT
625
+ * question about one exact order id and returns its `algoStatus`.
626
+ *
627
+ * Returns the UPPERCASE algoStatus on success — one of (doc-verified enum):
628
+ * NEW | WORKING | TRIGGERED | CANCELED | FINISHED | EXPIRED | REJECTED.
629
+ * Returns `null` on ANY non-answer: CCXT implicit method absent
630
+ * (non-algo/paper key — stable capability gap), ban / weight-paced,
631
+ * 429 / network, order-not-found (-2013 or aged-out per Binance's
632
+ * 3-day / 90-day retention rule), or a garbled / unparseable response.
633
+ *
634
+ * `null` is the explicit "could not get an answer" — the SAME null≠empty
635
+ * contract as the rest of the bracket path. Callers MUST map `null` to
636
+ * 'unknown' and never to a destructive decision. Best-effort by design:
637
+ * never throws into the caller (a thrown ban/pace is swallowed to `null`
638
+ * because "I was rate-limited" is just another flavour of "no answer").
639
+ * Ships dark in Phase 1 — no call sites yet. */
640
+ async queryAlgoOrderStatus(clientAlgoId) {
641
+ if (!clientAlgoId)
642
+ return null;
643
+ try {
644
+ // Read path → pre-gate (weight 1, paced like other reads; the gate
645
+ // throws BinanceBanned/WeightPaced which the catch below absorbs to
646
+ // `null` = safe "unknown", never a verdict).
647
+ assertNotBanned('queryAlgoOrder');
648
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
649
+ const ex = this.exchange;
650
+ if (typeof ex.fapiPrivateGetAlgoOrder !== 'function') {
651
+ return null;
652
+ }
653
+ // CCXT signs fapiPrivate* implicit calls and injects timestamp/recvWindow.
654
+ // Binance requires algoId OR clientAlgoId; we always have the cid.
655
+ const raw = await ex.fapiPrivateGetAlgoOrder({ clientAlgoId });
656
+ noteSuccess();
657
+ // Doc response is a single object { algoId, clientAlgoId, algoStatus,
658
+ // ... }. Defensive: some CCXT/proxy layers wrap singletons in an array.
659
+ let row;
660
+ if (Array.isArray(raw)) {
661
+ const arr = raw;
662
+ row = arr.find(o => String(o.clientAlgoId ?? '') === clientAlgoId)
663
+ ?? (arr.length === 1 ? arr[0] : undefined);
664
+ }
665
+ else if (raw && typeof raw === 'object') {
666
+ row = raw;
667
+ }
668
+ if (!row)
669
+ return null;
670
+ const status = row.algoStatus;
671
+ if (status == null || String(status).trim() === '')
672
+ return null;
673
+ return String(status).toUpperCase();
674
+ }
675
+ catch (err) {
676
+ // Not-found / aged-out / 429 / network / ban / pace all collapse here.
677
+ // noteBinanceError is a no-op for BinanceBannedError (gate already
678
+ // open) and arms the gate for real Binance errors — same pattern as
679
+ // every other read. Best-effort: return null, never a verdict.
680
+ noteBinanceError(err);
681
+ logger.warn(TAG, `queryAlgoOrderStatus(${clientAlgoId}) → null/unknown (NOT a verdict): ${formatError(err)}`);
682
+ return null;
683
+ }
684
+ }
685
+ /** Fetch historical fills (trades) for a symbol via GET /fapi/v1/userTrades.
686
+ * Used by the REST gap-filler (rest-gap-filler.ts) to backfill any fills
687
+ * that landed during a WS blind window — when the user-data stream was
688
+ * between reconnects, ORDER_TRADE_UPDATE events for that interval are
689
+ * unreachable from the WS side. Binance requires a symbol per call; the
690
+ * gap-filler iterates over every symbol it's seen this session.
691
+ *
692
+ * Returns raw CCXT trade objects (permissive shape — caller extracts the
693
+ * fields it needs). `since` is epoch milliseconds. Binance caps `limit` at
694
+ * 1000 per request. Falls back to [] on error so the caller keeps going
695
+ * rather than aborting the whole reconnect flow.
696
+ *
697
+ * `opts.endTime` (epoch ms) bounds the upper end of the window. CCXT 4.5.x
698
+ * reads it from params (`safeInteger2(params, 'until', 'endTime')`) and maps
699
+ * it onto the `/fapi/v1/userTrades` `endTime` query param; the gap-filler's
700
+ * startup deep-fill passes it to chunk a multi-day lookback into the ≤7-day
701
+ * windows Binance requires (the span between startTime and endTime cannot
702
+ * exceed 7 days). Without it, CCXT auto-clamps a `since` older than 7 days to
703
+ * `since + 7d` for linear futures, so only the first week would ever return.
704
+ *
705
+ * Spec: https://developers.binance.com/docs/derivatives/usds-margined-futures/trade/rest-api/Account-Trade-List */
706
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
707
+ async fetchMyTrades(symbol, since, limit = 500, opts) {
708
+ // Legacy error-tolerant surface: [] on any failure so best-effort callers
709
+ // (reconnect gap-fill, active probe) keep going. Callers that must
710
+ // distinguish "read failed" from "genuinely no trades" use
711
+ // fetchMyTradesOrNull below — the [] here is NOT safe for cursor/window
712
+ // bookkeeping (an error-as-empty reads as "window exhausted").
713
+ return (await this.fetchMyTradesOrNull(symbol, since, limit, opts)) ?? [];
714
+ }
715
+ /** Null-returning variant of fetchMyTrades — the null≠empty contract
716
+ * (same rule as getPositionsOrNull / fetchOpenOrders):
717
+ * - array (possibly empty) ONLY on a successful response — a real `[]`
718
+ * means Binance CONFIRMS no trades in the queried range;
719
+ * - `null` on ANY non-answer (ban/pace, 429, network, garbled non-array
720
+ * body). Callers must treat null as "trades UNKNOWN" and must NOT
721
+ * advance a pagination cursor or mark a backfill window complete.
722
+ *
723
+ * `opts.fromId` pages by Binance trade id (ascending, inclusive) instead
724
+ * of by time. Doc-verified (Account-Trade-List, 2026-07-02): "The
725
+ * parameter fromId cannot be sent with startTime or endTime", so a fromId
726
+ * call must omit `since`/`endTime` — enforced here rather than silently
727
+ * sending a request Binance rejects. Weight is 5 in all variants (same
728
+ * endpoint), so the existing ban-gate context accounting is unchanged. */
729
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
730
+ async fetchMyTradesOrNull(symbol, since, limit = 500, opts) {
731
+ if (opts?.fromId != null && (since != null || opts?.endTime != null)) {
732
+ throw new Error('fetchMyTradesOrNull: fromId cannot be combined with since/endTime (Binance rejects the combination)');
733
+ }
734
+ try {
735
+ assertNotBanned('fetchMyTrades');
736
+ const params = {};
737
+ if (opts?.endTime != null)
738
+ params.endTime = opts.endTime;
739
+ if (opts?.fromId != null)
740
+ params.fromId = opts.fromId;
741
+ const raw = await this.exchange.fetchMyTrades(symbol, since, limit, params);
742
+ noteSuccess();
743
+ // null≠empty: a non-array body is a garbled non-answer, NOT "no trades".
744
+ return Array.isArray(raw) ? raw : null;
745
+ }
746
+ catch (err) {
747
+ noteBinanceError(err);
748
+ logger.warn(TAG, `fetchMyTrades(${symbol}, since=${since}) failed: ${formatError(err)}`);
749
+ return null;
750
+ }
751
+ }
752
+ /** Fetch order book depth (same data as public, validates auth path). */
753
+ async fetchOrderBook(symbol, limit = 20) {
754
+ try {
755
+ assertNotBanned('fetchOrderBook');
756
+ const raw = await this.exchange.fetchOrderBook(symbol, limit);
757
+ noteSuccess();
758
+ return {
759
+ bids: (raw.bids ?? []).map((l) => [l[0], l[1]]),
760
+ asks: (raw.asks ?? []).map((l) => [l[0], l[1]]),
761
+ timestamp: raw.timestamp ?? Date.now(),
762
+ };
763
+ }
764
+ catch (err) {
765
+ noteBinanceError(err);
766
+ logger.error(TAG, `fetchOrderBook(${symbol}) failed: ${formatError(err)}`);
767
+ return null;
768
+ }
769
+ }
770
+ /** Fetch ticker. Returns null on error. */
771
+ async fetchTicker(symbol) {
772
+ try {
773
+ assertNotBanned('fetchTicker');
774
+ const raw = await this.exchange.fetchTicker(symbol);
775
+ noteSuccess();
776
+ return {
777
+ symbol: raw.symbol,
778
+ last: raw.last ?? 0,
779
+ bid: raw.bid ?? 0,
780
+ ask: raw.ask ?? 0,
781
+ baseVolume: raw.baseVolume ?? 0,
782
+ quoteVolume: raw.quoteVolume ?? 0,
783
+ change: raw.change ?? 0,
784
+ percentage: raw.percentage ?? 0,
785
+ timestamp: raw.timestamp ?? Date.now(),
786
+ datetime: raw.datetime ?? new Date().toISOString(),
787
+ };
788
+ }
789
+ catch (err) {
790
+ noteBinanceError(err);
791
+ logger.error(TAG, `fetchTicker(${symbol}) failed: ${formatError(err)}`);
792
+ return null;
793
+ }
794
+ }
795
+ /**
796
+ * Submit a real order to the exchange.
797
+ * Used in micro-live and live modes only. NOT called in shadow mode.
798
+ *
799
+ * @param params — extra CCXT params forwarded to Binance (e.g. newClientOrderId, reduceOnly, positionSide).
800
+ */
801
+ async createOrder(symbol, side, type, amount, price,
802
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
803
+ params) {
804
+ // Order submission is intentionally NOT assertNotBanned-gated (orders
805
+ // must always reach the exchange — kill/flatten/bracket-arming can never
806
+ // be blocked). But it MUST still ARM the shared gate on a 429/418 so the
807
+ // *read* pollers back off — otherwise an order that trips the IP limit
808
+ // leaves every poller hammering into the ban (CLAUDE.md ban-gate
809
+ // contract). Catch → noteBinanceError → rethrow (preserve the throw).
810
+ let raw;
811
+ try {
812
+ raw = await this.exchange.createOrder(symbol, type, side, amount, price, params ?? {});
813
+ }
814
+ catch (err) {
815
+ noteBinanceError(err);
816
+ throw err;
817
+ }
818
+ return {
819
+ id: String(raw.id),
820
+ symbol: raw.symbol,
821
+ side: raw.side,
822
+ type: raw.type,
823
+ status: raw.status,
824
+ amount: raw.amount ?? amount,
825
+ filled: raw.filled ?? 0,
826
+ remaining: raw.remaining ?? amount,
827
+ average: raw.average ?? null,
828
+ price: raw.price ?? price ?? null,
829
+ cost: raw.cost ?? 0,
830
+ fee: raw.fee ?? { cost: 0, currency: 'USDT' },
831
+ timestamp: raw.timestamp ?? Date.now(),
832
+ datetime: raw.datetime ?? new Date().toISOString(),
833
+ timeInForce: raw.timeInForce ?? 'GTC',
834
+ };
835
+ }
836
+ /** Fetch a single order by ID. Used for fill polling and idempotency checks. */
837
+ async fetchOrder(orderId, symbol) {
838
+ try {
839
+ // Pre-gate: OrderPoller calls this every 5s. During an active 418
840
+ // an ungated poll keeps hitting Binance and EXTENDS the ban. Throwing
841
+ // here (→ caught → null) makes the poller skip the tick instead.
842
+ assertNotBanned('fetchOrder');
843
+ const raw = await this.exchange.fetchOrder(orderId, symbol);
844
+ noteSuccess();
845
+ return {
846
+ id: String(raw.id),
847
+ symbol: raw.symbol ?? symbol ?? '',
848
+ side: (raw.side ?? 'buy'),
849
+ type: (raw.type ?? 'limit'),
850
+ status: raw.status,
851
+ amount: raw.amount ?? 0,
852
+ filled: raw.filled ?? 0,
853
+ remaining: raw.remaining ?? 0,
854
+ average: raw.average ?? null,
855
+ price: raw.price ?? null,
856
+ cost: raw.cost ?? 0,
857
+ fee: raw.fee ?? { cost: 0, currency: 'USDT' },
858
+ timestamp: raw.timestamp ?? Date.now(),
859
+ datetime: raw.datetime ?? new Date().toISOString(),
860
+ timeInForce: raw.timeInForce ?? 'GTC',
861
+ };
862
+ }
863
+ catch (err) {
864
+ noteBinanceError(err);
865
+ logger.error(TAG, `fetchOrder(${orderId}) failed: ${formatError(err)}`);
866
+ return null;
867
+ }
868
+ }
869
+ /**
870
+ * Fetch a single order by clientOrderId. Used for idempotency recovery
871
+ * after network timeouts — check if the order went through before retrying.
872
+ */
873
+ async fetchOrderByClientId(clientOrderId, symbol) {
874
+ try {
875
+ // Pre-gate: never hammer Binance during a ban. Caveat: during a ban
876
+ // this returns null (= "not found"), so an idempotency caller could
877
+ // resubmit. Accepted tradeoff — extending the IP ban for everyone is
878
+ // worse, and the resubmit path is itself reduce-only/bounded.
879
+ assertNotBanned('fetchOrder');
880
+ const raw = await this.exchange.fetchOrder(undefined, symbol, {
881
+ origClientOrderId: clientOrderId,
882
+ });
883
+ noteSuccess();
884
+ return {
885
+ id: String(raw.id),
886
+ symbol: raw.symbol ?? symbol,
887
+ side: (raw.side ?? 'buy'),
888
+ type: (raw.type ?? 'limit'),
889
+ status: raw.status,
890
+ amount: raw.amount ?? 0,
891
+ filled: raw.filled ?? 0,
892
+ remaining: raw.remaining ?? 0,
893
+ average: raw.average ?? null,
894
+ price: raw.price ?? null,
895
+ cost: raw.cost ?? 0,
896
+ fee: raw.fee ?? { cost: 0, currency: 'USDT' },
897
+ timestamp: raw.timestamp ?? Date.now(),
898
+ datetime: raw.datetime ?? new Date().toISOString(),
899
+ timeInForce: raw.timeInForce ?? 'GTC',
900
+ };
901
+ }
902
+ catch (err) {
903
+ noteBinanceError(err);
904
+ // Order not found is expected when checking idempotency — don't log as error
905
+ logger.info(TAG, `fetchOrderByClientId(${clientOrderId}) not found or failed: ${formatError(err)}`);
906
+ return null;
907
+ }
908
+ }
909
+ /** Cancel a real order on the exchange. Binance Futures splits orders across
910
+ * two endpoints — regular (`/fapi/v1/order`) and algo/conditional
911
+ * (`/fapi/v1/algoOrder`). Bracket SL/TP live in the algo bucket.
912
+ * The caller usually can't tell which bucket a given id belongs to, so we
913
+ * try regular first, then fall back to algo by algoId on "not found".
914
+ * Symbol is resolved from open-orders snapshot if the caller omits it. */
915
+ async cancelOrder(orderId, symbol) {
916
+ const looksAlreadyGone = (msg) => msg.includes('-2011') || msg.includes('-2013') ||
917
+ /unknown order/i.test(msg) || /does not exist/i.test(msg);
918
+ let resolvedSymbol = symbol;
919
+ let hint;
920
+ if (!resolvedSymbol) {
921
+ // fetchOpenOrders() already merges regular + algo buckets, so a single
922
+ // probe is enough to locate the order and its symbol. If a TRUSTED
923
+ // (non-null) list doesn't contain it, the order is not open — treat the
924
+ // cancel as an idempotent no-op rather than throwing "symbol required".
925
+ const open = await this.fetchOpenOrders();
926
+ if (open === null) {
927
+ // null = order state UNKNOWN (read failed / weight-paced / banned),
928
+ // NOT "order gone". Fabricating status:'canceled' here reported a
929
+ // still-working live order as cancelled exactly when Binance was
930
+ // shedding us. Same null≠empty rule as cancelAllOrders below: only a
931
+ // trusted list licenses the idempotent not-found fabrication.
932
+ throw new Error(`cancelOrder(${orderId}): fetchOpenOrders returned null (order state unknown) — ` +
933
+ 'cannot locate the order to cancel; refusing to fabricate a cancelled status');
934
+ }
935
+ const match = open.find((o) => o.id === orderId);
936
+ if (match?.symbol) {
937
+ resolvedSymbol = match.symbol;
938
+ hint = match;
939
+ }
940
+ else {
941
+ logger.info(TAG, `cancelOrder(${orderId}): not in open-orders snapshot — already cancelled/filled`);
942
+ return this.fabricateCanceledOrder(orderId, '', undefined);
943
+ }
944
+ }
945
+ try {
946
+ const raw = await this.exchange.cancelOrder(orderId, resolvedSymbol);
947
+ return this.normalizeCanceledOrder(raw, resolvedSymbol);
948
+ }
949
+ catch (err) {
950
+ const msg = formatError(err);
951
+ if (!looksAlreadyGone(msg)) {
952
+ noteBinanceError(err);
953
+ throw err;
954
+ }
955
+ // Fall through to algo cancel.
956
+ }
957
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
958
+ const ex = this.exchange;
959
+ if (typeof ex.fapiPrivateDeleteAlgoOrder !== 'function') {
960
+ logger.info(TAG, `cancelOrder(${orderId}): already cancelled/filled (no algo endpoint)`);
961
+ return this.fabricateCanceledOrder(orderId, resolvedSymbol, hint);
962
+ }
963
+ let marketId;
964
+ try {
965
+ marketId = ex.marketId(resolvedSymbol);
966
+ }
967
+ catch {
968
+ marketId = resolvedSymbol.replace('/', '').split(':')[0];
969
+ }
970
+ this.logAlgoCancelChokepoint(`cancelOrder(algoId=${orderId})`, marketId);
971
+ try {
972
+ await ex.fapiPrivateDeleteAlgoOrder({ symbol: marketId, algoId: orderId });
973
+ logger.info(TAG, `cancelOrder(${orderId}): cancelled via algo endpoint`);
974
+ return this.fabricateCanceledOrder(orderId, resolvedSymbol, hint);
975
+ }
976
+ catch (algoErr) {
977
+ const algoMsg = formatError(algoErr);
978
+ if (looksAlreadyGone(algoMsg)) {
979
+ logger.info(TAG, `cancelOrder(${orderId}): already cancelled/filled`);
980
+ return this.fabricateCanceledOrder(orderId, resolvedSymbol, hint);
981
+ }
982
+ noteBinanceError(algoErr);
983
+ throw algoErr;
984
+ }
985
+ }
986
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
987
+ normalizeCanceledOrder(raw, fallbackSymbol) {
988
+ return {
989
+ id: String(raw.id),
990
+ symbol: raw.symbol ?? fallbackSymbol ?? '',
991
+ side: (raw.side ?? 'buy'),
992
+ type: (raw.type ?? 'limit'),
993
+ status: 'canceled',
994
+ amount: raw.amount ?? 0,
995
+ filled: raw.filled ?? 0,
996
+ remaining: raw.remaining ?? 0,
997
+ average: raw.average ?? null,
998
+ price: raw.price ?? null,
999
+ cost: raw.cost ?? 0,
1000
+ fee: raw.fee ?? { cost: 0, currency: 'USDT' },
1001
+ timestamp: raw.timestamp ?? Date.now(),
1002
+ datetime: raw.datetime ?? new Date().toISOString(),
1003
+ timeInForce: raw.timeInForce ?? 'GTC',
1004
+ };
1005
+ }
1006
+ fabricateCanceledOrder(orderId, symbol, hint) {
1007
+ return {
1008
+ id: orderId,
1009
+ symbol,
1010
+ side: hint?.side ?? 'buy',
1011
+ type: hint?.type ?? 'market',
1012
+ status: 'canceled',
1013
+ amount: hint?.amount ?? 0,
1014
+ filled: hint?.filled ?? 0,
1015
+ remaining: hint?.remaining ?? 0,
1016
+ average: hint?.average ?? null,
1017
+ price: hint?.price ?? null,
1018
+ cost: hint?.cost ?? 0,
1019
+ fee: { cost: 0, currency: 'USDT' },
1020
+ timestamp: Date.now(),
1021
+ datetime: new Date().toISOString(),
1022
+ timeInForce: 'GTC',
1023
+ };
1024
+ }
1025
+ /**
1026
+ * Submit a bracket leg (STOP_MARKET or TAKE_PROFIT_MARKET). Sibling of
1027
+ * `createOrder` that widens the `type` parameter to the Binance-specific
1028
+ * trigger-order types. Used by BracketManager to attach SL/TP to an
1029
+ * already-open position.
1030
+ */
1031
+ async createBracketOrder(symbol, type, side, amount,
1032
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1033
+ params) {
1034
+ // Order path: NOT pre-gated (bracket arming must never be blocked — a
1035
+ // naked position is worse than a ban) but MUST arm the shared gate on a
1036
+ // 429/418 so the read pollers back off. Catch → note → rethrow.
1037
+ let raw;
1038
+ try {
1039
+ // Binance ignores `price` on market-type triggers; pass undefined.
1040
+ raw = await this.exchange.createOrder(symbol, type, side, amount, undefined, params);
1041
+ }
1042
+ catch (err) {
1043
+ noteBinanceError(err);
1044
+ throw err;
1045
+ }
1046
+ return {
1047
+ id: String(raw.id),
1048
+ symbol: raw.symbol ?? symbol,
1049
+ side: (raw.side ?? side),
1050
+ // Normalise the bracket types into our narrower CcxtOrder.type so downstream
1051
+ // readers don't need to know about STOP_MARKET/TAKE_PROFIT_MARKET. The
1052
+ // bracket identity is carried by the clientOrderId.
1053
+ type: 'market',
1054
+ status: (raw.status ?? 'open'),
1055
+ amount: raw.amount ?? amount,
1056
+ filled: raw.filled ?? 0,
1057
+ remaining: raw.remaining ?? amount,
1058
+ average: raw.average ?? null,
1059
+ price: raw.price ?? null,
1060
+ cost: raw.cost ?? 0,
1061
+ fee: raw.fee ?? { cost: 0, currency: 'USDT' },
1062
+ timestamp: raw.timestamp ?? Date.now(),
1063
+ datetime: raw.datetime ?? new Date().toISOString(),
1064
+ timeInForce: raw.timeInForce ?? 'GTC',
1065
+ };
1066
+ }
1067
+ /**
1068
+ * Cancel an order by its clientOrderId (not exchange id). Idempotent:
1069
+ * "order does not exist" is treated as success since the intent was to
1070
+ * remove the order. Binance returns -2011 when the order is already gone.
1071
+ *
1072
+ * Tries the regular-order cancel first, then the algo-order cancel on
1073
+ * failure, because Binance Futures splits the two endpoints and we can't
1074
+ * tell from the clientOrderId alone which bucket the order landed in.
1075
+ */
1076
+ /** MANDATORY ALGO-CANCEL CHOKEPOINT TRACE (2026-05-16, widened after the
1077
+ * Dropbox-revert blinded the cancelOrderByClientId-only tracer).
1078
+ *
1079
+ * EVERY bracket SL/TP is a Binance *algo* order; EVERY cancel of one that
1080
+ * reaches the exchange goes through `fapiPrivateDeleteAlgoOrder` — no
1081
+ * matter which higher-level method ordered it (cancelOrder by algoId,
1082
+ * cancelOrderByClientId by cid, cancelSymbolBracketOrders, cancelBrackets,
1083
+ * modify_*, the reconciler, the agent, anything). Logging the caller stack
1084
+ * at THIS one REST chokepoint makes the next bracket disappearance
1085
+ * unambiguous, and — critically — if a bracket leg goes `X:CANCELED` on
1086
+ * the user-data WS with NO chokepoint log around that timestamp, then it
1087
+ * was NOT cancelled by our code at all (Binance-side auto-cancel — a
1088
+ * fundamentally different fix). Pure observability, zero behaviour change.
1089
+ * Remove once the proven source is fixed and soaked. */
1090
+ logAlgoCancelChokepoint(via, marketId) {
1091
+ const stack = new Error('algo-cancel-trace').stack ?? '(no stack)';
1092
+ logger.warn(TAG, `ALGO-CANCEL CHOKEPOINT — fapiPrivateDeleteAlgoOrder about to fire ` +
1093
+ `(via ${via}, symbol=${marketId}). If this id is an rc-*-{s,t} ` +
1094
+ `bracket leg, this is the cancel that strips protection. ` +
1095
+ `Caller stack follows:\n${stack}`);
1096
+ }
1097
+ async cancelOrderByClientId(clientOrderId, symbol) {
1098
+ // ---- MANDATORY BRACKET-CANCEL ATTRIBUTION (2026-05-16) --------------
1099
+ // Every bracket-protection cancel in the entire plugin funnels through
1100
+ // this one method. All prior fixes failed because each cancel path logs
1101
+ // NOTHING on success, so the rogue silent canceller could only be
1102
+ // INFERRED (and was, wrongly, repeatedly). This logs the exact caller
1103
+ // stack for ANY rc-*-{s,t} (stop/target protection-leg) cancel — zero
1104
+ // behaviour change, pure observability — so the very next time a live
1105
+ // position's bracket disappears, the journal names the precise function
1106
+ // that ordered it instead of leaving us to guess. Remove once the
1107
+ // proven source is fixed and soaked.
1108
+ if (isBracketCid(clientOrderId)) {
1109
+ const stack = new Error('bracket-cancel-trace').stack ?? '(no stack)';
1110
+ logger.warn(TAG, `BRACKET-CANCEL ATTRIBUTION — cancelOrderByClientId(${clientOrderId}, ` +
1111
+ `${symbol}) was called. This cancels a STOP/TP protection leg. ` +
1112
+ `Caller stack follows:\n${stack}`);
1113
+ }
1114
+ const looksAlreadyGone = (msg) => msg.includes('-2011') || msg.includes('-2013') ||
1115
+ /unknown order/i.test(msg) || /does not exist/i.test(msg);
1116
+ // Try regular-order cancel first.
1117
+ let regularErr;
1118
+ try {
1119
+ await this.exchange.cancelOrder(undefined, symbol, {
1120
+ origClientOrderId: clientOrderId,
1121
+ });
1122
+ return;
1123
+ }
1124
+ catch (err) {
1125
+ const msg = formatError(err);
1126
+ if (looksAlreadyGone(msg)) {
1127
+ // Maybe it's an algo order instead — fall through to algo cancel.
1128
+ regularErr = err;
1129
+ }
1130
+ else {
1131
+ noteBinanceError(err);
1132
+ throw err;
1133
+ }
1134
+ }
1135
+ // Fall back to algo-order cancel by clientAlgoId.
1136
+ try {
1137
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1138
+ const ex = this.exchange;
1139
+ if (typeof ex.fapiPrivateDeleteAlgoOrder !== 'function') {
1140
+ // Older CCXT without algo support — treat regular "gone" as success.
1141
+ logger.info(TAG, `cancelOrderByClientId(${clientOrderId}): already cancelled/filled (no algo endpoint)`);
1142
+ return;
1143
+ }
1144
+ let marketId;
1145
+ try {
1146
+ marketId = ex.marketId(symbol);
1147
+ }
1148
+ catch {
1149
+ marketId = symbol.replace('/', '').split(':')[0];
1150
+ }
1151
+ this.logAlgoCancelChokepoint(`cancelOrderByClientId(cid=${clientOrderId})`, marketId);
1152
+ await ex.fapiPrivateDeleteAlgoOrder({
1153
+ symbol: marketId,
1154
+ clientAlgoId: clientOrderId,
1155
+ });
1156
+ logger.info(TAG, `cancelOrderByClientId(${clientOrderId}): cancelled via algo endpoint`);
1157
+ }
1158
+ catch (algoErr) {
1159
+ const algoMsg = formatError(algoErr);
1160
+ if (looksAlreadyGone(algoMsg)) {
1161
+ logger.info(TAG, `cancelOrderByClientId(${clientOrderId}): already cancelled/filled`);
1162
+ return;
1163
+ }
1164
+ // Neither endpoint worked. Surface the original regular error so callers
1165
+ // see the most informative message.
1166
+ noteBinanceError(algoErr);
1167
+ throw regularErr ?? algoErr;
1168
+ }
1169
+ }
1170
+ /** Cancel all orders on the exchange for a symbol (or everything, if omitted).
1171
+ * Covers BOTH regular orders and algo/conditional orders via per-order
1172
+ * cancellation through `cancelOrderByClientId`, which internally falls
1173
+ * back across both buckets.
1174
+ *
1175
+ * **Bracket protection is preserved by default.** Orders whose
1176
+ * clientOrderId parses as a bracket stop/target (`rc-<16hex>-s` or `-t`)
1177
+ * are NEVER cancelled by this method — otherwise a kill-switch fire or an
1178
+ * agent-initiated `cancel_all_orders` would strip exchange-side protection
1179
+ * off open positions and leave them naked. This was the root cause of
1180
+ * the 2026-04-21 naked-position incident (4 live positions ran without
1181
+ * stops for hours after kill-switch cancels swept their brackets).
1182
+ *
1183
+ * Callers that explicitly need to cancel brackets (e.g. `closePosition`
1184
+ * after flattening the position) must go through
1185
+ * `BracketManager.cancelBrackets(symbol)` which is symbol-scoped and
1186
+ * tracks the cancellation in the ledger. */
1187
+ async cancelAllOrders(symbol) {
1188
+ try {
1189
+ // fetchOpenOrders merges regular + algo — every open order in either
1190
+ // bucket is visible here with its clientOrderId.
1191
+ const open = await this.fetchOpenOrders(symbol);
1192
+ if (open === null) {
1193
+ // null = order state UNKNOWN (read failed / weight-paced / banned),
1194
+ // NOT "no orders". This method cancels per-order (bracket
1195
+ // preservation), so without the list it cannot do its job — throw so
1196
+ // the emergency caller's retry loop escalates loudly instead of
1197
+ // reporting a silent "0 cancelled" success while orders are live.
1198
+ throw new Error('cancelAllOrders: fetchOpenOrders returned null (order state unknown) — cannot enumerate orders to cancel');
1199
+ }
1200
+ const all = open;
1201
+ const cancelable = [];
1202
+ let preservedBrackets = 0;
1203
+ for (const o of all) {
1204
+ if (!o.clientOrderId) {
1205
+ // No clientOrderId (rare; manual orders from the Binance UI) — we
1206
+ // can't identify it as a bracket protective order, but we also
1207
+ // can't cancel it via cancelOrderByClientId. Skip with a warning.
1208
+ logger.warn(TAG, `cancelAllOrders: order without clientOrderId skipped (id=${o.id} symbol=${o.symbol})`);
1209
+ continue;
1210
+ }
1211
+ const parsed = parseBracketCid(o.clientOrderId);
1212
+ if (parsed && (parsed.role === 'stop' || parsed.role === 'target')) {
1213
+ preservedBrackets++;
1214
+ continue;
1215
+ }
1216
+ cancelable.push(o);
1217
+ }
1218
+ if (cancelable.length === 0) {
1219
+ if (preservedBrackets > 0) {
1220
+ logger.info(TAG, `cancelAllOrders: nothing to cancel; preserved ${preservedBrackets} bracket protective order(s)`);
1221
+ }
1222
+ return;
1223
+ }
1224
+ // Cancel each non-bracket order per-order. cancelOrderByClientId tries
1225
+ // the regular endpoint first and falls back to the algo endpoint, so
1226
+ // this one call covers either order type. A bulk sweep (the previous
1227
+ // implementation) couldn't do the bracket-preservation filtering.
1228
+ for (const o of cancelable) {
1229
+ if (!o.symbol || !o.clientOrderId)
1230
+ continue;
1231
+ try {
1232
+ await this.cancelOrderByClientId(o.clientOrderId, o.symbol);
1233
+ }
1234
+ catch (err) {
1235
+ logger.warn(TAG, `cancelAllOrders: cancel ${o.clientOrderId} (${o.symbol}) failed: ${formatError(err)}`);
1236
+ }
1237
+ }
1238
+ if (preservedBrackets > 0) {
1239
+ logger.info(TAG, `cancelAllOrders: cancelled ${cancelable.length}, preserved ${preservedBrackets} bracket protective order(s)`);
1240
+ }
1241
+ else {
1242
+ logger.info(TAG, `cancelAllOrders: cancelled ${cancelable.length} order(s)`);
1243
+ }
1244
+ }
1245
+ catch (err) {
1246
+ noteBinanceError(err);
1247
+ logger.error(TAG, `cancelAllOrders failed: ${formatError(err)}`);
1248
+ throw err;
1249
+ }
1250
+ }
1251
+ /** Disable Binance's per-symbol `countdownCancelAll` "dead-man's-switch"
1252
+ * (POST /fapi/v1/countdownCancelAll, countdownTime=0).
1253
+ *
1254
+ * ROOT-CAUSE FIX 2026-05-16. A countdownCancelAll timer was armed on this
1255
+ * account from OUTSIDE our codebase (verified: nothing in plugin/skill/
1256
+ * scripts/OpenClaw-gateway/ccxt ever sets it). When armed, Binance purges
1257
+ * EVERY open order for the symbol when the countdown lapses — server-side,
1258
+ * empty reason, NOT via the cancel endpoint — which is why every bracket
1259
+ * (both order forms, all symbols) was auto-cancelled ~17-53s after
1260
+ * placement and no instrumentation ever caught a caller. Proven decisively:
1261
+ * a clean order placed with the switch disabled survived 5 min when every
1262
+ * churned bracket died in ~25s. countdownTime=0 is Binance's documented
1263
+ * DISABLE value; purely protective (cannot cancel/modify/close anything),
1264
+ * idempotent, harmless no-op if none is armed. We proactively disable it
1265
+ * per active symbol so an externally-armed switch can never again strip
1266
+ * protection. Best-effort + safety-critical: like order paths it is NOT
1267
+ * pre-gated (must never be blocked) but arms the ban gate on error. */
1268
+ async disableCountdownCancelAll(symbol) {
1269
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1270
+ const ex = this.exchange;
1271
+ if (typeof ex.fapiPrivatePostCountdownCancelAll !== 'function') {
1272
+ logger.warn(TAG, `disableCountdownCancelAll(${symbol}): CCXT lacks fapiPrivatePostCountdownCancelAll — cannot neutralise the kill-switch; check ccxt version`);
1273
+ return;
1274
+ }
1275
+ let marketId;
1276
+ try {
1277
+ marketId = ex.marketId(symbol);
1278
+ }
1279
+ catch {
1280
+ marketId = symbol.replace('/', '').split(':')[0];
1281
+ }
1282
+ try {
1283
+ await ex.fapiPrivatePostCountdownCancelAll({ symbol: marketId, countdownTime: 0 });
1284
+ }
1285
+ catch (err) {
1286
+ // Never throw into the bracket-attach path — a failed disable must not
1287
+ // block protection placement. Arm the gate on 429/418 like order paths.
1288
+ noteBinanceError(err);
1289
+ logger.warn(TAG, `disableCountdownCancelAll(${marketId}) failed (non-fatal): ${formatError(err)}`);
1290
+ }
1291
+ }
1292
+ /** Translate a Binance market ID (e.g. `BTCUSDT`) back to the unified
1293
+ * CCXT symbol (e.g. `BTC/USDT:USDT`, with the `:SETTLE` suffix stripped
1294
+ * to match the rest of the system's un-suffixed format). Used when parsing
1295
+ * user-data WebSocket events — Binance sends raw market IDs, but the
1296
+ * plugin's state store + downstream consumers use unified symbols.
1297
+ * Falls back to the raw id if the market isn't in CCXT's cache. */
1298
+ symbolFromMarketId(marketId) {
1299
+ try {
1300
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1301
+ const ex = this.exchange;
1302
+ const raw = ex.markets_by_id?.[marketId];
1303
+ // markets_by_id can return a single market or an array (shared ids).
1304
+ const market = Array.isArray(raw) ? raw[0] : raw;
1305
+ const unified = typeof market?.symbol === 'string' ? market.symbol : null;
1306
+ if (unified) {
1307
+ // Strip `:SETTLE` so every consumer sees the same format. See
1308
+ // memory/feedback_symbol_normalize_boundary.md.
1309
+ return unified.replace(/:[A-Z]+$/, '');
1310
+ }
1311
+ }
1312
+ catch { /* fall through */ }
1313
+ return marketId;
1314
+ }
1315
+ /** Create a user-data listenKey via POST /fapi/v1/listenKey. The returned
1316
+ * key is the authentication token for the user-data WebSocket stream
1317
+ * (`wss://fstream.binance.com/private/ws/<listenKey>`). Keys expire after 60 min
1318
+ * of inactivity — callers must schedule `keepAliveListenKey` every ~25 min. */
1319
+ async createListenKey() {
1320
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1321
+ const ex = this.exchange;
1322
+ if (typeof ex.fapiPrivatePostListenKey !== 'function') {
1323
+ throw new Error('CCXT missing fapiPrivatePostListenKey — check ccxt version');
1324
+ }
1325
+ try {
1326
+ // Gate listenKey ops too: a POST during an active 418 ban EXTENDS the
1327
+ // ban (Binance extends on ANY request while banned). Better to fail the
1328
+ // mint fast and let UserDataStream back off than to keep the ban alive.
1329
+ assertNotBanned('createListenKey');
1330
+ const res = await ex.fapiPrivatePostListenKey();
1331
+ noteSuccess();
1332
+ const key = typeof res?.listenKey === 'string' ? res.listenKey : null;
1333
+ if (!key)
1334
+ throw new Error('listenKey missing from Binance response');
1335
+ return key;
1336
+ }
1337
+ catch (err) {
1338
+ noteBinanceError(err);
1339
+ throw err;
1340
+ }
1341
+ }
1342
+ /** Keep-alive the current listenKey via PUT /fapi/v1/listenKey. Must be
1343
+ * called within 60 min of the last keep-alive; 25-min cadence gives a 2x
1344
+ * safety margin. Binance does not require the key to be passed — the
1345
+ * endpoint acts on whichever key the API key last minted. */
1346
+ async keepAliveListenKey() {
1347
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1348
+ const ex = this.exchange;
1349
+ if (typeof ex.fapiPrivatePutListenKey !== 'function') {
1350
+ throw new Error('CCXT missing fapiPrivatePutListenKey — check ccxt version');
1351
+ }
1352
+ try {
1353
+ assertNotBanned('keepAliveListenKey');
1354
+ await ex.fapiPrivatePutListenKey();
1355
+ noteSuccess();
1356
+ }
1357
+ catch (err) {
1358
+ noteBinanceError(err);
1359
+ throw err;
1360
+ }
1361
+ }
1362
+ /** Close the current listenKey via DELETE /fapi/v1/listenKey. Polite
1363
+ * teardown on graceful shutdown; Binance will otherwise expire the key
1364
+ * after 60 min of inactivity. */
1365
+ async closeListenKey() {
1366
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1367
+ const ex = this.exchange;
1368
+ if (typeof ex.fapiPrivateDeleteListenKey !== 'function') {
1369
+ throw new Error('CCXT missing fapiPrivateDeleteListenKey — check ccxt version');
1370
+ }
1371
+ try {
1372
+ // During a ban, skip the polite DELETE entirely — it would extend the
1373
+ // ban. The key expires on its own after 60 min of inactivity anyway.
1374
+ assertNotBanned('closeListenKey');
1375
+ await ex.fapiPrivateDeleteListenKey();
1376
+ noteSuccess();
1377
+ }
1378
+ catch (err) {
1379
+ noteBinanceError(err);
1380
+ throw err;
1381
+ }
1382
+ }
1383
+ /** Whether this instance is connected to testnet. */
1384
+ isTestnet() {
1385
+ return this.testnet;
1386
+ }
1387
+ /** Get the underlying CCXT exchange instance (for loadMarkets, position mode detection, etc.). */
1388
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1389
+ getExchange() {
1390
+ return this.exchange;
1391
+ }
1392
+ /**
1393
+ * Get rate-limit headers from the last exchange response.
1394
+ * CCXT stores these on the exchange instance after each request.
1395
+ * Returns empty object if not available.
1396
+ */
1397
+ getLastResponseHeaders() {
1398
+ try {
1399
+ // CCXT stores last response headers in exchange.last_response_headers (Python)
1400
+ // or exchange.lastResponseHeaders (JS). Try both patterns.
1401
+ const headers = this.exchange.last_response_headers
1402
+ ?? this.exchange.lastResponseHeaders
1403
+ ?? {};
1404
+ // Normalize keys to lowercase
1405
+ const result = {};
1406
+ for (const [k, v] of Object.entries(headers)) {
1407
+ result[k.toLowerCase()] = String(v);
1408
+ }
1409
+ return result;
1410
+ }
1411
+ catch {
1412
+ return {};
1413
+ }
1414
+ }
1415
+ }