@reefclaw/openclaw-plugin 0.1.5 → 0.1.7

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 (152) hide show
  1. package/bridge/gateway/event-parser.d.ts +6 -1
  2. package/bridge/gateway/event-parser.js +19 -2
  3. package/bridge/gateway/poller.d.ts +1 -0
  4. package/bridge/gateway/poller.js +14 -2
  5. package/bridge/providers/gateway.d.ts +22 -2
  6. package/bridge/providers/gateway.js +67 -9
  7. package/ccxt/binance-private.d.ts +21 -0
  8. package/ccxt/binance-private.js +132 -22
  9. package/ccxt/public-market-data-api.d.ts +14 -0
  10. package/ccxt/public-market-data-api.js +15 -1
  11. package/config/plugin-config-io.d.ts +13 -0
  12. package/config/plugin-config-io.js +15 -0
  13. package/config/tool-gate.js +3 -0
  14. package/exchange-adapter.d.ts +16 -0
  15. package/index.js +658 -83
  16. package/ingest/position-auto-capture.d.ts +68 -0
  17. package/ingest/position-auto-capture.js +321 -23
  18. package/ingest/position-decisions-client.d.ts +7 -2
  19. package/ingest/position-decisions-client.js +13 -3
  20. package/ingest/reconcile-db-vs-exchange.d.ts +39 -1
  21. package/ingest/reconcile-db-vs-exchange.js +66 -10
  22. package/lifecycle/trading-operation-lock.d.ts +17 -0
  23. package/lifecycle/trading-operation-lock.js +14 -0
  24. package/live/bracket-id.d.ts +2 -3
  25. package/live/bracket-id.js +22 -9
  26. package/live/fill-price.d.ts +13 -0
  27. package/live/fill-price.js +37 -0
  28. package/live/live-adapter.d.ts +57 -2
  29. package/live/live-adapter.js +290 -49
  30. package/live/local-signal-service.js +11 -6
  31. package/live/local-strategy-evaluator.js +4 -0
  32. package/live/position-state-store.d.ts +4 -0
  33. package/live/proposal-decision-listener.d.ts +6 -0
  34. package/live/proposal-decision-listener.js +4 -0
  35. package/live/stop-watcher.d.ts +35 -2
  36. package/live/stop-watcher.js +63 -3
  37. package/onboarding/runtime.d.ts +19 -0
  38. package/onboarding/runtime.js +34 -3
  39. package/openclaw.plugin.json +1 -0
  40. package/package.json +2 -2
  41. package/portfolio/reentry-tracker.d.ts +36 -0
  42. package/portfolio/reentry-tracker.js +127 -0
  43. package/portfolio/wave9-admission.d.ts +67 -0
  44. package/portfolio/wave9-admission.js +262 -0
  45. package/portfolio/wave9-policy.d.ts +36 -0
  46. package/portfolio/wave9-policy.js +183 -0
  47. package/signals/conditions/registry.js +61 -2
  48. package/signals/strategy-adapter.js +17 -7
  49. package/simulator/exchange-simulator.d.ts +12 -0
  50. package/simulator/exchange-simulator.js +78 -3
  51. package/simulator/fill-engine.js +5 -1
  52. package/simulator/types.d.ts +10 -1
  53. package/skills/reefclaw/SKILL.md +2 -0
  54. package/strategy/evaluator.d.ts +3 -0
  55. package/strategy/evaluator.js +5 -0
  56. package/tools/assessment-validation.d.ts +23 -0
  57. package/tools/assessment-validation.js +58 -0
  58. package/tools/attach-brackets.d.ts +7 -2
  59. package/tools/attach-brackets.js +201 -0
  60. package/tools/audit-bracket-protection.js +157 -1
  61. package/tools/bracket-control.d.ts +12 -0
  62. package/tools/bracket-control.js +35 -0
  63. package/tools/cancel-all-orders.d.ts +2 -0
  64. package/tools/cancel-all-orders.js +4 -1
  65. package/tools/cancel-order.d.ts +4 -0
  66. package/tools/cancel-order.js +49 -3
  67. package/tools/close-position.d.ts +23 -0
  68. package/tools/close-position.js +286 -13
  69. package/tools/create-order.d.ts +28 -0
  70. package/tools/create-order.js +1390 -190
  71. package/tools/get-analytics.js +2 -2
  72. package/tools/get-basis.js +2 -2
  73. package/tools/get-cascade-risk.js +2 -2
  74. package/tools/get-crypto-metrics.js +14 -4
  75. package/tools/get-cvd.js +2 -2
  76. package/tools/get-divergences.js +2 -2
  77. package/tools/get-funding-context.js +2 -2
  78. package/tools/get-liquidation-levels.js +2 -2
  79. package/tools/get-liquidation-pulse.js +2 -2
  80. package/tools/get-pattern-scan.js +2 -2
  81. package/tools/get-regime.js +2 -2
  82. package/tools/get-resting-liquidity.js +2 -2
  83. package/tools/get-risk-scenario.js +2 -2
  84. package/tools/get-session-review.js +2 -2
  85. package/tools/get-setup-detail.js +21 -2
  86. package/tools/get-signals.js +2 -2
  87. package/tools/get-sizing.js +2 -2
  88. package/tools/get-trade-feedback.js +2 -2
  89. package/tools/get-trade-flow.js +2 -2
  90. package/tools/get-volume-profile.js +2 -2
  91. package/tools/get-wave9-status.d.ts +127 -0
  92. package/tools/get-wave9-status.js +796 -0
  93. package/tools/intel-api.d.ts +20 -0
  94. package/tools/intel-api.js +67 -0
  95. package/tools/intel-cache.d.ts +1 -1
  96. package/tools/intel-cache.js +20 -5
  97. package/tools/list-strategies.d.ts +11 -1
  98. package/tools/list-strategies.js +17 -0
  99. package/tools/modify-stop.d.ts +4 -0
  100. package/tools/modify-stop.js +63 -24
  101. package/tools/modify-target.d.ts +4 -0
  102. package/tools/modify-target.js +62 -23
  103. package/tools/scan-pairs.d.ts +4 -0
  104. package/tools/scan-pairs.js +22 -8
  105. package/tools/toggle-strategy.js +7 -0
  106. package/types.d.ts +5 -0
  107. package/venues/hyperliquid/hl-balance.d.ts +116 -0
  108. package/venues/hyperliquid/hl-balance.js +145 -0
  109. package/venues/hyperliquid/hl-bracket-coordinator.d.ts +123 -0
  110. package/venues/hyperliquid/hl-bracket-coordinator.js +533 -0
  111. package/venues/hyperliquid/hl-brackets.d.ts +102 -0
  112. package/venues/hyperliquid/hl-brackets.js +172 -0
  113. package/venues/hyperliquid/hl-cloid.d.ts +22 -0
  114. package/venues/hyperliquid/hl-cloid.js +82 -0
  115. package/venues/hyperliquid/hl-info-cache.d.ts +46 -0
  116. package/venues/hyperliquid/hl-info-cache.js +125 -0
  117. package/venues/hyperliquid/hl-live-adapter.d.ts +146 -0
  118. package/venues/hyperliquid/hl-live-adapter.js +728 -0
  119. package/venues/hyperliquid/hl-precision.d.ts +61 -0
  120. package/venues/hyperliquid/hl-precision.js +176 -0
  121. package/venues/hyperliquid/hl-private.d.ts +88 -0
  122. package/venues/hyperliquid/hl-private.js +357 -0
  123. package/venues/hyperliquid/hl-public.d.ts +31 -4
  124. package/venues/hyperliquid/hl-public.js +163 -12
  125. package/venues/hyperliquid/hl-rate-gate.d.ts +57 -0
  126. package/venues/hyperliquid/hl-rate-gate.js +220 -0
  127. package/venues/hyperliquid/hl-user-stream.d.ts +90 -0
  128. package/venues/hyperliquid/hl-user-stream.js +220 -0
  129. package/venues/registry.d.ts +23 -9
  130. package/venues/registry.js +12 -13
  131. package/venues/symbols.d.ts +43 -0
  132. package/venues/symbols.js +107 -0
  133. package/wave9/live-account-capture.d.ts +67 -0
  134. package/wave9/live-account-capture.js +435 -0
  135. package/wave9/live-autonomous-protection.d.ts +39 -0
  136. package/wave9/live-autonomous-protection.js +112 -0
  137. package/wave9/live-durable-reconciliation-scheduler.d.ts +33 -0
  138. package/wave9/live-durable-reconciliation-scheduler.js +115 -0
  139. package/wave9/live-execution-ledger.d.ts +107 -0
  140. package/wave9/live-execution-ledger.js +498 -0
  141. package/wave9/live-position-confirmation.d.ts +18 -0
  142. package/wave9/live-position-confirmation.js +111 -0
  143. package/wave9/live-residual-protection.d.ts +18 -0
  144. package/wave9/live-residual-protection.js +250 -0
  145. package/wave9/live-startup-reconciliation.d.ts +38 -0
  146. package/wave9/live-startup-reconciliation.js +454 -0
  147. package/wave9/live-symbol-ownership.d.ts +20 -0
  148. package/wave9/live-symbol-ownership.js +132 -0
  149. package/wave9/paper-admission-guard.d.ts +199 -0
  150. package/wave9/paper-admission-guard.js +650 -0
  151. package/wave9/usdm-evidence-provider.d.ts +42 -0
  152. package/wave9/usdm-evidence-provider.js +133 -0
