@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,2302 @@
1
+ // GatewayProvider — real OpenClaw gateway integration.
2
+ // Connects to the OpenClaw gateway via HTTP and WebSocket,
3
+ // streams live trading data, and emits ProviderEvents.
4
+ import { readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'fs';
5
+ import { join } from 'path';
6
+ import { homedir } from 'os';
7
+ import { logger, formatError } from '../logger.js';
8
+ import { isTradingMode } from '../types.js';
9
+ import { GatewayHttpClient } from '../gateway/gateway-http-client.js';
10
+ import { GatewayWsClient } from '../gateway/gateway-ws-client.js';
11
+ import { discoverTools } from '../gateway/tool-discovery.js';
12
+ import { EventParser, mapCcxtBalance, mapCcxtOrder, extractLiquidationFields, extractBracketField, } from '../gateway/event-parser.js';
13
+ import { Poller } from '../gateway/poller.js';
14
+ import { computeEquity as _computeEquity, computePositionNotional as _computePositionNotional, computeRiskMetrics as _computeRiskMetrics, DEFAULT_RISK_LIMITS, } from './risk-calculator.js';
15
+ import { executeKill as _executeKill, executeFlatten as _executeFlatten, executePause as _executePause, executeResume as _executeResume, } from './emergency-commands.js';
16
+ import { executeSetTradingMode, executeGetBracketConfig, executeSetBracketRequirement, executeSetExchangeCredentials, executeTestExchangeCredentials, executeClearExchangeCredentials, } from './onboarding-commands.js';
17
+ const TAG = 'gateway';
18
+ // ---- Session NAV persistence ----
19
+ // Persists sessionStartNav per date so Day P&L survives skill restarts.
20
+ const SESSION_NAV_FILENAME = 'session-nav.json';
21
+ const TRADING_MODE_FILENAME = 'trading-mode.json';
22
+ function getSessionNavPath() {
23
+ return join(homedir(), '.openclaw', 'workspace', SESSION_NAV_FILENAME);
24
+ }
25
+ function getTradingModePath() {
26
+ return join(homedir(), '.openclaw', 'workspace', TRADING_MODE_FILENAME);
27
+ }
28
+ function todayDateStr() {
29
+ return new Date().toISOString().slice(0, 10);
30
+ }
31
+ /** Decide whether the session-start-NAV anchor needs to be (re)set. Pure so it
32
+ * can be unit-tested without the full provider. Returns true when the anchor
33
+ * has never been set, OR — for PAPER only — when it belongs to a previous UTC
34
+ * day (so Day P&L re-anchors at midnight). Live/shadow anchor once and then
35
+ * sync to the plugin's Binance income-endpoint value, so they never
36
+ * self-re-anchor here. */
37
+ export function shouldAnchorSessionNav(args) {
38
+ if (args.sessionStartNav < 0)
39
+ return true; // never anchored
40
+ return args.mode === 'PAPER' && args.sessionDate !== args.today; // paper midnight rollover
41
+ }
42
+ function loadSessionStartNav() {
43
+ try {
44
+ const raw = readFileSync(getSessionNavPath(), 'utf-8');
45
+ const data = JSON.parse(raw);
46
+ if (data.date === todayDateStr() && typeof data.sessionStartNav === 'number' && data.sessionStartNav > 0) {
47
+ return data.sessionStartNav;
48
+ }
49
+ return null; // Different day or invalid
50
+ }
51
+ catch {
52
+ return null; // File doesn't exist or is corrupt
53
+ }
54
+ }
55
+ /**
56
+ * Persisted across skill restarts so the FIRST snapshot the gateway emits
57
+ * after boot reflects the last-known trading mode instead of the hardcoded
58
+ * 'PAPER' default. Otherwise every skill deploy clobbers the dashboard's
59
+ * localStorage cache with PAPER and the badge sticks until a hard refresh.
60
+ * The fresh fetchBalance round-trip will still correct any drift via the
61
+ * trading_mode event in fetchFreshBalance() — but until then, this prevents
62
+ * the visible PAPER flash.
63
+ */
64
+ function loadLastTradingMode() {
65
+ try {
66
+ const raw = readFileSync(getTradingModePath(), 'utf-8');
67
+ const data = JSON.parse(raw);
68
+ if (isTradingMode(data.mode)) {
69
+ return data.mode;
70
+ }
71
+ return null;
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ }
77
+ function saveLastTradingMode(mode) {
78
+ try {
79
+ const dir = join(homedir(), '.openclaw', 'workspace');
80
+ mkdirSync(dir, { recursive: true });
81
+ const data = { mode, savedAt: new Date().toISOString() };
82
+ writeFileSync(getTradingModePath(), JSON.stringify(data, null, 2), 'utf-8');
83
+ }
84
+ catch {
85
+ /* non-fatal — next save will retry */
86
+ }
87
+ }
88
+ function saveSessionStartNav(nav) {
89
+ try {
90
+ const dir = join(homedir(), '.openclaw', 'workspace');
91
+ mkdirSync(dir, { recursive: true });
92
+ const data = { date: todayDateStr(), sessionStartNav: nav };
93
+ writeFileSync(getSessionNavPath(), JSON.stringify(data, null, 2), 'utf-8');
94
+ }
95
+ catch (err) {
96
+ logger.warn(TAG, `Failed to save session NAV: ${err instanceof Error ? err.message : String(err)}`);
97
+ }
98
+ }
99
+ // ---- Agent state emission interval ----
100
+ const AGENT_STATE_INTERVAL_MS = 5_000;
101
+ // ---- GatewayProvider ----
102
+ export class GatewayProvider {
103
+ config;
104
+ // ---- Sub-components (created during initialization) ----
105
+ http = null;
106
+ wsClient = null;
107
+ poller = null;
108
+ eventParser = null;
109
+ toolMap = {};
110
+ // ---- Internal state ----
111
+ agentMode = 'ACTIVE';
112
+ // 'agent_discretionary' is the honest default for trades the agent opens
113
+ // via create_order without a named strategy-engine signal firing. The
114
+ // legacy 'Unknown' value polluted every trade_results row on intel — see
115
+ // [[feedback_skill_bridge_version_compare_pitfall]] adjacent bug. Agents
116
+ // should classify by setup_type (validated NOT NULL on position_entries),
117
+ // not by this skill-side hint field.
118
+ lastKnownStrategy = { name: 'agent_discretionary', version: '0.0.0' };
119
+ /** Agent display name + live model id, resolved from the gateway (best-effort).
120
+ * Surfaced in agent_state so the dashboard header can show the real agent +
121
+ * model instead of a hardcoded placeholder. Empty until the gateway answers. */
122
+ agentIdentity = {};
123
+ /** Emit the one-time rollout probe (gateway response shape) only on the first attempt. */
124
+ loggedAgentIdentityProbe = false;
125
+ /** Live heartbeat cadence (seconds), read from the OpenClaw cron store; cached 60s. */
126
+ heartbeatSeconds;
127
+ heartbeatReadAtMs = 0;
128
+ loggedHeartbeat = false;
129
+ lastTicker = null;
130
+ positions = [];
131
+ balance = { currency: 'USDT', total: 0, available: 0, locked: 0 };
132
+ /** Today's realized PnL (filtered by closedAt) from plugin trade history. */
133
+ realizedPnlToday = null;
134
+ /** Cumulative all-time realized PnL — used for the webapp KPI strip. */
135
+ realizedPnlAllTime = null;
136
+ /** Paper session execution costs (since plugin boot), forwarded to the
137
+ * dashboard cost cells so "Fees"/"Avg Slip" reflect the drag already in NAV
138
+ * instead of reading $0 (M1). Null in live (LiveAdapter omits them). */
139
+ paperSessionFees = null;
140
+ paperAvgSlippageBps = null;
141
+ /** Mark-to-market equity from plugin (wallet + collateral + unrealized). */
142
+ equity = null;
143
+ openOrders = [];
144
+ startTime = Date.now();
145
+ /** Session start NAV — -1 means "not yet set", 0 is valid (fresh account). */
146
+ sessionStartNav = -1;
147
+ /** UTC date (YYYY-MM-DD) the current sessionStartNav anchor belongs to.
148
+ * Null until first anchored. Used to re-anchor Day P&L at UTC midnight —
149
+ * without it a long-running PAPER session kept yesterday's anchor and Day
150
+ * P&L / Realized accumulated across days. */
151
+ sessionDate = null;
152
+ /** Track whether we've received at least one positions poll (needed for equity calculation). */
153
+ hasReceivedPositions = false;
154
+ /** Track whether we've received at least one balance poll (needed for equity calculation). */
155
+ hasReceivedBalance = false;
156
+ quoteCurrency;
157
+ // ---- Rate tracking ----
158
+ orderTimestamps = [];
159
+ cancelTimestamps = [];
160
+ // ---- Timers ----
161
+ agentStateInterval = null;
162
+ toolRetryInterval = null;
163
+ regimeInterval = null;
164
+ signalInterval = null;
165
+ missionInterval = null;
166
+ analyticsInterval = null;
167
+ decisionTraceInterval = null;
168
+ pendingStartTimers = [];
169
+ started = false;
170
+ startedAt = 0;
171
+ // ---- Mission tracking ----
172
+ lastSeenMissionIds = new Set();
173
+ // ---- Intelligence poller error tracking (circuit-breaker) ----
174
+ intelErrors = {};
175
+ // ---- Event listeners ----
176
+ // ---- Cached data for trade journaling (Feature C) ----
177
+ lastCryptoMetrics = null;
178
+ lastVolumeAnalysis = null;
179
+ // ---- ATR data for volatility-adaptive risk limits ----
180
+ atrData = null;
181
+ atrSampleCount = 0;
182
+ // ---- Current regime (Phase 9e: regime-conditional risk) ----
183
+ currentRegime = 'UNKNOWN';
184
+ // ---- Trade tracking ----
185
+ /** Timestamp of the most recent trade (fill event) — used in buildAgentState */
186
+ lastTradeTs = null;
187
+ /** Tracks open position entries for computing trade results on close.
188
+ * Key: `symbol:side` (e.g. "BTC/USDT:long"), Value: { entryPrice, quantity, side, entryTime, missionId?, setupType?, regime?, confluenceScore? } */
189
+ openTradeEntries = new Map();
190
+ /** Potential closes awaiting 2-poll confirmation. Binance's fetchPositions
191
+ * occasionally returns a held position as missing or zero-contract for a
192
+ * single poll; those transient drops were the root cause of ~350 phantom
193
+ * trade_results rows/day. We only treat a position as closed after it's
194
+ * been absent on TWO consecutive polls. Key: `symbol:side`. */
195
+ pendingCloses = new Map();
196
+ /** Tracks whether the previous poll returned an empty / all-flat positions
197
+ * snapshot while we were holding non-zero positions. A single empty poll is
198
+ * treated as tentative (most often a Binance 429 / partial response). We
199
+ * skip the diff detector and do NOT overwrite this.positions until the
200
+ * next poll confirms — without this guard, the next non-empty poll re-emits
201
+ * synthetic OPEN fills for positions that were there all along (the
202
+ * open-side mirror of the close-side phantom that pendingCloses guards
203
+ * against). See Track 1 verification 2026-04-21 +
204
+ * docs/TRADE_AUDIT_TRAIL_PLAN.md Phase 0. */
205
+ pendingEmptyPoll = false;
206
+ /** In-flight trade result reports — drained on stop() to prevent data loss during kill/flatten. */
207
+ inFlightReports = [];
208
+ /** File-backed retry queue for failed trade reports */
209
+ retryQueuePath;
210
+ retryTimer = null;
211
+ // ---- Trading mode (Phase 9b) ----
212
+ // Initial value is loaded from the persisted file written on every prior
213
+ // mode change. Falls back to 'PAPER' only on first-ever boot (or after
214
+ // file is corrupt/deleted). Prevents the "dashboard flashes PAPER on every
215
+ // skill restart" UX bug — see saveLastTradingMode() comment above.
216
+ tradingMode = loadLastTradingMode() ?? 'PAPER';
217
+ listeners = {
218
+ ticker: new Set(),
219
+ candle: new Set(),
220
+ orderUpdate: new Set(),
221
+ agentState: new Set(),
222
+ riskUpdate: new Set(),
223
+ chatMessage: new Set(),
224
+ chatMessageStart: new Set(),
225
+ chatMessageChunk: new Set(),
226
+ chatMessageEnd: new Set(),
227
+ chatStreamingKeepAlive: new Set(),
228
+ marketStructure: new Set(),
229
+ cryptoMetrics: new Set(),
230
+ volumeAnalysis: new Set(),
231
+ tradeJournal: new Set(),
232
+ regimeUpdate: new Set(),
233
+ signalUpdate: new Set(),
234
+ missionUpdate: new Set(),
235
+ analyticsUpdate: new Set(),
236
+ fillError: new Set(),
237
+ shadowComparison: new Set(),
238
+ tradingModeUpdate: new Set(),
239
+ decisionTraceUpdate: new Set(),
240
+ };
241
+ constructor(config) {
242
+ this.config = config;
243
+ // Extract quote currency from symbol (e.g. "BTC/USDT" -> "USDT")
244
+ const parts = config.symbol.split('/');
245
+ this.quoteCurrency = parts.length > 1 ? parts[1] : 'USDT';
246
+ // Retry queue for failed trade reports
247
+ const retryDir = join(homedir(), '.reefclaw');
248
+ try {
249
+ mkdirSync(retryDir, { recursive: true });
250
+ }
251
+ catch { /* exists */ }
252
+ this.retryQueuePath = join(retryDir, 'trade-report-queue.json');
253
+ logger.debug(TAG, `Config: url=${config.gatewayUrl}, symbol=${config.symbol}, quote=${this.quoteCurrency}`);
254
+ }
255
+ // ---- Provider interface ----
256
+ start() {
257
+ if (this.started) {
258
+ logger.warn(TAG, 'GatewayProvider already started');
259
+ return;
260
+ }
261
+ this.started = true;
262
+ this.startedAt = Date.now();
263
+ this.startTime = Date.now();
264
+ logger.info(TAG, 'Starting GatewayProvider');
265
+ this.startRetryTimer();
266
+ // Async initialization — errors are logged, not thrown
267
+ void this.initialize().catch((err) => {
268
+ logger.error(TAG, `Initialization failed: ${err instanceof Error ? err.message : String(err)}`);
269
+ this.agentMode = 'ERROR';
270
+ this.emitAgentState();
271
+ });
272
+ }
273
+ stop() {
274
+ logger.info(TAG, 'Stopping GatewayProvider');
275
+ this.stopRetryTimer();
276
+ this.started = false;
277
+ if (this.toolRetryInterval) {
278
+ clearInterval(this.toolRetryInterval);
279
+ this.toolRetryInterval = null;
280
+ }
281
+ if (this.agentStateInterval) {
282
+ clearInterval(this.agentStateInterval);
283
+ this.agentStateInterval = null;
284
+ }
285
+ if (this.regimeInterval) {
286
+ clearInterval(this.regimeInterval);
287
+ this.regimeInterval = null;
288
+ }
289
+ if (this.signalInterval) {
290
+ clearInterval(this.signalInterval);
291
+ this.signalInterval = null;
292
+ }
293
+ if (this.missionInterval) {
294
+ clearInterval(this.missionInterval);
295
+ this.missionInterval = null;
296
+ }
297
+ if (this.analyticsInterval) {
298
+ clearInterval(this.analyticsInterval);
299
+ this.analyticsInterval = null;
300
+ }
301
+ if (this.decisionTraceInterval) {
302
+ clearInterval(this.decisionTraceInterval);
303
+ this.decisionTraceInterval = null;
304
+ }
305
+ // Clear any pending delayed-start timers (prevents orphaned setInterval after stop)
306
+ for (const timer of this.pendingStartTimers) {
307
+ clearTimeout(timer);
308
+ }
309
+ this.pendingStartTimers = [];
310
+ if (this.poller) {
311
+ this.poller.stop();
312
+ this.poller = null;
313
+ }
314
+ if (this.wsClient) {
315
+ this.wsClient.destroy();
316
+ this.wsClient = null;
317
+ }
318
+ // Reset trade tracking state to avoid stale data on restart
319
+ this.hasReceivedPositions = false;
320
+ this.hasReceivedBalance = false;
321
+ this.openTradeEntries.clear();
322
+ this.pendingCloses.clear();
323
+ this.pendingEmptyPoll = false;
324
+ this.lastTradeTs = null;
325
+ this.realizedPnlToday = null;
326
+ this.realizedPnlAllTime = null;
327
+ this.paperSessionFees = null;
328
+ this.paperAvgSlippageBps = null;
329
+ this.equity = null;
330
+ this.http = null;
331
+ this.eventParser = null;
332
+ }
333
+ /**
334
+ * Drain in-flight trade result reports. Call during shutdown to prevent
335
+ * data loss during kill/flatten sequences. Returns when all pending
336
+ * reports have completed (or timed out).
337
+ */
338
+ async drainReports() {
339
+ if (this.inFlightReports.length === 0)
340
+ return;
341
+ logger.info(TAG, `Draining ${this.inFlightReports.length} in-flight trade report(s)...`);
342
+ await Promise.allSettled(this.inFlightReports);
343
+ logger.info(TAG, 'All trade reports drained');
344
+ }
345
+ // ---- Trade report retry queue ----
346
+ loadRetryQueue() {
347
+ try {
348
+ const raw = readFileSync(this.retryQueuePath, 'utf-8');
349
+ const parsed = JSON.parse(raw);
350
+ return Array.isArray(parsed) ? parsed : [];
351
+ }
352
+ catch {
353
+ return [];
354
+ }
355
+ }
356
+ saveRetryQueue(queue) {
357
+ try {
358
+ writeFileSync(this.retryQueuePath, JSON.stringify(queue));
359
+ }
360
+ catch (err) {
361
+ logger.warn(TAG, `Failed to save retry queue: ${formatError(err)}`);
362
+ }
363
+ }
364
+ /** Guards processRetryQueue against overlapping runs — the retry timer fires
365
+ * every 60s and startRetryTimer also kicks an immediate run, so a slow
366
+ * (100-item × 10s-timeout) drain could still be in flight when the next
367
+ * timer ticks. Two concurrent drains would double-POST every queued report
368
+ * AND race their reconcile-saves against each other. */
369
+ retryInProgress = false;
370
+ /** Monotonic tag stamped on every queued report so processRetryQueue can
371
+ * remove EXACTLY the reports it delivered when it reconciles the file — a
372
+ * blind `save(remaining)` would clobber any report enqueued DURING the
373
+ * network phase (read-modify-write race with enqueueFailedReport). */
374
+ nextRetryQid = 0;
375
+ enqueueFailedReport(tradeResult) {
376
+ // Synchronous read-modify-write: no `await` between load and save, so a
377
+ // concurrent processRetryQueue (which only touches the file synchronously
378
+ // outside its network phase) can never interleave and clobber this push.
379
+ const queue = this.loadRetryQueue();
380
+ queue.push({ ...tradeResult, _qid: `${Date.now()}-${this.nextRetryQid++}` });
381
+ // Cap at 100 to prevent unbounded growth
382
+ if (queue.length > 100)
383
+ queue.splice(0, queue.length - 100);
384
+ this.saveRetryQueue(queue);
385
+ logger.info(TAG, `Trade report queued for retry (${queue.length} pending)`);
386
+ }
387
+ async processRetryQueue() {
388
+ // Timer-overlap / in-flight guard — single-drainer at a time.
389
+ if (this.retryInProgress)
390
+ return;
391
+ let snapshot = this.loadRetryQueue();
392
+ if (snapshot.length === 0)
393
+ return;
394
+ // One-time migration: reports queued by a pre-_qid binary carry no tag, so
395
+ // the reconcile-by-qid below couldn't remove them and they'd retry forever
396
+ // (harmless idempotent dup POSTs, but never clearing). Backfill a _qid for
397
+ // any legacy entry. Synchronous read-modify-write — atomic vs enqueue.
398
+ if (snapshot.some((it) => typeof it._qid !== 'string')) {
399
+ snapshot = snapshot.map((it) => typeof it._qid === 'string' ? it : { ...it, _qid: `${Date.now()}-${this.nextRetryQid++}` });
400
+ this.saveRetryQueue(snapshot);
401
+ }
402
+ const url = this.config.intelligenceUrl;
403
+ const token = this.config.connectionToken;
404
+ if (!url || !token)
405
+ return;
406
+ this.retryInProgress = true;
407
+ try {
408
+ const fullUrl = `${url}/api/trades`;
409
+ const deliveredQids = new Set();
410
+ for (const item of snapshot) {
411
+ const qid = typeof item._qid === 'string' ? item._qid : undefined;
412
+ // Strip the internal tag so the POST body is byte-identical to the
413
+ // original report (the intel /api/trades endpoint never sees _qid).
414
+ const tradeResult = { ...item };
415
+ delete tradeResult._qid;
416
+ try {
417
+ const res = await fetch(fullUrl, {
418
+ method: 'POST',
419
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
420
+ body: JSON.stringify(tradeResult),
421
+ signal: AbortSignal.timeout(10_000),
422
+ });
423
+ if (res.ok) {
424
+ if (qid)
425
+ deliveredQids.add(qid);
426
+ logger.info(TAG, `Retry succeeded: ${tradeResult.symbol} ${tradeResult.direction}`);
427
+ }
428
+ // else: still failing — leave it in the queue (not marked delivered)
429
+ }
430
+ catch {
431
+ // Network error — stop retrying; leave this + the rest in the queue.
432
+ break;
433
+ }
434
+ }
435
+ // Reconcile synchronously (no await between load and save): re-read the
436
+ // CURRENT file — it may now hold reports enqueued while we were POSTing —
437
+ // and drop ONLY the ones we actually delivered. Everything else (still-
438
+ // failing + newly-enqueued) is preserved. A blind save(remaining) here
439
+ // would silently lose any report enqueued during the network phase.
440
+ const current = this.loadRetryQueue();
441
+ const remaining = deliveredQids.size > 0
442
+ ? current.filter((item) => !(typeof item._qid === 'string' && deliveredQids.has(item._qid)))
443
+ : current;
444
+ if (remaining.length !== current.length) {
445
+ this.saveRetryQueue(remaining);
446
+ logger.info(TAG, `Retry queue: ${deliveredQids.size} delivered, ${remaining.length} remaining`);
447
+ }
448
+ }
449
+ finally {
450
+ this.retryInProgress = false;
451
+ }
452
+ }
453
+ startRetryTimer() {
454
+ // Retry every 60 seconds
455
+ this.retryTimer = setInterval(() => void this.processRetryQueue(), 60_000);
456
+ // Also process immediately on startup (recover from prior crash)
457
+ void this.processRetryQueue();
458
+ }
459
+ stopRetryTimer() {
460
+ if (this.retryTimer) {
461
+ clearInterval(this.retryTimer);
462
+ this.retryTimer = null;
463
+ }
464
+ }
465
+ /** Dual-fire dedup. Every critical command fires on BOTH WebSocket and
466
+ * HTTP simultaneously (CLAUDE.md: "Agent handles idempotency; first to
467
+ * arrive wins") — so each emergency action normally arrives here TWICE,
468
+ * ~0-200ms apart. Without dedup a second `flatten` re-fetches positions
469
+ * before the first close settles on-exchange and submits a second market
470
+ * close — a position-flip risk. Duplicates of the same action while one
471
+ * is in flight (or within a short post-completion window) share the
472
+ * first invocation's promise/result instead of executing again. */
473
+ static EMERGENCY_DEDUP_MS = 3_000;
474
+ emergencyInFlight = new Map();
475
+ async executeEmergency(action) {
476
+ const existing = this.emergencyInFlight.get(action);
477
+ if (existing &&
478
+ (existing.settledAt === null ||
479
+ Date.now() - existing.settledAt < GatewayProvider.EMERGENCY_DEDUP_MS)) {
480
+ logger.warn(TAG, `Emergency ${action}: duplicate (dual-fire) — returning first invocation's result`);
481
+ return existing.promise;
482
+ }
483
+ const entry = { promise: Promise.resolve({ action, executed: false, timestamp: '' }), settledAt: null };
484
+ entry.promise = this.runEmergency(action).finally(() => {
485
+ entry.settledAt = Date.now();
486
+ });
487
+ this.emergencyInFlight.set(action, entry);
488
+ return entry.promise;
489
+ }
490
+ async runEmergency(action) {
491
+ logger.info(TAG, `Emergency command: ${action}`);
492
+ switch (action) {
493
+ case 'kill':
494
+ return this.executeKill();
495
+ case 'flatten':
496
+ return this.executeFlatten();
497
+ case 'pause':
498
+ return this.executePause();
499
+ case 'resume':
500
+ return this.executeResume();
501
+ default:
502
+ return { action, executed: false, timestamp: new Date().toISOString(), details: `Unknown action: ${action}` };
503
+ }
504
+ }
505
+ // ---- Emergency command implementations (delegated to emergency-commands.ts) ----
506
+ async executeKill() {
507
+ const ctx = { http: this.http, toolMap: this.toolMap, symbol: this.config.symbol };
508
+ // Pass a FRESH, all-symbols order snapshot so Kill cancels every working
509
+ // order portfolio-wide and correctly identifies (and preserves) protective
510
+ // reduceOnly brackets. fetchFreshOpenOrders falls back to this.openOrders on
511
+ // any fetch error, so this never blocks the kill.
512
+ const freshOrders = await this.fetchFreshOpenOrders();
513
+ const result = await _executeKill(ctx, freshOrders);
514
+ this.agentMode = 'STOPPED';
515
+ this.emitAgentState();
516
+ return result;
517
+ }
518
+ async executeFlatten() {
519
+ const ctx = { http: this.http, toolMap: this.toolMap, symbol: this.config.symbol };
520
+ const result = await _executeFlatten(ctx, this.positions);
521
+ this.agentMode = 'STOPPED';
522
+ this.emitAgentState();
523
+ return result;
524
+ }
525
+ executePause() {
526
+ const { result, newMode } = _executePause(this.agentMode);
527
+ if (newMode !== this.agentMode) {
528
+ this.agentMode = newMode;
529
+ this.emitAgentState();
530
+ }
531
+ return result;
532
+ }
533
+ executeResume() {
534
+ const { result, newMode } = _executeResume(this.agentMode);
535
+ if (newMode !== this.agentMode) {
536
+ this.agentMode = newMode;
537
+ this.emitAgentState();
538
+ }
539
+ return result;
540
+ }
541
+ async handleChat(content) {
542
+ // Capture reference before await — defensive against stop() mid-execution
543
+ const ws = this.wsClient;
544
+ if (!ws) {
545
+ const reason = 'Gateway WebSocket not initialized';
546
+ logger.error(TAG, `Chat send failed: ${reason}`);
547
+ return { received: false, error: reason };
548
+ }
549
+ if (!this.started) {
550
+ const reason = 'Provider not started';
551
+ logger.error(TAG, `Chat send failed: ${reason}`);
552
+ return { received: false, error: reason };
553
+ }
554
+ const wsState = ws.getState();
555
+ if (wsState !== 'connected') {
556
+ const reason = `Gateway WebSocket is ${wsState} (not connected)`;
557
+ logger.error(TAG, `Chat send failed: ${reason}`);
558
+ return { received: false, error: reason };
559
+ }
560
+ try {
561
+ const idempotencyKey = `chat-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
562
+ await ws.sendRpc('agent', { message: content, idempotencyKey, agentId: 'main' });
563
+ return { received: true };
564
+ }
565
+ catch (err) {
566
+ const reason = err instanceof Error ? err.message : String(err);
567
+ logger.error(TAG, `Chat send failed: ${reason}`);
568
+ return { received: false, error: reason };
569
+ }
570
+ }
571
+ async closePosition(symbol) {
572
+ const tool = this.toolMap.close_position;
573
+ if (!tool || !this.http) {
574
+ return { closed: false, symbol, error: 'close_position tool not available' };
575
+ }
576
+ try {
577
+ // reason='operator_command' is MANDATORY: close_position's schema gate
578
+ // (validateClosePosition) rejects a reason-less close, and only
579
+ // operator_command bypasses the assessment requirement. Without it the
580
+ // operator's per-symbol Close is silently rejected and never executes.
581
+ // See plugin/src/tools/close-position.ts.
582
+ const result = await this.http.invoke(tool, { symbol, reason: 'operator_command' });
583
+ if (result.data && typeof result.data === 'object' && 'error' in result.data) {
584
+ return { closed: false, symbol, error: String(result.data.error) };
585
+ }
586
+ return { closed: true, symbol };
587
+ }
588
+ catch (err) {
589
+ const reason = err instanceof Error ? err.message : String(err);
590
+ logger.error(TAG, `Close position failed for ${symbol}: ${reason}`);
591
+ return { closed: false, symbol, error: reason };
592
+ }
593
+ }
594
+ // ---- Operator onboarding (PR2) ----
595
+ // Thin wrappers — real logic lives in providers/onboarding-commands.ts
596
+ // so unit tests can drive pure functions with a mocked HTTP client.
597
+ async setTradingMode(mode, acknowledged) {
598
+ return executeSetTradingMode({ http: this.http, toolMap: this.toolMap }, mode, acknowledged);
599
+ }
600
+ async setExchangeCredentials(apiKey, secret, testnet) {
601
+ return executeSetExchangeCredentials({ http: this.http, toolMap: this.toolMap }, apiKey, secret, testnet);
602
+ }
603
+ async testExchangeCredentials(apiKey, secret, testnet) {
604
+ return executeTestExchangeCredentials({ http: this.http, toolMap: this.toolMap }, apiKey, secret, testnet);
605
+ }
606
+ async clearExchangeCredentials() {
607
+ return executeClearExchangeCredentials({ http: this.http, toolMap: this.toolMap });
608
+ }
609
+ /** Operator-only. Read the current bracket-orders config from the plugin. */
610
+ async getBracketConfig() {
611
+ return executeGetBracketConfig({ http: this.http, toolMap: this.toolMap });
612
+ }
613
+ /** Operator-only. Flip requireStopLoss or requireTakeProfit. */
614
+ async setBracketRequirement(flag, value) {
615
+ return executeSetBracketRequirement({ http: this.http, toolMap: this.toolMap }, flag, value);
616
+ }
617
+ async getSnapshot() {
618
+ logger.info(TAG, 'Reconciliation snapshot requested');
619
+ // Parallel fetch — fresh data with cached fallback
620
+ const [posResult, balResult, ordersResult, healthResult] = await Promise.allSettled([
621
+ this.fetchFreshPositions(),
622
+ this.fetchFreshBalance(),
623
+ this.fetchFreshOpenOrders(),
624
+ this.fetchAgentHealth(),
625
+ ]);
626
+ // Guard: stop() may have been called during the fetches
627
+ if (!this.started) {
628
+ logger.info(TAG, 'Snapshot aborted — provider stopped during fetch');
629
+ return {
630
+ positions: [],
631
+ balance: this.balance,
632
+ openOrders: [],
633
+ agentState: this.buildAgentState(),
634
+ riskMetrics: this.computeRiskMetrics(),
635
+ gapFill: [],
636
+ };
637
+ }
638
+ // Extract results (all have fallbacks, so fulfilled is guaranteed by the helper methods)
639
+ const positions = posResult.status === 'fulfilled' ? posResult.value : this.positions;
640
+ const balance = balResult.status === 'fulfilled' ? balResult.value : this.balance;
641
+ const openOrders = ordersResult.status === 'fulfilled' ? ordersResult.value : this.openOrders;
642
+ const agentState = healthResult.status === 'fulfilled' ? healthResult.value : this.buildAgentState();
643
+ // Log if all fetches used cached data (degraded snapshot)
644
+ const freshCount = [posResult, balResult, ordersResult, healthResult]
645
+ .filter((r) => r.status === 'fulfilled').length;
646
+ if (freshCount === 0) {
647
+ logger.error(TAG, 'Snapshot DEGRADED — all fetches failed, returning cached data');
648
+ }
649
+ // Canonicalize symbols at the snapshot ingress too — same single-form
650
+ // invariant as onPollerPositions. The reconnect fetch hits the same
651
+ // plugin getPositions WS-store(unsuffixed)↔REST(suffixed) flip, so
652
+ // without this the first poll AFTER a snapshot diffs a suffixed prev
653
+ // against an unsuffixed curr (or vice-versa) and re-triggers the
654
+ // phantom-fill / NAV-thrash flip (2026-05-16 incident).
655
+ for (const p of positions) {
656
+ if (typeof p.symbol === 'string')
657
+ p.symbol = this.canonicalSymbol(p.symbol);
658
+ }
659
+ // Update internal state with fresh data (poller may be paused during reconnect)
660
+ this.positions = positions;
661
+ this.balance = balance;
662
+ this.openOrders = openOrders;
663
+ // Compute current NAV for portfolio percent calculation (consistent with computeRiskMetrics)
664
+ const equity = this.computeEquity();
665
+ const nav = this.sessionStartNav > 0 ? this.sessionStartNav : Math.max(equity, 1);
666
+ // Map CCXT positions to snapshot format
667
+ const snapshotPositions = [];
668
+ for (const pos of positions) {
669
+ const signedNotional = this.computePositionNotional(pos);
670
+ if (signedNotional === 0) {
671
+ logger.warn(TAG, `Skipping zero-notional position: ${pos.symbol} ${pos.side} contracts=${pos.contracts} notional=${pos.notional} markPrice=${pos.markPrice} entryPrice=${pos.entryPrice}`);
672
+ continue;
673
+ }
674
+ const contractSize = pos.contractSize ?? 1;
675
+ const quantity = Math.abs(pos.contracts ?? 0) * contractSize;
676
+ const entryPrice = pos.entryPrice ?? 0;
677
+ const currentPrice = pos.markPrice ?? pos.entryPrice ?? this.lastTicker?.lastPrice ?? 0;
678
+ // Side mapping — guard against non-standard CCXT values (e.g. 'both' in hedge mode)
679
+ let side;
680
+ if (pos.side === 'short') {
681
+ side = 'SHORT';
682
+ }
683
+ else if (pos.side === 'long') {
684
+ side = 'LONG';
685
+ }
686
+ else {
687
+ logger.warn(TAG, `Unknown position side "${pos.side}" for ${pos.symbol}, defaulting to LONG`);
688
+ side = 'LONG';
689
+ }
690
+ // Unrealized PnL — prefer exchange-provided value, compute as fallback
691
+ let unrealizedPnl = pos.unrealizedPnl;
692
+ if (unrealizedPnl == null && entryPrice > 0 && currentPrice > 0) {
693
+ unrealizedPnl = pos.side === 'long'
694
+ ? (currentPrice - entryPrice) * quantity
695
+ : (entryPrice - currentPrice) * quantity;
696
+ }
697
+ unrealizedPnl ??= 0;
698
+ const costBasis = entryPrice * quantity;
699
+ const unrealizedPnlPercent = costBasis > 0 ? (unrealizedPnl / costBasis) * 100 : 0;
700
+ const portfolioPercent = (Math.abs(signedNotional) / nav) * 100;
701
+ const liqFields = extractLiquidationFields(pos);
702
+ const bracketField = extractBracketField(pos);
703
+ snapshotPositions.push({
704
+ symbol: pos.symbol,
705
+ side,
706
+ quantity,
707
+ entryPrice,
708
+ currentPrice,
709
+ unrealizedPnl: +unrealizedPnl.toFixed(4),
710
+ unrealizedPnlPercent: +unrealizedPnlPercent.toFixed(2),
711
+ portfolioPercent: +portfolioPercent.toFixed(2),
712
+ // Component C: MFE / give-back surfacing. The plugin computes these on
713
+ // every mark-price tick (paper) or every getPositions call (live);
714
+ // we just pass them through unchanged. Undefined when the position
715
+ // opened without a stop (R-multiple denominator missing). Index
716
+ // signature on CcxtPosition is `unknown`, so narrow at the boundary.
717
+ mfeR: typeof pos.mfeR === 'number' ? pos.mfeR : undefined,
718
+ mfePeakPrice: typeof pos.mfePeakPrice === 'number' ? pos.mfePeakPrice : undefined,
719
+ giveBackRatio: typeof pos.giveBackRatio === 'number' ? pos.giveBackRatio : undefined,
720
+ originalStopPrice: typeof pos.originalStopPrice === 'number' ? pos.originalStopPrice : undefined,
721
+ ...(bracketField !== undefined ? { bracket: bracketField } : {}),
722
+ ...liqFields,
723
+ });
724
+ }
725
+ logger.info(TAG, `Snapshot: ${positions.length} raw positions → ${snapshotPositions.length} in snapshot (equity=${equity.toFixed(2)}, wallet=${balance.total?.toFixed(2)})`);
726
+ // Compute risk metrics (reuse existing logic)
727
+ const riskMetrics = this.computeRiskMetrics();
728
+ // NAV must equal Binance's "Margin Balance" = wallet + unrealized.
729
+ // Compute directly from the fresh wallet (balance.total — BNFCR-aware,
730
+ // uPnl-excluded) + unrealized already summed across snapshotPositions.
731
+ // We deliberately do NOT use this.equity: it is only assigned in
732
+ // fetchFreshBalance() (dormant under userDataStream.mode=enforce), so
733
+ // the old `this.equity ?? balance.total` collapsed live NAV to wallet-
734
+ // only on reconnect (2026-05-16 KPI incident). Paper keeps computeEquity
735
+ // (adds back entry notional — correct, paper deducts full notional at
736
+ // open).
737
+ const snapshotUnrealizedTotal = snapshotPositions.reduce((sum, p) => sum + p.unrealizedPnl, 0);
738
+ const snapshotTotal = this.tradingMode !== 'PAPER'
739
+ ? balance.total + snapshotUnrealizedTotal // live: wallet + unrealized
740
+ : equity; // paper: wallet + notional
741
+ const adjustedBalance = {
742
+ ...balance,
743
+ total: snapshotTotal,
744
+ available: balance.available,
745
+ locked: snapshotTotal - balance.available,
746
+ };
747
+ return {
748
+ positions: snapshotPositions,
749
+ balance: adjustedBalance,
750
+ openOrders,
751
+ agentState,
752
+ riskMetrics,
753
+ gapFill: [],
754
+ sessionStartNav: this.sessionStartNav > 0 ? this.sessionStartNav : undefined,
755
+ realizedPnlToday: this.realizedPnlToday ?? undefined,
756
+ realizedPnlAllTime: this.realizedPnlAllTime ?? undefined,
757
+ sessionFeesPaid: this.paperSessionFees ?? undefined,
758
+ sessionAvgSlippageBps: this.paperAvgSlippageBps ?? undefined,
759
+ equity: this.equity ?? undefined,
760
+ tradingMode: this.tradingMode,
761
+ };
762
+ }
763
+ on(event, listener) {
764
+ this.listeners[event].add(listener);
765
+ }
766
+ off(event, listener) {
767
+ this.listeners[event].delete(listener);
768
+ }
769
+ // ---- Async initialization ----
770
+ async initialize() {
771
+ // Step 1: Create HTTP client (initially with raw gateway token)
772
+ this.http = new GatewayHttpClient(this.config);
773
+ logger.info(TAG, 'HTTP client created');
774
+ // Step 2: Create event parser with async event callback for inactivity timeouts.
775
+ // When the parser's inactivity timer fires (no new chunks for 5s), it flushes
776
+ // the buffer and emits chatMessageEnd outside the normal parseAgentEvent flow.
777
+ this.eventParser = new EventParser(this.quoteCurrency, (events) => {
778
+ for (const evt of events) {
779
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
780
+ this.fire(evt.event, evt.data);
781
+ }
782
+ });
783
+ // Step 3: Create WS client and connect FIRST to obtain device token.
784
+ // OpenClaw's REST API requires a device token from the WS handshake,
785
+ // not the raw gateway auth token. Tool discovery must wait for this.
786
+ this.wsClient = new GatewayWsClient(this.config);
787
+ this.wireWsEvents();
788
+ // Wait for WS handshake to complete (or fail)
789
+ const deviceToken = await this.waitForDeviceToken();
790
+ if (!this.started) {
791
+ logger.info(TAG, 'Stopped during WS handshake — aborting initialization');
792
+ this.http = null;
793
+ return;
794
+ }
795
+ // Update HTTP client with device token for REST API calls
796
+ if (deviceToken) {
797
+ this.http.setToken(deviceToken);
798
+ }
799
+ else {
800
+ logger.warn(TAG, 'No device token from WS handshake — REST calls may fail (using raw gateway token)');
801
+ }
802
+ // Step 4: Discover available tools (async — check for stop() after)
803
+ const discovery = await discoverTools(this.http);
804
+ if (!this.started) {
805
+ logger.info(TAG, 'Stopped during tool discovery — aborting initialization');
806
+ this.http = null;
807
+ return;
808
+ }
809
+ this.toolMap = discovery.toolMap;
810
+ const noToolsFound = discovery.found.length === 0;
811
+ if (noToolsFound) {
812
+ // No trading tools found — enter PAUSED mode and start retry loop.
813
+ // Still connect WS + relay so the dashboard shows the agent is alive.
814
+ logger.warn(TAG, 'No trading tools found. Agent will connect but cannot trade.');
815
+ logger.info(TAG, 'Install a trading plugin: openclaw skills install reefclaw-paper-trading');
816
+ this.agentMode = 'PAUSED';
817
+ }
818
+ else {
819
+ if (discovery.missingRequired.length > 0) {
820
+ logger.error(TAG, `Missing required tools: ${discovery.missingRequired.join(', ')}`);
821
+ }
822
+ logger.info(TAG, `Tools discovered: ${discovery.found.join(', ')}`);
823
+ // Warn if emergency tools are missing
824
+ if (!this.toolMap.cancel_all_orders && !this.toolMap.cancel_order) {
825
+ logger.warn(TAG, 'Kill switch degraded: no cancel tools discovered (cancel_all_orders, cancel_order)');
826
+ }
827
+ if (!this.toolMap.close_position && !this.toolMap.create_order) {
828
+ logger.warn(TAG, 'Flatten degraded: no position close tools discovered (close_position, create_order)');
829
+ }
830
+ }
831
+ // Start retry loop if not all essential tools are discovered.
832
+ // This handles both "no tools at all" and "partial discovery during gateway startup".
833
+ if (!this.hasAllEssentialTools()) {
834
+ const missing = GatewayProvider.ESSENTIAL_OPTIONAL_TOOLS.filter(t => !this.toolMap[t]);
835
+ const missingRequired = ['fetch_ticker', 'fetch_balance'].filter(t => !this.toolMap[t]);
836
+ const allMissing = [...missingRequired, ...missing];
837
+ logger.info(TAG, `Retrying tool discovery every 30s for missing tools: ${allMissing.join(', ')}`);
838
+ this.startToolRetry();
839
+ }
840
+ // Step 5: Start poller if tools available (previously started from onWsConnected)
841
+ const hasTools = Object.keys(this.toolMap).length > 0;
842
+ if (hasTools && this.http && !this.poller) {
843
+ this.poller = this.createPoller();
844
+ this.poller.start();
845
+ }
846
+ // Step 6: Start regime poller if intelligence service is configured
847
+ this.startRegimePoller();
848
+ // Step 7: Start signal poller if intelligence service is configured
849
+ this.startSignalPoller();
850
+ // Step 8: Start mission poller if intelligence service is configured
851
+ this.startMissionPoller();
852
+ // Step 9: Start analytics poller if intelligence service is configured
853
+ this.startAnalyticsPoller();
854
+ // Step 10: Start decision trace poller if intelligence service is configured
855
+ this.startDecisionTracePoller();
856
+ // Step 11: Seed lastTradeTs from Intelligence API (one-shot, best-effort)
857
+ // Without this, "Last Trade" shows "Never" after every restart until a new trade fires.
858
+ void this.seedLastTradeTs();
859
+ // Emit initial agent state
860
+ this.emitAgentState();
861
+ }
862
+ /**
863
+ * Wait for the WS handshake to complete and return the device token.
864
+ * Returns null if handshake fails or times out.
865
+ */
866
+ waitForDeviceToken() {
867
+ return new Promise((resolve) => {
868
+ if (!this.wsClient) {
869
+ resolve(null);
870
+ return;
871
+ }
872
+ let settled = false;
873
+ const cleanup = () => {
874
+ if (this.wsClient) {
875
+ this.wsClient.off('connected', onConnected);
876
+ this.wsClient.off('failed', onFailed);
877
+ }
878
+ };
879
+ const settle = (value) => {
880
+ if (settled)
881
+ return;
882
+ settled = true;
883
+ clearTimeout(timeout);
884
+ cleanup();
885
+ resolve(value);
886
+ };
887
+ const timeout = setTimeout(() => {
888
+ logger.warn(TAG, 'WS handshake timed out after 15s — proceeding without device token');
889
+ settle(null);
890
+ }, 15_000);
891
+ const onConnected = (helloOk) => {
892
+ const token = helloOk.auth?.deviceToken ?? null;
893
+ if (token) {
894
+ logger.info(TAG, 'Got device token from WS handshake');
895
+ }
896
+ settle(token);
897
+ };
898
+ const onFailed = () => {
899
+ logger.warn(TAG, 'WS handshake failed — proceeding without device token');
900
+ settle(null);
901
+ };
902
+ this.wsClient.on('connected', onConnected);
903
+ this.wsClient.on('failed', onFailed);
904
+ // Initiate the connection
905
+ this.wsClient.connect();
906
+ });
907
+ }
908
+ /**
909
+ * Essential optional tools — retry loop keeps running until these are discovered.
910
+ * These are needed for position tracking, emergency controls, and trade display.
911
+ */
912
+ static ESSENTIAL_OPTIONAL_TOOLS = [
913
+ 'fetch_positions',
914
+ 'create_order',
915
+ 'close_position',
916
+ 'cancel_all_orders',
917
+ ];
918
+ /** Check if all essential tools are discovered. */
919
+ hasAllEssentialTools() {
920
+ for (const tool of GatewayProvider.ESSENTIAL_OPTIONAL_TOOLS) {
921
+ if (!this.toolMap[tool])
922
+ return false;
923
+ }
924
+ // Also need the required tools
925
+ if (!this.toolMap.fetch_ticker || !this.toolMap.fetch_balance)
926
+ return false;
927
+ return true;
928
+ }
929
+ /** Retry tool discovery every 30s until all essential tools are found. */
930
+ startToolRetry() {
931
+ if (this.toolRetryInterval)
932
+ return;
933
+ const RETRY_MS = 30_000;
934
+ this.toolRetryInterval = setInterval(async () => {
935
+ if (!this.started || !this.http) {
936
+ if (this.toolRetryInterval) {
937
+ clearInterval(this.toolRetryInterval);
938
+ this.toolRetryInterval = null;
939
+ }
940
+ return;
941
+ }
942
+ logger.info(TAG, 'Retrying tool discovery...');
943
+ try {
944
+ // Capture http ref before await — stop() can nullify it
945
+ const http = this.http;
946
+ const discovery = await discoverTools(http);
947
+ // Guard: stop() may have been called during async discovery
948
+ if (!this.started || !this.http)
949
+ return;
950
+ if (discovery.found.length > 0) {
951
+ // Merge newly discovered tools into existing toolMap
952
+ const prevCount = Object.keys(this.toolMap).length;
953
+ for (const [key, val] of Object.entries(discovery.toolMap)) {
954
+ this.toolMap[key] = val;
955
+ }
956
+ const newCount = Object.keys(this.toolMap).length;
957
+ logger.info(TAG, `Tools discovered on retry: ${discovery.found.join(', ')} (total: ${newCount})`);
958
+ // Transition from PAUSED to ACTIVE once we have required tools
959
+ if (this.agentMode === 'PAUSED' && this.toolMap.fetch_ticker && this.toolMap.fetch_balance) {
960
+ this.agentMode = 'ACTIVE';
961
+ this.emitAgentState();
962
+ }
963
+ // If new tools were added, restart the poller with the updated toolMap
964
+ if (newCount > prevCount && this.started && this.http) {
965
+ if (this.poller) {
966
+ logger.info(TAG, 'Stopping poller to restart with updated tools');
967
+ this.poller.stop();
968
+ this.poller = null;
969
+ }
970
+ this.poller = this.createPoller();
971
+ this.poller.start();
972
+ logger.info(TAG, `Poller (re)started with ${newCount} tools`);
973
+ }
974
+ else if (!this.poller && this.started && this.http) {
975
+ // First time finding tools — create poller
976
+ this.poller = this.createPoller();
977
+ this.poller.start();
978
+ logger.info(TAG, `Poller started with ${newCount} tools`);
979
+ }
980
+ // Stop retry loop only when ALL essential tools are discovered
981
+ if (this.hasAllEssentialTools()) {
982
+ logger.info(TAG, 'All essential tools discovered — stopping retry loop');
983
+ if (this.toolRetryInterval) {
984
+ clearInterval(this.toolRetryInterval);
985
+ this.toolRetryInterval = null;
986
+ }
987
+ }
988
+ else {
989
+ const missing = GatewayProvider.ESSENTIAL_OPTIONAL_TOOLS
990
+ .filter(t => !this.toolMap[t]);
991
+ logger.info(TAG, `Still waiting for essential tools: ${missing.join(', ')}`);
992
+ }
993
+ }
994
+ else {
995
+ logger.debug(TAG, 'Tool retry: still no trading tools found');
996
+ }
997
+ }
998
+ catch (err) {
999
+ logger.warn(TAG, `Tool retry failed: ${err instanceof Error ? err.message : String(err)}`);
1000
+ }
1001
+ }, RETRY_MS);
1002
+ }
1003
+ // ---- Poller factory (DRY helper for all Poller creation sites) ----
1004
+ createPoller() {
1005
+ return new Poller(this.http, this.toolMap, this.config.symbol, {
1006
+ onTicker: (data) => this.onPollerTicker(data),
1007
+ onCandle: (data) => this.fire('candle', data),
1008
+ onPositions: (positions) => this.onPollerPositions(positions),
1009
+ onBalance: (balance) => this.onPollerBalance(balance),
1010
+ onOpenOrders: (orders) => this.onPollerOpenOrders(orders),
1011
+ onMarketStructure: (data) => this.onPollerMarketStructure(data),
1012
+ onCryptoMetrics: (data) => this.onPollerCryptoMetrics(data),
1013
+ onVolumeAnalysis: (data) => this.onPollerVolumeAnalysis(data),
1014
+ }, this.quoteCurrency);
1015
+ }
1016
+ // ---- WS event wiring ----
1017
+ wireWsEvents() {
1018
+ if (!this.wsClient)
1019
+ return;
1020
+ this.wsClient.on('connected', (helloOk) => this.onWsConnected(helloOk));
1021
+ this.wsClient.on('disconnected', ({ code, reason }) => {
1022
+ logger.warn(TAG, `WS disconnected: ${code} ${reason}`);
1023
+ this.poller?.pause();
1024
+ });
1025
+ this.wsClient.on('reconnecting', ({ attempt, delayMs }) => {
1026
+ logger.info(TAG, `WS reconnecting: attempt ${attempt}, delay ${Math.round(delayMs)}ms`);
1027
+ });
1028
+ this.wsClient.on('failed', ({ reason }) => {
1029
+ logger.error(TAG, `WS connection failed permanently: ${reason}`);
1030
+ this.agentMode = 'ERROR';
1031
+ this.emitAgentState();
1032
+ });
1033
+ this.wsClient.on('agent', (payload) => this.onAgentEvent(payload));
1034
+ }
1035
+ onWsConnected(helloOk) {
1036
+ if (!this.started)
1037
+ return; // Guard: stop() called before WS connected
1038
+ logger.info(TAG, 'WS reconnected — resuming poller');
1039
+ // Log snapshot info from hello-ok
1040
+ const snap = helloOk.snapshot;
1041
+ if (snap) {
1042
+ logger.info(TAG, `Gateway uptime: ${Math.round((snap.uptimeMs ?? 0) / 1000)}s, stateVersion: ${JSON.stringify(snap.stateVersion)}`);
1043
+ }
1044
+ // Update device token on reconnect
1045
+ if (helloOk.auth?.deviceToken && this.http) {
1046
+ this.http.setToken(helloOk.auth.deviceToken);
1047
+ }
1048
+ // Resume poller on reconnect (initial poller creation is in initialize())
1049
+ if (this.poller) {
1050
+ this.poller.resume();
1051
+ }
1052
+ // Emit agent state
1053
+ this.emitAgentState();
1054
+ this.emitRiskUpdate();
1055
+ // Resolve the agent's real name + model from the gateway (best-effort,
1056
+ // fire-and-forget; re-emits agent_state if it finds anything). Refreshed on
1057
+ // every (re)connect so a model swap — which restarts the gateway and drops
1058
+ // this socket — is reflected on the next handshake.
1059
+ void this.refreshAgentIdentity();
1060
+ // Start periodic agent state emission (every 5s)
1061
+ if (!this.agentStateInterval) {
1062
+ this.agentStateInterval = setInterval(() => {
1063
+ this.emitAgentState();
1064
+ }, AGENT_STATE_INTERVAL_MS);
1065
+ }
1066
+ }
1067
+ // ---- Agent event handling (from WS) ----
1068
+ onAgentEvent(payload) {
1069
+ if (!this.started || !this.eventParser)
1070
+ return;
1071
+ const parsed = this.eventParser.parseAgentEvent(payload);
1072
+ for (const evt of parsed) {
1073
+ // Track order/cancel rates for risk computation
1074
+ if (evt.event === 'orderUpdate') {
1075
+ const data = evt.data;
1076
+ if (data.status === 'CANCELLED') {
1077
+ this.cancelTimestamps.push(Date.now());
1078
+ }
1079
+ else {
1080
+ this.orderTimestamps.push(Date.now());
1081
+ }
1082
+ // Track last trade timestamp for agent state
1083
+ if (data.status === 'FILLED') {
1084
+ this.lastTradeTs = data.timestamp ?? new Date().toISOString();
1085
+ }
1086
+ }
1087
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1088
+ this.fire(evt.event, evt.data);
1089
+ }
1090
+ }
1091
+ // ---- Poller callbacks ----
1092
+ onPollerTicker(data) {
1093
+ this.lastTicker = data;
1094
+ this.fire('ticker', data);
1095
+ }
1096
+ /** Strip the live-Binance settle suffix (`SOL/USDT:USDT` → `SOL/USDT`)
1097
+ * to ONE canonical form, matching the webapp/plugin normalizeSymbol
1098
+ * convention (chart/DB are unsuffixed). The plugin's getPositions
1099
+ * returns the suffixed CCXT form via REST but the unsuffixed form via
1100
+ * the WS store; under weight pressure isStoreTrusted() flickers between
1101
+ * the two, so the raw symbol alternates poll-to-poll. */
1102
+ canonicalSymbol(s) {
1103
+ return typeof s === 'string' ? s.replace(/:[A-Z]+$/, '') : s;
1104
+ }
1105
+ async onPollerPositions(positions) {
1106
+ // Canonicalize symbols at this single position-ingress chokepoint so
1107
+ // the ENTIRE skill (position-diff, openTradeEntries keys, snapshots,
1108
+ // NAV/unrealized, trade journal) sees ONE stable symbol form. Without
1109
+ // this, the plugin's WS-store(unsuffixed)↔REST(suffixed) flip — driven
1110
+ // by isStoreTrusted() flickering under weight pressure — made
1111
+ // detectPositionChanges read every flip as close+open: phantom
1112
+ // round-trip fills into the journal/analytics AND a thrashing position
1113
+ // set behind NAV/unrealized. Realized still matched the exchange
1114
+ // because the unrealized term cancels in (NAV−anchor)−unrealized;
1115
+ // NAV/Day P&L/Unrealized did not (2026-05-16 incident). Unsuffixed is
1116
+ // already a live-exercised form through every skill path (it was half
1117
+ // the flip), so this removes the other form, it does not add a new one.
1118
+ for (const p of positions) {
1119
+ if (typeof p.symbol === 'string')
1120
+ p.symbol = this.canonicalSymbol(p.symbol);
1121
+ }
1122
+ // Empty-poll guard: a snapshot showing zero positions while we were
1123
+ // holding open positions is most often a Binance flake (429, partial
1124
+ // response, listenKey rotation). A single empty poll is treated as
1125
+ // tentative — we skip the entire detector path and preserve this.positions
1126
+ // as-is. If the next poll is also empty we fall through and let the
1127
+ // existing 2-poll close-confirmation logic emit confirmed closes. If
1128
+ // positions reappear we silently recover with no synthetic fills emitted.
1129
+ //
1130
+ // Without this guard, an empty-poll flake clears prevMap and the very next
1131
+ // non-empty poll re-emits OPEN fills for positions that were there all
1132
+ // along — the open-side mirror of the close-side phantom. Track 1
1133
+ // (2026-04-21) confirmed 56 phantom rows on 2026-04-20 19:50-19:56 UTC
1134
+ // were caused by this exact failure mode.
1135
+ const prevHadPositions = this.positions.some(p => (p.contracts ?? 0) !== 0);
1136
+ const currIsEmpty = positions.length === 0 || positions.every(p => (p.contracts ?? 0) === 0);
1137
+ if (this.hasReceivedPositions && prevHadPositions && currIsEmpty) {
1138
+ if (!this.pendingEmptyPoll) {
1139
+ this.pendingEmptyPoll = true;
1140
+ logger.warn(TAG, `Position poll returned empty but prev had ${this.positions.length} non-zero positions — treating as tentative (likely Binance flake), awaiting next-poll confirmation`);
1141
+ return;
1142
+ }
1143
+ // Second consecutive empty poll — fall through; existing 2-poll close
1144
+ // confirmation in detectPositionChanges queues + confirms.
1145
+ }
1146
+ this.pendingEmptyPoll = false;
1147
+ // Skip synthetic fill detection on the very first poll after startup —
1148
+ // the skill has no prior state, so every position looks "new". Emitting
1149
+ // synthetic opens here causes phantom trades (and phantom closes on the
1150
+ // next poll cycle). The first poll is just the skill learning what exists.
1151
+ if (this.hasReceivedPositions) {
1152
+ this.detectPositionChanges(this.positions, positions);
1153
+ }
1154
+ else {
1155
+ // Seed openTradeEntries on first poll so we can track closes later
1156
+ for (const pos of positions) {
1157
+ const contracts = pos.contracts ?? 0;
1158
+ if (contracts !== 0 && pos.entryPrice) {
1159
+ const contractSize = pos.contractSize ?? 1;
1160
+ const side = pos.side === 'short' ? 'short' : 'long';
1161
+ const key = `${pos.symbol}:${side}`;
1162
+ this.openTradeEntries.set(key, {
1163
+ entryPrice: pos.entryPrice,
1164
+ quantity: Math.abs(contracts) * contractSize,
1165
+ side,
1166
+ entryTime: new Date().toISOString(), // Approximate — true entry time unknown on restart
1167
+ setupType: pos.setupType,
1168
+ regime: pos.regime,
1169
+ confluenceScore: pos.confluenceScore,
1170
+ });
1171
+ logger.info(TAG, `Seeded trade entry for ${pos.symbol}: ${side} ${Math.abs(contracts)} @ ${pos.entryPrice}`);
1172
+ }
1173
+ }
1174
+ }
1175
+ // Paper mark-to-market: update all positions with fresh prices BEFORE
1176
+ // emitting risk update. Live modes preserve Binance's exchange-provided
1177
+ // unrealizedPnl, which is mark-price based and matches the app.
1178
+ if (this.tradingMode === 'PAPER') {
1179
+ const updatePromises = [];
1180
+ for (const pos of positions) {
1181
+ if (this.lastTicker && pos.symbol === this.lastTicker.symbol) {
1182
+ this.applyMarkPrice(pos, this.lastTicker.lastPrice);
1183
+ }
1184
+ else if (pos.symbol && pos.symbol !== this.config.symbol && this.http && this.toolMap.fetch_ticker) {
1185
+ const sym = pos.symbol;
1186
+ const p = this.http.invoke(this.toolMap.fetch_ticker, { symbol: sym }).then(result => {
1187
+ const raw = result.data;
1188
+ if (!raw || typeof raw !== 'object')
1189
+ return;
1190
+ const lastPrice = Number(raw.last ?? raw.lastPrice ?? 0);
1191
+ if (lastPrice <= 0)
1192
+ return;
1193
+ this.applyMarkPrice(pos, lastPrice);
1194
+ }).catch(() => { });
1195
+ updatePromises.push(p);
1196
+ }
1197
+ }
1198
+ // Wait for all ticker fetches (typically <100ms) so risk_update has fresh marks.
1199
+ if (updatePromises.length > 0) {
1200
+ await Promise.all(updatePromises);
1201
+ }
1202
+ }
1203
+ this.positions = positions;
1204
+ this.hasReceivedPositions = true;
1205
+ this.trySetSessionStartNav();
1206
+ this.emitRiskUpdate();
1207
+ }
1208
+ /**
1209
+ * Compare previous and current positions from polling.
1210
+ * When a position appears, changes size, or disappears,
1211
+ * emit synthetic orderUpdate events so chart markers show trades.
1212
+ */
1213
+ /** Apply a fresh mark price to a position (mutates in place). */
1214
+ applyMarkPrice(pos, markPrice) {
1215
+ pos.markPrice = markPrice;
1216
+ const contracts = Math.abs(pos.contracts ?? 0);
1217
+ const contractSize = pos.contractSize ?? 1;
1218
+ const quantity = contracts * contractSize;
1219
+ const entryPrice = pos.entryPrice ?? 0;
1220
+ const pnlMultiplier = pos.side === 'short' ? -1 : 1;
1221
+ pos.unrealizedPnl = (markPrice - entryPrice) * quantity * pnlMultiplier;
1222
+ pos.notional = quantity * markPrice;
1223
+ pos.percentage = entryPrice > 0
1224
+ ? ((markPrice - entryPrice) / entryPrice) * 100 * pnlMultiplier
1225
+ : 0;
1226
+ }
1227
+ detectPositionChanges(prev, curr) {
1228
+ // Build lookup by symbol:side to handle hedge mode (both long + short on same symbol)
1229
+ const posKey = (p) => `${p.symbol}:${p.side}`;
1230
+ const prevMap = new Map();
1231
+ for (const p of prev) {
1232
+ if ((p.contracts ?? 0) !== 0)
1233
+ prevMap.set(posKey(p), p);
1234
+ }
1235
+ const currMap = new Map();
1236
+ for (const p of curr) {
1237
+ if ((p.contracts ?? 0) !== 0)
1238
+ currMap.set(posKey(p), p);
1239
+ }
1240
+ // Detect new or changed positions
1241
+ for (const [, pos] of currMap) {
1242
+ const prevPos = prevMap.get(posKey(pos));
1243
+ const prevContracts = prevPos?.contracts ?? 0;
1244
+ const currContracts = pos.contracts ?? 0;
1245
+ if (prevContracts === 0) {
1246
+ // New position opened
1247
+ this.emitSyntheticFill(pos, Math.abs(currContracts));
1248
+ }
1249
+ else if (prevPos && prevPos.side !== pos.side) {
1250
+ // Side flipped (long→short or short→long) — close old + open new
1251
+ this.emitSyntheticFill(prevPos, Math.abs(prevContracts), true);
1252
+ this.emitSyntheticFill(pos, Math.abs(currContracts));
1253
+ }
1254
+ else if (Math.abs(currContracts) !== Math.abs(prevContracts)) {
1255
+ const delta = Math.abs(currContracts) - Math.abs(prevContracts);
1256
+ if (delta > 0) {
1257
+ // Position increased
1258
+ this.emitSyntheticFill(pos, delta);
1259
+ }
1260
+ else {
1261
+ // Position partially closed
1262
+ this.emitSyntheticFill(pos, Math.abs(delta), true);
1263
+ }
1264
+ }
1265
+ }
1266
+ // Detect fully closed positions — require 2-poll confirmation to reject
1267
+ // Binance fetchPositions flakes. A position gone for one poll is treated
1268
+ // as tentative; we emit the synthetic close (which writes to
1269
+ // trade_results) only if it's still gone on the next poll. Positions
1270
+ // that reappear after one missed poll were API artifacts, not real closes.
1271
+ //
1272
+ // Two-phase:
1273
+ // 1. Resolve every existing pending-close: if it reappeared, clear
1274
+ // (phantom avoided); if it's still missing, emit the real close.
1275
+ // 2. Queue any new missing-from-curr positions as pending for the
1276
+ // NEXT poll to confirm.
1277
+ //
1278
+ // Note: we process pending FIRST, then queue new, so a pending entry
1279
+ // never races with the same-poll new-queue for the same key. We also
1280
+ // pull pending keys from this.pendingCloses (not prevMap) because a
1281
+ // pending close may survive across polls where prevMap has already
1282
+ // moved on (e.g. side-flip: long → short, after poll 1 prev is [short]).
1283
+ for (const [key, pendingPrevPos] of this.pendingCloses) {
1284
+ if (currMap.has(key)) {
1285
+ this.pendingCloses.delete(key);
1286
+ logger.info(TAG, `Position ${key} reappeared — phantom-close avoided`);
1287
+ }
1288
+ else {
1289
+ this.emitSyntheticFill(pendingPrevPos, Math.abs(pendingPrevPos.contracts ?? 0), true);
1290
+ this.pendingCloses.delete(key);
1291
+ }
1292
+ }
1293
+ for (const [key, prevPos] of prevMap) {
1294
+ if (!currMap.has(key) && !this.pendingCloses.has(key)) {
1295
+ this.pendingCloses.set(key, prevPos);
1296
+ logger.debug(TAG, `Pending close for ${key} — awaiting next-poll confirmation`);
1297
+ }
1298
+ }
1299
+ }
1300
+ /**
1301
+ * Emit a synthetic orderUpdate event derived from position polling data.
1302
+ * Used when WS agent events are unavailable (gateway event filtering).
1303
+ */
1304
+ emitSyntheticFill(pos, quantity, isClose = false) {
1305
+ const now = new Date().toISOString();
1306
+ const id = `poll-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1307
+ // Determine order side from position
1308
+ let side;
1309
+ if (isClose) {
1310
+ side = pos.side === 'long' ? 'SELL' : 'BUY';
1311
+ }
1312
+ else {
1313
+ side = pos.side === 'long' ? 'BUY' : 'SELL';
1314
+ }
1315
+ // For opens, use entryPrice. For closes, use markPrice / lastTicker (exit price).
1316
+ const fillPrice = isClose
1317
+ ? (pos.markPrice ?? this.lastTicker?.lastPrice ?? pos.entryPrice ?? 0)
1318
+ : (pos.entryPrice ?? pos.markPrice ?? this.lastTicker?.lastPrice ?? 0);
1319
+ const order = {
1320
+ id,
1321
+ symbol: pos.symbol,
1322
+ side,
1323
+ type: 'MARKET',
1324
+ status: 'FILLED',
1325
+ quantity,
1326
+ filledQuantity: quantity,
1327
+ averageFillPrice: fillPrice,
1328
+ timeInForce: 'GTC',
1329
+ createdAt: now,
1330
+ updatedAt: now,
1331
+ };
1332
+ const payload = {
1333
+ orderId: id,
1334
+ status: 'FILLED',
1335
+ order,
1336
+ fill: {
1337
+ id: `fill-${id}`,
1338
+ orderId: id,
1339
+ symbol: pos.symbol,
1340
+ side,
1341
+ quantity,
1342
+ price: fillPrice,
1343
+ expectedPrice: fillPrice,
1344
+ slippage: 0,
1345
+ slippageBps: 0,
1346
+ fee: 0,
1347
+ feeCurrency: this.quoteCurrency,
1348
+ timestamp: now,
1349
+ reduceOnly: isClose,
1350
+ },
1351
+ timestamp: now,
1352
+ };
1353
+ logger.info(TAG, `Synthetic fill: ${side} ${quantity} ${pos.symbol} @ ${fillPrice} (from position poll)`);
1354
+ this.lastTradeTs = now;
1355
+ this.fire('orderUpdate', payload);
1356
+ // Track open entries for trade result reporting
1357
+ const tradeSide = pos.side === 'short' ? 'short' : 'long';
1358
+ const tradeKey = `${pos.symbol}:${tradeSide}`;
1359
+ if (!isClose) {
1360
+ this.openTradeEntries.set(tradeKey, {
1361
+ entryPrice: fillPrice,
1362
+ quantity,
1363
+ side: tradeSide,
1364
+ entryTime: now,
1365
+ setupType: pos.setupType,
1366
+ regime: pos.regime,
1367
+ confluenceScore: pos.confluenceScore,
1368
+ });
1369
+ }
1370
+ else {
1371
+ // Report completed trade to Intelligence API (with actual closed quantity)
1372
+ this.reportTradeResult(tradeKey, pos.symbol, fillPrice, quantity);
1373
+ }
1374
+ // Feature C: emit trade journal entry with market/risk context
1375
+ const action = isClose ? 'CLOSE' : 'OPEN';
1376
+ this.emitTradeJournal(pos.symbol, side, action, quantity, fillPrice);
1377
+ }
1378
+ onPollerBalance(balance) {
1379
+ // Defensive guard: never downgrade a known-good wallet balance to zero.
1380
+ // Binance Futures 429 rate-limits were causing fetchBalance responses
1381
+ // to come back with an empty currency map; mapCcxtBalance then returned
1382
+ // { total: 0, available: 0, locked: 0 }, overwriting the real wallet.
1383
+ // A genuine liquidation would be reflected in positions + drawdown
1384
+ // before the wallet total reaches exactly 0, so a delta from a real
1385
+ // non-zero total to exactly 0 in one poll is almost always an API
1386
+ // artifact. Keep the previous value and wait for a valid poll.
1387
+ if (balance.total === 0 && this.hasReceivedBalance && this.balance.total > 0) {
1388
+ logger.warn(TAG, `Balance poll returned total=0 but last known was ${this.balance.total.toFixed(2)} ${this.balance.currency} — ignoring (likely Binance rate limit or empty response)`);
1389
+ return;
1390
+ }
1391
+ this.balance = balance;
1392
+ this.hasReceivedBalance = true;
1393
+ this.trySetSessionStartNav();
1394
+ this.emitRiskUpdate();
1395
+ }
1396
+ /**
1397
+ * Set session start NAV once both balance AND positions have been received.
1398
+ * Checks persisted file first — if today's NAV was already captured in a
1399
+ * previous skill run, reuse it so Day P&L is stable across restarts.
1400
+ */
1401
+ trySetSessionStartNav() {
1402
+ // Re-anchor Day P&L at UTC midnight — PAPER only. Without it a long-running
1403
+ // paper session held yesterday's anchor and Day P&L / Realized accumulated
1404
+ // across days (the plugin re-anchors every UTC day; the skill must too).
1405
+ // Called every poll from onPollerBalance/onPollerPositions, so paper
1406
+ // re-anchors within one poll of midnight. Live/shadow are intentionally
1407
+ // excluded: they anchor to the plugin's Binance income-endpoint value
1408
+ // (synced in fetchFreshBalance), and a self-recomputed midnight anchor
1409
+ // would risk the KPI-must-equal-Binance invariant on real money.
1410
+ const today = todayDateStr();
1411
+ if (!shouldAnchorSessionNav({
1412
+ sessionStartNav: this.sessionStartNav,
1413
+ sessionDate: this.sessionDate,
1414
+ today,
1415
+ mode: this.tradingMode,
1416
+ }))
1417
+ return; // Already anchored for today (or live/shadow already set)
1418
+ if (!this.hasReceivedPositions)
1419
+ return; // Wait for positions
1420
+ if (!this.hasReceivedBalance)
1421
+ return; // Wait for balance
1422
+ if (this.balance.total === 0 && this.positions.length === 0)
1423
+ return; // No data yet
1424
+ // Try loading from file first (persisted across restarts within same day).
1425
+ // loadSessionStartNav() itself returns null on a different day, so on a
1426
+ // fresh UTC day this falls through to recompute.
1427
+ const saved = loadSessionStartNav();
1428
+ if (saved !== null) {
1429
+ this.sessionStartNav = saved;
1430
+ this.sessionDate = today;
1431
+ logger.info(TAG, `Session start NAV restored from file: ${saved} ${this.quoteCurrency}`);
1432
+ return;
1433
+ }
1434
+ // First anchor of the (UTC) day — compute and persist.
1435
+ this.sessionStartNav = this.computeEquity();
1436
+ this.sessionDate = today;
1437
+ saveSessionStartNav(this.sessionStartNav);
1438
+ logger.info(TAG, `Session start NAV: ${this.sessionStartNav} ${this.quoteCurrency} (wallet=${this.balance.total}, positions=${this.positions.length})`);
1439
+ }
1440
+ onPollerOpenOrders(orders) {
1441
+ this.openOrders = orders;
1442
+ // Open orders stored internally for reconciliation (Phase 7j)
1443
+ // and emergency commands (Phase 7h). No event emission here —
1444
+ // real-time order updates come from the WS agent event stream.
1445
+ }
1446
+ onPollerMarketStructure(data) {
1447
+ this.lastMarketStructure = data;
1448
+ this.fire('marketStructure', data);
1449
+ // Cache ATR data for volatility-adaptive risk limits
1450
+ if (data.atr && data.atr.atr14 > 0) {
1451
+ const currentAtr = data.atr.atr14;
1452
+ this.atrSampleCount++;
1453
+ if (!this.atrData) {
1454
+ // First sample — baseline equals current
1455
+ this.atrData = { currentAtr, baselineAtr: currentAtr };
1456
+ }
1457
+ else {
1458
+ // Update baseline with EMA (capped at 20 samples)
1459
+ const n = Math.min(this.atrSampleCount, 20);
1460
+ const alpha = 2 / (n + 1);
1461
+ const newBaseline = alpha * currentAtr + (1 - alpha) * this.atrData.baselineAtr;
1462
+ this.atrData = { currentAtr, baselineAtr: newBaseline };
1463
+ }
1464
+ }
1465
+ }
1466
+ onPollerCryptoMetrics(data) {
1467
+ this.lastCryptoMetrics = data;
1468
+ this.fire('cryptoMetrics', data);
1469
+ }
1470
+ onPollerVolumeAnalysis(data) {
1471
+ this.lastVolumeAnalysis = data;
1472
+ this.fire('volumeAnalysis', data);
1473
+ }
1474
+ // ---- Trade journal emission (Feature C) ----
1475
+ /**
1476
+ * Build and emit a trade journal entry whenever a synthetic fill is detected.
1477
+ * Called from emitSyntheticFill — captures market + risk context at trade time.
1478
+ */
1479
+ emitTradeJournal(symbol, side, action, quantity, price) {
1480
+ // Gather market structure snapshot (if available)
1481
+ const ms = this.lastMarketStructure;
1482
+ const find = (tf) => ms?.timeframes.find(t => t.timeframe === tf) ?? null;
1483
+ // Gather risk snapshot
1484
+ const risk = this.computeRiskMetrics();
1485
+ const entry = {
1486
+ id: `journal-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
1487
+ symbol,
1488
+ side,
1489
+ action,
1490
+ quantity,
1491
+ price,
1492
+ marketContext: {
1493
+ direction5m: find('5m')?.direction ?? null,
1494
+ direction1h: find('1h')?.direction ?? null,
1495
+ direction4h: find('4h')?.direction ?? null,
1496
+ alignment: ms?.alignment?.summary ?? null,
1497
+ fundingRate: this.lastCryptoMetrics?.fundingRate ?? null,
1498
+ volumeExpansion: this.lastVolumeAnalysis?.volumeExpansion ?? null,
1499
+ closePosition: this.lastVolumeAnalysis?.closePosition ?? null,
1500
+ },
1501
+ riskContext: {
1502
+ grossExposure: risk.utilization.grossExposure,
1503
+ netExposure: risk.utilization.netExposure,
1504
+ dailyDrawdown: risk.utilization.dailyDrawdown,
1505
+ heatScore: this.computeHeatScoreSimple(risk),
1506
+ },
1507
+ timestamp: new Date().toISOString(),
1508
+ };
1509
+ logger.info(TAG, `Trade journal: ${action} ${side} ${quantity} ${symbol} @ ${price}`);
1510
+ this.fire('tradeJournal', entry);
1511
+ }
1512
+ /** Simple heat score (0–100) matching webapp's HeatGauge logic */
1513
+ computeHeatScoreSimple(metrics) {
1514
+ const { grossExposure, dailyDrawdown, ordersPerMinute, cancelsPerMinute } = metrics.utilization;
1515
+ const limits = DEFAULT_RISK_LIMITS;
1516
+ const scores = [
1517
+ limits.position.maxGrossExposure > 0 ? (grossExposure / limits.position.maxGrossExposure) * 100 : 0,
1518
+ limits.loss.maxDailyDrawdown < 0 ? (dailyDrawdown / limits.loss.maxDailyDrawdown) * 100 : 0,
1519
+ limits.rate.maxOrdersPerMinute > 0 ? (ordersPerMinute / limits.rate.maxOrdersPerMinute) * 100 : 0,
1520
+ limits.rate.maxCancelsPerMinute > 0 ? (cancelsPerMinute / limits.rate.maxCancelsPerMinute) * 100 : 0,
1521
+ ];
1522
+ return Math.min(100, Math.max(0, Math.max(...scores)));
1523
+ }
1524
+ /** Cache last market structure for journal entries */
1525
+ lastMarketStructure = null;
1526
+ // ---- Snapshot fetch helpers (fresh data with cache fallback) ----
1527
+ async fetchFreshPositions() {
1528
+ const tool = this.toolMap.fetch_positions;
1529
+ if (!tool || !this.http)
1530
+ return this.positions;
1531
+ try {
1532
+ // Fetch ALL positions (no symbol filter) so multi-symbol trades are visible
1533
+ const result = await this.http.invoke(tool, {});
1534
+ if (!Array.isArray(result.data)) {
1535
+ logger.warn(TAG, `fetch_positions returned non-array: ${typeof result.data}`);
1536
+ return this.positions;
1537
+ }
1538
+ return result.data;
1539
+ }
1540
+ catch (err) {
1541
+ logger.warn(TAG, `Fresh positions fetch failed, using cached: ${err instanceof Error ? err.message : String(err)}`);
1542
+ return this.positions;
1543
+ }
1544
+ }
1545
+ async fetchFreshBalance() {
1546
+ const tool = this.toolMap.fetch_balance;
1547
+ if (!tool || !this.http)
1548
+ return this.balance;
1549
+ try {
1550
+ const result = await this.http.invoke(tool, {});
1551
+ // Extract trading mode piggybacked on balance response (Phase 9b)
1552
+ const mode = result.data.tradingMode;
1553
+ if (isTradingMode(mode)) {
1554
+ // Persist the confirmed mode to disk so the next skill restart starts
1555
+ // up reading the correct value instead of the 'PAPER' constructor
1556
+ // default. Cheap, idempotent — safe to write on every balance fetch
1557
+ // even when no transition happened.
1558
+ saveLastTradingMode(mode);
1559
+ if (mode !== this.tradingMode) {
1560
+ const prevMode = this.tradingMode;
1561
+ this.tradingMode = mode;
1562
+ this.fire('tradingModeUpdate', {
1563
+ event: 'trading_mode',
1564
+ mode: this.tradingMode,
1565
+ timestamp: new Date().toISOString(),
1566
+ });
1567
+ logger.info(TAG, `Trading mode: ${prevMode} → ${this.tradingMode}`);
1568
+ // Reset session NAV when mode changes — different modes have different
1569
+ // balances (paper ~$8k vs real ~$65), so the old anchor would produce
1570
+ // wildly wrong Day P&L. Delete the persisted file too so
1571
+ // trySetSessionStartNav() recomputes from the new mode's equity.
1572
+ this.sessionStartNav = -1;
1573
+ this.sessionDate = null;
1574
+ this.hasReceivedBalance = false;
1575
+ this.hasReceivedPositions = false;
1576
+ this.pendingEmptyPoll = false;
1577
+ try {
1578
+ unlinkSync(getSessionNavPath());
1579
+ }
1580
+ catch { /* ok if missing */ }
1581
+ logger.info(TAG, `Session start NAV reset on mode change (${prevMode} → ${this.tradingMode})`);
1582
+ }
1583
+ }
1584
+ // Extract realized P&L fields (roundtrip-fee inclusive, plugin-computed).
1585
+ if (typeof result.data.realizedPnlToday === 'number') {
1586
+ this.realizedPnlToday = result.data.realizedPnlToday;
1587
+ }
1588
+ if (typeof result.data.realizedPnlAllTime === 'number') {
1589
+ this.realizedPnlAllTime = result.data.realizedPnlAllTime;
1590
+ }
1591
+ // Paper session execution costs (M1) — paper only; LiveAdapter omits them.
1592
+ if (typeof result.data.sessionFeesPaid === 'number') {
1593
+ this.paperSessionFees = result.data.sessionFeesPaid;
1594
+ }
1595
+ if (typeof result.data.sessionAvgSlippageBps === 'number') {
1596
+ this.paperAvgSlippageBps = result.data.sessionAvgSlippageBps;
1597
+ }
1598
+ if (typeof result.data.equity === 'number') {
1599
+ this.equity = result.data.equity;
1600
+ }
1601
+ // In non-PAPER modes, the plugin anchors sessionStartNav to Binance's
1602
+ // /fapi/v1/income at UTC midnight on every balance fetch — that's the
1603
+ // authoritative truth. Sync continuously, not just on first set: the
1604
+ // pre-2026-05-14 guard `this.sessionStartNav < 0` meant once the skill
1605
+ // had ANY value (from file load or computeEquity), the plugin's correct
1606
+ // value was ignored forever. The skill's self-computed value drifts
1607
+ // whenever trading has already happened before the skill first polls
1608
+ // (operator screenshot 2026-05-14 found a $3.92 dashboard-vs-Binance
1609
+ // gap from a $1095.95 self-computed seed vs the plugin's $1099.86
1610
+ // midnight-anchored truth). Crossing UTC midnight is also handled
1611
+ // automatically — plugin's anchor refreshes, skill syncs next poll.
1612
+ if (this.tradingMode !== 'PAPER') {
1613
+ const pluginNav = result.data.sessionStartNav;
1614
+ if (typeof pluginNav === 'number' && pluginNav > 0
1615
+ && Math.abs(this.sessionStartNav - pluginNav) > 0.001) {
1616
+ const prev = this.sessionStartNav;
1617
+ this.sessionStartNav = pluginNav;
1618
+ saveSessionStartNav(pluginNav);
1619
+ logger.info(TAG, `Session start NAV synced from plugin: ${prev.toFixed(4)} -> ${pluginNav} (${this.tradingMode} mode)`);
1620
+ }
1621
+ }
1622
+ return mapCcxtBalance(result.data, this.quoteCurrency);
1623
+ }
1624
+ catch (err) {
1625
+ logger.warn(TAG, `Fresh balance fetch failed, using cached: ${err instanceof Error ? err.message : String(err)}`);
1626
+ return this.balance;
1627
+ }
1628
+ }
1629
+ async fetchFreshOpenOrders() {
1630
+ const tool = this.toolMap.fetch_open_orders;
1631
+ if (!tool || !this.http)
1632
+ return this.openOrders;
1633
+ try {
1634
+ // Fetch ALL open orders (no symbol filter). Multi-symbol orphan orders
1635
+ // submitted while the browser was disconnected must show up in the
1636
+ // reconcile snapshot — single-symbol filtering here was the orphan-order
1637
+ // failure mode CLAUDE.md warns about.
1638
+ const result = await this.http.invoke(tool, {});
1639
+ if (!Array.isArray(result.data)) {
1640
+ logger.warn(TAG, `fetch_open_orders returned non-array: ${typeof result.data}`);
1641
+ return this.openOrders;
1642
+ }
1643
+ const mapped = result.data
1644
+ .map((o) => mapCcxtOrder(o))
1645
+ .filter((o) => o !== null);
1646
+ const dropped = result.data.length - mapped.length;
1647
+ if (dropped > 0) {
1648
+ logger.error(TAG, `Snapshot: ${dropped}/${result.data.length} open orders dropped (malformed data)`);
1649
+ }
1650
+ return mapped;
1651
+ }
1652
+ catch (err) {
1653
+ logger.warn(TAG, `Fresh open orders fetch failed, using cached: ${err instanceof Error ? err.message : String(err)}`);
1654
+ return this.openOrders;
1655
+ }
1656
+ }
1657
+ async fetchAgentHealth() {
1658
+ const ws = this.wsClient;
1659
+ if (!ws)
1660
+ return this.buildAgentState();
1661
+ try {
1662
+ // health RPC returns an agent health snapshot from the gateway. Its shape
1663
+ // doesn't map directly to our AgentStateData format (we keep our internal,
1664
+ // event-driven state for mode/health), but it can carry the resolved model
1665
+ // id — so we mine it for identity as a cheap secondary source.
1666
+ const resp = await ws.sendRpc('health', {});
1667
+ const model = this.extractAgentModel(resp);
1668
+ if (model)
1669
+ this.mergeAgentIdentity({ model });
1670
+ return this.buildAgentState();
1671
+ }
1672
+ catch (err) {
1673
+ logger.warn(TAG, `Health RPC failed, using internal state: ${err instanceof Error ? err.message : String(err)}`);
1674
+ return this.buildAgentState();
1675
+ }
1676
+ }
1677
+ /**
1678
+ * Resolve the agent's display name + live model id directly from the gateway.
1679
+ * The primary carrier is the `sessions.list` RPC (rows carry agentId + the
1680
+ * live model); `health` (above) is a secondary feed. Best-effort and
1681
+ * fail-open — the gateway/version may not expose either, in which case the
1682
+ * header simply falls back to its placeholder. Re-emits agent_state on a hit
1683
+ * so the dashboard updates without waiting for the next periodic tick.
1684
+ */
1685
+ async refreshAgentIdentity() {
1686
+ const ws = this.wsClient;
1687
+ if (!ws)
1688
+ return;
1689
+ let sessionsResp;
1690
+ let sessionsErr;
1691
+ try {
1692
+ sessionsResp = await ws.sendRpc('sessions.list', {});
1693
+ }
1694
+ catch (err) {
1695
+ sessionsErr = err instanceof Error ? err.message : String(err);
1696
+ }
1697
+ // Model from the gateway (live, authoritative); name from the local
1698
+ // OpenClaw workspace IDENTITY.md (sessions rows carry only chat ids).
1699
+ const model = this.extractAgentModel(sessionsResp);
1700
+ const name = this.resolveAgentNameFromIdentity();
1701
+ // One-time rollout probe: the sessions.list shape isn't contractually stable
1702
+ // across gateway versions, so log its top-level keys + what we resolved on the
1703
+ // first attempt — makes an unexpected shape diagnosable instead of silent.
1704
+ if (!this.loggedAgentIdentityProbe) {
1705
+ this.loggedAgentIdentityProbe = true;
1706
+ const keys = sessionsResp && typeof sessionsResp === 'object'
1707
+ ? Object.keys(sessionsResp).join(',') || '(none)'
1708
+ : sessionsErr
1709
+ ? `error:${sessionsErr}`
1710
+ : typeof sessionsResp;
1711
+ logger.info(TAG, `Agent identity probe: sessions.list keys=[${keys}] -> model=${model ?? '(none)'} identityName=${name ?? '(none)'}`);
1712
+ }
1713
+ if (this.mergeAgentIdentity({ name, model })) {
1714
+ logger.info(TAG, `Agent identity updated: name=${this.agentIdentity.name ?? '(none)'} model=${this.agentIdentity.model ?? '(none)'}`);
1715
+ this.emitAgentState();
1716
+ }
1717
+ }
1718
+ /** Merge newly-resolved identity fields into the cache. Returns true if anything changed. */
1719
+ mergeAgentIdentity(found) {
1720
+ let changed = false;
1721
+ if (found.name && found.name !== this.agentIdentity.name) {
1722
+ this.agentIdentity.name = found.name;
1723
+ changed = true;
1724
+ }
1725
+ if (found.model && found.model !== this.agentIdentity.model) {
1726
+ this.agentIdentity.model = found.model;
1727
+ changed = true;
1728
+ }
1729
+ return changed;
1730
+ }
1731
+ /**
1732
+ * Extract the live model id from a `sessions.list` (or `health`) gateway
1733
+ * payload. We deliberately do NOT take the agent NAME from here: sessions
1734
+ * rows are per-chat and their `name` is a channel/chat id (e.g. a Telegram
1735
+ * id), not the agent's identity — the display name comes from
1736
+ * resolveAgentNameFromIdentity(). The exact shape isn't contractually stable,
1737
+ * so probe the documented paths (row → runtime → defaults → root) and accept
1738
+ * either a bare string or a `{ primary }` object.
1739
+ */
1740
+ extractAgentModel(payload) {
1741
+ if (!payload || typeof payload !== 'object')
1742
+ return undefined;
1743
+ const pickStr = (v) => typeof v === 'string' && v.trim().length > 0 ? v.trim() : undefined;
1744
+ const asObj = (v) => v && typeof v === 'object' && !Array.isArray(v) ? v : undefined;
1745
+ const flatModel = (v) => {
1746
+ const o = asObj(v);
1747
+ return pickStr(v) ?? pickStr(o?.primary) ?? pickStr(o?.id);
1748
+ };
1749
+ const root = payload;
1750
+ const rowsRaw = (Array.isArray(payload) && payload) ||
1751
+ (Array.isArray(root.sessions) && root.sessions) ||
1752
+ (Array.isArray(root.items) && root.items) ||
1753
+ (Array.isArray(root.data) && root.data) ||
1754
+ [root];
1755
+ const rows = rowsRaw.filter((r) => !!r && typeof r === 'object');
1756
+ const row = rows.find((r) => r.agentId === 'main') ?? rows[0];
1757
+ const runtime = row ? (asObj(row.agentRuntime) ?? asObj(row.runtime)) : undefined;
1758
+ const defaults = asObj(root.defaults);
1759
+ return flatModel(row?.model) ?? flatModel(runtime?.model) ?? flatModel(defaults?.model) ?? flatModel(root.model);
1760
+ }
1761
+ /**
1762
+ * Resolve the agent's display name from the OpenClaw workspace IDENTITY.md
1763
+ * (`- **Name:** <X>`), local to this deployment (per-tenant correct — each
1764
+ * trader's own agent has its own IDENTITY.md). Best-effort + fail-open:
1765
+ * returns undefined when the file is missing/unreadable, the field is the
1766
+ * unfilled placeholder, or the value looks like an id rather than a name.
1767
+ */
1768
+ resolveAgentNameFromIdentity() {
1769
+ const candidates = [
1770
+ process.env.OPENCLAW_WORKSPACE ? join(process.env.OPENCLAW_WORKSPACE, 'IDENTITY.md') : undefined,
1771
+ join(homedir(), '.openclaw', 'workspace', 'IDENTITY.md'),
1772
+ ].filter((p) => !!p);
1773
+ for (const p of candidates) {
1774
+ try {
1775
+ const txt = readFileSync(p, 'utf8');
1776
+ const m = txt.match(/\*\*\s*Name\s*:?\s*\*\*\s*:?\s*(.+)/i);
1777
+ let name = m?.[1]?.trim();
1778
+ if (!name)
1779
+ continue;
1780
+ name = name.replace(/^[_*`]+|[_*`]+$/g, '').trim(); // strip stray markdown emphasis
1781
+ if (!name || /^\d+$/.test(name) || /\btbd\b|fill this in/i.test(name))
1782
+ continue;
1783
+ return name;
1784
+ }
1785
+ catch {
1786
+ /* not found / unreadable — try the next candidate */
1787
+ }
1788
+ }
1789
+ return undefined;
1790
+ }
1791
+ /**
1792
+ * Live heartbeat cadence in seconds — the agent's TRUE beat interval, read
1793
+ * from the OpenClaw cron store (`~/.openclaw/cron/jobs.json` → the enabled
1794
+ * heartbeat job's `schedule.everyMs`). This is the source of truth: the old
1795
+ * hardcoded 1800 and `agents.defaults.heartbeat` are both stale/wrong once
1796
+ * the operator edits the cron (`openclaw cron ... --every 15m`). Drives both
1797
+ * the "beat every Nm" label and the unresponsive (2×) threshold. Cached 60s;
1798
+ * returns undefined (caller defaults to 1800) when unreadable.
1799
+ */
1800
+ resolveHeartbeatSeconds() {
1801
+ const now = Date.now();
1802
+ if (this.heartbeatSeconds !== undefined && now - this.heartbeatReadAtMs < 60_000) {
1803
+ return this.heartbeatSeconds;
1804
+ }
1805
+ const candidates = [
1806
+ process.env.OPENCLAW_HOME ? join(process.env.OPENCLAW_HOME, 'cron', 'jobs.json') : undefined,
1807
+ join(homedir(), '.openclaw', 'cron', 'jobs.json'),
1808
+ ].filter((p) => !!p);
1809
+ const everyMsOf = (j) => {
1810
+ if (!j || typeof j !== 'object')
1811
+ return undefined;
1812
+ const job = j;
1813
+ if (job.enabled === false)
1814
+ return undefined;
1815
+ const s = job.schedule;
1816
+ return s?.kind === 'every' && typeof s.everyMs === 'number' && s.everyMs > 0 ? s.everyMs : undefined;
1817
+ };
1818
+ const nameOf = (j) => {
1819
+ const n = j?.name;
1820
+ return typeof n === 'string' ? n : '';
1821
+ };
1822
+ for (const p of candidates) {
1823
+ try {
1824
+ const parsed = JSON.parse(readFileSync(p, 'utf8'));
1825
+ const jobs = parsed?.jobs;
1826
+ if (!Array.isArray(jobs))
1827
+ continue;
1828
+ const everyJobs = jobs.filter((j) => everyMsOf(j) !== undefined);
1829
+ // Prefer the explicitly heartbeat-named job; else fall back only when a
1830
+ // single recurring job exists (avoid mislabelling some other cron).
1831
+ const hb = everyJobs.find((j) => /heart\s*beat/i.test(nameOf(j))) ??
1832
+ (everyJobs.length === 1 ? everyJobs[0] : undefined);
1833
+ const ms = hb ? everyMsOf(hb) : undefined;
1834
+ if (ms) {
1835
+ this.heartbeatSeconds = Math.round(ms / 1000);
1836
+ this.heartbeatReadAtMs = now;
1837
+ if (!this.loggedHeartbeat) {
1838
+ this.loggedHeartbeat = true;
1839
+ logger.info(TAG, `Heartbeat cadence resolved: ${this.heartbeatSeconds}s (~${Math.round(this.heartbeatSeconds / 60)}m) from ${p}`);
1840
+ }
1841
+ return this.heartbeatSeconds;
1842
+ }
1843
+ }
1844
+ catch {
1845
+ /* unreadable / malformed — try the next candidate */
1846
+ }
1847
+ }
1848
+ this.heartbeatReadAtMs = now; // don't hammer the FS when absent
1849
+ return this.heartbeatSeconds; // last known (may be undefined)
1850
+ }
1851
+ /** Build agent state from internal fields (used by both emitAgentState and getSnapshot). */
1852
+ buildAgentState() {
1853
+ return {
1854
+ mode: this.agentMode,
1855
+ strategy: this.lastKnownStrategy,
1856
+ // Conditional spread keeps the payload byte-identical to the pre-feature
1857
+ // shape when the gateway hasn't surfaced an identity yet.
1858
+ ...(this.agentIdentity.name ? { agentName: this.agentIdentity.name } : {}),
1859
+ ...(this.agentIdentity.model ? { model: this.agentIdentity.model } : {}),
1860
+ health: {
1861
+ uptime: Math.floor((Date.now() - this.startTime) / 1000),
1862
+ lastDecision: this.eventParser?.getLastDecision() ?? new Date(this.startTime).toISOString(),
1863
+ lastTrade: this.lastTradeTs,
1864
+ errorRate: this.eventParser?.getErrorRate() ?? 0,
1865
+ thinkingInterval: this.resolveHeartbeatSeconds() ?? 1800,
1866
+ gatewayWs: this.wsClient?.getState() ?? 'disconnected',
1867
+ },
1868
+ };
1869
+ }
1870
+ // ---- State emission ----
1871
+ emitAgentState() {
1872
+ this.fire('agentState', {
1873
+ state: this.buildAgentState(),
1874
+ timestamp: new Date().toISOString(),
1875
+ });
1876
+ }
1877
+ emitRiskUpdate() {
1878
+ const metrics = this.computeRiskMetrics();
1879
+ // Auto-flatten on RED zone: if drawdown exceeds -2.5% and agent is active, flatten all.
1880
+ // SAFETY guards — all three must be clear before we trust the RED verdict:
1881
+ // 1. Startup grace (30s): on restart, wallet loads instantly but positions lag,
1882
+ // making equity look artificially low.
1883
+ // 2. Balance freshness: hasReceivedBalance must be true AND balance.total > 0.
1884
+ // Binance 429 rate limits were leaving `this.balance` stale at 0 in live mode,
1885
+ // and computeEquity() (paper-mode formula) then read equity ≈ position_notional
1886
+ // only — a $186 account with a $20 short showed as -89% drawdown, triggering
1887
+ // an auto-flatten loop every minute. If we don't have fresh wallet data we
1888
+ // cannot trust the drawdown number; wait for the next poll.
1889
+ // 3. Sanity floor: a single-minute drawdown more negative than -50% is
1890
+ // physically impossible under normal market conditions on a bracketed
1891
+ // account. If the math says worse than that, it's bad data — log loudly
1892
+ // and skip instead of closing real positions on a calculation error.
1893
+ const uptimeMs = Date.now() - this.startedAt;
1894
+ const STARTUP_GRACE_PERIOD_MS = 30_000;
1895
+ const IMPOSSIBLE_DRAWDOWN = -0.5;
1896
+ if (metrics.drawdownZone === 'RED' && this.agentMode === 'ACTIVE') {
1897
+ if (uptimeMs < STARTUP_GRACE_PERIOD_MS) {
1898
+ logger.warn(TAG, `RED zone drawdown detected (${metrics.drawdownZoneMessage}) but within startup grace period (${Math.round(uptimeMs / 1000)}s) — skipping auto-flatten`);
1899
+ }
1900
+ else if (!this.hasReceivedBalance || this.balance.total <= 0) {
1901
+ logger.warn(TAG, `RED zone drawdown detected (${metrics.drawdownZoneMessage}) but balance data is stale/missing (received=${this.hasReceivedBalance}, total=${this.balance.total}) — skipping auto-flatten, waiting for fresh wallet data`);
1902
+ }
1903
+ else if (metrics.utilization.dailyDrawdown < IMPOSSIBLE_DRAWDOWN) {
1904
+ logger.error(TAG, `RED zone drawdown ${(metrics.utilization.dailyDrawdown * 100).toFixed(1)}% exceeds sanity floor ${IMPOSSIBLE_DRAWDOWN * 100}% — refusing auto-flatten, likely equity calculation error (balance.total=${this.balance.total}, sessionStartNav=${this.sessionStartNav}, positions=${this.positions.length})`);
1905
+ }
1906
+ else {
1907
+ logger.warn(TAG, `RED zone drawdown detected (${metrics.drawdownZoneMessage}) — auto-flattening all positions`);
1908
+ // Route through executeEmergency (NOT executeFlatten directly) so the
1909
+ // dual-fire dedup guard applies: emitRiskUpdate runs on every risk
1910
+ // recompute, and a fire-and-forget flatten only flips agentMode→STOPPED
1911
+ // once it resolves. Two rapid RED recomputes (or a concurrent operator
1912
+ // Flatten) would otherwise each launch a separate flatten — a second
1913
+ // market close against not-yet-settled positions is a position-flip
1914
+ // risk. The dedup collapses them to ONE in-flight execution.
1915
+ void this.executeEmergency('flatten').catch(err => {
1916
+ logger.error(TAG, `Auto-flatten failed: ${err instanceof Error ? err.message : String(err)}`);
1917
+ });
1918
+ }
1919
+ }
1920
+ // Include position snapshots + balance in risk_update so the webapp can
1921
+ // update KPI/PnL without depending on market_data ticker events.
1922
+ const equity = this.computeEquity();
1923
+ const nav = this.sessionStartNav > 0 ? this.sessionStartNav : Math.max(equity, 1);
1924
+ const positionSnapshots = this.positions
1925
+ .filter(p => (p.contracts ?? 0) !== 0)
1926
+ .map(p => {
1927
+ const contracts = Math.abs(p.contracts ?? 0);
1928
+ const contractSize = p.contractSize ?? 1;
1929
+ const quantity = contracts * contractSize;
1930
+ const entryPrice = p.entryPrice ?? 0;
1931
+ const currentPrice = p.markPrice ?? entryPrice;
1932
+ const pnlMul = p.side === 'short' ? -1 : 1;
1933
+ const unrealizedPnl = p.unrealizedPnl ?? (currentPrice - entryPrice) * quantity * pnlMul;
1934
+ const costBasis = entryPrice * quantity;
1935
+ const liqFields = extractLiquidationFields(p);
1936
+ const bracketField = extractBracketField(p);
1937
+ return {
1938
+ symbol: p.symbol,
1939
+ side: (p.side === 'short' ? 'SHORT' : 'LONG'),
1940
+ quantity,
1941
+ entryPrice,
1942
+ currentPrice,
1943
+ unrealizedPnl: +unrealizedPnl.toFixed(4),
1944
+ unrealizedPnlPercent: costBasis > 0 ? +((unrealizedPnl / costBasis) * 100).toFixed(2) : 0,
1945
+ portfolioPercent: +(((quantity * currentPrice) / nav) * 100).toFixed(2),
1946
+ // Component C: MFE / give-back passthrough (see snapshotPositions site).
1947
+ mfeR: typeof p.mfeR === 'number' ? p.mfeR : undefined,
1948
+ mfePeakPrice: typeof p.mfePeakPrice === 'number' ? p.mfePeakPrice : undefined,
1949
+ giveBackRatio: typeof p.giveBackRatio === 'number' ? p.giveBackRatio : undefined,
1950
+ originalStopPrice: typeof p.originalStopPrice === 'number' ? p.originalStopPrice : undefined,
1951
+ ...(bracketField !== undefined ? { bracket: bracketField } : {}),
1952
+ ...liqFields,
1953
+ };
1954
+ });
1955
+ // Live KPI NAV must equal Binance's "Margin Balance" = wallet +
1956
+ // unrealized. Compute it directly from the fresh poller wallet
1957
+ // (this.balance.total — BNFCR-aware, uPnl-excluded) + the unrealized
1958
+ // already summed across positionSnapshots. We deliberately do NOT use
1959
+ // this.equity here: it is only ever assigned in fetchFreshBalance(),
1960
+ // which is dormant under userDataStream.mode=enforce, so it stayed null
1961
+ // and the old `this.equity ?? this.balance.total` collapsed NAV to
1962
+ // wallet-only (2026-05-16: dashboard NAV ≈ wallet, Realized off by the
1963
+ // unrealized vs the Binance app). Paper keeps computeEquity (wallet +
1964
+ // notional + unrealized) — correct there because paper deducts the full
1965
+ // notional at open.
1966
+ const liveUnrealizedTotal = positionSnapshots.reduce((sum, p) => sum + p.unrealizedPnl, 0);
1967
+ const balanceTotal = this.tradingMode !== 'PAPER'
1968
+ ? this.balance.total + liveUnrealizedTotal // live: wallet + unrealized = Binance Margin Balance
1969
+ : equity; // paper: wallet + notional + unrealized
1970
+ this.fire('riskUpdate', {
1971
+ event: 'risk_update',
1972
+ metrics,
1973
+ positions: positionSnapshots,
1974
+ balance: { total: balanceTotal, available: this.balance.available, locked: balanceTotal - this.balance.available },
1975
+ sessionStartNav: this.sessionStartNav > 0 ? this.sessionStartNav : undefined,
1976
+ realizedPnlToday: this.realizedPnlToday ?? undefined,
1977
+ realizedPnlAllTime: this.realizedPnlAllTime ?? undefined,
1978
+ sessionFeesPaid: this.paperSessionFees ?? undefined,
1979
+ sessionAvgSlippageBps: this.paperAvgSlippageBps ?? undefined,
1980
+ equity: this.equity ?? undefined,
1981
+ timestamp: new Date().toISOString(),
1982
+ });
1983
+ }
1984
+ /** Compute risk metrics — delegates to pure functions in risk-calculator.ts */
1985
+ computeRiskMetrics() {
1986
+ const result = _computeRiskMetrics({
1987
+ balanceTotal: this.balance.total,
1988
+ positions: this.positions,
1989
+ lastTickerPrice: this.lastTicker?.lastPrice ?? null,
1990
+ sessionStartNav: this.sessionStartNav,
1991
+ orderTimestamps: this.orderTimestamps,
1992
+ cancelTimestamps: this.cancelTimestamps,
1993
+ atrData: this.atrData ?? undefined,
1994
+ regime: this.currentRegime,
1995
+ realizedPnlToday: this.realizedPnlToday ?? undefined,
1996
+ });
1997
+ // Store pruned timestamps back (non-mutating function returns new arrays)
1998
+ this.orderTimestamps = result.prunedOrderTimestamps;
1999
+ this.cancelTimestamps = result.prunedCancelTimestamps;
2000
+ return result.metrics;
2001
+ }
2002
+ /** Compute total equity — delegates to pure function in risk-calculator.ts */
2003
+ computeEquity() {
2004
+ return _computeEquity({
2005
+ balanceTotal: this.balance.total,
2006
+ positions: this.positions,
2007
+ lastTickerPrice: this.lastTicker?.lastPrice ?? null,
2008
+ });
2009
+ }
2010
+ /** Compute signed notional — delegates to pure function in risk-calculator.ts */
2011
+ computePositionNotional(pos) {
2012
+ return _computePositionNotional(pos, this.lastTicker?.lastPrice ?? null);
2013
+ }
2014
+ // ---- Trade result reporting to Intelligence API ----
2015
+ /**
2016
+ * Report a completed trade to the Intelligence API for persistent cross-session analytics.
2017
+ * Called when a position closes (detected via position polling diffs).
2018
+ * Handles partial closes: updates remaining quantity in openTradeEntries instead of deleting.
2019
+ *
2020
+ * @param key Composite key `symbol:side` into openTradeEntries
2021
+ * @param symbol CCXT symbol (e.g. "BTC/USDT")
2022
+ * @param exitPrice Fill price for the close
2023
+ * @param closedQty Quantity actually closed (may be less than full position for partial closes)
2024
+ */
2025
+ reportTradeResult(key, symbol, exitPrice, closedQty) {
2026
+ const entry = this.openTradeEntries.get(key);
2027
+ if (!entry) {
2028
+ logger.debug(TAG, `No tracked entry for ${key} — skipping trade result report`);
2029
+ return;
2030
+ }
2031
+ // Determine if this is a partial or full close
2032
+ const remaining = entry.quantity - closedQty;
2033
+ if (remaining > 0.0001) {
2034
+ // Partial close — update remaining quantity, keep entry alive
2035
+ this.openTradeEntries.set(key, { ...entry, quantity: remaining });
2036
+ }
2037
+ else {
2038
+ // Full close — remove entry
2039
+ this.openTradeEntries.delete(key);
2040
+ }
2041
+ const url = this.config.intelligenceUrl;
2042
+ const token = this.config.connectionToken;
2043
+ if (!url || !token) {
2044
+ logger.debug(TAG, 'Intelligence not configured — skipping trade result report');
2045
+ return;
2046
+ }
2047
+ const direction = entry.side === 'long' ? 'LONG' : 'SHORT';
2048
+ const pnlMultiplier = entry.side === 'long' ? 1 : -1;
2049
+ const realizedPnl = (exitPrice - entry.entryPrice) * closedQty * pnlMultiplier;
2050
+ const realizedPnlPct = entry.entryPrice > 0
2051
+ ? ((exitPrice - entry.entryPrice) / entry.entryPrice) * 100 * pnlMultiplier
2052
+ : 0;
2053
+ const durationSeconds = Math.floor((Date.now() - new Date(entry.entryTime).getTime()) / 1000);
2054
+ const tradeResult = {
2055
+ missionId: entry.missionId ?? `trade-${Date.now()}`,
2056
+ symbol: symbol.replace('/', ''), // BTC/USDT → BTCUSDT for Intelligence API
2057
+ direction,
2058
+ strategy: this.lastKnownStrategy.name,
2059
+ regime: entry.regime ?? this.currentRegime,
2060
+ entryPrice: entry.entryPrice,
2061
+ exitPrice,
2062
+ quantity: closedQty,
2063
+ realizedPnl: +realizedPnl.toFixed(4),
2064
+ realizedPnlPct: +realizedPnlPct.toFixed(4),
2065
+ durationSeconds,
2066
+ fees: 0, // Fees tracked separately by simulator
2067
+ timestamp: new Date().toISOString(),
2068
+ setupType: entry.setupType,
2069
+ confluenceScore: entry.confluenceScore,
2070
+ };
2071
+ const fullUrl = `${url}/api/trades`;
2072
+ const report = fetch(fullUrl, {
2073
+ method: 'POST',
2074
+ headers: {
2075
+ 'Content-Type': 'application/json',
2076
+ Authorization: `Bearer ${token}`,
2077
+ },
2078
+ body: JSON.stringify(tradeResult),
2079
+ signal: AbortSignal.timeout(10_000),
2080
+ })
2081
+ .then((res) => {
2082
+ if (!res.ok) {
2083
+ logger.warn(TAG, `Trade result report failed: HTTP ${res.status} — queuing for retry`);
2084
+ this.enqueueFailedReport(tradeResult);
2085
+ }
2086
+ else {
2087
+ logger.info(TAG, `Trade result reported: ${symbol} ${direction} ${closedQty} @ ${exitPrice} PnL=${realizedPnl > 0 ? '+' : ''}${realizedPnl.toFixed(2)} (${durationSeconds}s)`);
2088
+ }
2089
+ })
2090
+ .catch((err) => {
2091
+ logger.warn(TAG, `Trade result report error: ${formatError(err)} — queuing for retry`);
2092
+ this.enqueueFailedReport(tradeResult);
2093
+ })
2094
+ .finally(() => {
2095
+ this.inFlightReports = this.inFlightReports.filter((p) => p !== report);
2096
+ });
2097
+ this.inFlightReports.push(report);
2098
+ }
2099
+ /**
2100
+ * Seed lastTradeTs from Intelligence API on startup. One-shot, best-effort.
2101
+ * Prevents "Last Trade: Never" after every restart when the agent is flat.
2102
+ */
2103
+ async seedLastTradeTs() {
2104
+ if (this.lastTradeTs)
2105
+ return; // Already set (e.g. from WS event during init)
2106
+ const url = this.config.intelligenceUrl;
2107
+ const token = this.config.connectionToken;
2108
+ if (!url || !token)
2109
+ return;
2110
+ try {
2111
+ const res = await fetch(`${url}/api/analytics/${this.config.symbol.replace('/', '')}/trades?limit=1`, {
2112
+ headers: { Authorization: `Bearer ${token}` },
2113
+ signal: AbortSignal.timeout(5_000),
2114
+ });
2115
+ if (!res.ok)
2116
+ return;
2117
+ const trades = await res.json();
2118
+ if (trades.length > 0 && trades[0].timestamp) {
2119
+ this.lastTradeTs = trades[0].timestamp;
2120
+ logger.info(TAG, `Seeded lastTradeTs from Intelligence: ${this.lastTradeTs}`);
2121
+ this.emitAgentState(); // Re-emit with updated timestamp
2122
+ }
2123
+ }
2124
+ catch {
2125
+ // Best-effort — don't log noise on failure
2126
+ }
2127
+ }
2128
+ // ---- Intelligence service polling ----
2129
+ /** Symbol formatted for intelligence API (BTC/USDT → BTCUSDT). */
2130
+ get intelligenceSymbol() {
2131
+ return this.config.symbol.replace('/', '');
2132
+ }
2133
+ /** Shared intelligence poller with circuit-breaker (exponential backoff on errors). */
2134
+ startIntelligencePoller(name, endpoint, intervalMs, onData, intervalRef, delayFirstMs = 0) {
2135
+ const url = this.config.intelligenceUrl;
2136
+ const token = this.config.connectionToken;
2137
+ if (!url || !token) {
2138
+ if (name === 'regime') {
2139
+ logger.info(TAG, 'Intelligence service not configured — intelligence polling disabled');
2140
+ }
2141
+ return;
2142
+ }
2143
+ const symbol = this.intelligenceSymbol;
2144
+ const fullUrl = `${url}/api/${endpoint}/${symbol}`;
2145
+ logger.info(TAG, `Starting ${name} poller: ${fullUrl}`);
2146
+ const poll = async () => {
2147
+ if (!this.started)
2148
+ return;
2149
+ try {
2150
+ const res = await fetch(fullUrl, {
2151
+ headers: { Authorization: `Bearer ${token}` },
2152
+ signal: AbortSignal.timeout(10_000),
2153
+ });
2154
+ if (!res.ok) {
2155
+ this.trackIntelError(name, `HTTP ${res.status}`);
2156
+ return;
2157
+ }
2158
+ // Reset error tracking on success
2159
+ this.intelErrors[name] = 0;
2160
+ onData(await res.json());
2161
+ }
2162
+ catch (err) {
2163
+ this.trackIntelError(name, formatError(err));
2164
+ }
2165
+ };
2166
+ const start = () => {
2167
+ void poll();
2168
+ this[intervalRef] = setInterval(() => void poll(), intervalMs);
2169
+ };
2170
+ if (delayFirstMs > 0) {
2171
+ const timer = setTimeout(start, delayFirstMs);
2172
+ this.pendingStartTimers.push(timer);
2173
+ }
2174
+ else {
2175
+ start();
2176
+ }
2177
+ }
2178
+ /** Log first error per poller, suppress subsequent repeats. */
2179
+ trackIntelError(name, detail) {
2180
+ const count = (this.intelErrors[name] ?? 0) + 1;
2181
+ this.intelErrors[name] = count;
2182
+ if (count === 1) {
2183
+ logger.warn(TAG, `${name} poll failed: ${detail} (suppressing repeats)`);
2184
+ }
2185
+ }
2186
+ startRegimePoller() {
2187
+ this.startIntelligencePoller('regime', 'regime', 60_000, (raw) => {
2188
+ const data = raw;
2189
+ const symbol = this.intelligenceSymbol;
2190
+ // Store regime for regime-conditional risk limits
2191
+ if (data.regime) {
2192
+ this.currentRegime = data.regime;
2193
+ }
2194
+ this.fire('regimeUpdate', { ...data, event: 'regime_update', symbol });
2195
+ }, 'regimeInterval');
2196
+ }
2197
+ startSignalPoller() {
2198
+ this.startIntelligencePoller('signals', 'signals', 10_000, (raw) => {
2199
+ const data = raw;
2200
+ this.fire('signalUpdate', { ...data, event: 'signal_update' });
2201
+ }, 'signalInterval');
2202
+ }
2203
+ startMissionPoller() {
2204
+ this.startIntelligencePoller('missions', 'missions', 10_000, (raw) => {
2205
+ const data = raw;
2206
+ this.fire('missionUpdate', { ...data, event: 'mission_update' });
2207
+ // Detect new PENDING missions — present to agent for execution
2208
+ const currentIds = new Set(data.activeMissions.map((m) => m.missionId));
2209
+ for (const m of data.activeMissions) {
2210
+ if (!this.lastSeenMissionIds.has(m.missionId)) {
2211
+ logger.info(TAG, `New mission: ${m.missionId} (${m.strategy} ${m.direction} ${m.symbol}, size: ${(m.sizePct * 100).toFixed(2)}%)`);
2212
+ if (m.status === 'PENDING') {
2213
+ void this.presentMissionToAgent(m);
2214
+ }
2215
+ }
2216
+ }
2217
+ this.lastSeenMissionIds = currentIds;
2218
+ }, 'missionInterval');
2219
+ }
2220
+ startAnalyticsPoller() {
2221
+ this.startIntelligencePoller('analytics', 'analytics', 30_000, (raw) => {
2222
+ const data = raw;
2223
+ this.fire('analyticsUpdate', { ...data, event: 'analytics_update' });
2224
+ }, 'analyticsInterval', 15_000);
2225
+ }
2226
+ startDecisionTracePoller() {
2227
+ this.startIntelligencePoller('decision-trace', 'decision-trace', 30_000, (raw) => {
2228
+ const data = raw;
2229
+ this.fire('decisionTraceUpdate', { ...data, event: 'decision_trace' });
2230
+ }, 'decisionTraceInterval', 10_000);
2231
+ }
2232
+ // ---- Mission → Agent presentation ----
2233
+ static STRATEGY_LABELS = {
2234
+ trend_continuation: 'Trend Continuation',
2235
+ liquidity_sweep: 'Liquidity Sweep',
2236
+ funding_reversion: 'Funding Reversion',
2237
+ };
2238
+ async presentMissionToAgent(mission) {
2239
+ const ws = this.wsClient;
2240
+ if (!ws || !this.started)
2241
+ return;
2242
+ // Compute absolute position size from sizePct * NAV
2243
+ const nav = this.computeEquity();
2244
+ if (nav <= 0) {
2245
+ logger.warn(TAG, `Cannot present mission ${mission.missionId}: NAV is ${nav}`);
2246
+ return;
2247
+ }
2248
+ const absoluteSize = nav * mission.sizePct;
2249
+ const lastPrice = this.lastTicker?.lastPrice ?? mission.entryZone.high;
2250
+ const quantity = lastPrice > 0 ? absoluteSize / lastPrice : 0;
2251
+ const strategyLabel = GatewayProvider.STRATEGY_LABELS[mission.strategy] ?? mission.strategy;
2252
+ const conditionsSummary = mission.conditions
2253
+ .map((c) => `${c.met ? '[x]' : '[ ]'} ${c.description}`)
2254
+ .join('\n');
2255
+ const targetLines = mission.targets
2256
+ .map((t, i) => ` T${i + 1}: $${t.price.toFixed(2)} (${(t.pct * 100).toFixed(0)}%)`)
2257
+ .join('\n');
2258
+ const message = [
2259
+ `TRADE MISSION: ${strategyLabel} ${mission.direction} ${mission.symbol}`,
2260
+ '',
2261
+ `Strategy: ${strategyLabel}`,
2262
+ `Direction: ${mission.direction}`,
2263
+ `Regime: ${mission.regime} (${(mission.regimeConfidence * 100).toFixed(0)}% confidence)`,
2264
+ `Confluence: ${mission.confluenceScore}/${mission.conditions.length} conditions met`,
2265
+ '',
2266
+ 'Conditions:',
2267
+ conditionsSummary,
2268
+ '',
2269
+ `Entry zone: $${mission.entryZone.low.toFixed(2)} — $${mission.entryZone.high.toFixed(2)}`,
2270
+ `Stop: $${mission.stopLevel.toFixed(2)}`,
2271
+ `Targets:`,
2272
+ targetLines,
2273
+ '',
2274
+ `Position size: ${(mission.sizePct * 100).toFixed(2)}% of NAV = $${absoluteSize.toFixed(2)} (≈${quantity.toFixed(6)} ${mission.symbol.replace(/\/.*/, '')})`,
2275
+ `Sizing: Kelly=${(mission.sizingDetails.kellyFraction * 100).toFixed(1)}%, WR=${(mission.sizingDetails.winRate * 100).toFixed(0)}%, ${mission.sizingDetails.tradeCount} trades${mission.sizingDetails.cappedReason ? `, capped: ${mission.sizingDetails.cappedReason}` : ''}`,
2276
+ '',
2277
+ `Mission ID: ${mission.missionId}`,
2278
+ `Expires: ${new Date(mission.expiresAt).toLocaleTimeString()}`,
2279
+ '',
2280
+ 'Evaluate this setup and execute if you agree. Use place_order to enter. Respect your risk limits.',
2281
+ ].join('\n');
2282
+ try {
2283
+ const idempotencyKey = `mission-${mission.missionId}-${Date.now()}`;
2284
+ await ws.sendRpc('agent', { message, idempotencyKey, agentId: 'main' });
2285
+ logger.info(TAG, `Presented mission ${mission.missionId} to agent ($${absoluteSize.toFixed(2)}, ${quantity.toFixed(6)} units)`);
2286
+ }
2287
+ catch (err) {
2288
+ logger.error(TAG, `Failed to present mission ${mission.missionId}: ${formatError(err)}`);
2289
+ }
2290
+ }
2291
+ // ---- Event emission ----
2292
+ fire(event, ...args) {
2293
+ for (const listener of this.listeners[event]) {
2294
+ try {
2295
+ listener(...args);
2296
+ }
2297
+ catch (err) {
2298
+ logger.error(TAG, `Event listener error (${event}): ${formatError(err)}`);
2299
+ }
2300
+ }
2301
+ }
2302
+ }