@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,1665 @@
1
+ // LiveAdapter — wraps BinancePrivateApi to implement IExchangeAdapter.
2
+ // Integrates all Sprint 2 safety modules: rate limiter, intent journal,
3
+ // order poller, slippage tracker, and position reconciler.
4
+ import { EventEmitter } from 'node:events';
5
+ import { randomUUID } from 'node:crypto';
6
+ import { BinancePrivateApi } from '../ccxt/binance-private.js';
7
+ import { ExchangeInfoCache } from './exchange-info-cache.js';
8
+ import { BinanceRateLimiter, ENDPOINT_WEIGHTS } from './rate-limiter.js';
9
+ import { IntentJournal } from './intent-journal.js';
10
+ import { OrderPoller } from './order-poller.js';
11
+ import { SlippageTracker } from './slippage-tracker.js';
12
+ import { PositionReconciler } from './reconciler.js';
13
+ import { parseExchangeError } from './exchange-errors.js';
14
+ import { logger, formatError } from '../logger.js';
15
+ import { getQuoteWalletBalance } from '../balance-utils.js';
16
+ import { LiveBalanceEnricher, utcDateString, utcMidnightMs } from './live-balance-enricher.js';
17
+ import { DepositTracker } from './deposit-tracker.js';
18
+ import { BracketLedger, normalizeBracketSymbol } from './bracket-ledger.js';
19
+ import { noteResponseWeight, assertNotBanned, noteBinanceError, noteSuccess } from '../ccxt/binance-ban-gate.js';
20
+ import { BracketManager } from './bracket-manager.js';
21
+ import { BracketReconciler } from './bracket-reconciler.js';
22
+ import { LiveBracketApi } from './live-bracket-api.js';
23
+ import { generateBracketId, buildBracketCid, parseBracketCid } from './bracket-id.js';
24
+ import { validateStopDirection, validateTargetDirection } from './bracket-params.js';
25
+ import { bracketsEnabled } from '../config/brackets-config.js';
26
+ import { userDataStreamEnabled, userDataStreamAuthoritative, DEFAULT_TUNABLES as USER_DATA_STREAM_DEFAULT_TUNABLES, } from '../config/user-data-stream-config.js';
27
+ import { UserDataStreamController } from './user-data-stream-controller.js';
28
+ import { assertNotShuttingDown, registerOp } from '../lifecycle/shutdown-coordinator.js';
29
+ import { updateMfe } from '../mfe.js';
30
+ import { computeInvalidationHit } from '../pinned-plan.js';
31
+ const TAG = 'live-adapter';
32
+ /** Minimum interval between /fapi/v1/income re-anchor calls.
33
+ *
34
+ * Endpoint: GET /fapi/v1/income, weight=30, no per-call cap — verified
35
+ * against developers.binance.com/.../Get-Income-History (2026-05-16).
36
+ *
37
+ * Why this can safely be long (NOT 60s):
38
+ * - `sessionStartNav = wallet_now − netNonTransfer = wallet_at_midnight`
39
+ * is INVARIANT under non-transfer activity within a UTC day — wallet
40
+ * grows by exactly `netNonTransfer` (realized PnL + fees + funding) as
41
+ * trades close, so a re-poll recomputes the same constant.
42
+ * - The dashboard's Realized + Day P&L are derived by the webapp from
43
+ * WS-fresh wallet + unrealized (`totalPnl − unrealizedPnl`), NOT from
44
+ * this seed (see live-balance-enricher.seedRealizedPnlToday doc +
45
+ * webapp slices/trades.ts:updateLiveKpi). `recordFill` is unused in
46
+ * prod, so the seed never moves between re-anchors anyway.
47
+ * The only re-anchors that change anything: startup (done once in
48
+ * initialize()), UTC-midnight rollover (FORCED — bypasses this throttle
49
+ * via the dayRolled branch), and catching a BNFCR/multi-asset transfer
50
+ * type DepositTracker can't see (rare; a long safety reconcile suffices —
51
+ * DepositTracker handles ordinary transfers within its own poll).
52
+ *
53
+ * Polling this every 60s was ~30 weight/min of permanent, redundant IP
54
+ * weight that fed the 2026-05-14→16 steady-state weight floor which paced
55
+ * out the bracket audit's getOpenOrders and blinded the agent on live
56
+ * protection. 30 min preserves the BNFCR safety reconcile while cutting
57
+ * the load ~97% (30w/60s → ~1w/min). Env-overridable for emergency tuning
58
+ * without a redeploy; clamp 60s–6h. */
59
+ const INCOME_REFRESH_MIN_INTERVAL_MS = (() => {
60
+ const raw = Number(process.env.RC_INCOME_REFRESH_MS);
61
+ return Number.isFinite(raw) && raw >= 60_000 && raw <= 21_600_000
62
+ ? raw
63
+ : 30 * 60_000;
64
+ })();
65
+ /** Generate a Binance-compatible client order ID (max 36 chars, alphanumeric). */
66
+ function generateClientOrderId() {
67
+ return randomUUID();
68
+ }
69
+ /**
70
+ * Backfill `balance.free[cur] = total[cur]` for currencies where Binance's CCXT
71
+ * response left the per-currency `free` and `used` at zero despite a non-zero
72
+ * total. Only safe when `used` is zero — otherwise the exchange really is
73
+ * reserving margin and we must not inflate availability.
74
+ */
75
+ export function repairFreeFromTotal(balance) {
76
+ if (!balance.total || !balance.free)
77
+ return;
78
+ for (const cur of Object.keys(balance.total)) {
79
+ const total = balance.total[cur] ?? 0;
80
+ const used = balance.used?.[cur] ?? 0;
81
+ const free = balance.free[cur] ?? 0;
82
+ if (total > 0 && free <= 0 && used <= 0) {
83
+ balance.free[cur] = total;
84
+ // Mirror into the per-currency object shape if present.
85
+ const perCur = balance[cur];
86
+ if (perCur && typeof perCur === 'object' && 'free' in perCur) {
87
+ perCur.free = total;
88
+ }
89
+ }
90
+ }
91
+ }
92
+ export class LiveAdapter extends EventEmitter {
93
+ mode;
94
+ isLive = true;
95
+ api;
96
+ sizeCapMultiplier;
97
+ maxPositionUSDT;
98
+ exchangeInfo = new ExchangeInfoCache();
99
+ isHedgeMode = false;
100
+ // Sticky cooldown for getOpenOrders. When fetchOpenOrders is rate-limited
101
+ // (null / paced / banned), the agent's SKILL.md-mandated recovery loop
102
+ // (audit_bracket_protection → attach_brackets) re-calls it every ~3 s.
103
+ // Each unscoped call is 40 weight, so the loop ALONE pins the IP weight at
104
+ // the pacer ceiling → every other read is shed → the agent never completes
105
+ // a heartbeat → no operator messages, naked position never healed
106
+ // (2026-05-15 incident). This makes the failure STICKY: after a rate-limit
107
+ // failure, every getOpenOrders short-circuits with the same error WITHOUT
108
+ // touching Binance for the cooldown, so the weight window drains, the
109
+ // pacer clears, and the next post-cooldown call can actually succeed.
110
+ getOpenOrdersUnavailableUntil = 0;
111
+ static OPEN_ORDERS_RL_COOLDOWN_MS = 45_000;
112
+ // Sprint 2 safety modules — all owned by the adapter
113
+ rateLimiter = new BinanceRateLimiter();
114
+ intentJournal;
115
+ orderPoller;
116
+ slippageTracker = new SlippageTracker();
117
+ reconciler;
118
+ enricher;
119
+ depositTracker;
120
+ // Exchange-native bracket orders — null when mode='off' (legacy stop-watcher owns it).
121
+ bracketMode;
122
+ bracketLedger = null;
123
+ bracketManager = null;
124
+ bracketReconciler = null;
125
+ // User-data WebSocket stream — null when mode='off' (REST polling owns reads).
126
+ // In Phase 1 this is dead code on prod (mode='off' is the default); the wiring
127
+ // is in place so Phase 2 onwards only requires a flag flip. Read paths
128
+ // (getPositions/getBalance/getOpenOrders) remain REST-backed until
129
+ // userDataStreamAuthoritative(mode) is true (observe/enforce — Phase 2+).
130
+ userDataStreamMode;
131
+ userDataStream = null;
132
+ // Per-cid last-seen ALGO_UPDATE status — the authoritative WS bracket-leg
133
+ // signal for resolveBracketLegLiveness()'s Tier-1 fast-path. onAlgoUpdate
134
+ // does NOT terminalize the ledger on CANCELED (by design — see its doc
135
+ // block), so the ledger row state is a LOSSY proxy that misses the exact
136
+ // spiral cancel. This map keeps the real per-leg WS truth. Insertion-order
137
+ // bounded (oldest evicted past the cap) — cids are one-shot per bracket.
138
+ wsAlgoStatusByCid = new Map();
139
+ static WS_ALGO_STATUS_CAP = 1000;
140
+ // Freshness window for the symbol-scoped trusted-live sweep
141
+ // (findTrustedLiveBracketCidsForSymbol). The 2026-05-16 INJ spiral's fresh
142
+ // leg is seconds old when the guard needs it, so this only has to be
143
+ // generous enough to never miss a genuinely-live leg while short enough
144
+ // that an ancient record can't suppress a real repair forever. Past this
145
+ // age the sweep ignores the record and the caller falls back to the
146
+ // per-leg REST authority — never to a guess.
147
+ static WS_LIVE_LEG_MAX_AGE_MS = 5 * 60_000;
148
+ // Session-start NAV — captured once at initialization, used for drawdown calculation.
149
+ // If not yet captured, getSessionStartNav() returns null.
150
+ _sessionStartNav = null;
151
+ // Last time the realized-today seed + sessionStartNav were re-anchored from
152
+ // Binance's income endpoint. Throttled to INCOME_REFRESH_MIN_INTERVAL_MS
153
+ // (30 min default) because the anchor is invariant within a UTC day — see
154
+ // that constant's doc for why a re-poll is otherwise pure redundant
155
+ // weight. UTC-midnight rollover forces a refresh regardless of throttle.
156
+ lastIncomeRefreshMs = 0;
157
+ // Day-anchor tracking. utcDayAnchor = the UTC date the current sessionStartNav
158
+ // is computed against. Crossing UTC midnight forces a re-anchor on the next
159
+ // refresh tick regardless of throttle.
160
+ incomeAnchorUtcDay = utcDateString(Date.now());
161
+ // Per-symbol position metadata for live mode. Live positions live on Binance,
162
+ // but the agent-supplied entry context (thesis, setupType, regime, scorecard)
163
+ // and the running MFE state must live somewhere on our side. In-memory only —
164
+ // a plugin restart wipes this. Bracket ledger covers stopPrice persistence
165
+ // through restarts; thesis/setup metadata is recoverable from the create_order
166
+ // log / heartbeat reasoning if needed. Seeded in createOrder, updated on
167
+ // getPositions tick, dropped on closePosition or when a symbol vanishes from
168
+ // the open-positions snapshot.
169
+ liveMetadata = new Map();
170
+ // Readiness state — mutable, starts INIT_PENDING
171
+ _readiness = 'INIT_PENDING';
172
+ get readiness() {
173
+ return this._readiness;
174
+ }
175
+ /** Session-start NAV, captured once at initialization. Used for drawdown calculation. */
176
+ getSessionStartNav() {
177
+ return this._sessionStartNav;
178
+ }
179
+ constructor(config, mode, microLiveConfig, bracketMode = 'off', userDataStreamMode = 'off', userDataStreamTunables = USER_DATA_STREAM_DEFAULT_TUNABLES,
180
+ /** TRADE_AUDIT_TRAIL_PLAN Phase 1 — optional audit-trail wiring. Wired
181
+ * through to UserDataStreamController. Absent today on every existing
182
+ * call site (default off); operator-driven once activation is approved. */
183
+ tradeIngest,
184
+ /** Position-decision auto-capture wiring. When provided alongside
185
+ * tradeIngest, ws-ingest also routes every TRADE event through
186
+ * `onWsFillObserved` to capture limit-order entries + scale-ins
187
+ * (PR1 deferral resolution, 2026-05-03). Optional for back-compat. */
188
+ autoCapture) {
189
+ super();
190
+ this.mode = mode;
191
+ this.bracketMode = bracketMode;
192
+ this.userDataStreamMode = userDataStreamMode;
193
+ this.api = new BinancePrivateApi(config);
194
+ if (mode === 'MICRO_LIVE') {
195
+ // sizeCapMultiplier is 1.0 — the agent already sizes for the real wallet.
196
+ // maxPositionUSDT is the safety cap (e.g., $50 max per position).
197
+ this.sizeCapMultiplier = 1.0;
198
+ this.maxPositionUSDT = microLiveConfig?.maxPositionUSDT ?? 50;
199
+ }
200
+ else {
201
+ this.sizeCapMultiplier = 1.0;
202
+ this.maxPositionUSDT = null;
203
+ }
204
+ // Initialize safety modules
205
+ this.intentJournal = new IntentJournal();
206
+ this.orderPoller = new OrderPoller(this);
207
+ this.reconciler = new PositionReconciler(this);
208
+ this.enricher = new LiveBalanceEnricher();
209
+ this.depositTracker = new DepositTracker(this.api);
210
+ // Bracket-orders system — only initialised when the operator has flipped
211
+ // the flag to 'observe' or 'enforce'. In 'off' mode the legacy stop-watcher
212
+ // continues to own protection.
213
+ if (bracketsEnabled(this.bracketMode)) {
214
+ this.bracketLedger = new BracketLedger();
215
+ const liveBracketApi = new LiveBracketApi(this.api);
216
+ this.bracketManager = new BracketManager(liveBracketApi, this.bracketLedger);
217
+ this.bracketManager.on('event', (ev) => this.emit('bracket_event', ev));
218
+ this.bracketReconciler = new BracketReconciler(this, this.bracketLedger, this.bracketManager, liveBracketApi);
219
+ this.bracketReconciler.on('drift', (d) => this.emit('bracket_drift', d));
220
+ logger.info(TAG, `Bracket orders ENABLED in mode=${this.bracketMode}`);
221
+ }
222
+ // Forward poller events
223
+ this.orderPoller.on('order_event', (event) => {
224
+ this.emit('order_event', event);
225
+ // Track slippage on fills
226
+ if (event.type === 'filled' && event.order.average != null) {
227
+ this.slippageTracker.recordFill({
228
+ orderId: event.orderId,
229
+ symbol: event.order.symbol,
230
+ side: event.order.side,
231
+ expectedPrice: event.order.price ?? event.order.average,
232
+ actualPrice: event.order.average,
233
+ amount: event.order.filled,
234
+ cost: event.order.cost,
235
+ fee: event.order.fee?.cost ?? 0,
236
+ });
237
+ }
238
+ // Attach brackets when a pending limit entry fills. For market orders
239
+ // the attach happens inline in createOrder; this branch covers limits.
240
+ if (event.type === 'filled' && this.bracketManager && this.bracketLedger) {
241
+ const pending = this.bracketLedger.getBySymbol(event.order.symbol);
242
+ if (pending && pending.state === 'pending_entry') {
243
+ const filledAmount = event.order.filled;
244
+ if (filledAmount > 0) {
245
+ this.attachBracketsAsync(event.order.symbol, filledAmount);
246
+ }
247
+ }
248
+ }
249
+ });
250
+ // Forward reconciler events
251
+ this.reconciler.on('drift_detected', (result) => {
252
+ this.emit('drift_detected', result);
253
+ });
254
+ // User-data WebSocket stream — constructed eagerly but only started in
255
+ // `initialize()` when mode!='off'. The controller defaults are safe to
256
+ // instantiate without I/O (no network calls until .start()).
257
+ if (userDataStreamEnabled(this.userDataStreamMode)) {
258
+ this.userDataStream = new UserDataStreamController(this.api, {
259
+ mode: this.userDataStreamMode,
260
+ testnet: config.testnet ?? false,
261
+ tunables: userDataStreamTunables,
262
+ tradeIngest,
263
+ autoCapture,
264
+ });
265
+ this.userDataStream.on('snapshotApplied', (snap) => this.emit('user_data_snapshot', snap));
266
+ this.userDataStream.on('drift', (drift) => this.emit('user_data_drift', drift));
267
+ this.userDataStream.on('forcedResync', (cause) => this.emit('user_data_forced_resync', cause));
268
+ this.userDataStream.on('marginCall', (ev) => this.emit('user_data_margin_call', ev));
269
+ this.userDataStream.on('reconnecting', (attempt, delay) => this.emit('user_data_reconnecting', attempt, delay));
270
+ // AUTHORITATIVE bracket-lifecycle signal. Replaces REST-audit guessing
271
+ // (the audit→re-attach spiral that flushed live positions on
272
+ // 2026-05-15). Doc-verified status semantics drive the action.
273
+ this.userDataStream.on('algoUpdate', (ev) => this.onAlgoUpdate(ev));
274
+ logger.info(TAG, `User-data stream ENABLED in mode=${this.userDataStreamMode}`);
275
+ }
276
+ logger.info(TAG, `LiveAdapter created in ${mode} mode (size cap: ${this.sizeCapMultiplier * 100}%, max pos USDT: ${this.maxPositionUSDT ?? 'unlimited'})`);
277
+ }
278
+ /** Get the underlying BinancePrivateApi (for advanced queries like fetchTicker). */
279
+ getApi() {
280
+ return this.api;
281
+ }
282
+ /** Get the exchange info cache (for symbol rules). */
283
+ getExchangeInfo() {
284
+ return this.exchangeInfo;
285
+ }
286
+ /** Whether the account is in hedge mode. */
287
+ getIsHedgeMode() {
288
+ return this.isHedgeMode;
289
+ }
290
+ /** Get safety modules for external access (dashboard, tools, etc.). */
291
+ getRateLimiter() { return this.rateLimiter; }
292
+ getIntentJournal() { return this.intentJournal; }
293
+ getOrderPoller() { return this.orderPoller; }
294
+ getSlippageTracker() { return this.slippageTracker; }
295
+ getReconciler() { return this.reconciler; }
296
+ getEnricher() { return this.enricher; }
297
+ getDepositTracker() { return this.depositTracker; }
298
+ /** Active user-data-stream mode. `'off'` when the feature flag is disabled. */
299
+ getUserDataStreamMode() { return this.userDataStreamMode; }
300
+ /** User-data stream controller, or null if mode='off'. Used by dashboards
301
+ * and diagnostic tools to read the live-state store + health. */
302
+ getUserDataStream() { return this.userDataStream; }
303
+ /**
304
+ * Post-registration async initialization.
305
+ * Must complete before create_order is allowed (readiness gate).
306
+ * Emergency controls (cancel, close) work regardless of readiness.
307
+ */
308
+ async initialize() {
309
+ try {
310
+ // 1. Validate API permissions
311
+ logger.info(TAG, 'Validating API permissions...');
312
+ const perms = await this.api.validatePermissions();
313
+ if (!perms.canReadBalance || !perms.canReadPositions) {
314
+ logger.error(TAG, `API key lacks required permissions: ${perms.errors.join(', ')}`);
315
+ this._readiness = 'BLOCKED';
316
+ this.emit('readiness_changed', this._readiness, 'API key missing read permissions');
317
+ return;
318
+ }
319
+ logger.info(TAG, 'API permissions validated');
320
+ // 2. Fetch real balance and capture session-start NAV (once)
321
+ const balance = await this.api.fetchBalance();
322
+ const quoteBalance = getQuoteWalletBalance(balance);
323
+ if (this._sessionStartNav == null) {
324
+ // Anchor today's KPIs to Binance's income endpoint so the dashboard
325
+ // Realized + Day P&L match the Binance app even across plugin
326
+ // restarts. One call, two uses of the same `netNonTransfer` figure:
327
+ // - Realized KPI = netNonTransfer (REALIZED_PNL + COMMISSION +
328
+ // FUNDING_FEE + rebates). This is what the Binance app shows
329
+ // under "Today's Realized PNL" — verified against the mobile
330
+ // app on 2026-05-14 (Binance -$5.98 vs ours when we were
331
+ // using gross-only -$3.70 = $2.28 fees/funding gap).
332
+ // - sessionStartNav anchor = wallet_now − netNonTransfer = the
333
+ // wallet at UTC midnight. Day P&L = NAV − sessionStartNav.
334
+ // The `realizedPnlGross` field on the breakdown is retained for
335
+ // debug logging only — it does NOT match Binance's UI label.
336
+ // Refreshed periodically inside getBalance() — see refreshIncomeAnchor.
337
+ // forced:true → never-paced context. This boot anchor establishes
338
+ // the wallet_at_midnight baseline the ENTIRE Day-P&L / Realized KPI
339
+ // chain is derived from; it must not be shed by a heartbeat scan
340
+ // burst (2026-05-16 KPI incident).
341
+ const utcMidnight = utcMidnightMs();
342
+ const breakdown = await this.api.fetchTodayIncomeBreakdown(utcMidnight, {
343
+ forced: true,
344
+ });
345
+ if (breakdown != null) {
346
+ this.enricher.seedRealizedPnlToday(breakdown.netNonTransfer);
347
+ const navAtMidnight = quoteBalance - breakdown.netNonTransfer;
348
+ this._sessionStartNav = navAtMidnight;
349
+ this.enricher.setSessionStartNav(navAtMidnight);
350
+ this.lastIncomeRefreshMs = Date.now();
351
+ logger.info(TAG, `Session-start NAV anchored: ${navAtMidnight.toFixed(4)} (wallet ${quoteBalance.toFixed(4)} − netNonTransfer ${breakdown.netNonTransfer.toFixed(4)}); realizedPnlGross ${breakdown.realizedPnlGross.toFixed(4)} (debug only)`);
352
+ }
353
+ else {
354
+ // null≠empty: do NOT coerce to {0,0} (that pins sessionStartNav to
355
+ // wallet_now and zeroes Day P&L — 2026-05-16 KPI incident). Leave
356
+ // the anchor uncaptured and lastIncomeRefreshMs at 0 so
357
+ // refreshIncomeAnchor() self-heals on the next getBalance cycle
358
+ // (forced while _sessionStartNav == null → lands as soon as we're
359
+ // not under a real ban).
360
+ logger.warn(TAG, 'Session-start NAV NOT anchored — /fapi/v1/income unavailable at boot (real ban/network). Day P&L anchors on the first successful income refresh; refusing to fabricate a {0,0} anchor.');
361
+ }
362
+ // Arm the deposit tracker from this instant forward; any pre-init
363
+ // transfers are already baked into the starting wallet so they must
364
+ // not be double-counted.
365
+ this.depositTracker.reset(Date.now());
366
+ }
367
+ logger.info(TAG, `Live balance: ${quoteBalance}`);
368
+ // 3. Check for micro-live minimum balance
369
+ if (this.mode === 'MICRO_LIVE' && this.maxPositionUSDT != null && quoteBalance < this.maxPositionUSDT) {
370
+ logger.warn(TAG, `Balance ${quoteBalance} is below micro-live max position ${this.maxPositionUSDT}`);
371
+ // Not blocking — but log the warning
372
+ }
373
+ // 4. Load exchange info + position mode BEFORE position check.
374
+ // Emergency controls (close/cancel) need these even in DEGRADED state.
375
+ logger.info(TAG, 'Loading exchange info...');
376
+ await this.loadExchangeInfo();
377
+ await this.detectPositionMode();
378
+ // 4b. Hedge-mode accounts are not supported in v1 (one-way only — see
379
+ // CLAUDE.md "User-Data WebSocket Stream" + BRACKET_ORDERS_DESIGN.md
380
+ // §2). The entry path only sends `positionSide` when a caller-
381
+ // supplied option is present (the tool path never supplies one) and
382
+ // bracket legs always send `reduceOnly:true` with no `positionSide` —
383
+ // both are rejected by Binance in Hedge Mode (positionSide is
384
+ // mandatory; reduceOnly must not be sent). Going READY here would let
385
+ // the agent place entries that fill while their brackets are rejected,
386
+ // i.e. naked live positions. Refuse trading instead. Emergency
387
+ // controls (close/cancel) are NOT readiness-gated and still work.
388
+ if (this.isHedgeMode) {
389
+ this._readiness = 'BLOCKED';
390
+ const reason = 'Hedge-mode account not supported in v1 — switch the Binance Futures account to One-Way position mode to enable trading';
391
+ this.emit('readiness_changed', this._readiness, reason);
392
+ logger.error(TAG, `Adapter BLOCKED: ${reason}`);
393
+ return;
394
+ }
395
+ // 5. Fetch existing positions (reconciliation)
396
+ const positions = await this.api.fetchPositions();
397
+ if (positions && positions.length > 0) {
398
+ logger.warn(TAG, `Found ${positions.length} existing position(s) on exchange at startup`);
399
+ this._readiness = 'DEGRADED';
400
+ this.emit('readiness_changed', this._readiness, 'Existing positions found — auto-acknowledging after lockout');
401
+ this.emit('positions_found_on_startup', { count: positions.length, positions });
402
+ // Auto-acknowledge after 15s lockout — the agent needs to place protective
403
+ // stops on existing positions, which requires READY status. Staying DEGRADED
404
+ // forever leaves live positions unprotected after every deploy/restart.
405
+ setTimeout(() => {
406
+ if (this._readiness === 'DEGRADED') {
407
+ logger.info(TAG, 'Auto-acknowledging existing positions after startup lockout');
408
+ void this.acknowledgePositions();
409
+ }
410
+ }, 16_000);
411
+ return;
412
+ }
413
+ // 6. Compact the intent journal (prevents unbounded file growth)
414
+ this.intentJournal.compact();
415
+ // 7. Resolve any pending order intents from a prior crash
416
+ const unresolved = this.intentJournal.getUnresolved();
417
+ let hasUnresolvedIntents = false;
418
+ if (unresolved.length > 0) {
419
+ logger.warn(TAG, `Resolving ${unresolved.length} unresolved order intents from prior session`);
420
+ for (const intent of unresolved) {
421
+ try {
422
+ const existing = await this.api.fetchOrderByClientId(intent.clientOrderId, intent.symbol);
423
+ if (existing) {
424
+ this.intentJournal.updateIntent(intent.clientOrderId, { status: 'confirmed', exchangeOrderId: existing.id });
425
+ logger.info(TAG, `Resolved intent ${intent.clientOrderId} → order ${existing.id} (${existing.status})`);
426
+ }
427
+ else {
428
+ this.intentJournal.updateIntent(intent.clientOrderId, { status: 'failed', error: 'Order not found on exchange after restart' });
429
+ logger.warn(TAG, `Intent ${intent.clientOrderId} not found on exchange — marked failed`);
430
+ }
431
+ }
432
+ catch (err) {
433
+ // Resolution failed — this intent's state is ambiguous
434
+ logger.error(TAG, `Failed to resolve intent ${intent.clientOrderId}: ${formatError(err)}`);
435
+ hasUnresolvedIntents = true;
436
+ }
437
+ }
438
+ }
439
+ // If any intents couldn't be resolved, stay DEGRADED — operator must acknowledge
440
+ if (hasUnresolvedIntents) {
441
+ const stillUnresolved = this.intentJournal.getUnresolved();
442
+ logger.warn(TAG, `${stillUnresolved.length} intents still unresolved — holding DEGRADED`);
443
+ this._readiness = 'DEGRADED';
444
+ this.emit('readiness_changed', this._readiness, `${stillUnresolved.length} order intents could not be resolved — operator acknowledgment required`);
445
+ this.emit('unresolved_intents', { count: stillUnresolved.length, intents: stillUnresolved });
446
+ // Reconciler still starts — read-only ops are fine in DEGRADED
447
+ this.reconciler.start();
448
+ return;
449
+ }
450
+ // 7. Start periodic position reconciliation (every 60s)
451
+ this.reconciler.start();
452
+ // 7b. Bracket restart recovery — one pass before accepting orders, then
453
+ // start the periodic reconciler. Any unknown orphans on the exchange
454
+ // (from a prior crash) are cancelled here; stale brackets on
455
+ // already-closed positions are cleaned up.
456
+ if (this.bracketReconciler) {
457
+ try {
458
+ const result = await this.bracketReconciler.runPass();
459
+ const notable = result.drifts.filter(d => d.kind !== 'healthy');
460
+ if (notable.length > 0) {
461
+ logger.warn(TAG, `Bracket restart recovery found ${notable.length} drift(s): ${notable.map(d => `${d.kind}:${d.symbol ?? '?'}`).join(', ')}`);
462
+ }
463
+ this.bracketReconciler.start();
464
+ }
465
+ catch (err) {
466
+ logger.error(TAG, `Bracket restart recovery pass failed (non-fatal): ${formatError(err)}`);
467
+ // Still start the periodic reconciler — next pass may succeed.
468
+ this.bracketReconciler.start();
469
+ }
470
+ }
471
+ // 8. Start the user-data WebSocket stream (if enabled). Fires after the
472
+ // REST reconciliation is clean so the initial snapshot doesn't race
473
+ // with a DEGRADED-state entry. On hedge-mode accounts we refuse to
474
+ // enable observe/enforce — the store is one-way-only in v1.
475
+ if (this.userDataStream) {
476
+ if (this.isHedgeMode && (this.userDataStreamMode === 'observe' || this.userDataStreamMode === 'enforce')) {
477
+ logger.error(TAG, `User-data stream ${this.userDataStreamMode} refused on hedge-mode account — falling back to REST reads`);
478
+ }
479
+ else {
480
+ try {
481
+ await this.userDataStream.start();
482
+ logger.info(TAG, `User-data stream started in mode=${this.userDataStreamMode}`);
483
+ }
484
+ catch (err) {
485
+ // Fail-open: a broken WS shouldn't block trading on REST.
486
+ logger.error(TAG, `User-data stream start failed (continuing on REST): ${formatError(err)}`);
487
+ }
488
+ }
489
+ }
490
+ // 9. All clear
491
+ this._readiness = 'READY';
492
+ this.emit('readiness_changed', this._readiness, `Adapter ready in ${this.mode} mode`);
493
+ logger.info(TAG, `Adapter READY in ${this.mode} mode (${this.exchangeInfo.size} symbols, ${this.isHedgeMode ? 'hedge' : 'one-way'} mode)`);
494
+ }
495
+ catch (err) {
496
+ logger.error(TAG, `Init failed: ${formatError(err)}`);
497
+ this._readiness = 'BLOCKED';
498
+ this.emit('readiness_changed', this._readiness, formatError(err));
499
+ }
500
+ }
501
+ /**
502
+ * Acknowledge existing positions on startup.
503
+ * Transitions DEGRADED → READY. Exchange info + position mode are already loaded
504
+ * (they're loaded before the position check in initialize()).
505
+ */
506
+ async acknowledgePositions() {
507
+ if (this._readiness !== 'DEGRADED') {
508
+ logger.warn(TAG, `acknowledgePositions called in ${this._readiness} state — ignored`);
509
+ return;
510
+ }
511
+ // Start position reconciler (was skipped in initialize() when DEGRADED due to existing positions)
512
+ this.reconciler.start();
513
+ // Bracket restart recovery + periodic reconciler — mirrors the block in initialize()
514
+ // that's skipped when startup finds existing positions (early return before step 7b).
515
+ if (this.bracketReconciler) {
516
+ try {
517
+ const result = await this.bracketReconciler.runPass();
518
+ const notable = result.drifts.filter(d => d.kind !== 'healthy');
519
+ if (notable.length > 0) {
520
+ logger.warn(TAG, `Bracket restart recovery found ${notable.length} drift(s): ${notable.map(d => `${d.kind}:${d.symbol ?? '?'}`).join(', ')}`);
521
+ }
522
+ this.bracketReconciler.start();
523
+ }
524
+ catch (err) {
525
+ logger.error(TAG, `Bracket restart recovery pass failed (non-fatal): ${formatError(err)}`);
526
+ this.bracketReconciler.start();
527
+ }
528
+ }
529
+ // User-data WebSocket stream — mirrors the block in initialize() that's skipped
530
+ // on the existing-positions DEGRADED path. Without this, the stream is constructed
531
+ // but never connects whenever the plugin restarts with an open position.
532
+ if (this.userDataStream) {
533
+ if (this.isHedgeMode && (this.userDataStreamMode === 'observe' || this.userDataStreamMode === 'enforce')) {
534
+ logger.error(TAG, `User-data stream ${this.userDataStreamMode} refused on hedge-mode account — falling back to REST reads`);
535
+ }
536
+ else {
537
+ try {
538
+ await this.userDataStream.start();
539
+ logger.info(TAG, `User-data stream started in mode=${this.userDataStreamMode}`);
540
+ }
541
+ catch (err) {
542
+ logger.error(TAG, `User-data stream start failed (continuing on REST): ${formatError(err)}`);
543
+ }
544
+ }
545
+ }
546
+ this._readiness = 'READY';
547
+ this.emit('readiness_changed', this._readiness, 'Positions acknowledged — trading enabled');
548
+ logger.info(TAG, 'Positions acknowledged — adapter now READY (reconciler started)');
549
+ }
550
+ /**
551
+ * Tear down background loops so the adapter can be replaced at runtime
552
+ * (e.g. when the operator changes credentials or trading mode from the
553
+ * dashboard). Must be called before discarding the LiveAdapter reference
554
+ * or the poller + reconciler timers will keep firing against stale state.
555
+ *
556
+ * Idempotent — safe to call multiple times.
557
+ */
558
+ shutdown() {
559
+ try {
560
+ this.orderPoller.stopAll();
561
+ }
562
+ catch (err) {
563
+ logger.warn(TAG, `orderPoller.stopAll failed: ${formatError(err)}`);
564
+ }
565
+ try {
566
+ this.reconciler.stop();
567
+ }
568
+ catch (err) {
569
+ logger.warn(TAG, `reconciler.stop failed: ${formatError(err)}`);
570
+ }
571
+ try {
572
+ this.bracketReconciler?.stop();
573
+ }
574
+ catch (err) {
575
+ logger.warn(TAG, `bracketReconciler.stop failed: ${formatError(err)}`);
576
+ }
577
+ // User-data stream stop is async; fire-and-forget since shutdown is sync.
578
+ if (this.userDataStream) {
579
+ void this.userDataStream.stop().catch((err) => logger.warn(TAG, `userDataStream.stop failed: ${formatError(err)}`));
580
+ }
581
+ this._readiness = 'BLOCKED';
582
+ this.removeAllListeners();
583
+ logger.info(TAG, 'LiveAdapter shut down');
584
+ }
585
+ // ---- IExchangeAdapter implementation ----
586
+ async createOrder(symbol, side, type, amount, price, metadata, options) {
587
+ // Refuse new entries during shutdown. Risk-reducing orders (emergency
588
+ // flatten, reduceOnly close) bypass this gate so the operator can still
589
+ // size down mid-deploy. In-flight orders that already started continue.
590
+ const isRiskReducing = options?.emergency === true || options?.reduceOnly === true;
591
+ if (!isRiskReducing) {
592
+ assertNotShuttingDown(`createOrder(${symbol} ${side} ${type})`);
593
+ }
594
+ const releaseOp = registerOp(`createOrder(${symbol} ${side} ${type}${isRiskReducing ? ' reduce' : ''})`);
595
+ try {
596
+ // ---- Rate limit check (skipped for emergency close/flatten) ----
597
+ const weight = ENDPOINT_WEIGHTS.createOrder ?? 1;
598
+ if (!options?.emergency) {
599
+ const rateCheck = this.rateLimiter.canSubmitOrder(weight);
600
+ if (!rateCheck.allowed) {
601
+ throw new Error(`Rate limit: ${rateCheck.reason}. Retry after ${rateCheck.retryAfterMs}ms.`);
602
+ }
603
+ }
604
+ // ---- Size caps ----
605
+ let effectiveAmount = amount * this.sizeCapMultiplier;
606
+ // Apply max position USDT cap for micro-live
607
+ if (this.maxPositionUSDT != null) {
608
+ const ticker = await this.api.fetchTicker(symbol);
609
+ this.rateLimiter.recordQuery(ENDPOINT_WEIGHTS.fetchTicker ?? 1);
610
+ const tickerPrice = ticker?.last;
611
+ if (tickerPrice && tickerPrice > 0) {
612
+ const maxAmount = this.maxPositionUSDT / tickerPrice;
613
+ if (effectiveAmount > maxAmount) {
614
+ logger.info(TAG, `Micro-live cap: reducing amount from ${effectiveAmount} to ${maxAmount} (max $${this.maxPositionUSDT})`);
615
+ effectiveAmount = maxAmount;
616
+ }
617
+ }
618
+ else if (!isRiskReducing) {
619
+ // The ticker read is SHED_FIRST under weight pressure — silently
620
+ // proceeding uncapped exactly when Binance is pacing us would bypass
621
+ // the micro-live size cap on a NEW exposure. Reject instead. Risk-
622
+ // reducing orders (emergency / reduceOnly) pass: they cannot increase
623
+ // the position, and blocking a close is worse than an unverified cap.
624
+ throw new Error(`Micro-live size cap ($${this.maxPositionUSDT}) cannot be verified — ticker unavailable for ${symbol}; ` +
625
+ 'rejecting order rather than proceeding uncapped');
626
+ }
627
+ }
628
+ // ---- Exchange filter validation ----
629
+ const refPrice = price ?? (await this.api.fetchTicker(symbol))?.last ?? undefined;
630
+ const validation = this.exchangeInfo.validate(symbol, effectiveAmount, price, refPrice);
631
+ if (!validation.valid) {
632
+ throw new Error(`Order validation failed: ${validation.error}`);
633
+ }
634
+ // ---- Bracket direction sanity-check (PRE-submission) ----
635
+ // A flipped stop/target sign must be caught BEFORE the entry order is
636
+ // submitted. The old code only validated this AFTER createOrder returned,
637
+ // so a sign error on a market entry left the position live + unprotected
638
+ // (the caller saw only the thrown error). Validate against the best
639
+ // pre-trade reference: the limit price, else the ticker ref price. The
640
+ // post-fill block below stays as defense-in-depth.
641
+ if (metadata && (metadata.stopPrice !== undefined || metadata.targetPrice !== undefined)) {
642
+ const entryRef = price ?? refPrice ?? 0;
643
+ if (entryRef > 0) {
644
+ if (metadata.stopPrice !== undefined) {
645
+ const msg = validateStopDirection(side, entryRef, metadata.stopPrice);
646
+ if (msg)
647
+ throw new Error(`Bracket rejected (pre-submit): ${msg}`);
648
+ }
649
+ if (metadata.targetPrice !== undefined) {
650
+ const msg = validateTargetDirection(side, entryRef, metadata.targetPrice);
651
+ if (msg)
652
+ throw new Error(`Bracket rejected (pre-submit): ${msg}`);
653
+ }
654
+ }
655
+ }
656
+ // ---- Idempotency: clientOrderId + intent journal ----
657
+ const clientOrderId = options?.clientOrderId ?? generateClientOrderId();
658
+ // Capture expected price for slippage tracking
659
+ const expectedPrice = type === 'limit' ? (price ?? refPrice ?? 0) : (refPrice ?? 0);
660
+ // Write intent to journal BEFORE submitting to exchange
661
+ this.intentJournal.recordIntent({
662
+ clientOrderId,
663
+ symbol,
664
+ side,
665
+ type,
666
+ amount: validation.roundedAmount,
667
+ price: validation.roundedPrice,
668
+ });
669
+ // Build CCXT params — these are actually sent to Binance
670
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
671
+ const ccxtParams = {
672
+ newClientOrderId: clientOrderId,
673
+ };
674
+ if (this.isHedgeMode && options?.positionSide) {
675
+ ccxtParams.positionSide = options.positionSide;
676
+ }
677
+ else if (!this.isHedgeMode && options?.reduceOnly) {
678
+ ccxtParams.reduceOnly = true;
679
+ }
680
+ // ---- Submit to exchange ----
681
+ logger.info(TAG, `Submitting ${type} ${side} ${validation.roundedAmount} ${symbol} @ ${validation.roundedPrice ?? 'market'} (clientOrderId: ${clientOrderId})`);
682
+ let order;
683
+ try {
684
+ order = await this.api.createOrder(symbol, side, type, validation.roundedAmount, validation.roundedPrice, ccxtParams);
685
+ this.syncRateLimits();
686
+ this.rateLimiter.recordOrder(weight);
687
+ }
688
+ catch (err) {
689
+ // ---- Exchange error handling ----
690
+ const parsed = parseExchangeError(err);
691
+ if (parsed.isNetworkError) {
692
+ // Network timeout — check if order went through via clientOrderId
693
+ logger.warn(TAG, `Network error on submit — checking by clientOrderId ${clientOrderId}`);
694
+ const existing = await this.api.fetchOrderByClientId(clientOrderId, symbol);
695
+ if (existing) {
696
+ // The order DID land during the timeout. Returning here (the old
697
+ // behaviour) skipped ALL post-submission bookkeeping — slippage,
698
+ // limit-order polling, live metadata, and critically bracket
699
+ // registration/attach — so a market entry that filled during the
700
+ // timeout came back "recovered" yet permanently NAKED. Resume the
701
+ // normal path instead: assign `order` and fall through so brackets
702
+ // attach exactly as if createOrder had returned this order.
703
+ logger.info(TAG, `Order found after timeout: ${existing.id} status=${existing.status} — resuming full post-submission bookkeeping (brackets/poller/slippage)`);
704
+ this.intentJournal.updateIntent(clientOrderId, { status: 'confirmed', exchangeOrderId: existing.id });
705
+ this.syncRateLimits();
706
+ this.rateLimiter.recordOrder(weight);
707
+ order = existing;
708
+ }
709
+ else {
710
+ // Not found — mark failed, throw the original error
711
+ this.intentJournal.updateIntent(clientOrderId, { status: 'failed', error: parsed.humanMessage });
712
+ throw new Error(parsed.humanMessage);
713
+ }
714
+ }
715
+ else {
716
+ this.intentJournal.updateIntent(clientOrderId, { status: 'failed', error: parsed.humanMessage });
717
+ throw new Error(parsed.humanMessage);
718
+ }
719
+ }
720
+ // ---- Post-submission bookkeeping ----
721
+ this.intentJournal.updateIntent(clientOrderId, { status: 'confirmed', exchangeOrderId: order.id });
722
+ // Track slippage for market orders that filled immediately
723
+ if (order.status === 'closed' && order.average != null && expectedPrice > 0) {
724
+ this.slippageTracker.recordFill({
725
+ orderId: order.id,
726
+ clientOrderId,
727
+ symbol,
728
+ side,
729
+ expectedPrice,
730
+ actualPrice: order.average,
731
+ amount: order.filled,
732
+ cost: order.cost,
733
+ fee: order.fee?.cost ?? 0,
734
+ });
735
+ }
736
+ // Start polling for limit orders that are still open
737
+ if (order.status === 'open') {
738
+ this.orderPoller.startPolling(order.id, symbol);
739
+ }
740
+ // ---- Per-symbol metadata store (Component C: MFE tracking) ----
741
+ // Capture entry context so getPositions can decorate Binance's position
742
+ // snapshot with thesis/setup + freeze the at-fill stop into
743
+ // originalStopPrice for R-multiple calculations. Only seed on what looks
744
+ // like a NEW position (no existing entry) — scale-ins preserve the
745
+ // original entry's frozen stop.
746
+ if (metadata && !this.liveMetadata.has(symbol)) {
747
+ const seeded = { ...metadata };
748
+ if (seeded.originalStopPrice === undefined && seeded.stopPrice !== undefined) {
749
+ seeded.originalStopPrice = seeded.stopPrice;
750
+ }
751
+ // mfePeakPrice is left undefined — getPositions seeds it from the first
752
+ // observed markPrice rather than guessing here (createOrder doesn't have
753
+ // a reliable fill price for limit orders).
754
+ this.liveMetadata.set(symbol, seeded);
755
+ }
756
+ // ---- Bracket orders ----
757
+ // If the feature flag is on AND the agent asked for at least one protective
758
+ // leg, register the bracket and (for market orders) attach immediately.
759
+ // Limit orders defer attach to the poller's 'filled' event handler.
760
+ if (this.bracketManager && this.bracketLedger && metadata && (metadata.stopPrice !== undefined || metadata.targetPrice !== undefined)) {
761
+ // Defense-in-depth: the pre-submission check above already rejected sign
762
+ // errors against the ref price, but re-check against the actual fill
763
+ // (order.average) in case the fill landed across the level. Crucially, a
764
+ // failure HERE is post-submission: bare-throwing would abandon a live
765
+ // position (filled market order) or a resting entry that fills naked
766
+ // (open limit). So clean up BEFORE throwing.
767
+ const refPrice = order.average ?? order.price ?? price ?? 0;
768
+ if (refPrice > 0) {
769
+ let dirMsg = null;
770
+ if (metadata.stopPrice !== undefined) {
771
+ dirMsg = validateStopDirection(side, refPrice, metadata.stopPrice);
772
+ }
773
+ if (!dirMsg && metadata.targetPrice !== undefined) {
774
+ dirMsg = validateTargetDirection(side, refPrice, metadata.targetPrice);
775
+ }
776
+ if (dirMsg) {
777
+ if (order.status === 'closed' && order.filled > 0) {
778
+ logger.error(TAG, `Post-fill bracket direction invalid for ${symbol} (${dirMsg}) — flattening the just-filled position to avoid a naked entry`);
779
+ // We have HARD evidence the entry filled (status=closed, filled>0).
780
+ // closePosition throws "No open position" when its attempt-1
781
+ // fetchPositions returns a *confirmed-empty* snapshot — which, in
782
+ // the moments right after a fill, is almost always Binance
783
+ // position-state lagging the order response, NOT a real flat.
784
+ // Accepting that single failure would fail open and leave the
785
+ // position naked (the exact high-risk branch this cleanup exists
786
+ // for). Retry across the lag window instead of trusting one snapshot.
787
+ let flattened = false;
788
+ for (let a = 1; a <= 4 && !flattened; a++) {
789
+ try {
790
+ await this.closePosition(symbol, 'bracket_attach_failed');
791
+ flattened = true;
792
+ }
793
+ catch (err) {
794
+ logger.warn(TAG, `Post-fill flatten attempt ${a}/4 for ${symbol} failed: ${formatError(err)}`);
795
+ if (a < 4)
796
+ await sleep(1500);
797
+ }
798
+ }
799
+ if (!flattened) {
800
+ logger.error(TAG, `CRITICAL: post-fill flatten of ${symbol} FAILED after 4 attempts — position is NAKED, immediate operator intervention required`);
801
+ this.emit('emergency_progress', { action: 'flatten', status: 'failed', symbol, message: `Naked ${symbol} after invalid bracket — manual close required` });
802
+ }
803
+ }
804
+ else if (order.status === 'open') {
805
+ logger.error(TAG, `Post-submit bracket direction invalid for ${symbol} (${dirMsg}) — cancelling the resting entry order so it can't fill unprotected`);
806
+ try {
807
+ await this.api.cancelOrderByClientId(clientOrderId, symbol);
808
+ }
809
+ catch (err) {
810
+ logger.error(TAG, `Cancel of resting entry after invalid bracket failed for ${symbol}: ${formatError(err)}`);
811
+ }
812
+ }
813
+ throw new Error(`Bracket rejected (post-submit): ${dirMsg}`);
814
+ }
815
+ }
816
+ const bracketId = generateBracketId();
817
+ this.bracketManager.registerEntry({ symbol, side, stopPrice: metadata.stopPrice, targetPrice: metadata.targetPrice }, bracketId, buildBracketCid(bracketId, 'entry'));
818
+ if (order.status === 'closed' && order.filled > 0) {
819
+ // Market order (or limit-that-filled-instantly) — attach now.
820
+ // We do NOT await: attach can take seconds and we don't want to block
821
+ // the agent's createOrder return. Auto-flatten handles failures.
822
+ this.attachBracketsAsync(symbol, order.filled);
823
+ }
824
+ // For status='open' limit orders, the order_event handler attaches
825
+ // brackets on fill.
826
+ }
827
+ logger.info(TAG, `Order confirmed: ${order.id} status=${order.status} filled=${order.filled}`);
828
+ return order;
829
+ }
830
+ finally {
831
+ releaseOp();
832
+ }
833
+ }
834
+ /**
835
+ * Attach brackets in the background. Auto-flattens on failure per locked
836
+ * decision #2. Never throws — errors are surfaced via emitted events and
837
+ * the subsequent flatten.
838
+ */
839
+ async attachBracketsAsync(symbol, entryAmount) {
840
+ if (!this.bracketManager)
841
+ return;
842
+ try {
843
+ const result = await this.bracketManager.attachBrackets(symbol, entryAmount);
844
+ if (!result.ok) {
845
+ logger.error(TAG, `Bracket attach FAILED for ${symbol} after ${result.attempts} attempts: ${result.error}. Auto-flattening position.`);
846
+ // Best-effort emergency close. closePosition has its own retry logic.
847
+ try {
848
+ await this.closePosition(symbol, 'bracket_attach_failed');
849
+ }
850
+ catch (err) {
851
+ logger.error(TAG, `Auto-flatten after bracket failure ALSO failed: ${formatError(err)}`);
852
+ // Operator alert handled by the bracket.attach_failed event listener upstream.
853
+ }
854
+ }
855
+ }
856
+ catch (err) {
857
+ logger.error(TAG, `attachBracketsAsync unexpected error for ${symbol}: ${formatError(err)}`);
858
+ }
859
+ }
860
+ /**
861
+ * AUTHORITATIVE bracket-lifecycle handler — Binance's ALGO_UPDATE push.
862
+ *
863
+ * Why this exists (2026-05-15 RCA, doc-verified): pre-fix the plugin
864
+ * learned bracket state by REST-polling `audit_bracket_protection` /
865
+ * `fetchOpenOrders` and *guessing* — under a 429 storm that produced the
866
+ * audit→re-attach→cancel spiral that flushed live positions. Binance
867
+ * already PUSHES authoritative conditional-order lifecycle. Doc-exact
868
+ * status semantics (developers.binance.com → Event Algo Order Update):
869
+ *
870
+ * EXPIRED = SYSTEM cancel. The canonical trigger is "GTE_GTC
871
+ * conditional + position closed → Binance auto-cancels the
872
+ * surviving sibling". So EXPIRED on our bracket cid ⇒ the
873
+ * position just closed by a non-close_position path (bracket
874
+ * fill, manual, opposite-side). EXPECTED — not a fault.
875
+ * TRIGGERED / FINISHED = the bracket fired (SL/TP hit) → position closed.
876
+ * CANCELED = MANUAL cancel (an explicit cancelOrder from us). Post the
877
+ * bracket-reconciler fix we should NOT be doing this while a
878
+ * position is open; if we see it, it's the canary that some
879
+ * *other* path still strips protection — WARN loudly.
880
+ *
881
+ * For EXPIRED/TRIGGERED/FINISHED we (a) mark the ledger terminal so the
882
+ * attach-brackets re-attach loop STOPS chasing a bracket Binance already
883
+ * removed, and (b) emit the SAME `drift_detected` shape the position
884
+ * reconciler emits, so the existing, tested close-bypass cleanup
885
+ * (`onReconcilerObservedClose` in index.ts) runs immediately instead of
886
+ * up to 60 s later — which is what clears the stale state-store entry and
887
+ * stops the NEXT entry on the symbol being mis-classified as a scale-in
888
+ * (the bug that left fresh positions naked). No auto-flatten here: that
889
+ * stays deferred until this signal has soaked.
890
+ */
891
+ onAlgoUpdate(ev) {
892
+ const cid = ev.clientAlgoId ?? '';
893
+ const parsed = parseBracketCid(cid);
894
+ if (!parsed)
895
+ return; // not one of our rc-*-{s,t} bracket legs
896
+ const symbol = normalizeBracketSymbol(ev.symbol);
897
+ const row = this.bracketLedger?.getBySymbol(symbol);
898
+ const status = ev.status.toUpperCase();
899
+ // Record the authoritative per-leg WS status for the Tier-1 fast-path of
900
+ // resolveBracketLegLiveness(). Every parsed rc-* lifecycle event, every
901
+ // branch — including CANCELED, which the ledger deliberately does NOT
902
+ // terminalize. Re-setting an existing cid keeps its insertion position
903
+ // (Map semantics) so the cap evicts genuinely-old legs, not active ones.
904
+ this.wsAlgoStatusByCid.set(cid, { status, ts: Date.now(), symbol });
905
+ if (this.wsAlgoStatusByCid.size > LiveAdapter.WS_ALGO_STATUS_CAP) {
906
+ const oldest = this.wsAlgoStatusByCid.keys().next().value;
907
+ if (oldest !== undefined)
908
+ this.wsAlgoStatusByCid.delete(oldest);
909
+ }
910
+ const emitSyntheticClose = (reasonTag) => {
911
+ // Pre-close size from the reconciler's last poll snapshot — the position
912
+ // only just closed, so its last-known contracts are still cached there.
913
+ // The close-bypass cleanup (onReconcilerObservedClose) needs a non-zero
914
+ // size to post the synthetic close that flips webapp positions.status to
915
+ // 'closed'. A hardcoded 0 here made every bracket-fill close SKIP that DB
916
+ // update (fillSize>0 guard) while still wiping the state-store — leaking
917
+ // the position as status='open' forever. The reduce-only-fill close
918
+ // handler (ws-ingest → onWsFillObserved) is the primary, exact-PnL closer;
919
+ // this stays the backstop for a missed WS fill.
920
+ let lastContracts = 0;
921
+ try {
922
+ const lastKnown = this.reconciler
923
+ .getLastExchangePositions()
924
+ .find((p) => normalizeBracketSymbol(p.symbol) === symbol);
925
+ if (lastKnown && Number.isFinite(lastKnown.contracts)) {
926
+ lastContracts = Math.abs(lastKnown.contracts);
927
+ }
928
+ }
929
+ catch {
930
+ /* best-effort — fall back to 0 (state-store still gets cleaned) */
931
+ }
932
+ // Mirror PositionReconciler's ReconciliationResult shape so the
933
+ // existing index.ts `drift_detected` → onReconcilerObservedClose
934
+ // wiring fires without new plumbing.
935
+ this.emit('drift_detected', {
936
+ timestamp: new Date().toISOString(),
937
+ hasDrift: true,
938
+ exchangePositions: [],
939
+ exchangeBalance: null,
940
+ exchangeOpenOrders: [],
941
+ drifts: [{
942
+ symbol,
943
+ localSide: row?.entrySide ?? null,
944
+ exchangeSide: null,
945
+ localContracts: lastContracts,
946
+ exchangeContracts: 0,
947
+ type: 'closed',
948
+ }],
949
+ source: `algo_update:${reasonTag}`,
950
+ });
951
+ };
952
+ if (status === 'EXPIRED' || status === 'TRIGGERED' || status === 'FINISHED') {
953
+ if (row && row.state !== 'cancelled' && row.state !== 'triggered_sl'
954
+ && row.state !== 'triggered_tp' && row.state !== 'failed') {
955
+ const nextState = status === 'TRIGGERED' || status === 'FINISHED'
956
+ ? (parsed.role === 'stop' ? 'triggered_sl' : 'triggered_tp')
957
+ : 'cancelled';
958
+ const closeReason = nextState === 'cancelled'
959
+ ? 'cancelled_auto'
960
+ : (nextState === 'triggered_sl' ? 'triggered_sl' : 'triggered_tp');
961
+ try {
962
+ this.bracketLedger?.markState(symbol, nextState, { closeReason });
963
+ logger.info(TAG, `ALGO_UPDATE ${status} on ${symbol} (${cid}, role=${parsed.role}) — ` +
964
+ `ledger→${nextState}. Position closed by exchange/bracket; ` +
965
+ `firing close-bypass cleanup so the next entry isn't mis-classified as scale-in.`);
966
+ }
967
+ catch (err) {
968
+ logger.warn(TAG, `ALGO_UPDATE ${status}: ledger markState(${symbol}) failed: ${formatError(err)}`);
969
+ }
970
+ }
971
+ // Always signal the close even if the ledger row was already terminal
972
+ // — the state-store/webapp cleanup is idempotent and we want it prompt.
973
+ emitSyntheticClose(status.toLowerCase());
974
+ return;
975
+ }
976
+ if (status === 'CANCELED') {
977
+ // Doc: CANCELED = an explicit cancelOrder. Post the bracket-reconciler
978
+ // null-collapse fix WE should not be cancelling a live position's
979
+ // brackets. If the ledger still thinks this bracket is active, some
980
+ // path is still stripping protection — surface it loudly. We do NOT
981
+ // mark terminal (that would block a legit re-attach by the now-safe
982
+ // audit/attach path) and we do NOT auto-flatten (deferred).
983
+ if (row && (row.state === 'active' || row.state === 'partial' || row.state === 'attaching')) {
984
+ logger.warn(TAG, `ALGO_UPDATE CANCELED on ${symbol} (${cid}) while ledger=${row.state} — ` +
985
+ `a bracket leg was explicitly cancelled while we believe the position ` +
986
+ `is still protected. This should not happen post the reconciler fix; ` +
987
+ `investigate the cancel source (reason=${ev.reason ?? 'none'}).`);
988
+ }
989
+ else {
990
+ logger.info(TAG, `ALGO_UPDATE CANCELED on ${symbol} (${cid}) — ledger=${row?.state ?? 'none'} (benign: cancel of an already-terminal/absent bracket).`);
991
+ }
992
+ return;
993
+ }
994
+ // NEW / TRIGGERING / REJECTED — informational. REJECTED is worth a WARN
995
+ // (Binance refused the conditional order — e.g. margin check).
996
+ if (status === 'REJECTED') {
997
+ logger.warn(TAG, `ALGO_UPDATE REJECTED on ${symbol} (${cid}) reason=${ev.reason ?? 'none'} — bracket leg refused by Binance; position may be unprotected.`);
998
+ }
999
+ }
1000
+ /** Expose the bracket manager for agent-facing tools (modify_stop, etc.).
1001
+ * Returns null when bracket mode is 'off'. */
1002
+ getBracketManager() {
1003
+ return this.bracketManager;
1004
+ }
1005
+ /** Expose the bracket ledger for reconciliation + admin queries. */
1006
+ getBracketLedger() {
1007
+ return this.bracketLedger;
1008
+ }
1009
+ /** Is the user-data WS store currently trusted (seeded + fresh + no gap)?
1010
+ * The Tier-1 gate for resolveBracketLegLiveness — when false the WS
1011
+ * per-cid signal is NOT consulted (a stale/gapped stream that missed a
1012
+ * CANCELED would otherwise yield a false 'live' on a naked leg — the exact
1013
+ * flaw the layered design exists to prevent). Null stream (mode=off) ⇒
1014
+ * never trusted ⇒ always the REST authority. */
1015
+ isUserDataStoreTrusted() {
1016
+ try {
1017
+ return this.userDataStream?.isStoreTrusted() ?? false;
1018
+ }
1019
+ catch {
1020
+ return false;
1021
+ }
1022
+ }
1023
+ /** Authoritative per-bracket-leg liveness — the Phase 2 disambiguator that
1024
+ * replaces inferring presence/absence from a broad openOrders snapshot
1025
+ * (the SOL/INJ spiral root: a *successful* empty /fapi/v1/openAlgoOrders
1026
+ * is NOT proof a leg is gone).
1027
+ *
1028
+ * Precedence (the agreed layering):
1029
+ * 1. WS fast-path — ONLY behind isUserDataStoreTrusted(): the real
1030
+ * per-cid ALGO_UPDATE status (incl. CANCELED, which the ledger does
1031
+ * NOT record). Zero weight, sub-second. No record for the cid ⇒ no
1032
+ * WS opinion ⇒ fall through (never infer from absence).
1033
+ * 2. REST authority — queryAlgoOrderStatus(cid): a DIRECT weight-1
1034
+ * question about that exact order id. Used when WS is untrusted or
1035
+ * silent on the cid.
1036
+ * 3. 'unknown' — both unavailable. The d59e51b safe floor: callers MUST
1037
+ * refuse destructive action and retry; NEVER treat 'unknown' as
1038
+ * 'terminal'/'stale'.
1039
+ *
1040
+ * 'live' = leg is on the exchange (do NOT reattach/clobber).
1041
+ * 'terminal' = positively confirmed gone (CANCELED/EXPIRED/REJECTED) or
1042
+ * fired (TRIGGERED/FINISHED) — only this licenses repair.
1043
+ * 'unknown' = could not get an answer — safe-wait. */
1044
+ async resolveBracketLegLiveness(cid) {
1045
+ if (!cid)
1046
+ return 'unknown';
1047
+ const classify = (status) => status === 'NEW' || status === 'WORKING' || status === 'TRIGGERING'
1048
+ ? 'live'
1049
+ : 'terminal'; // CANCELED | EXPIRED | REJECTED | TRIGGERED | FINISHED
1050
+ // Tier 1 — trusted WS fast-path (zero weight).
1051
+ if (this.isUserDataStoreTrusted()) {
1052
+ const rec = this.wsAlgoStatusByCid.get(cid);
1053
+ if (rec)
1054
+ return classify(rec.status);
1055
+ // Healthy stream but no lifecycle event ever seen for this cid — do
1056
+ // NOT infer from absence; fall to the REST authority.
1057
+ }
1058
+ // Tier 2 — REST authority (weight 1, WS-independent).
1059
+ const restStatus = await this.api.queryAlgoOrderStatus(cid);
1060
+ if (restStatus)
1061
+ return classify(restStatus);
1062
+ // Tier 3 — no answer anywhere.
1063
+ return 'unknown';
1064
+ }
1065
+ /** Symbol-scoped generalization of the trusted-WS fast-path — the
1066
+ * authoritative action-layer guard for the 2026-05-16 INJ bracket spiral.
1067
+ *
1068
+ * `resolveBracketLegLiveness(cid)` answers about ONE cid. The spiral is:
1069
+ * a prior `attach_brackets` reattach placed a FRESH bracket that is live
1070
+ * on the exchange under a NEW cid, while the ledger row still holds the
1071
+ * PREVIOUS, genuinely-terminal cids. Classifying only the ledger's cids
1072
+ * yields a false 'stale' → clear+reattach cancels the live fresh bracket
1073
+ * → naked → audit unprotected → attach → spiral (prod: INJ/USDT cancelled
1074
+ * every ~15-40s for hours). This asks the question the ledger cids can't:
1075
+ * "is ANY rc-* bracket leg for this symbol live RIGHT NOW, under any cid?"
1076
+ *
1077
+ * Trust model (identical to resolveBracketLegLiveness Tier-1): the WS map
1078
+ * is consulted ONLY when `isUserDataStoreTrusted()` (seeded + fresh +
1079
+ * no-gap — the property that guarantees no missed CANCELED, so a record
1080
+ * reading 'live' really is live). When the store is NOT trusted this
1081
+ * returns `{ trusted:false, liveCids:[] }` and the caller must NOT infer
1082
+ * "no live leg" from it — it falls back to the per-leg REST authority and,
1083
+ * failing that, refuses the destructive action (the d59e51b safe floor).
1084
+ * A record older than WS_LIVE_LEG_MAX_AGE_MS is ignored (caller falls back
1085
+ * too) so a stale 'live' can never suppress a genuine repair forever. */
1086
+ findTrustedLiveBracketCidsForSymbol(symbol) {
1087
+ if (!this.isUserDataStoreTrusted())
1088
+ return { trusted: false, liveCids: [] };
1089
+ const norm = normalizeBracketSymbol(symbol);
1090
+ const now = Date.now();
1091
+ const liveCids = [];
1092
+ for (const [cid, rec] of this.wsAlgoStatusByCid) {
1093
+ if (rec.symbol !== norm)
1094
+ continue;
1095
+ if (now - rec.ts > LiveAdapter.WS_LIVE_LEG_MAX_AGE_MS)
1096
+ continue;
1097
+ if (rec.status === 'NEW' || rec.status === 'WORKING' || rec.status === 'TRIGGERING') {
1098
+ liveCids.push(cid);
1099
+ }
1100
+ }
1101
+ return { trusted: true, liveCids };
1102
+ }
1103
+ /** Cancel every bracket-tagged (SL or TP) open order for a symbol. Used by
1104
+ * the `attach_brackets` recovery tool to clear stale bracket orders before
1105
+ * re-attaching a fresh pair. Does not touch non-bracket orders. Returns
1106
+ * the number successfully cancelled. Errors are logged + swallowed —
1107
+ * "already gone" is treated as success by the underlying cancel path. */
1108
+ async cancelSymbolBracketOrders(symbol) {
1109
+ const open = await this.api.fetchOpenOrders(symbol);
1110
+ if (open === null) {
1111
+ // null = order state UNKNOWN — returning 0 here would read as
1112
+ // "checked, no orphans" in the attach_brackets tool result. Throw
1113
+ // instead; the caller logs the cleanup failure and still proceeds
1114
+ // with the attach (protection beats orphan hygiene).
1115
+ throw new Error(`cancelSymbolBracketOrders(${symbol}): fetchOpenOrders returned null (order state unknown)`);
1116
+ }
1117
+ const all = open;
1118
+ let cancelled = 0;
1119
+ for (const o of all) {
1120
+ if (!o.clientOrderId)
1121
+ continue;
1122
+ const parsed = parseBracketCid(o.clientOrderId);
1123
+ if (!parsed || (parsed.role !== 'stop' && parsed.role !== 'target'))
1124
+ continue;
1125
+ try {
1126
+ await this.api.cancelOrderByClientId(o.clientOrderId, symbol);
1127
+ cancelled++;
1128
+ }
1129
+ catch (err) {
1130
+ logger.warn(TAG, `cancelSymbolBracketOrders: ${o.clientOrderId} failed: ${formatError(err)}`);
1131
+ }
1132
+ }
1133
+ return cancelled;
1134
+ }
1135
+ async cancelOrder(orderId, symbol) {
1136
+ return this.api.cancelOrder(orderId, symbol);
1137
+ }
1138
+ async cancelAllOrders(symbol) {
1139
+ // Emergency path — NO readiness gate, 3x retry, progress emission.
1140
+ const openOrders = await this.api.fetchOpenOrders(symbol);
1141
+ if (openOrders === null) {
1142
+ // null = order state UNKNOWN (read failed / paced / banned) — never
1143
+ // report "nothing to cancel" off a failed read. Proceed into the retry
1144
+ // loop: api.cancelAllOrders re-fetches and throws on null, so a
1145
+ // transient blip recovers on retry and a persistent outage escalates
1146
+ // to the loud MANUAL-INTERVENTION failure instead of a silent [].
1147
+ logger.warn(TAG, `KILL: fetchOpenOrders returned null (order state unknown)${symbol ? ` for ${symbol}` : ''} — proceeding with cancel attempts`);
1148
+ }
1149
+ else if (openOrders.length === 0) {
1150
+ return [];
1151
+ }
1152
+ const countLabel = openOrders === null ? 'unknown #' : String(openOrders.length);
1153
+ logger.warn(TAG, `KILL: cancelling ${countLabel} orders${symbol ? ` for ${symbol}` : ''}`);
1154
+ this.emit('emergency_progress', { action: 'kill', status: 'in_progress', orders: openOrders?.length ?? -1 });
1155
+ for (let attempt = 1; attempt <= 3; attempt++) {
1156
+ try {
1157
+ await this.api.cancelAllOrders(symbol);
1158
+ logger.info(TAG, `KILL: ${countLabel} orders cancelled (attempt ${attempt})`);
1159
+ this.emit('emergency_progress', { action: 'kill', status: 'completed' });
1160
+ // Return empty — all orders are cancelled. Don't fabricate status on the pre-cancel snapshot
1161
+ // because some may have filled between the fetch and the cancel.
1162
+ return [];
1163
+ }
1164
+ catch (err) {
1165
+ logger.warn(TAG, `KILL attempt ${attempt}/3 failed: ${formatError(err)}`);
1166
+ if (attempt < 3)
1167
+ await sleep(1000);
1168
+ }
1169
+ }
1170
+ // All retries exhausted
1171
+ const msg = 'MANUAL INTERVENTION REQUIRED — cancel orders manually on Binance';
1172
+ logger.error(TAG, msg);
1173
+ this.emit('emergency_progress', { action: 'kill', status: 'failed', message: msg });
1174
+ throw new Error(msg);
1175
+ }
1176
+ async closePosition(symbol, closeReason) {
1177
+ // Risk-reducing — allowed during shutdown, but tracked so the drain awaits
1178
+ // it before the process exits. Without tracking, a SIGTERM mid-close could
1179
+ // leave the position open on Binance with the plugin restarting and
1180
+ // assuming it's still tracked.
1181
+ const releaseOp = registerOp(`closePosition(${symbol})`);
1182
+ try {
1183
+ // Emergency path — NO readiness gate, 3x retry, reduceOnly safety.
1184
+ // closeReason flows onto the symbol's metadata so the next getPositions
1185
+ // cycle's eventual cleanup carries the right reason. The metadata entry
1186
+ // itself is dropped after the close completes (see end of this method).
1187
+ const reasonSuffix = closeReason ? ` (reason=${closeReason})` : '';
1188
+ logger.warn(TAG, `FLATTEN: closing position ${symbol}${reasonSuffix}`);
1189
+ if (closeReason) {
1190
+ const existing = this.liveMetadata.get(symbol);
1191
+ if (existing)
1192
+ this.liveMetadata.set(symbol, { ...existing, closeReason });
1193
+ }
1194
+ // ---- Trusted position read FIRST — BEFORE the destructive bracket cancel.
1195
+ // The old order (cancelBrackets, then fetchPositions) opened a naked
1196
+ // window: cancelling protection off a position we then couldn't confirm
1197
+ // (paced/failed read → throw) or that didn't exist left live exposure
1198
+ // with no brackets and no close order. Only a TRUSTED read that confirms
1199
+ // an open position licenses stripping its protection.
1200
+ //
1201
+ // CRITICAL null-vs-empty split (2026-05-15 XRP phantom-close incident).
1202
+ // `BinancePrivateApi.fetchPositions` returns `null` on a FAILED fetch
1203
+ // (429 / rate-limited / transient) and `[]` only when the exchange
1204
+ // CONFIRMS zero positions. Conflating them here is catastrophic: under
1205
+ // a rate-limit storm a still-open naked position 429s → `null` → the
1206
+ // old `!positions` branch either threw "No open position" (the agent
1207
+ // interpreted that as "position gone from exchange truth" and recorded
1208
+ // a phantom close, abandoning a live losing short) or, on a retry,
1209
+ // returned a SYNTHETIC closed-success. "Couldn't read" must NEVER be
1210
+ // reported as "closed". Only a real, non-null empty array means flat.
1211
+ // Mirrors the getOpenOrders / reconciler null-collapse rule. See
1212
+ // `memory/feedback_getopenorders_null_collapse_naked_loop.md`.
1213
+ const preCheck = await this.api.fetchPositions(symbol);
1214
+ if (preCheck === null) {
1215
+ throw new Error(`closePosition: exchange position state UNAVAILABLE for ${symbol} ` +
1216
+ `(fetchPositions failed — 429 / rate-limited / transient). Refusing ` +
1217
+ `to cancel brackets or treat this as "closed": the position may ` +
1218
+ `still be open and protected. Retry once exchange reads recover; ` +
1219
+ `do NOT record a close.`);
1220
+ }
1221
+ if (preCheck.length === 0) {
1222
+ // Confirmed flat (trusted empty read) — nothing to close, and
1223
+ // critically the brackets were NOT touched.
1224
+ throw new Error(`No open position for ${symbol} on exchange`);
1225
+ }
1226
+ let lastKnownContracts = Math.abs(preCheck[0].contracts ?? 0);
1227
+ // Cancel any attached brackets — leaving them on the exchange after
1228
+ // the close would book a phantom reverse trade if the trigger fires on a
1229
+ // re-entry. cancelBrackets is idempotent and swallows not-found errors.
1230
+ if (this.bracketManager) {
1231
+ try {
1232
+ await this.bracketManager.cancelBrackets(symbol, closeReason ?? 'close_position');
1233
+ }
1234
+ catch (err) {
1235
+ logger.warn(TAG, `cancelBrackets during closePosition failed: ${formatError(err)}`);
1236
+ }
1237
+ }
1238
+ for (let attempt = 1; attempt <= 3; attempt++) {
1239
+ // Fresh position snapshot each attempt (position may have partially closed)
1240
+ const positions = await this.api.fetchPositions(symbol);
1241
+ // Same null-vs-empty split as the pre-check above.
1242
+ if (positions === null) {
1243
+ throw new Error(`closePosition: exchange position state UNAVAILABLE for ${symbol} ` +
1244
+ `(fetchPositions failed — 429 / rate-limited / transient). Refusing ` +
1245
+ `to treat this as "closed": the position may still be open. Retry ` +
1246
+ `once exchange reads recover; do NOT record a close.`);
1247
+ }
1248
+ if (positions.length === 0) {
1249
+ // The pre-check CONFIRMED an open position, so a trusted empty read
1250
+ // now means it closed while we were working — a prior attempt's
1251
+ // market order landed, a bracket fired in the race window, or an
1252
+ // external close. Success either way (confirmed empty, not a failed read).
1253
+ logger.info(TAG, `FLATTEN: ${symbol} confirmed flat on attempt ${attempt}`);
1254
+ this.emit('emergency_progress', { action: 'flatten', status: 'completed', symbol });
1255
+ // Return a synthetic closed order
1256
+ return { id: 'flatten-done', symbol, side: 'sell', type: 'market', status: 'closed', amount: 0, filled: 0, remaining: 0, average: null, price: null, cost: 0, fee: { cost: 0, currency: 'USDT' }, timestamp: Date.now(), datetime: new Date().toISOString(), timeInForce: 'GTC' };
1257
+ }
1258
+ const position = positions[0];
1259
+ lastKnownContracts = Math.abs(position.contracts ?? 0);
1260
+ const closeSide = position.side === 'long' ? 'sell' : 'buy';
1261
+ // Build CCXT params with reduceOnly/positionSide for close safety
1262
+ const clientOrderId = generateClientOrderId();
1263
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1264
+ const ccxtParams = { newClientOrderId: clientOrderId };
1265
+ if (this.isHedgeMode) {
1266
+ ccxtParams.positionSide = position.side === 'long' ? 'LONG' : 'SHORT';
1267
+ }
1268
+ else {
1269
+ ccxtParams.reduceOnly = true;
1270
+ }
1271
+ try {
1272
+ this.emit('emergency_progress', { action: 'flatten', status: 'in_progress', symbol, attempt });
1273
+ const order = await this.api.createOrder(symbol, closeSide, 'market', position.contracts, undefined, ccxtParams);
1274
+ logger.info(TAG, `FLATTEN: ${symbol} close order ${order.id} status=${order.status}`);
1275
+ this.emit('emergency_progress', { action: 'flatten', status: 'completed', symbol });
1276
+ return order;
1277
+ }
1278
+ catch (err) {
1279
+ logger.warn(TAG, `FLATTEN ${symbol} attempt ${attempt}/3 failed: ${formatError(err)}`);
1280
+ if (attempt < 3)
1281
+ await sleep(1000);
1282
+ }
1283
+ }
1284
+ // Every close attempt failed AND the brackets were cancelled above — the
1285
+ // position is live and UNPROTECTED. Best-effort re-protect from the
1286
+ // ledger's preserved levels before escalating (cancelBrackets marks the
1287
+ // row terminal but keeps stopPrice/targetPrice/entrySide).
1288
+ if (this.bracketManager) {
1289
+ await this.reattachBracketsAfterFailedClose(symbol, lastKnownContracts);
1290
+ }
1291
+ const msg = `MANUAL INTERVENTION REQUIRED — close ${symbol} manually on Binance`;
1292
+ logger.error(TAG, msg);
1293
+ this.emit('emergency_progress', { action: 'flatten', status: 'failed', symbol, message: msg });
1294
+ throw new Error(msg);
1295
+ }
1296
+ finally {
1297
+ releaseOp();
1298
+ }
1299
+ }
1300
+ /** Re-arm protection on a position whose closePosition flow cancelled the
1301
+ * brackets and then failed every market-close attempt. Reuses the normal
1302
+ * attach machinery: re-register the ledger row (terminal row → fresh
1303
+ * pending_entry with the SAME stop/target levels) and run the standard
1304
+ * 3-retry attach. Never throws — this runs on the already-failing close
1305
+ * path; on any failure it emits the reconciler's `position_unprotected`
1306
+ * drift shape (re-emitted upstream as `bracket_drift`) plus a loud log so
1307
+ * the operator knows re-protection is required. */
1308
+ async reattachBracketsAfterFailedClose(symbol, sizeHint) {
1309
+ const emitUnprotected = (details) => {
1310
+ logger.error(TAG, `UNPROTECTED POSITION after failed close: ${symbol} — brackets were cancelled before the ` +
1311
+ `close attempts and could not be re-attached (${details}). Manual re-protection required ` +
1312
+ `(attach_brackets or close on Binance).`);
1313
+ this.emit('bracket_drift', {
1314
+ kind: 'position_unprotected',
1315
+ symbol: normalizeBracketSymbol(symbol),
1316
+ details: `close failed after bracket cancel; re-attach ${details}`,
1317
+ ts: new Date().toISOString(),
1318
+ });
1319
+ };
1320
+ if (!this.bracketManager || !this.bracketLedger)
1321
+ return;
1322
+ try {
1323
+ const row = this.bracketLedger.getBySymbol(normalizeBracketSymbol(symbol));
1324
+ if (!row || (row.stopPrice === undefined && row.targetPrice === undefined)) {
1325
+ // No preserved levels — either the position never had brackets (no
1326
+ // protection was stripped) or the row is gone. Nothing to restore.
1327
+ if (row)
1328
+ emitUnprotected('skipped: ledger row has no stop/target levels');
1329
+ return;
1330
+ }
1331
+ const size = sizeHint > 0 ? sizeHint : row.qty ?? 0;
1332
+ if (!(size > 0)) {
1333
+ emitUnprotected('skipped: no valid position size to attach at');
1334
+ return;
1335
+ }
1336
+ const bracketId = generateBracketId();
1337
+ // registerEntry removes the terminal row left by cancelBrackets and
1338
+ // creates a fresh pending_entry carrying the same levels.
1339
+ this.bracketManager.registerEntry({ symbol: row.symbol, side: row.entrySide, stopPrice: row.stopPrice, targetPrice: row.targetPrice }, bracketId, buildBracketCid(bracketId, 'entry'));
1340
+ const result = await this.bracketManager.attachBrackets(row.symbol, size);
1341
+ if (result.ok) {
1342
+ logger.warn(TAG, `closePosition(${symbol}) failed but protection was RE-ATTACHED ` +
1343
+ `(sl=${row.stopPrice ?? '-'} tp=${row.targetPrice ?? '-'} size=${size}) — position is protected, not closed`);
1344
+ }
1345
+ else {
1346
+ emitUnprotected(`failed after ${result.attempts} attempts: ${result.error ?? 'unknown'}`);
1347
+ }
1348
+ }
1349
+ catch (err) {
1350
+ emitUnprotected(`threw: ${formatError(err)}`);
1351
+ }
1352
+ }
1353
+ async getBalance() {
1354
+ const balance = await this.api.fetchBalance();
1355
+ this.syncRateLimits();
1356
+ this.rateLimiter.recordQuery(ENDPOINT_WEIGHTS.fetchBalance ?? 5);
1357
+ if (!balance) {
1358
+ return { free: {}, used: {}, total: {} };
1359
+ }
1360
+ // Binance USDⓈ-M quirk: `availableBalance` is only populated for the primary
1361
+ // margin asset (USDT). Secondary collaterals like USDC come back with
1362
+ // total > 0 but free = 0 and used = 0 — internally inconsistent. If nothing
1363
+ // is used, everything is free by definition, so backfill free = total.
1364
+ repairFreeFromTotal(balance);
1365
+ const bnfcrWallet = (() => {
1366
+ const assets = balance?.info?.assets;
1367
+ if (!Array.isArray(assets))
1368
+ return undefined;
1369
+ const a = assets.find(x => x?.asset === 'BNFCR');
1370
+ return a == null ? undefined : parseFloat(String(a.walletBalance ?? 0));
1371
+ })();
1372
+ logger.info(TAG, `Raw balance: USDT=${JSON.stringify(balance.total.USDT ?? 0)}, USDC=${JSON.stringify(balance.total.USDC ?? 0)}, BNFCR.wallet=${bnfcrWallet ?? 'n/a'}, free.USDT=${balance.free.USDT ?? 0}, free.USDC=${balance.free.USDC ?? 0}`);
1373
+ // Detect capital inflows/outflows (spot↔futures transfers, deposits,
1374
+ // withdrawals) and re-anchor sessionStartNav so they don't masquerade
1375
+ // as trading P&L. Fire-and-forget the first time (delta resolves on the
1376
+ // next cycle) so an income-endpoint hiccup never blocks a balance fetch.
1377
+ const transferDelta = await this.depositTracker.refresh();
1378
+ if (transferDelta !== 0) {
1379
+ const prev = this.enricher.getSessionStartNav();
1380
+ const next = prev + transferDelta;
1381
+ this.enricher.setSessionStartNav(next);
1382
+ this._sessionStartNav = next;
1383
+ logger.info(TAG, `Session NAV re-anchored for capital flow: ${prev.toFixed(4)} → ${next.toFixed(4)} (delta=${transferDelta >= 0 ? '+' : ''}${transferDelta.toFixed(4)})`);
1384
+ }
1385
+ // Re-anchor today's realized seed + sessionStartNav from Binance's
1386
+ // income endpoint. The anchor is invariant within a UTC day (see
1387
+ // INCOME_REFRESH_MIN_INTERVAL_MS doc), so this is heavily throttled and
1388
+ // exists only as a long safety reconcile against BNFCR/transfer types
1389
+ // DepositTracker can't see. UTC-midnight rollover forces a refresh
1390
+ // (throttle bypassed) to re-anchor the new day.
1391
+ await this.refreshIncomeAnchor(getQuoteWalletBalance(balance));
1392
+ // Enrich with session PnL + equity using cached positions (no extra API call)
1393
+ const cachedPositions = this.reconciler.getLastExchangePositions();
1394
+ return this.enricher.enrich(balance, cachedPositions);
1395
+ }
1396
+ /** Re-anchor the realized-today seed + sessionStartNav from /fapi/v1/income.
1397
+ * Throttled to {@link INCOME_REFRESH_MIN_INTERVAL_MS}; UTC date rollover
1398
+ * forces an immediate refresh. Best-effort — failures are logged and
1399
+ * swallowed so a transient income-endpoint blip never blocks a balance
1400
+ * read. Called from getBalance() with the fresh quote wallet so we don't
1401
+ * fetch balance twice.
1402
+ */
1403
+ async refreshIncomeAnchor(quoteBalance) {
1404
+ const now = Date.now();
1405
+ const today = utcDateString(now);
1406
+ const dayRolled = today !== this.incomeAnchorUtcDay;
1407
+ const noAnchorYet = this._sessionStartNav == null;
1408
+ // Throttle bypassed while no anchor exists yet (boot fetch failed) so we
1409
+ // retry promptly until the baseline is established.
1410
+ if (!dayRolled && !noAnchorYet && now - this.lastIncomeRefreshMs < INCOME_REFRESH_MIN_INTERVAL_MS)
1411
+ return;
1412
+ // forced (never-paced) ONLY for the anchors that actually change the
1413
+ // value: a UTC-day rollover (new wallet_at_midnight) and the bootstrap
1414
+ // case (no anchor yet). Routine same-day refreshes stay sheddable — the
1415
+ // anchor is invariant within a UTC day, so a paced miss is a no-op
1416
+ // (we keep the last good value), which is the whole point.
1417
+ const forced = dayRolled || noAnchorYet;
1418
+ const utcMidnight = utcMidnightMs(now);
1419
+ const breakdown = await this.api.fetchTodayIncomeBreakdown(utcMidnight, { forced });
1420
+ if (breakdown == null) {
1421
+ // null≠empty: paced/failed income fetch. sessionStartNav =
1422
+ // wallet_at_midnight is INVARIANT within a UTC day, so keeping the
1423
+ // last good anchor is exactly correct — NEVER coerce to {0,0} (that
1424
+ // pins it to wallet_now and zeroes Day P&L — 2026-05-16 KPI
1425
+ // incident). Routine same-day miss with an anchor already set: arm
1426
+ // the normal cadence (the value can't have changed). Rollover /
1427
+ // bootstrap miss: leave the throttle unarmed so the next getBalance
1428
+ // cycle retries (forced → lands once not under a real ban).
1429
+ if (!dayRolled && !noAnchorYet)
1430
+ this.lastIncomeRefreshMs = now;
1431
+ return;
1432
+ }
1433
+ this.lastIncomeRefreshMs = now;
1434
+ this.incomeAnchorUtcDay = today;
1435
+ // Realized KPI = netNonTransfer (REALIZED_PNL + fees + funding) — matches
1436
+ // Binance's "Today's Realized PNL" UI label. See seedRealizedPnlToday
1437
+ // comment block above for the 2026-05-14 verification against the
1438
+ // Binance mobile app.
1439
+ this.enricher.seedRealizedPnlToday(breakdown.netNonTransfer, today);
1440
+ // sessionStartNav = wallet_now − netNonTransfer = wallet_at_midnight.
1441
+ // This is invariant under non-transfer activity so it self-corrects on
1442
+ // every refresh, including BNFCR transfers that DepositTracker can't
1443
+ // see (TRANSFER incomeType is excluded from netNonTransfer).
1444
+ const navAtMidnight = quoteBalance - breakdown.netNonTransfer;
1445
+ this.enricher.setSessionStartNav(navAtMidnight);
1446
+ this._sessionStartNav = navAtMidnight;
1447
+ if (dayRolled) {
1448
+ logger.info(TAG, `UTC day rolled to ${today}; income anchor reset (netNonTransfer=${breakdown.netNonTransfer.toFixed(4)}, sessionStartNav=${navAtMidnight.toFixed(4)})`);
1449
+ }
1450
+ }
1451
+ async getPositions(symbol) {
1452
+ // Display/KPI contract: a failed REST fetch collapses to []. 20+
1453
+ // display/KPI callers depend on this (KPI-must-equal-Binance). Decision
1454
+ // paths must call getPositionsOrNull() instead. Single code path.
1455
+ return (await this.getPositionsOrNull(symbol)) ?? [];
1456
+ }
1457
+ async getPositionsOrNull(symbol) {
1458
+ if (this.userDataStream &&
1459
+ userDataStreamAuthoritative(this.userDataStreamMode) &&
1460
+ this.userDataStream.isStoreTrusted()) {
1461
+ const positions = this.userDataStream.getStore().getPositions(symbol);
1462
+ // Store is trusted (seeded + fresh) — its state is authoritative and
1463
+ // never "unknown", so this branch never returns null.
1464
+ return this.decoratePositionsWithMetadata(positions, symbol);
1465
+ }
1466
+ // Store not trusted: either not yet seeded by a trusted snapshot
1467
+ // (startup / post-reset) OR stale beyond the dead-socket watchdog
1468
+ // window (extended disconnect / failed-resync). Serving its state here
1469
+ // would be the null≠empty / stale-as-truth collapse on the
1470
+ // authoritative read path — fall through to the REST read instead,
1471
+ // which carries real exchange state (or surfaces the failure honestly
1472
+ // as null below). See isStoreTrusted().
1473
+ const positions = await this.api.fetchPositions(symbol);
1474
+ this.syncRateLimits();
1475
+ this.rateLimiter.recordQuery(ENDPOINT_WEIGHTS.fetchPositions ?? 5);
1476
+ // null = fetch FAILED (429 / weight-paced / transient) → state UNKNOWN.
1477
+ // Propagate it; callers on decision paths must not treat it as "flat".
1478
+ if (positions === null)
1479
+ return null;
1480
+ return this.decoratePositionsWithMetadata(positions, symbol);
1481
+ }
1482
+ /** Merge agent-supplied entry metadata + ratchet MFE state into the raw
1483
+ * CCXT position list. Side effect: drops metadata for symbols no longer
1484
+ * in the open-positions snapshot (only when called without a symbol filter
1485
+ * — symbol-scoped calls skip cleanup so we don't lose context for other
1486
+ * open positions). */
1487
+ decoratePositionsWithMetadata(positions, symbolFilter) {
1488
+ const out = [];
1489
+ const openSymbols = new Set();
1490
+ // Helper: read live bracket state for a symbol. Only active/partial/attaching
1491
+ // brackets surface — terminal states leave the row in the ledger for the
1492
+ // reconciler but mean "no live protection on exchange right now".
1493
+ const lookupBracket = (symbol) => {
1494
+ if (!this.bracketLedger)
1495
+ return undefined;
1496
+ const row = this.bracketLedger.getBySymbol(symbol);
1497
+ if (!row)
1498
+ return undefined;
1499
+ if (row.state !== 'active' && row.state !== 'partial' && row.state !== 'attaching') {
1500
+ return undefined;
1501
+ }
1502
+ return { slPrice: row.stopPrice, tpPrice: row.targetPrice, state: row.state };
1503
+ };
1504
+ for (const p of positions) {
1505
+ openSymbols.add(p.symbol);
1506
+ const meta = this.liveMetadata.get(p.symbol);
1507
+ if (!meta) {
1508
+ // Position observed without agent metadata (e.g. opened externally via
1509
+ // Binance UI). Still surface the bracket — operator should see live
1510
+ // protection levels regardless of how the position was opened.
1511
+ out.push({ ...p, bracket: lookupBracket(p.symbol) });
1512
+ continue;
1513
+ }
1514
+ // Lazy seed of MFE peak — first observation uses entryPrice (matches the
1515
+ // paper-side seedMfeMetadata behaviour). originalStopPrice is set at
1516
+ // createOrder time when stopPrice was known then, OR opportunistically
1517
+ // here from current stopPrice if the agent supplied one post-entry via
1518
+ // modify_stop. Whichever path sets it first wins (frozen-at-first-known).
1519
+ const freshMeta = { ...meta };
1520
+ if (freshMeta.originalStopPrice === undefined && freshMeta.stopPrice !== undefined) {
1521
+ freshMeta.originalStopPrice = freshMeta.stopPrice;
1522
+ }
1523
+ const out2 = updateMfe({
1524
+ side: p.side,
1525
+ entryPrice: p.entryPrice,
1526
+ originalStopPrice: freshMeta.originalStopPrice,
1527
+ markPrice: p.markPrice,
1528
+ priorPeakPrice: freshMeta.mfePeakPrice ?? p.entryPrice,
1529
+ });
1530
+ freshMeta.mfePeakPrice = out2.mfePeakPrice;
1531
+ freshMeta.mfeR = out2.mfeR;
1532
+ freshMeta.giveBackRatio = out2.giveBackRatio;
1533
+ this.liveMetadata.set(p.symbol, freshMeta);
1534
+ out.push({
1535
+ ...p,
1536
+ setupType: p.setupType ?? freshMeta.setupType,
1537
+ thesis: p.thesis ?? freshMeta.thesis,
1538
+ stopPrice: p.stopPrice ?? freshMeta.stopPrice,
1539
+ targetPrice: p.targetPrice ?? freshMeta.targetPrice,
1540
+ regime: p.regime ?? freshMeta.regime,
1541
+ regimeConfidence: p.regimeConfidence ?? freshMeta.regimeConfidence,
1542
+ scorecardVerdict: p.scorecardVerdict ?? freshMeta.scorecardVerdict,
1543
+ confluenceScore: p.confluenceScore ?? freshMeta.confluenceScore,
1544
+ originalStopPrice: freshMeta.originalStopPrice,
1545
+ mfePeakPrice: freshMeta.mfePeakPrice,
1546
+ mfeR: freshMeta.mfeR,
1547
+ giveBackRatio: freshMeta.giveBackRatio,
1548
+ invalidationPrice: freshMeta.invalidationPrice,
1549
+ realizationRule: freshMeta.realizationRule,
1550
+ invalidationHit: computeInvalidationHit(p.side, p.markPrice, freshMeta.invalidationPrice),
1551
+ bracket: lookupBracket(p.symbol),
1552
+ });
1553
+ }
1554
+ // Cleanup: drop metadata for any symbol that no longer has an open
1555
+ // position. Skip when caller supplied a symbol filter — the snapshot is
1556
+ // partial and would falsely evict every other symbol.
1557
+ if (symbolFilter === undefined) {
1558
+ for (const sym of this.liveMetadata.keys()) {
1559
+ if (!openSymbols.has(sym))
1560
+ this.liveMetadata.delete(sym);
1561
+ }
1562
+ }
1563
+ return out;
1564
+ }
1565
+ async getOpenOrders(symbol) {
1566
+ if (this.userDataStream &&
1567
+ userDataStreamAuthoritative(this.userDataStreamMode) &&
1568
+ this.userDataStream.isStoreTrusted()) {
1569
+ return this.userDataStream.getStore().getOpenOrders(symbol);
1570
+ }
1571
+ // Store not trusted (unseeded OR stale beyond the watchdog window) —
1572
+ // do NOT serve its order set as authoritative (a phantom-naked or
1573
+ // stale verdict would re-attach / cancel against real protective
1574
+ // orders). See isStoreTrusted(). Fall through to the REST path
1575
+ // below, which throws on a failed/rate-limited fetch rather than
1576
+ // collapsing "unknown" to "no orders".
1577
+ // Sticky rate-limit cooldown: while armed, fail fast WITHOUT a Binance
1578
+ // call so a tight agent retry loop can't pin the IP weight budget and
1579
+ // keep the whole agent blind. Same null/throw contract as below — every
1580
+ // existing caller already handles it safely.
1581
+ const nowMs = Date.now();
1582
+ if (nowMs < this.getOpenOrdersUnavailableUntil) {
1583
+ const waitS = Math.ceil((this.getOpenOrdersUnavailableUntil - nowMs) / 1000);
1584
+ throw new Error(`getOpenOrders: exchange rate-limited — sticky cooldown active (~${waitS}s left, ` +
1585
+ 'NO Binance call made so the weight window can drain). Caller must NOT proceed ' +
1586
+ 'as if the exchange has zero orders.');
1587
+ }
1588
+ const orders = await this.api.fetchOpenOrders(symbol);
1589
+ this.syncRateLimits();
1590
+ this.rateLimiter.recordQuery(symbol ? (ENDPOINT_WEIGHTS.fetchOpenOrders ?? 1) : (ENDPOINT_WEIGHTS.fetchOpenOrdersAll ?? 40));
1591
+ // Critical: `BinancePrivateApi.fetchOpenOrders` returns `null` on fetch
1592
+ // error (429 / transient network). Collapsing that to `[]` here destroys
1593
+ // the "unknown" signal — downstream consumers (audit-bracket-protection,
1594
+ // attach-brackets verifyLedgerStale, bracket reconciler) then treat
1595
+ // "fetch failed" as "exchange truly has zero orders" and trigger
1596
+ // re-attach / cancel paths against real protective orders that ARE on
1597
+ // the exchange. This caused the 2026-05-14 ATOM naked-bracket loop:
1598
+ // ~17 cycles of spurious re-attach while the agent's bracket-cid
1599
+ // protective orders kept getting clobbered. Throwing here turns the
1600
+ // unknown into an error the existing try/catches in callers handle
1601
+ // safely (audit returns error to agent, verifyLedgerStale returns
1602
+ // `false` = "not stale, can't tell", bracket-reconciler aborts the
1603
+ // cycle). See `memory/feedback_getopenorders_null_collapse_naked_loop.md`.
1604
+ if (orders === null) {
1605
+ // Arm the sticky cooldown so the next ~45 s of retries cost zero
1606
+ // Binance weight and the budget can actually drain.
1607
+ this.getOpenOrdersUnavailableUntil =
1608
+ Date.now() + LiveAdapter.OPEN_ORDERS_RL_COOLDOWN_MS;
1609
+ throw new Error('getOpenOrders: fetchOpenOrders returned null (likely 429 / transient network). ' +
1610
+ 'Caller must NOT proceed as if the exchange has zero orders — that path causes ' +
1611
+ 'phantom-naked re-attach loops that clobber real protective orders. ' +
1612
+ `Sticky cooldown armed ${LiveAdapter.OPEN_ORDERS_RL_COOLDOWN_MS / 1000}s.`);
1613
+ }
1614
+ // Clean read — clear any cooldown so recovery resumes immediately.
1615
+ this.getOpenOrdersUnavailableUntil = 0;
1616
+ return orders;
1617
+ }
1618
+ async fetchOrder(orderId, symbol) {
1619
+ return this.api.fetchOrder(orderId, symbol);
1620
+ }
1621
+ async getLastPrice(symbol) {
1622
+ const ticker = await this.api.fetchTicker(symbol);
1623
+ return ticker?.last ?? null;
1624
+ }
1625
+ // ---- Internal helpers ----
1626
+ /** Sync rate limiter from exchange response headers after every private API call. */
1627
+ syncRateLimits() {
1628
+ try {
1629
+ const headers = this.api.getLastResponseHeaders();
1630
+ if (Object.keys(headers).length > 0) {
1631
+ this.rateLimiter.syncFromHeaders(headers);
1632
+ // Reconcile the process-wide weight pacer against Binance truth.
1633
+ noteResponseWeight(headers);
1634
+ }
1635
+ }
1636
+ catch {
1637
+ // Best-effort — don't crash on header sync failure
1638
+ }
1639
+ }
1640
+ async loadExchangeInfo() {
1641
+ const exchange = this.api.getExchange();
1642
+ await this.exchangeInfo.load(exchange);
1643
+ }
1644
+ async detectPositionMode() {
1645
+ try {
1646
+ // GET /fapi/v1/positionSide/dual is weight 30 (doc-verified). Route it
1647
+ // through the shared gate like every other Binance read so it can't
1648
+ // pile onto a startup storm or extend an active ban.
1649
+ assertNotBanned('fetchPositionMode');
1650
+ const exchange = this.api.getExchange();
1651
+ const result = await exchange.fapiPrivateGetPositionSideDual();
1652
+ noteSuccess();
1653
+ this.isHedgeMode = result?.dualSidePosition === true;
1654
+ logger.info(TAG, `Account position mode: ${this.isHedgeMode ? 'HEDGE' : 'ONE-WAY'}`);
1655
+ }
1656
+ catch (err) {
1657
+ noteBinanceError(err);
1658
+ logger.warn(TAG, `Position mode detection failed (assuming one-way): ${formatError(err)}`);
1659
+ this.isHedgeMode = false;
1660
+ }
1661
+ }
1662
+ }
1663
+ function sleep(ms) {
1664
+ return new Promise(resolve => setTimeout(resolve, ms));
1665
+ }