package/index.js CHANGED
@@ -12,6 +12,7 @@ import { join, dirname } from 'node:path';
12
12
  import { fileURLToPath } from 'node:url';
13
13
  import { BinancePublicApi } from './ccxt/binance-public.js';
14
14
  import { IntelPublicApi } from './ccxt/intel-public.js';
15
+ import { resolveMarketDataSource } from './ccxt/public-market-data-api.js';
15
16
  import { BinancePrivateApi } from './ccxt/binance-private.js';
16
17
  import { ExchangeSimulator } from './simulator/exchange-simulator.js';
17
18
  import { ShadowTracker } from './shadow/shadow-tracker.js';
@@ -19,9 +20,10 @@ import { StateManager } from './persistence/state-manager.js';
19
20
  import { logger, setLogLevel, formatError } from './logger.js';
20
21
  import { DEFAULT_CONFIG } from './types.js';
21
22
  import { PaperAdapter } from './paper-adapter.js';
23
+ import { LiveAdapter, } from './live/live-adapter.js';
22
24
  import { createLiveAdapter, fillExchangeId, isLiveVenueSupported, parseVenue, venueQuoteCurrency, } from './venues/registry.js';
23
25
  import { HyperliquidPublicApi } from './venues/hyperliquid/hl-public.js';
24
- import { loadBracketMode } from './config/brackets-config.js';
26
+ import { bracketsEnabled, loadBracketMode } from './config/brackets-config.js';
25
27
  import { loadUserDataStreamMode, loadUserDataStreamTunables, loadUserDataStreamDbWrite, getUserDataStreamIngestBaseUrl, resolveIngestToken, resolveReefclawUserId, } from './config/user-data-stream-config.js';
26
28
  import { TradeStoreClient } from './ingest/trade-store-client.js';
27
29
  import { ProposalManager } from './live/proposal-manager.js';
@@ -30,7 +32,9 @@ import { PositionDecisionsClient } from './ingest/position-decisions-client.js';
30
32
  import { PositionStateStore } from './live/position-state-store.js';
31
33
  import { PendingEntryStore } from './ingest/pending-entry-metadata.js';
32
34
  import { onReconcilerObservedClose, reconcileStateStoreOnStartup } from './ingest/reconciler-cleanup.js';
33
- import { reconcileDbOpenVsExchange } from './ingest/reconcile-db-vs-exchange.js';
35
+ import { reconcileDbOpenVsExchange, startPeriodicDbReconcile } from './ingest/reconcile-db-vs-exchange.js';
36
+ import { onStopWatcherClose } from './ingest/position-auto-capture.js';
37
+ import { ReentryTracker } from './portfolio/reentry-tracker.js';
34
38
  import { startReadinessReporter } from './ingest/readiness-reporter.js';
35
39
  import { IntelMicrostructureAssembler } from './live/microstructure-assembler.js';
36
40
  import { recordPositionReviewsTool } from './tools/record-position-reviews.js';
@@ -42,7 +46,8 @@ import { proposeLearningTool } from './tools/propose-learning.js';
42
46
  import { queryReviewOutcomesTool } from './tools/query-review-outcomes.js';
43
47
  import { loadPositionReviewMode } from './config/position-review-config.js';
44
48
  import { installSignalHandlers } from './lifecycle/install-signal-handlers.js';
45
- import { readPluginConfig, readOpenClawConnection } from './config/plugin-config-io.js';
49
+ import { SerialTradingOperationLock } from './lifecycle/trading-operation-lock.js';
50
+ import { readPluginConfig, readOpenClawConnection, loadStopWatcherIntervalMs } from './config/plugin-config-io.js';
46
51
  import { startConnectorSupervisor, hasBundledBridge } from './connector-supervisor.js';
47
52
  import { ToolGate } from './config/tool-gate.js';
48
53
  import { gateStore } from './config/gate-store.js';
@@ -94,7 +99,7 @@ import { getRiskScenarioTool } from './tools/get-risk-scenario.js';
94
99
  import { getSessionReviewTool } from './tools/get-session-review.js';
95
100
  import { getBacktestTool } from './tools/get-backtest.js';
96
101
  import { saveStrategyTool } from './tools/save-strategy.js';
97
- import { listStrategiesTool } from './tools/list-strategies.js';
102
+ import { listStrategiesTool, prepareListStrategiesArgs } from './tools/list-strategies.js';
98
103
  import { toggleStrategyTool } from './tools/toggle-strategy.js';
99
104
  import { getPatternScanTool } from './tools/get-pattern-scan.js';
100
105
  import { scanPairsTool } from './tools/scan-pairs.js';
@@ -110,6 +115,14 @@ import { getDivergencesTool } from './tools/get-divergences.js';
110
115
  import { getLiquidationPulseTool } from './tools/get-liquidation-pulse.js';
111
116
  import { getRestingLiquidityTool } from './tools/get-resting-liquidity.js';
112
117
  import { getFundingContextTool } from './tools/get-funding-context.js';
118
+ import { captureWave9PaperAccountSnapshot, captureWave9PaperPositionFingerprint, Wave9PaperAdmissionGuard, } from './wave9/paper-admission-guard.js';
119
+ import { captureWave9LiveAccount, captureWave9LivePosition, confirmWave9LiveNativeStop, } from './wave9/live-account-capture.js';
120
+ import { Wave9LiveExecutionLedger } from './wave9/live-execution-ledger.js';
121
+ import { recoverWave9LiveExposure } from './wave9/live-autonomous-protection.js';
122
+ import { reconcileWave9LiveDurableRows, } from './wave9/live-startup-reconciliation.js';
123
+ import { Wave9LiveDurableReconciliationScheduler } from './wave9/live-durable-reconciliation-scheduler.js';
124
+ import { inspectWave9LiveSymbolOwnership, } from './wave9/live-symbol-ownership.js';
125
+ import { getWave9StatusTool, IntelWave9ActivationClient, } from './tools/get-wave9-status.js';
113
126
  import { refreshTradingParams } from './trading-params-cache.js';
114
127
  const TAG = 'paper-trading';
115
128
  const PLUGIN_ID = 'reefclaw-paper-trading';
@@ -157,6 +170,9 @@ const TOOL_PARAMS = {
157
170
  amount: { type: 'number', description: 'Quantity in base currency (e.g. BTC amount)' },
158
171
  price: { type: 'number', description: 'Limit price (required for limit orders)' },
159
172
  stopPrice: { type: 'number', description: 'Protective stop-loss trigger price. REQUIRED in live mode when brackets.requireStopLoss is on (default). Must be below entry for longs, above entry for shorts.' },
173
+ mission_id: { type: 'string', description: 'Canonical mission ID for attribution. Wave 9 requires the exact mission ID returned by get_wave9_status.' },
174
+ candidate_id: { type: 'string', description: 'Wave 9 only: exact candidate ID returned by get_wave9_status. Invalid on generic orders.' },
175
+ wave9_admission_token: { type: 'string', description: 'Wave 9 only: short-lived single-use PAPER/LIVE admission token returned by get_wave9_status. Invalid on generic orders.' },
160
176
  setup_type: { type: 'string', description: 'Setup classification (e.g. pullback_to_ema, breakout, mean_reversion). REQUIRED in live mode.' },
161
177
  thesis: { type: 'string', description: 'Entry reason in 1-2 sentences (≥20 chars). REQUIRED in live mode — the heartbeat reassessment compares against this.' },
162
178
  target_price: { type: 'number', description: 'Planned profit target price. REQUIRED in live mode when brackets.requireTakeProfit is on (default).' },
@@ -191,9 +207,11 @@ const TOOL_PARAMS = {
191
207
  symbol: { type: 'string', description: 'Symbol of the position to close' },
192
208
  reason: {
193
209
  type: 'string',
194
- enum: ['regime_flip', 'risk_limit', 'bracket_integrity', 'operator_command'],
195
- description: 'Why you are closing manually instead of letting brackets handle it. Must match the decision-matrix trigger in your thinking.',
210
+ enum: ['regime_flip', 'risk_limit', 'bracket_integrity', 'operator_command', 'wave9_signal_reversal'],
211
+ description: 'Why you are closing manually instead of letting brackets handle it. wave9_signal_reversal is reserved for an exact get_wave9_status exit authorization.',
196
212
  },
213
+ candidate_id: { type: 'string', description: 'Wave 9 only: exact held candidate ID returned by get_wave9_status.' },
214
+ wave9_exit_token: { type: 'string', description: 'Wave 9 only: short-lived single-use PAPER/LIVE signal-reversal exit token returned by get_wave9_status.' },
197
215
  assessment: {
198
216
  type: 'object',
199
217
  description: 'Structured reasoning — populated from a fresh scorecard run this heartbeat. Required for all reasons except operator_command. Reason-specific consistency is enforced server-side.',
@@ -505,6 +523,11 @@ const TOOL_PARAMS = {
505
523
  },
506
524
  required: ['symbol'],
507
525
  },
526
+ get_wave9_status: {
527
+ type: 'object',
528
+ properties: {},
529
+ additionalProperties: false,
530
+ },
508
531
  get_analytics: {
509
532
  type: 'object',
510
533
  properties: {
@@ -651,8 +674,10 @@ const TOOL_PARAMS = {
651
674
  list_strategies: {
652
675
  type: 'object',
653
676
  properties: {
654
- active_only: { type: 'boolean', description: 'If true, only return active strategies (default: false returns all)' },
677
+ active_only: { type: 'boolean', description: 'Required. Use true for every question about strategies active, usable, available, or tradeable now; use false only when the operator explicitly asks to include inactive strategies.' },
655
678
  },
679
+ required: ['active_only'],
680
+ additionalProperties: false,
656
681
  },
657
682
  toggle_strategy: {
658
683
  type: 'object',
@@ -926,6 +951,7 @@ const paperTradingPlugin = {
926
951
  let apiBaseUrl = 'https://www.reefclaw.com';
927
952
  let intelligenceUrl = 'https://intel.reefclaw.com';
928
953
  let exchangeConfig = null;
954
+ let hlCredentials = null;
929
955
  let tradingMode = 'PAPER';
930
956
  // Trading venue (multi-venue Phase 0 — docs/HYPERLIQUID_INTEGRATION_PLAN.md).
931
957
  // Absent config field = 'binance', so every pre-venue install is untouched.
@@ -969,6 +995,18 @@ const paperTradingPlugin = {
969
995
  testnet: exchange.testnet ?? false,
970
996
  };
971
997
  }
998
+ // Hyperliquid credentials (Phase 3): a MASTER address (queries; holds the
999
+ // funds) + an AGENT wallet private key (signs; holds nothing, cannot
1000
+ // withdraw or transfer — verified live 2026-07-12). Both are required:
1001
+ // an agent key alone cannot be queried against, and an address alone
1002
+ // cannot sign.
1003
+ if (venue === 'hyperliquid' && exchange?.walletAddress && exchange?.agentPrivateKey) {
1004
+ hlCredentials = {
1005
+ walletAddress: String(exchange.walletAddress),
1006
+ agentPrivateKey: String(exchange.agentPrivateKey),
1007
+ testnet: exchange.testnet === true,
1008
+ };
1009
+ }
972
1010
  // Read trading mode (default: PAPER)
973
1011
  const mode = rcConfig?.tradingMode?.toUpperCase();
974
1012
  if (mode === 'SHADOW' || mode === 'MICRO_LIVE' || mode === 'LIVE') {
@@ -1004,10 +1042,18 @@ const paperTradingPlugin = {
1004
1042
  logger.warn(TAG, `Trading mode ${tradingMode} on venue '${venue}' is not supported in this build — falling back to PAPER (live support arrives in Phase 3 of docs/HYPERLIQUID_INTEGRATION_PLAN.md)`);
1005
1043
  tradingMode = 'PAPER';
1006
1044
  }
1007
- // Validate trading mode vs credentials
1008
- if (tradingMode !== 'PAPER' && !exchangeConfig) {
1009
- logger.warn(TAG, `Trading mode ${tradingMode} requires exchange API keys — falling back to PAPER`);
1010
- tradingMode = 'PAPER';
1045
+ // Validate trading mode vs credentials — PER VENUE (the credential SHAPES
1046
+ // differ: Binance = apiKey/secret, Hyperliquid = master address + agent key).
1047
+ if (tradingMode !== 'PAPER') {
1048
+ if (venue === 'binance' && !exchangeConfig) {
1049
+ logger.warn(TAG, `Trading mode ${tradingMode} requires exchange API keys — falling back to PAPER`);
1050
+ tradingMode = 'PAPER';
1051
+ }
1052
+ else if (venue === 'hyperliquid' && !hlCredentials) {
1053
+ logger.warn(TAG, `Trading mode ${tradingMode} on Hyperliquid requires exchange.walletAddress (master) + ` +
1054
+ 'exchange.agentPrivateKey (agent wallet) — falling back to PAPER');
1055
+ tradingMode = 'PAPER';
1056
+ }
1011
1057
  }
1012
1058
  // Create a BinancePrivateApi for SHADOW mode balance reads whenever
1013
1059
  // credentials exist — even if we start in PAPER, the user can switch
@@ -1092,6 +1138,9 @@ const paperTradingPlugin = {
1092
1138
  // position doesn't leak as status='open' forever (close-bypass class). On by
1093
1139
  // default; env kill-switch RC_JOURNAL_CLOSE_ON_FILL=off disables it.
1094
1140
  const closeOnReduceOnlyFill = process.env.RC_JOURNAL_CLOSE_ON_FILL !== 'off';
1141
+ // Re-entry tracker (issue #204): every close path records the exit so
1142
+ // scan_pairs can flag setups already traded within the current signal bar.
1143
+ const reentryTracker = new ReentryTracker();
1095
1144
  const autoCapture = {
1096
1145
  decisionsClient: positionDecisionsClient,
1097
1146
  stateStore: positionStateStore,
@@ -1103,6 +1152,10 @@ const paperTradingPlugin = {
1103
1152
  // closure — only invoked on a fill, long after `runtime` is built), so it
1104
1153
  // follows a live<->paper reconnect.
1105
1154
  resolveMode: () => (runtime.adapter.isLive ? 'live' : 'paper'),
1155
+ // Active adapter at capture time (deferred closure, same as resolveMode).
1156
+ // Drives the stale-state defense in onCreateOrderFilled (issue #199).
1157
+ resolveAdapter: () => runtime.adapter,
1158
+ reentryTracker,
1106
1159
  // Venue tag (positions.exchange, migration 0058) — static per process;
1107
1160
  // a venue change requires config edit + restart.
1108
1161
  venue,
@@ -1119,6 +1172,7 @@ const paperTradingPlugin = {
1119
1172
  // Read by the create_order tool below to dual-write proposals. See §12 of
1120
1173
  // docs/APPROVAL_MODE_DESIGN.md.
1121
1174
  let proposalManagerCtx;
1175
+ let proposalListenerCredentials;
1122
1176
  // Approval mode read at init from env + plugin-config.json; passed to the
1123
1177
  // create_order tool so it picks the right branch (off / shadow / per_trade).
1124
1178
  let approvalModeForTool = 'off';
@@ -1245,6 +1299,7 @@ const paperTradingPlugin = {
1245
1299
  manager: new ProposalManager({ baseUrl: ingestBaseUrl, ingestToken }),
1246
1300
  userId: reefclawUserId,
1247
1301
  };
1302
+ proposalListenerCredentials = { ingestToken, userId: reefclawUserId };
1248
1303
  logger.info(TAG, `Approval wiring active (mode=${approvalModeForTool}) → ${ingestBaseUrl} (userId=${reefclawUserId.slice(0, 8)}…)`);
1249
1304
  }
1250
1305
  else if (approvalModeForTool !== 'off') {
@@ -1256,40 +1311,30 @@ const paperTradingPlugin = {
1256
1311
  // args, identical behavior; the unsupported-venue arm is unreachable
1257
1312
  // here because the isLiveVenueSupported() gate above already fell back
1258
1313
  // to PAPER. Phase 3 adds the hyperliquid adapter inside the factory.
1259
- const liveAdapter = createLiveAdapter(venue, exchangeConfig, tradingMode, microLiveConfig, bracketMode, userDataStreamMode, userDataStreamTunables, tradeIngest, autoCapture);
1260
- adapter = liveAdapter;
1261
- // ---- Approval-mode Phase B — start ProposalDecisionListener ----
1262
- // Started only when approval.mode='per_trade' AND proposalManagerCtx
1263
- // is wired (creds present). The listener polls /pending-decisions and
1264
- // fires approved proposals via createOrderTool with proposalManager
1265
- // omitted — that path runs the real adapter.createOrder.
1266
- if (approvalModeForTool === 'per_trade' && proposalManagerCtx) {
1267
- try {
1268
- const approvalCfg = readPluginConfig().approval;
1269
- let cfgForUrl;
1270
- try {
1271
- cfgForUrl = readPluginConfig();
1272
- }
1273
- catch {
1274
- cfgForUrl = undefined;
1275
- }
1276
- const ingestBaseUrl = getUserDataStreamIngestBaseUrl(cfgForUrl);
1277
- proposalDecisionListener = new ProposalDecisionListener({
1278
- baseUrl: ingestBaseUrl,
1279
- ingestToken: ingestToken,
1280
- userId: reefclawUserId,
1281
- adapter: liveAdapter,
1282
- binanceApi,
1283
- autoCapture,
1284
- pollIntervalMs: approvalCfg?.pollIntervalMs ?? 3_000,
1285
- });
1286
- proposalDecisionListener.start();
1287
- logger.info(TAG, `ProposalDecisionListener started (poll ${approvalCfg?.pollIntervalMs ?? 3_000} ms)`);
1288
- }
1289
- catch (err) {
1290
- logger.warn(TAG, `Failed to start ProposalDecisionListener: ${String(err)}`);
1314
+ const liveAdapter = createLiveAdapter(venue === 'hyperliquid'
1315
+ ? {
1316
+ venue: 'hyperliquid',
1317
+ args: {
1318
+ // Non-null by the per-venue credential gate above.
1319
+ credentials: hlCredentials,
1320
+ mode: tradingMode,
1321
+ marketSlippagePct: readPluginConfig().hl?.marketSlippagePct,
1322
+ },
1291
1323
  }
1292
- }
1324
+ : {
1325
+ venue: 'binance',
1326
+ args: [
1327
+ exchangeConfig,
1328
+ tradingMode,
1329
+ microLiveConfig,
1330
+ bracketMode,
1331
+ userDataStreamMode,
1332
+ userDataStreamTunables,
1333
+ tradeIngest,
1334
+ autoCapture,
1335
+ ],
1336
+ });
1337
+ adapter = liveAdapter;
1293
1338
  // Reconciler-driven close-bypass cleanup. The reconciler emits
1294
1339
  // 'drift_detected' on every poll; we filter for 'closed' drifts
1295
1340
  // (position observed flat on exchange while local thinks it's open).
@@ -1356,7 +1401,17 @@ const paperTradingPlugin = {
1356
1401
  // are status='open' but absent from the exchange (closed on-exchange
1357
1402
  // while down / state-store wiped). Same trusted-snapshot-only rule —
1358
1403
  // a null fetch must never be read as "flat" and close the book.
1359
- await reconcileDbOpenVsExchange({ decisionsClient: positionDecisionsClient, userId: positionDecisionsUserId }, trusted.map((p) => p.symbol));
1404
+ await reconcileDbOpenVsExchange({
1405
+ decisionsClient: positionDecisionsClient,
1406
+ userId: positionDecisionsUserId,
1407
+ // This boot pass only runs in the live-adapter init path and
1408
+ // diffs against the LIVE snapshot — scope the DB read to the
1409
+ // live book so open paper rows aren't misread as orphans.
1410
+ resolveMode: () => 'live',
1411
+ // …and to the ACTIVE venue (issue #209 item 5): the other
1412
+ // venue's open rows can never appear in this snapshot.
1413
+ resolveExchange: () => venue,
1414
+ }, trusted.map((p) => p.symbol));
1360
1415
  }
1361
1416
  else {
1362
1417
  logger.warn(TAG, 'Startup reconciliation skipped — positions fetch untrusted (null); state-store left untouched. Runtime drift_detected + periodic reconcilers cover the gap.');
@@ -1373,27 +1428,30 @@ const paperTradingPlugin = {
1373
1428
  else {
1374
1429
  adapter = new PaperAdapter(simulator);
1375
1430
  }
1376
- // PAPER market-data source selection.
1431
+ // Public market-data source selection.
1377
1432
  //
1378
1433
  // Venue precedence (Hyperliquid Phase 1 — docs/HYPERLIQUID_INTEGRATION_PLAN.md):
1379
- // the configured VENUE decides which exchange prices paper mode, full stop.
1380
- // venue='hyperliquid' → every public read (PaperMarketFeed marks, the
1381
- // simulator's fill prices, fetch_ticker/fetch_ohlcv/get_orderbook tools)
1382
- // comes from Hyperliquid's keyless /info endpoints; the Binance-only
1434
+ // the configured VENUE decides which exchange serves public reads, full
1435
+ // stop, in BOTH books. venue='hyperliquid' → every public read
1436
+ // (PaperMarketFeed marks, the simulator's fill prices,
1437
+ // fetch_ticker/fetch_ohlcv/get_orderbook tools paper AND live) comes
1438
+ // from Hyperliquid's keyless /info endpoints; the Binance-only
1383
1439
  // `paperMarketDataSource:'intel'` escape hatch does not apply (intel has
1384
1440
  // no HL rows until Phase 2 — silently serving Binance prices for a
1385
1441
  // Hyperliquid book would be a lie, the exact class the symbol-translation
1386
- // rule forbids).
1442
+ // rule forbids). Pre-fix this was gated on !adapter.isLive, so a LIVE
1443
+ // boot on venue=hyperliquid fell through to Binance for all six
1444
+ // market-data tools while executing on HL.
1387
1445
  //
1388
1446
  // Binance venue (default) is unchanged: a host Binance geo-blocks (HTTP
1389
- // 451) can route PRICE reads through the intel service by setting
1447
+ // 451) can route PAPER price reads through the intel service by setting
1390
1448
  // plugin-config `paperMarketDataSource:'intel'` (GET /api/price/:symbol).
1391
- // LIVE always uses the real exchange, and the readiness reporter probes
1392
- // the CONFIGURED venue. Kill-switch RC_PAPER_MARKET_DATA=binance forces
1393
- // the Binance-direct default (binance venue only).
1449
+ // The readiness reporter probes the CONFIGURED venue. Kill-switch
1450
+ // RC_PAPER_MARKET_DATA=binance forces the Binance-direct default
1451
+ // (binance venue only).
1394
1452
  //
1395
1453
  // hlPublicApi is constructed once per process when the venue is
1396
- // hyperliquid — shared by the paper data path and the readiness probe.
1454
+ // hyperliquid — shared by the market-data path and the readiness probe.
1397
1455
  const hlPublicApi = venue === 'hyperliquid' ? new HyperliquidPublicApi({ testnet: venueTestnet }) : null;
1398
1456
  let configuredPaperSource;
1399
1457
  try {
@@ -1405,20 +1463,24 @@ const paperTradingPlugin = {
1405
1463
  const paperDataSource = process.env.RC_PAPER_MARKET_DATA === 'binance'
1406
1464
  ? 'binance'
1407
1465
  : configuredPaperSource ?? 'binance';
1408
- const useIntelPaperData = venue === 'binance' &&
1409
- !adapter.isLive && paperDataSource === 'intel' && connectionToken.length > 0;
1410
- const marketDataApi = !adapter.isLive && hlPublicApi
1466
+ const marketDataSource = resolveMarketDataSource({
1467
+ venue,
1468
+ isLive: adapter.isLive,
1469
+ paperDataSource,
1470
+ hasConnectionToken: connectionToken.length > 0,
1471
+ });
1472
+ const marketDataApi = marketDataSource === 'hyperliquid' && hlPublicApi
1411
1473
  ? hlPublicApi
1412
- : useIntelPaperData
1474
+ : marketDataSource === 'intel'
1413
1475
  ? new IntelPublicApi({ connectionToken, intelligenceUrl })
1414
1476
  : binanceApi;
1415
- if (!adapter.isLive && hlPublicApi) {
1416
- logger.info(TAG, `PAPER market data sourced from Hyperliquid${venueTestnet ? ' TESTNET' : ''} (venue=hyperliquid) — use USDC pairs (e.g. BTC/USDC)`);
1477
+ if (marketDataSource === 'hyperliquid') {
1478
+ logger.info(TAG, `${adapter.isLive ? 'LIVE' : 'PAPER'} market data sourced from Hyperliquid${venueTestnet ? ' TESTNET' : ''} (venue=hyperliquid) — use USDC pairs (e.g. BTC/USDC)`);
1417
1479
  if (paperDataSource === 'intel') {
1418
1480
  logger.warn(TAG, 'paperMarketDataSource=intel is a Binance-venue option — ignored on venue=hyperliquid (intel has no Hyperliquid rows until Phase 2)');
1419
1481
  }
1420
1482
  }
1421
- else if (useIntelPaperData) {
1483
+ else if (marketDataSource === 'intel') {
1422
1484
  logger.warn(TAG, 'PAPER market data routed through intel (paperMarketDataSource=intel) — prices from GET /api/price; live + readiness still use Binance directly');
1423
1485
  }
1424
1486
  else if (venue === 'binance' && !adapter.isLive && paperDataSource === 'intel' && connectionToken.length === 0) {
@@ -1444,19 +1506,14 @@ const paperTradingPlugin = {
1444
1506
  // see stop-watcher.ts. Operators can override / revert with zero deploy
1445
1507
  // via plugin-config.json `stopWatcher.intervalMs`. Best-effort read;
1446
1508
  // any failure falls back to the (already-reduced) default.
1447
- let stopWatcherIntervalMs;
1448
- try {
1449
- const swMs = readPluginConfig().stopWatcher?.intervalMs;
1450
- if (typeof swMs === 'number' && Number.isFinite(swMs) && swMs > 0) {
1451
- stopWatcherIntervalMs = swMs;
1452
- logger.info(TAG, `Stop-watcher interval overridden via plugin-config: ${swMs}ms`);
1453
- }
1509
+ const stopWatcherIntervalMs = loadStopWatcherIntervalMs();
1510
+ if (stopWatcherIntervalMs !== undefined) {
1511
+ logger.info(TAG, `Stop-watcher interval overridden via plugin-config: ${stopWatcherIntervalMs}ms`);
1454
1512
  }
1455
- catch { /* best-effort — default applies */ }
1513
+ const wave9OperationLock = new SerialTradingOperationLock();
1456
1514
  const bootstrapWatcher = stopWatcherIntervalMs !== undefined
1457
- ? new PositionWatcher(adapter, stopWatcherIntervalMs)
1458
- : new PositionWatcher(adapter);
1459
- bootstrapWatcher.start();
1515
+ ? new PositionWatcher(adapter, stopWatcherIntervalMs, wave9OperationLock)
1516
+ : new PositionWatcher(adapter, undefined, wave9OperationLock);
1460
1517
  // Paper-mode background market-data feed. Refreshes the simulator's marks
1461
1518
  // for every symbol with an open position/order (not just the dashboard
1462
1519
  // symbol), so the stop-watcher sees a fresh mark, NAV/uPnL stay live, and
@@ -1467,15 +1524,471 @@ const paperTradingPlugin = {
1467
1524
  if (!adapter.isLive) {
1468
1525
  marketFeed.start();
1469
1526
  }
1527
+ // Journal capture for stop-watcher auto-closes (issue #199). Wired onto
1528
+ // EVERY watcher — the bootstrap one here, reconnect-created ones via the
1529
+ // runtime's onWatcherCreated hook — so a watcher close always journals its
1530
+ // close and drops the state-store mapping (no more phantom positions).
1531
+ const wireWatcherJournalCapture = (watcher) => {
1532
+ watcher.on('stop_closed', (ev) => {
1533
+ onStopWatcherClose(autoCapture, {
1534
+ symbol: ev.symbol,
1535
+ stopPrice: ev.stopPrice,
1536
+ markPrice: ev.markPrice,
1537
+ order: ev.order,
1538
+ }).catch((err) => {
1539
+ logger.warn(TAG, `stop-watcher close capture failed: ${formatError(err)}`);
1540
+ });
1541
+ });
1542
+ };
1543
+ wireWatcherJournalCapture(bootstrapWatcher);
1470
1544
  const runtime = new PluginRuntime({
1471
1545
  adapter,
1472
1546
  mode: tradingMode,
1473
1547
  simulator,
1474
1548
  stopWatcher: bootstrapWatcher,
1475
1549
  marketFeed,
1550
+ operationLock: wave9OperationLock,
1551
+ onWatcherCreated: wireWatcherJournalCapture,
1476
1552
  });
1553
+ // Periodic DB-vs-exchange sweep (issues #199/#203): the boot-only reconcile
1554
+ // left phantom-open journal rows alive for days between restarts. Runs for
1555
+ // BOTH books (the paper book previously had NO reconcile at all); trusted-
1556
+ // snapshot discipline preserved (null → skip). First pass shortly after
1557
+ // boot so restart-deploys heal existing phantoms immediately.
1558
+ const dbReconcileSweep = startPeriodicDbReconcile({
1559
+ decisionsClient: positionDecisionsClient,
1560
+ userId: positionDecisionsUserId,
1561
+ resolveAdapter: () => runtime.adapter,
1562
+ // Scope each tick's DB read to the ACTIVE book (deferred closure —
1563
+ // follows a set_trading_mode reconnect). Without this, the sweep
1564
+ // misreads the inactive book's open rows as orphans and synthetically
1565
+ // closes them (they can never appear in this adapter's snapshot).
1566
+ resolveMode: () => (runtime.adapter.isLive ? 'live' : 'paper'),
1567
+ // Venue axis (issue #209 item 5): scope to the configured venue so a
1568
+ // two-venue tenant's other-venue rows are never misread as orphans.
1569
+ resolveExchange: () => venue,
1570
+ describeLastExit: (symbol) => {
1571
+ const a = runtime.adapter;
1572
+ if (a instanceof PaperAdapter) {
1573
+ const canon = symbol.split(':')[0];
1574
+ const th = a.getSimulator().getState().tradeHistory;
1575
+ for (let i = th.length - 1; i >= 0; i--) {
1576
+ if (th[i].symbol.split(':')[0] === canon) {
1577
+ const t = th[i];
1578
+ return `paper engine last trade: close ${t.side} ${t.quantity} @ ${t.exitPrice} at ${t.closedAt} (pnl ${t.realizedPnl.toFixed(4)})`;
1579
+ }
1580
+ }
1581
+ return 'paper engine has NO trade record for this symbol';
1582
+ }
1583
+ return undefined;
1584
+ },
1585
+ });
1586
+ if (dbReconcileSweep) {
1587
+ const bootSweep = setTimeout(() => { void dbReconcileSweep.runOnce(); }, 15_000);
1588
+ bootSweep.unref?.();
1589
+ }
1477
1590
  const intelDeps = { connectionToken, apiBaseUrl };
1478
- const intelApiDeps = { connectionToken, intelligenceUrl };
1591
+ // venue rides the deps so every intel tool maps agent-facing symbols to
1592
+ // the venue's intel namespace (plan §5.3) — absent/'binance' is
1593
+ // byte-identical passthrough.
1594
+ const intelApiDeps = { connectionToken, intelligenceUrl, venue };
1595
+ const wave9AdmissionGuard = new Wave9PaperAdmissionGuard();
1596
+ let wave9LiveLedger;
1597
+ try {
1598
+ wave9LiveLedger = new Wave9LiveExecutionLedger();
1599
+ }
1600
+ catch (error) {
1601
+ // A malformed durable ledger must disable Wave 9 LIVE admission without
1602
+ // taking unrelated trading and risk-reducing tools offline.
1603
+ logger.error(TAG, `Wave 9 live execution ledger unavailable: ${formatError(error)}`);
1604
+ }
1605
+ const checkWave9LiveSymbolOwnership = async (symbol) => {
1606
+ const ledger = wave9LiveLedger;
1607
+ if (!ledger) {
1608
+ return {
1609
+ status: 'unavailable',
1610
+ reason: 'durable_wave9_live_execution_ledger_unavailable',
1611
+ };
1612
+ }
1613
+ return inspectWave9LiveSymbolOwnership(runtime.adapter, ledger, symbol);
1614
+ };
1615
+ if (wave9LiveLedger) {
1616
+ const liveLedger = wave9LiveLedger;
1617
+ const canonicalWave9Symbol = (symbol) => symbol.toUpperCase().replace(/[^A-Z0-9]/g, '').replace(/USDTUSDT$/, 'USDT');
1618
+ const resolveSingleOpenOwner = (symbol) => {
1619
+ const canonical = canonicalWave9Symbol(symbol);
1620
+ const matches = liveLedger.getAll().filter((record) => record.state === 'open'
1621
+ && record.symbol === canonical);
1622
+ if (matches.length > 1) {
1623
+ throw new Error(`multiple durable OPEN Wave 9 candidates own ${symbol}; refusing ambiguous lifecycle mutation`);
1624
+ }
1625
+ return matches[0];
1626
+ };
1627
+ const logWave9Recovery = (trigger, result) => {
1628
+ if (result.status === 'flat') {
1629
+ logger.info(TAG, `Wave 9 LIVE ${trigger}: ${result.symbol} is stably flat and candidate ` +
1630
+ `${result.candidateId} is durably CLOSED (${result.detail})`);
1631
+ }
1632
+ else if (result.status === 'protected') {
1633
+ logger.warn(TAG, `Wave 9 LIVE ${trigger}: residual ${result.symbol} is protected by the exact ` +
1634
+ `frozen stop ${result.stopPrice} (${result.detail})`);
1635
+ }
1636
+ else {
1637
+ logger.error(TAG, `Wave 9 LIVE ${trigger}: ${result.detail}; candidate ${result.candidateId} ` +
1638
+ 'remains durably OPEN');
1639
+ }
1640
+ };
1641
+ const logWave9ReadyReconciliation = (trigger, result) => {
1642
+ for (const row of result.rows) {
1643
+ const message = `Wave 9 LIVE ${trigger} reconciliation ${row.status} for ${row.symbol}/` +
1644
+ `${row.candidateId}: ${row.detail}`;
1645
+ if (row.status === 'protected')
1646
+ logger.info(TAG, message);
1647
+ else if (row.status === 'closed')
1648
+ logger.info(TAG, message);
1649
+ else
1650
+ logger.error(TAG, message);
1651
+ }
1652
+ };
1653
+ const wave9DurableReconciliationScheduler = new Wave9LiveDurableReconciliationScheduler({
1654
+ operationLock: wave9OperationLock,
1655
+ isCurrentSource: (source) => runtime.mode === 'LIVE' && runtime.adapter === source,
1656
+ reconcile: (source) => reconcileWave9LiveDurableRows(source, liveLedger),
1657
+ onResult: logWave9ReadyReconciliation,
1658
+ onError: (trigger, error) => {
1659
+ logger.error(TAG, `Wave 9 LIVE ${trigger} durable reconciliation failed: ${formatError(error)}; ` +
1660
+ 'IMMEDIATE MANUAL INTERVENTION REQUIRED');
1661
+ },
1662
+ });
1663
+ const handleProtectionLoss = (source, event) => wave9OperationLock.withAccountLock(`wave9_protection_loss:${event.symbol}`, async () => {
1664
+ if (runtime.mode !== 'LIVE' || runtime.adapter !== source) {
1665
+ logger.warn(TAG, `Ignoring stale Wave 9 protection-loss event for ${event.symbol} from a replaced/non-LIVE adapter`);
1666
+ return;
1667
+ }
1668
+ const owner = resolveSingleOpenOwner(event.symbol);
1669
+ if (!owner)
1670
+ return; // Generic/non-Wave9 bracket behavior stays unchanged.
1671
+ if (event.entryClientOrderId !== owner.clientOrderId
1672
+ || event.entrySide !== owner.side) {
1673
+ throw new Error(`IMMEDIATE MANUAL INTERVENTION REQUIRED: ${event.symbol} protection-loss event ` +
1674
+ `belongs to entry ${event.entryClientOrderId}/${event.entrySide}, not durable ` +
1675
+ `Wave 9 owner ${owner.clientOrderId}/${owner.side}; refusing stale/generic mutation`);
1676
+ }
1677
+ const bracketLedger = source.getBracketLedger();
1678
+ const row = bracketLedger?.getBySymbol(event.symbol);
1679
+ if (row && (row.entryCid !== owner.clientOrderId || row.entrySide !== owner.side)) {
1680
+ throw new Error(`IMMEDIATE MANUAL INTERVENTION REQUIRED: ${event.symbol} has durable Wave 9 owner ` +
1681
+ `${owner.candidateId}, but its current bracket belongs to a different entry; ` +
1682
+ 'refusing to auto-close or replace potentially generic protection');
1683
+ }
1684
+ if (row && row.bracketId !== event.bracketId) {
1685
+ logger.info(TAG, `Ignoring stale Wave 9 stop-loss event ${event.bracketId} for ${event.symbol}; ` +
1686
+ `current bracket is ${row.bracketId}`);
1687
+ return;
1688
+ }
1689
+ if (row?.slCid && event.stopClientOrderId && row.slCid !== event.stopClientOrderId) {
1690
+ logger.info(TAG, `Ignoring stale Wave 9 stop CID ${event.stopClientOrderId} for ${event.symbol}`);
1691
+ return;
1692
+ }
1693
+ let terminalStopProven = event.source !== 'reconciler_missing';
1694
+ if (event.source === 'reconciler_missing' && event.stopClientOrderId) {
1695
+ const liveness = await source.resolveBracketLegLiveness(event.stopClientOrderId);
1696
+ if (liveness === 'live' || liveness === 'unknown') {
1697
+ logger.info(TAG, `Wave 9 reconciler reported ${event.symbol} stop missing, but exact CID ` +
1698
+ `${event.stopClientOrderId} is ${liveness}; deferring destructive recovery ` +
1699
+ 'until terminal loss is proven');
1700
+ return;
1701
+ }
1702
+ terminalStopProven = liveness === 'terminal';
1703
+ }
1704
+ else if (event.source === 'reconciler_missing' && !event.stopClientOrderId) {
1705
+ // A stop was required by the row but no exact stop CID was ever
1706
+ // persisted or observed in the trusted open-order snapshot.
1707
+ terminalStopProven = true;
1708
+ }
1709
+ if (row && terminalStopProven
1710
+ && row.state !== 'cancelled'
1711
+ && row.state !== 'failed'
1712
+ && row.state !== 'triggered_sl'
1713
+ && row.state !== 'triggered_tp') {
1714
+ try {
1715
+ bracketLedger?.markState(event.symbol, 'cancelled', {
1716
+ closeReason: 'cancelled_auto',
1717
+ lastError: event.detail,
1718
+ });
1719
+ }
1720
+ catch (error) {
1721
+ logger.error(TAG, `Could not durably terminalize lost Wave 9 stop for ${event.symbol}: ${formatError(error)}; ` +
1722
+ 'continuing to exact re-protection/emergency flatten');
1723
+ }
1724
+ }
1725
+ const result = await recoverWave9LiveExposure(source, liveLedger, owner.candidateId, event.symbol);
1726
+ logWave9Recovery(event.source, result);
1727
+ });
1728
+ runtime.setWave9LiveLifecycleHooks({
1729
+ configureLiveAdapter: (source) => {
1730
+ source.setWave9ProtectionLossHandler((event) => handleProtectionLoss(source, event));
1731
+ wave9DurableReconciliationScheduler.attach(source);
1732
+ },
1733
+ configurePositionWatcher: (watcher, source) => {
1734
+ wave9DurableReconciliationScheduler.stopIfSourceChanged(source);
1735
+ watcher.setWave9CloseLifecycle({
1736
+ resolveCandidateId: (position) => {
1737
+ if (!source.isLive || runtime.mode !== 'LIVE' || runtime.adapter !== source) {
1738
+ return undefined;
1739
+ }
1740
+ const owner = resolveSingleOpenOwner(position.symbol);
1741
+ if (!owner)
1742
+ return undefined;
1743
+ const expectedSide = owner.side === 'buy' ? 'long' : 'short';
1744
+ if (position.side !== expectedSide) {
1745
+ throw new Error(`durable Wave 9 owner ${owner.candidateId} is ${expectedSide}, but exchange position is ${position.side}`);
1746
+ }
1747
+ if (position.candidateId && position.candidateId !== owner.candidateId) {
1748
+ throw new Error(`position candidate ${position.candidateId} does not match durable owner ${owner.candidateId}`);
1749
+ }
1750
+ if (!position.candidateId) {
1751
+ if (!(source instanceof LiveAdapter)) {
1752
+ throw new Error('candidate-bound native bracket proof is unavailable');
1753
+ }
1754
+ const bracket = source.getBracketLedger()?.getBySymbol(position.symbol);
1755
+ if (!bracket
1756
+ || bracket.entryCid !== owner.clientOrderId
1757
+ || bracket.entrySide !== owner.side) {
1758
+ throw new Error(`native bracket ownership does not match durable Wave 9 owner ${owner.candidateId}`);
1759
+ }
1760
+ }
1761
+ return owner.candidateId;
1762
+ },
1763
+ settleAfterClose: async (candidateId, symbol) => {
1764
+ if (!source.isLive || runtime.mode !== 'LIVE' || runtime.adapter !== source) {
1765
+ const record = liveLedger.getByCandidateId(candidateId);
1766
+ return {
1767
+ status: 'manual_intervention',
1768
+ detail: 'IMMEDIATE MANUAL INTERVENTION REQUIRED: adapter changed during Wave 9 stop close recovery',
1769
+ candidateId,
1770
+ symbol,
1771
+ stopPrice: record?.initialStop ?? Number.NaN,
1772
+ };
1773
+ }
1774
+ const result = await recoverWave9LiveExposure(source, liveLedger, candidateId, symbol);
1775
+ logWave9Recovery('stop_watcher', result);
1776
+ return result;
1777
+ },
1778
+ });
1779
+ },
1780
+ });
1781
+ }
1782
+ // Start only after the durable Wave 9 close lifecycle has been installed.
1783
+ // If the ledger failed to load, Wave 9 admission is disabled and generic
1784
+ // watcher behavior still starts normally here.
1785
+ bootstrapWatcher.start();
1786
+ // Start the approved-order listener only after runtime, the shared
1787
+ // mutation lock, and durable Wave 9 ownership state are fully resolved.
1788
+ if (runtime.adapter.isLive
1789
+ && approvalModeForTool === 'per_trade'
1790
+ && proposalManagerCtx
1791
+ && proposalListenerCredentials) {
1792
+ try {
1793
+ const approvalCfg = readPluginConfig().approval;
1794
+ let cfgForUrl;
1795
+ try {
1796
+ cfgForUrl = readPluginConfig();
1797
+ }
1798
+ catch {
1799
+ cfgForUrl = undefined;
1800
+ }
1801
+ const ingestBaseUrl = getUserDataStreamIngestBaseUrl(cfgForUrl);
1802
+ proposalDecisionListener = new ProposalDecisionListener({
1803
+ baseUrl: ingestBaseUrl,
1804
+ ingestToken: proposalListenerCredentials.ingestToken,
1805
+ userId: proposalListenerCredentials.userId,
1806
+ adapter: runtime.adapter,
1807
+ binanceApi,
1808
+ autoCapture,
1809
+ operationLock: wave9OperationLock,
1810
+ checkWave9LiveSymbolOwnership,
1811
+ pollIntervalMs: approvalCfg?.pollIntervalMs ?? 3_000,
1812
+ });
1813
+ proposalDecisionListener.start();
1814
+ logger.info(TAG, `ProposalDecisionListener started (poll ${approvalCfg?.pollIntervalMs ?? 3_000} ms)`);
1815
+ }
1816
+ catch (err) {
1817
+ logger.warn(TAG, `Failed to start ProposalDecisionListener: ${String(err)}`);
1818
+ }
1819
+ }
1820
+ const wave9ActivationClient = new IntelWave9ActivationClient(intelApiDeps);
1821
+ const getWave9AccountSnapshot = async () => {
1822
+ if (!runtime.adapter.isLive) {
1823
+ reloadState();
1824
+ // Seed the generic daily-drawdown anchor before binding it into a
1825
+ // Wave 9 fingerprint. Normal create_order keeps getSessionStartNav().
1826
+ runtime.adapter.getSimulator().getSessionStartNav();
1827
+ const capture = captureWave9PaperAccountSnapshot(runtime.adapter);
1828
+ const quote = capture.snapshot.wallet.find((row) => row.currency === capture.snapshot.quoteCurrency);
1829
+ const positions = capture.snapshot.positions.map((position) => {
1830
+ let positionFingerprint;
1831
+ try {
1832
+ positionFingerprint = captureWave9PaperPositionFingerprint(runtime.adapter, position.symbol).fingerprint;
1833
+ }
1834
+ catch {
1835
+ // Malformed or duplicate local geometry remains visible but cannot
1836
+ // receive a mechanical exit authorization.
1837
+ }
1838
+ const strategy = position.strategy === 'tsmom_28d_long_flat_reversal_1d'
1839
+ || position.strategy === 'tsmom_28d_short_flat_reversal_1d'
1840
+ ? position.strategy
1841
+ : undefined;
1842
+ return {
1843
+ symbol: position.symbol,
1844
+ side: position.side,
1845
+ quantity: position.quantity,
1846
+ entryPrice: position.entryPrice,
1847
+ markPrice: position.markPrice,
1848
+ ...(position.stopPrice !== null ? { stopPrice: position.stopPrice } : {}),
1849
+ ...(position.missionId ? { missionId: position.missionId } : {}),
1850
+ ...(position.setupType ? { setupType: position.setupType } : {}),
1851
+ ...(position.candidateId ? { candidateId: position.candidateId } : {}),
1852
+ ...(strategy ? { strategy } : {}),
1853
+ ...(position.accruedFundingUsd !== null
1854
+ ? { accruedFundingUsd: position.accruedFundingUsd }
1855
+ : {}),
1856
+ openedAt: position.openedAt,
1857
+ ...(positionFingerprint ? { positionFingerprint } : {}),
1858
+ };
1859
+ });
1860
+ return {
1861
+ tradingMode: runtime.mode,
1862
+ positionsAvailable: true,
1863
+ openOrdersAvailable: true,
1864
+ openOrderCount: capture.snapshot.openOrders.length,
1865
+ cashEquityAvailable: quote !== undefined && Number.isFinite(quote.total),
1866
+ ...(quote !== undefined && Number.isFinite(quote.total)
1867
+ ? { cashEquityUsd: quote.total }
1868
+ : {}),
1869
+ positions,
1870
+ executedCandidateIds: capture.snapshot.closedWave9CandidateIds,
1871
+ source: 'paper_simulator_wallet_positions_and_orders',
1872
+ accountFingerprint: capture.fingerprint,
1873
+ authoritative: false,
1874
+ runtimeParity: false,
1875
+ provenance: capture.provenance,
1876
+ reasons: [
1877
+ 'cross_process_single_owner_lease_absent',
1878
+ 'cross_process_lease_absent_duplicate_concurrent_admission_not_ruled_out',
1879
+ 'market_mark_fingerprint_drift_requires_immediate_status_recall',
1880
+ ],
1881
+ };
1882
+ }
1883
+ const nativeStopProtectionAvailable = runtime.mode === 'LIVE'
1884
+ && runtime.adapter instanceof LiveAdapter
1885
+ && bracketsEnabled(loadBracketMode())
1886
+ && runtime.adapter.getBracketManager() !== null
1887
+ && runtime.adapter.getBracketLedger() !== null;
1888
+ if (runtime.mode === 'LIVE' && runtime.adapter.isLive && wave9LiveLedger) {
1889
+ try {
1890
+ const capture = await captureWave9LiveAccount(runtime.adapter, wave9LiveLedger, 'LIVE');
1891
+ return {
1892
+ tradingMode: 'LIVE',
1893
+ positionsAvailable: true,
1894
+ openOrdersAvailable: true,
1895
+ openOrderCount: capture.pendingExposureOrderCount,
1896
+ cashEquityAvailable: true,
1897
+ cashEquityUsd: capture.cashEquityUsd,
1898
+ positions: capture.positions,
1899
+ executedCandidateIds: capture.executedCandidateIds,
1900
+ source: 'live_exchange_wallet_positions_orders_and_wave9_ledger',
1901
+ accountFingerprint: capture.fingerprint,
1902
+ authoritative: capture.authoritative,
1903
+ runtimeParity: false,
1904
+ provenance: capture.provenance,
1905
+ adapterReadiness: runtime.adapter.readiness,
1906
+ nativeStopProtectionAvailable: nativeStopProtectionAvailable && capture.nativeStopProtectionComplete,
1907
+ reasons: [
1908
+ 'live_candidate_identity_uses_local_durable_execution_ledger',
1909
+ 'cross_process_lease_absent_duplicate_concurrent_admission_not_ruled_out',
1910
+ 'live_funding_is_not_attributed_per_wave9_position',
1911
+ ...capture.nativeStopIssues.map((issue) => `native_stop_issue:${issue}`),
1912
+ ],
1913
+ };
1914
+ }
1915
+ catch (error) {
1916
+ return {
1917
+ tradingMode: 'LIVE',
1918
+ positionsAvailable: false,
1919
+ openOrdersAvailable: false,
1920
+ cashEquityAvailable: false,
1921
+ positions: [],
1922
+ source: 'live_exchange_snapshot_unavailable',
1923
+ authoritative: false,
1924
+ runtimeParity: false,
1925
+ provenance: 'live_exchange_snapshot_unavailable',
1926
+ adapterReadiness: runtime.adapter.readiness,
1927
+ nativeStopProtectionAvailable,
1928
+ reasons: [`live_snapshot_failed:${formatError(error)}`],
1929
+ };
1930
+ }
1931
+ }
1932
+ return {
1933
+ tradingMode: runtime.mode,
1934
+ positionsAvailable: false,
1935
+ openOrdersAvailable: false,
1936
+ cashEquityAvailable: false,
1937
+ positions: [],
1938
+ source: 'wave9_execution_runtime_unavailable',
1939
+ authoritative: false,
1940
+ runtimeParity: false,
1941
+ provenance: 'wave9_execution_runtime_unavailable',
1942
+ adapterReadiness: runtime.adapter.readiness,
1943
+ nativeStopProtectionAvailable,
1944
+ reasons: [
1945
+ wave9LiveLedger
1946
+ ? 'Wave 9 execution requires the current mode to be PAPER or an exact LIVE exchange runtime'
1947
+ : 'Wave 9 live execution ledger is unavailable',
1948
+ ],
1949
+ };
1950
+ };
1951
+ const captureWave9AccountFingerprint = async () => {
1952
+ if (runtime.mode !== 'LIVE' || !runtime.adapter.isLive || !wave9LiveLedger) {
1953
+ throw new Error('Wave 9 authoritative LIVE account capture is unavailable');
1954
+ }
1955
+ const capture = await captureWave9LiveAccount(runtime.adapter, wave9LiveLedger, 'LIVE');
1956
+ return { fingerprint: capture.fingerprint };
1957
+ };
1958
+ const captureWave9PositionFingerprint = async (symbol) => {
1959
+ if (runtime.mode !== 'LIVE' || !runtime.adapter.isLive || !wave9LiveLedger) {
1960
+ throw new Error('Wave 9 authoritative LIVE position capture is unavailable');
1961
+ }
1962
+ return captureWave9LivePosition(runtime.adapter, wave9LiveLedger, 'LIVE', symbol);
1963
+ };
1964
+ const confirmWave9LiveStop = async (input) => {
1965
+ if (runtime.mode !== 'LIVE' || !(runtime.adapter instanceof LiveAdapter))
1966
+ return false;
1967
+ const liveAdapter = runtime.adapter;
1968
+ const bracketLedger = liveAdapter.getBracketLedger();
1969
+ if (!bracketLedger || !liveAdapter.getBracketManager())
1970
+ return false;
1971
+ const deadline = Date.now() + 20_000;
1972
+ while (Date.now() < deadline) {
1973
+ if (runtime.mode !== 'LIVE' || runtime.adapter !== liveAdapter)
1974
+ return false;
1975
+ const bracket = bracketLedger.getBySymbol(input.symbol);
1976
+ if (bracket?.state === 'failed'
1977
+ || bracket?.state === 'cancelled'
1978
+ || bracket?.state === 'triggered_sl'
1979
+ || bracket?.state === 'triggered_tp')
1980
+ return false;
1981
+ if (bracket && (bracket.entryCid !== input.entryClientOrderId
1982
+ || bracket.entrySide !== input.entrySide))
1983
+ return false;
1984
+ if (bracket
1985
+ && (bracket.state === 'active' || bracket.state === 'partial')) {
1986
+ return confirmWave9LiveNativeStop(liveAdapter, input);
1987
+ }
1988
+ await new Promise((resolve) => setTimeout(resolve, 250));
1989
+ }
1990
+ return false;
1991
+ };
1479
1992
  // Facts-out local signal service (step 5). Created ONLY when the operator
1480
1993
  // flips signals.evaluator to shadow/enforce in plugin-config — default
1481
1994
  // 'central' leaves the get_signals path byte-identical (no service, no SSE
@@ -1605,10 +2118,21 @@ const paperTradingPlugin = {
1605
2118
  parameters: TOOL_PARAMS.create_order,
1606
2119
  execute: async (_id, params) => jsonResult(await createOrderTool(params, {
1607
2120
  ...adapterDeps,
2121
+ adapter: runtime.adapter,
1608
2122
  autoCapture,
1609
2123
  proposalManager: proposalManagerCtx?.manager,
1610
2124
  userId: proposalManagerCtx?.userId,
1611
2125
  approvalMode: approvalModeForTool,
2126
+ wave9AdmissionGuard,
2127
+ wave9ActivationCheck: () => wave9ActivationClient.getActivation(),
2128
+ refreshPaperState: reloadState,
2129
+ wave9TradingModeCheck: () => runtime.mode,
2130
+ wave9VenueCheck: () => venue,
2131
+ captureWave9AccountFingerprint,
2132
+ confirmWave9LiveStop,
2133
+ wave9LiveLedger,
2134
+ checkWave9LiveSymbolOwnership,
2135
+ operationLock: wave9OperationLock,
1612
2136
  })),
1613
2137
  },
1614
2138
  {
@@ -1616,42 +2140,78 @@ const paperTradingPlugin = {
1616
2140
  label: 'Cancel Order',
1617
2141
  description: 'Cancel a single open order by its ID.',
1618
2142
  parameters: TOOL_PARAMS.cancel_order,
1619
- execute: async (_id, params) => jsonResult(await cancelOrderTool(params, adapterDeps)),
2143
+ execute: async (_id, params) => jsonResult(await cancelOrderTool(params, {
2144
+ ...adapterDeps,
2145
+ adapter: runtime.adapter,
2146
+ operationLock: wave9OperationLock,
2147
+ checkWave9LiveSymbolOwnership,
2148
+ })),
1620
2149
  },
1621
2150
  {
1622
2151
  name: 'cancel_all_orders',
1623
2152
  label: 'Cancel All Orders',
1624
2153
  description: 'Cancel all open orders, optionally filtered by symbol.',
1625
2154
  parameters: TOOL_PARAMS.cancel_all_orders,
1626
- execute: async (_id, params) => jsonResult(await cancelAllOrdersTool(params, adapterDeps)),
2155
+ execute: async (_id, params) => jsonResult(await cancelAllOrdersTool(params, {
2156
+ ...adapterDeps,
2157
+ adapter: runtime.adapter,
2158
+ operationLock: wave9OperationLock,
2159
+ })),
1627
2160
  },
1628
2161
  {
1629
2162
  name: 'close_position',
1630
2163
  label: 'Close Position',
1631
2164
  description: 'Close an open position at market price by creating an opposing market order.',
1632
2165
  parameters: TOOL_PARAMS.close_position,
1633
- execute: async (_id, params) => jsonResult(await closePositionTool(params, { ...adapterDeps, autoCapture })),
2166
+ execute: async (_id, params) => jsonResult(await closePositionTool(params, {
2167
+ ...adapterDeps,
2168
+ adapter: runtime.adapter,
2169
+ autoCapture,
2170
+ wave9AdmissionGuard,
2171
+ refreshPaperState: reloadState,
2172
+ wave9TradingModeCheck: () => runtime.mode,
2173
+ wave9VenueCheck: () => venue,
2174
+ captureWave9PositionFingerprint,
2175
+ wave9LiveLedger,
2176
+ checkWave9LiveSymbolOwnership,
2177
+ operationLock: wave9OperationLock,
2178
+ })),
1634
2179
  },
1635
2180
  {
1636
2181
  name: 'modify_stop',
1637
2182
  label: 'Modify Stop',
1638
2183
  description: 'Move the exchange-side stop-loss on an open live position. Atomic cancel-then-resubmit with automatic rollback. Live mode only; requires brackets.mode enabled.',
1639
2184
  parameters: TOOL_PARAMS.modify_stop,
1640
- execute: async (_id, params) => jsonResult(await modifyStopTool(params, adapterDeps)),
2185
+ execute: async (_id, params) => jsonResult(await modifyStopTool(params, {
2186
+ ...adapterDeps,
2187
+ adapter: runtime.adapter,
2188
+ operationLock: wave9OperationLock,
2189
+ checkWave9LiveSymbolOwnership,
2190
+ })),
1641
2191
  },
1642
2192
  {
1643
2193
  name: 'modify_target',
1644
2194
  label: 'Modify Target',
1645
2195
  description: 'Move the exchange-side take-profit on an open live position. Atomic cancel-then-resubmit with automatic rollback. Live mode only; requires brackets.mode enabled.',
1646
2196
  parameters: TOOL_PARAMS.modify_target,
1647
- execute: async (_id, params) => jsonResult(await modifyTargetTool(params, adapterDeps)),
2197
+ execute: async (_id, params) => jsonResult(await modifyTargetTool(params, {
2198
+ ...adapterDeps,
2199
+ adapter: runtime.adapter,
2200
+ operationLock: wave9OperationLock,
2201
+ checkWave9LiveSymbolOwnership,
2202
+ })),
1648
2203
  },
1649
2204
  {
1650
2205
  name: 'attach_brackets',
1651
2206
  label: 'Attach Brackets',
1652
2207
  description: 'Bootstrap exchange-side STOP_MARKET + TAKE_PROFIT_MARKET protection on an existing open live position that is currently naked. Use this to recover when a position exists without active brackets (e.g. after kill-switch stripped them or brackets failed to attach at entry). Rejects if brackets are already active for the symbol — use modify_stop / modify_target instead. Live mode only; requires brackets.mode enabled.',
1653
2208
  parameters: TOOL_PARAMS.attach_brackets,
1654
- execute: async (_id, params) => jsonResult(await attachBracketsTool(params, adapterDeps)),
2209
+ execute: async (_id, params) => jsonResult(await attachBracketsTool(params, {
2210
+ ...adapterDeps,
2211
+ adapter: runtime.adapter,
2212
+ operationLock: wave9OperationLock,
2213
+ checkWave9LiveSymbolOwnership,
2214
+ })),
1655
2215
  },
1656
2216
  {
1657
2217
  name: 'audit_bracket_protection',
@@ -1799,6 +2359,19 @@ const paperTradingPlugin = {
1799
2359
  parameters: TOOL_PARAMS.get_signals,
1800
2360
  execute: async (_id, params) => jsonResult(await getSignalsTool(params, getSignalsDeps)),
1801
2361
  },
2362
+ {
2363
+ name: 'get_wave9_status',
2364
+ label: 'Get Wave 9 Status',
2365
+ description: 'MANDATORY fresh call for every question about which strategies are active, usable, available, or tradeable now: never answer from memory; also call list_strategies fresh with active_only=true in the same turn. The operator reply MUST reproduce every line from requiredOperatorReport.lines exactly enough to preserve all labels, counts, and reasons. These are read-only status calls; the question alone does not authorize placing or closing an order or toggling a strategy. Return any single-use entry or reversal token. An empty token set means wait for the next valid signal/window; it does not disable LIVE trading. LIVE orders use real capital.',
2366
+ parameters: TOOL_PARAMS.get_wave9_status,
2367
+ execute: async () => jsonResult(await getWave9StatusTool({
2368
+ activationClient: wave9ActivationClient,
2369
+ getAccountSnapshot: getWave9AccountSnapshot,
2370
+ operationLock: wave9OperationLock,
2371
+ authorizationIssuer: wave9AdmissionGuard,
2372
+ executionVenue: venue,
2373
+ })),
2374
+ },
1802
2375
  {
1803
2376
  name: 'get_analytics',
1804
2377
  label: 'Get Edge Analytics',
@@ -1910,9 +2483,10 @@ const paperTradingPlugin = {
1910
2483
  {
1911
2484
  name: 'list_strategies',
1912
2485
  label: 'List Strategies',
1913
- description: 'List all strategies in the Strategy Box. Shows name, description, regime whitelist, conditions, active status, and whether it is a built-in strategy. Use to see which strategies are available for backtesting and live trading.',
2486
+ description: 'MANDATORY for every question about which strategies are active, usable, available, or tradeable now: never answer from memory; call this tool fresh with active_only=true and also call get_wave9_status fresh in the same turn. The operator reply MUST reproduce every line from get_wave9_status.requiredOperatorReport.lines exactly enough to preserve all labels, counts, and reasons. List strategies in the Strategy Box with name, description, regime whitelist, conditions, active status, and whether each is built in. Active means the operator enabled the strategy; it does not mean an order is authorized this instant. These are read-only status calls; the question alone does not authorize placing or closing an order or toggling a strategy.',
1914
2487
  parameters: TOOL_PARAMS.list_strategies,
1915
- execute: async (_id, params) => jsonResult(await listStrategiesTool(params ?? {}, intelApiDeps)),
2488
+ prepareArguments: prepareListStrategiesArgs,
2489
+ execute: async (_id, params) => jsonResult(await listStrategiesTool(prepareListStrategiesArgs(params), intelApiDeps)),
1916
2490
  },
1917
2491
  {
1918
2492
  name: 'toggle_strategy',
@@ -1929,6 +2503,7 @@ const paperTradingPlugin = {
1929
2503
  execute: async (_id, params) => jsonResult(await scanPairsTool(params ?? {}, intelApiDeps, {
1930
2504
  decisionsClient: positionDecisionsClient,
1931
2505
  userId: positionDecisionsUserId,
2506
+ reentryTracker,
1932
2507
  })),
1933
2508
  },
1934
2509
  {