@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
@@ -22,9 +22,11 @@ import { BracketReconciler } from './bracket-reconciler.js';
22
22
  import { LiveBracketApi } from './live-bracket-api.js';
23
23
  import { generateBracketId, buildBracketCid, parseBracketCid } from './bracket-id.js';
24
24
  import { validateStopDirection, validateTargetDirection } from './bracket-params.js';
25
+ import { fillPriceFromOrder } from './fill-price.js';
25
26
  import { bracketsEnabled } from '../config/brackets-config.js';
26
27
  import { userDataStreamEnabled, userDataStreamAuthoritative, DEFAULT_TUNABLES as USER_DATA_STREAM_DEFAULT_TUNABLES, } from '../config/user-data-stream-config.js';
27
28
  import { UserDataStreamController } from './user-data-stream-controller.js';
29
+ import { onAutoFlattenClose } from '../ingest/position-auto-capture.js';
28
30
  import { assertNotShuttingDown, registerOp } from '../lifecycle/shutdown-coordinator.js';
29
31
  import { updateMfe } from '../mfe.js';
30
32
  import { computeInvalidationHit } from '../pinned-plan.js';
@@ -122,6 +124,10 @@ export class LiveAdapter extends EventEmitter {
122
124
  bracketLedger = null;
123
125
  bracketManager = null;
124
126
  bracketReconciler = null;
127
+ wave9ProtectionLossHandler;
128
+ wave9ProtectionLossInFlight = new Set();
129
+ pendingWave9ProtectionLossBySymbol = new Map();
130
+ static PENDING_WAVE9_PROTECTION_LOSS_CAP = 100;
125
131
  // User-data WebSocket stream — null when mode='off' (REST polling owns reads).
126
132
  // In Phase 1 this is dead code on prod (mode='off' is the default); the wiring
127
133
  // is in place so Phase 2 onwards only requires a flag flip. Read paths
@@ -167,6 +173,11 @@ export class LiveAdapter extends EventEmitter {
167
173
  // getPositions tick, dropped on closePosition or when a symbol vanishes from
168
174
  // the open-positions snapshot.
169
175
  liveMetadata = new Map();
176
+ // Position-decision auto-capture wiring (optional). Retained so the internal
177
+ // bracket_attach_failed auto-flatten can journal its close (issue #196): that
178
+ // flatten bypasses the close_position tool, so onClosePositionFilled never
179
+ // runs and the row would orphan as status='open'.
180
+ autoCapture;
170
181
  // Readiness state — mutable, starts INIT_PENDING
171
182
  _readiness = 'INIT_PENDING';
172
183
  get readiness() {
@@ -190,6 +201,11 @@ export class LiveAdapter extends EventEmitter {
190
201
  this.mode = mode;
191
202
  this.bracketMode = bracketMode;
192
203
  this.userDataStreamMode = userDataStreamMode;
204
+ // Retained for the auto-flatten journal close capture (issue #196): the
205
+ // adapter-internal `closePosition(…, 'bracket_attach_failed')` calls bypass
206
+ // the close_position tool, so onClosePositionFilled never runs for them —
207
+ // the row orphaned as status='open' until this capture existed.
208
+ this.autoCapture = autoCapture;
193
209
  this.api = new BinancePrivateApi(config);
194
210
  if (mode === 'MICRO_LIVE') {
195
211
  // sizeCapMultiplier is 1.0 — the agent already sizes for the real wallet.
@@ -216,7 +232,26 @@ export class LiveAdapter extends EventEmitter {
216
232
  this.bracketManager = new BracketManager(liveBracketApi, this.bracketLedger);
217
233
  this.bracketManager.on('event', (ev) => this.emit('bracket_event', ev));
218
234
  this.bracketReconciler = new BracketReconciler(this, this.bracketLedger, this.bracketManager, liveBracketApi);
219
- this.bracketReconciler.on('drift', (d) => this.emit('bracket_drift', d));
235
+ this.bracketReconciler.on('drift', (d) => {
236
+ this.emit('bracket_drift', d);
237
+ if (d.kind !== 'position_unprotected'
238
+ || !d.symbol
239
+ || !d.bracketId
240
+ || !d.details?.split(/[+:\s]+/).includes('stop'))
241
+ return;
242
+ const row = this.bracketLedger?.getBySymbol(d.symbol);
243
+ if (!row || row.bracketId !== d.bracketId)
244
+ return;
245
+ this.notifyWave9ProtectionLoss({
246
+ source: 'reconciler_missing',
247
+ symbol: normalizeBracketSymbol(d.symbol),
248
+ bracketId: d.bracketId,
249
+ entryClientOrderId: row.entryCid,
250
+ entrySide: row.entrySide,
251
+ ...(row.slCid ? { stopClientOrderId: row.slCid } : {}),
252
+ detail: d.details,
253
+ });
254
+ });
220
255
  logger.info(TAG, `Bracket orders ENABLED in mode=${this.bracketMode}`);
221
256
  }
222
257
  // Forward poller events
@@ -719,6 +754,19 @@ export class LiveAdapter extends EventEmitter {
719
754
  }
720
755
  // ---- Post-submission bookkeeping ----
721
756
  this.intentJournal.updateIntent(clientOrderId, { status: 'confirmed', exchangeOrderId: order.id });
757
+ // ---- Fill-price enrichment (issue #196) ----
758
+ // For a marketable LIMIT order Binance's synchronous RESULT response can
759
+ // come back avgPrice=0 even though it filled (RESULT only guarantees the
760
+ // fill for MARKET / special-timeInForce LIMIT orders). Every downstream
761
+ // consumer — bracket direction validation, slippage, journal entry capture
762
+ // — MUST key off the real fill, never the limit price. Re-query by
763
+ // clientOrderId (weight 1) to patch order.average BEFORE any of them read
764
+ // it. Market orders report avgPrice reliably, so this fires only on the
765
+ // filled-limit edge; best-effort — a failed re-query leaves order.average
766
+ // as-is (consumers then use a mark price / skip, never the limit).
767
+ if (order.status === 'closed' && order.filled > 0 && fillPriceFromOrder(order) === null) {
768
+ await this.enrichFilledAvgPrice(order, clientOrderId, symbol);
769
+ }
722
770
  // Track slippage for market orders that filled immediately
723
771
  if (order.status === 'closed' && order.average != null && expectedPrice > 0) {
724
772
  this.slippageTracker.recordFill({
@@ -758,63 +806,84 @@ export class LiveAdapter extends EventEmitter {
758
806
  // leg, register the bracket and (for market orders) attach immediately.
759
807
  // Limit orders defer attach to the poller's 'filled' event handler.
760
808
  if (this.bracketManager && this.bracketLedger && metadata && (metadata.stopPrice !== undefined || metadata.targetPrice !== undefined)) {
761
- // Defense-in-depth: the pre-submission check above already rejected sign
762
- // errors against the ref price, but re-check against the actual fill
763
- // (order.average) in case the fill landed across the level. Crucially, a
764
- // failure HERE is post-submission: bare-throwing would abandon a live
765
- // position (filled market order) or a resting entry that fills naked
766
- // (open limit). So clean up BEFORE throwing.
767
- const refPrice = order.average ?? order.price ?? price ?? 0;
768
- if (refPrice > 0) {
769
- let dirMsg = null;
809
+ // Defense-in-depth direction re-check. The pre-submission check above
810
+ // already rejected sign errors against the ref price; this re-checks once
811
+ // the order's outcome is known. Reference price by order state:
812
+ // - FILLED (status=closed, filled>0): the ACTUAL average fill — NEVER
813
+ // the limit price (issue #196). A marketable limit can fill percent-
814
+ // scale away from its limit, so comparing the bracket to the limit
815
+ // gives a FALSE invalid (needless auto-flatten LTC 2026-07-14) or
816
+ // lets a genuinely wrong stop through. If the fill price is
817
+ // unresolvable, SKIP the check — the bracket attach (Binance -2021 on
818
+ // a wrong-side stop) is the arbiter; validating against the limit
819
+ // would resurrect the bug.
820
+ // - still-OPEN resting limit: no fill yet, so the limit price IS the
821
+ // correct reference (the order fills at/through its limit).
822
+ // A failure HERE is post-submission — clean up (flatten the filled
823
+ // position / cancel the resting order) BEFORE throwing.
824
+ let dirRef = null;
825
+ if (order.status === 'closed' && order.filled > 0) {
826
+ dirRef = fillPriceFromOrder(order);
827
+ if (dirRef === null) {
828
+ logger.warn(TAG, `Post-fill bracket direction check skipped for ${symbol}: actual fill price unresolvable (avg=${order.average ?? 'null'} cost=${order.cost} filled=${order.filled}); NOT validating against the limit price — the bracket attach (Binance -2021) is the arbiter`);
829
+ }
830
+ }
831
+ else if (order.status === 'open') {
832
+ const lim = order.price ?? price ?? 0;
833
+ dirRef = lim > 0 ? lim : null;
834
+ }
835
+ let dirMsg = null;
836
+ if (dirRef !== null && dirRef > 0) {
770
837
  if (metadata.stopPrice !== undefined) {
771
- dirMsg = validateStopDirection(side, refPrice, metadata.stopPrice);
838
+ dirMsg = validateStopDirection(side, dirRef, metadata.stopPrice);
772
839
  }
773
840
  if (!dirMsg && metadata.targetPrice !== undefined) {
774
- dirMsg = validateTargetDirection(side, refPrice, metadata.targetPrice);
841
+ dirMsg = validateTargetDirection(side, dirRef, metadata.targetPrice);
775
842
  }
776
- if (dirMsg) {
777
- if (order.status === 'closed' && order.filled > 0) {
778
- logger.error(TAG, `Post-fill bracket direction invalid for ${symbol} (${dirMsg}) flattening the just-filled position to avoid a naked entry`);
779
- // We have HARD evidence the entry filled (status=closed, filled>0).
780
- // closePosition throws "No open position" when its attempt-1
781
- // fetchPositions returns a *confirmed-empty* snapshot which, in
782
- // the moments right after a fill, is almost always Binance
783
- // position-state lagging the order response, NOT a real flat.
784
- // Accepting that single failure would fail open and leave the
785
- // position naked (the exact high-risk branch this cleanup exists
786
- // for). Retry across the lag window instead of trusting one snapshot.
787
- let flattened = false;
788
- for (let a = 1; a <= 4 && !flattened; a++) {
789
- try {
790
- await this.closePosition(symbol, 'bracket_attach_failed');
791
- flattened = true;
792
- }
793
- catch (err) {
794
- logger.warn(TAG, `Post-fill flatten attempt ${a}/4 for ${symbol} failed: ${formatError(err)}`);
795
- if (a < 4)
796
- await sleep(1500);
797
- }
798
- }
799
- if (!flattened) {
800
- logger.error(TAG, `CRITICAL: post-fill flatten of ${symbol} FAILED after 4 attempts — position is NAKED, immediate operator intervention required`);
801
- this.emit('emergency_progress', { action: 'flatten', status: 'failed', symbol, message: `Naked ${symbol} after invalid bracket — manual close required` });
802
- }
803
- }
804
- else if (order.status === 'open') {
805
- logger.error(TAG, `Post-submit bracket direction invalid for ${symbol} (${dirMsg}) — cancelling the resting entry order so it can't fill unprotected`);
843
+ }
844
+ if (dirMsg) {
845
+ if (order.status === 'closed' && order.filled > 0) {
846
+ logger.error(TAG, `Post-fill bracket direction invalid for ${symbol} (${dirMsg}, fill=${dirRef}) — flattening the just-filled position to avoid a naked entry`);
847
+ // We have HARD evidence the entry filled (status=closed, filled>0).
848
+ // closePosition throws "No open position" when its attempt-1
849
+ // fetchPositions returns a *confirmed-empty* snapshot which, in
850
+ // the moments right after a fill, is almost always Binance
851
+ // position-state lagging the order response, NOT a real flat.
852
+ // Accepting that single failure would fail open and leave the
853
+ // position naked (the exact high-risk branch this cleanup exists
854
+ // for). Retry across the lag window instead of trusting one snapshot.
855
+ // closePosition journals the auto-flatten close itself (keyed on the
856
+ // 'bracket_attach_failed' reason) so the row can't orphan (#196).
857
+ let flattened = false;
858
+ for (let a = 1; a <= 4 && !flattened; a++) {
806
859
  try {
807
- await this.api.cancelOrderByClientId(clientOrderId, symbol);
860
+ await this.closePosition(symbol, 'bracket_attach_failed');
861
+ flattened = true;
808
862
  }
809
863
  catch (err) {
810
- logger.error(TAG, `Cancel of resting entry after invalid bracket failed for ${symbol}: ${formatError(err)}`);
864
+ logger.warn(TAG, `Post-fill flatten attempt ${a}/4 for ${symbol} failed: ${formatError(err)}`);
865
+ if (a < 4)
866
+ await sleep(1500);
811
867
  }
812
868
  }
813
- throw new Error(`Bracket rejected (post-submit): ${dirMsg}`);
869
+ if (!flattened) {
870
+ logger.error(TAG, `CRITICAL: post-fill flatten of ${symbol} FAILED after 4 attempts — position is NAKED, immediate operator intervention required`);
871
+ this.emit('emergency_progress', { action: 'flatten', status: 'failed', symbol, message: `Naked ${symbol} after invalid bracket — manual close required` });
872
+ }
873
+ }
874
+ else if (order.status === 'open') {
875
+ logger.error(TAG, `Post-submit bracket direction invalid for ${symbol} (${dirMsg}) — cancelling the resting entry order so it can't fill unprotected`);
876
+ try {
877
+ await this.api.cancelOrderByClientId(clientOrderId, symbol);
878
+ }
879
+ catch (err) {
880
+ logger.error(TAG, `Cancel of resting entry after invalid bracket failed for ${symbol}: ${formatError(err)}`);
881
+ }
814
882
  }
883
+ throw new Error(`Bracket rejected (post-submit): ${dirMsg}`);
815
884
  }
816
885
  const bracketId = generateBracketId();
817
- this.bracketManager.registerEntry({ symbol, side, stopPrice: metadata.stopPrice, targetPrice: metadata.targetPrice }, bracketId, buildBracketCid(bracketId, 'entry'));
886
+ this.bracketManager.registerEntry({ symbol, side, stopPrice: metadata.stopPrice, targetPrice: metadata.targetPrice }, bracketId, clientOrderId);
818
887
  if (order.status === 'closed' && order.filled > 0) {
819
888
  // Market order (or limit-that-filled-instantly) — attach now.
820
889
  // We do NOT await: attach can take seconds and we don't want to block
@@ -843,7 +912,8 @@ export class LiveAdapter extends EventEmitter {
843
912
  const result = await this.bracketManager.attachBrackets(symbol, entryAmount);
844
913
  if (!result.ok) {
845
914
  logger.error(TAG, `Bracket attach FAILED for ${symbol} after ${result.attempts} attempts: ${result.error}. Auto-flattening position.`);
846
- // Best-effort emergency close. closePosition has its own retry logic.
915
+ // Best-effort emergency close. closePosition has its own retry logic +
916
+ // journals the auto-flatten close itself (keyed on the reason, #196).
847
917
  try {
848
918
  await this.closePosition(symbol, 'bracket_attach_failed');
849
919
  }
@@ -857,6 +927,114 @@ export class LiveAdapter extends EventEmitter {
857
927
  logger.error(TAG, `attachBracketsAsync unexpected error for ${symbol}: ${formatError(err)}`);
858
928
  }
859
929
  }
930
+ /**
931
+ * Patch `order.average` with the authoritative average fill price when a
932
+ * filled order came back without one (marketable-limit RESULT quirk, #196).
933
+ * Re-queries by clientOrderId (GET /fapi/v1/order, weight 1). Best-effort:
934
+ * on any failure or an unresolvable re-query the order is left as-is —
935
+ * downstream then falls back to a mark price / skips capture, NEVER to the
936
+ * limit price. Mutates `order` in place.
937
+ */
938
+ async enrichFilledAvgPrice(order, clientOrderId, symbol) {
939
+ const rawAvg = order.average;
940
+ try {
941
+ const requeried = await this.api.fetchOrderByClientId(clientOrderId, symbol);
942
+ const resolved = requeried ? fillPriceFromOrder(requeried) : null;
943
+ if (resolved !== null && resolved > 0) {
944
+ order.average = resolved;
945
+ // Carry cumQuote across too, so cost-based consumers (VWAP, slippage)
946
+ // see the settled notional rather than 0.
947
+ if ((order.cost === 0 || order.cost == null) && requeried && requeried.cost > 0) {
948
+ order.cost = requeried.cost;
949
+ }
950
+ logger.info(TAG, `Fill-price enriched for ${symbol}: avg ${rawAvg ?? 'null'} → ${resolved} (re-query by cid; limit price ${order.price ?? 'n/a'})`);
951
+ return;
952
+ }
953
+ logger.warn(TAG, `Fill-price enrichment for ${symbol} could not resolve an average (re-query avg=${requeried?.average ?? 'null'} cost=${requeried?.cost ?? 'null'} filled=${requeried?.filled ?? 'null'}); downstream uses mark price / skips capture, NOT the limit price ${order.price ?? 'n/a'}`);
954
+ }
955
+ catch (err) {
956
+ logger.warn(TAG, `Fill-price enrichment re-query for ${symbol} threw: ${formatError(err)}; leaving average as-is`);
957
+ }
958
+ }
959
+ /**
960
+ * Journal the close produced by an internal auto-flatten
961
+ * (`bracket_attach_failed`), so the position row can't orphan as
962
+ * status='open' (issue #196). These flattens go straight to
963
+ * `this.closePosition`, bypassing the close_position tool, so
964
+ * `onClosePositionFilled` never runs for them. Fire-and-forget + fail-open —
965
+ * never blocks or throws into the flatten path. The entry journal may not be
966
+ * ready at flatten time (the entry's WS fill can land ~1-2s later), so
967
+ * `onAutoFlattenClose` polls briefly for the webappPositionId; the boot DB
968
+ * reconcile sweep is the backstop if the entry never journals.
969
+ */
970
+ captureAutoFlattenClose(symbol, closeOrder) {
971
+ if (!this.autoCapture)
972
+ return;
973
+ const fillPrice = fillPriceFromOrder(closeOrder);
974
+ // A synthetic "already flat" return (filled=0, avg=null) means another path
975
+ // (a bracket fill / external close) owns the real close — don't mislabel it
976
+ // as bracket_attach_failed and don't invent a price.
977
+ if (fillPrice === null || !(closeOrder.filled > 0))
978
+ return;
979
+ void onAutoFlattenClose(this.autoCapture, {
980
+ symbol,
981
+ fillPrice,
982
+ fillSize: Math.abs(closeOrder.filled),
983
+ ...(typeof closeOrder.id === 'string' && closeOrder.id.length > 0
984
+ ? { exchangeTradeId: closeOrder.id }
985
+ : {}),
986
+ }).catch((err) => logger.warn(TAG, `auto-flatten close capture for ${symbol} threw: ${formatError(err)}`));
987
+ }
988
+ /** Install the Wave 9-only autonomous recovery bridge. With no handler,
989
+ * every generic bracket lifecycle remains byte-for-byte behaviorally
990
+ * unchanged. Runtime reapplies this setter after adapter reconnects. */
991
+ setWave9ProtectionLossHandler(handler) {
992
+ this.wave9ProtectionLossHandler = handler;
993
+ if (!handler || this.pendingWave9ProtectionLossBySymbol.size === 0)
994
+ return;
995
+ const pending = [...this.pendingWave9ProtectionLossBySymbol.values()];
996
+ this.pendingWave9ProtectionLossBySymbol.clear();
997
+ for (const event of pending)
998
+ this.notifyWave9ProtectionLoss(event);
999
+ }
1000
+ notifyWave9ProtectionLoss(event) {
1001
+ const handler = this.wave9ProtectionLossHandler;
1002
+ const key = normalizeBracketSymbol(event.symbol);
1003
+ if (!handler || this.wave9ProtectionLossInFlight.has(key)) {
1004
+ // initialize() may start the reconciler/user-data stream before the
1005
+ // durable Wave 9 ledger is loaded by index.ts. Coalesce one latest loss
1006
+ // per symbol and replay it when runtime installs the handler; never let
1007
+ // that startup window silently discard a native-stop failure. The same
1008
+ // queue catches a second loss while recovery is in flight — notably a
1009
+ // freshly re-armed stop cancelled just before the first handler returns.
1010
+ this.pendingWave9ProtectionLossBySymbol.set(key, event);
1011
+ if (this.pendingWave9ProtectionLossBySymbol.size
1012
+ > LiveAdapter.PENDING_WAVE9_PROTECTION_LOSS_CAP) {
1013
+ const oldest = this.pendingWave9ProtectionLossBySymbol.keys().next().value;
1014
+ if (oldest !== undefined) {
1015
+ this.pendingWave9ProtectionLossBySymbol.delete(oldest);
1016
+ logger.error(TAG, `Wave 9 protection-loss queue exceeded ${LiveAdapter.PENDING_WAVE9_PROTECTION_LOSS_CAP} ` +
1017
+ `symbols and evicted ${oldest}; IMMEDIATE MANUAL INTERVENTION REQUIRED`);
1018
+ }
1019
+ }
1020
+ return;
1021
+ }
1022
+ this.wave9ProtectionLossInFlight.add(key);
1023
+ void Promise.resolve()
1024
+ .then(() => handler(event))
1025
+ .catch((error) => {
1026
+ logger.error(TAG, `Wave 9 protection-loss recovery failed for ${event.symbol}: ${formatError(error)}; ` +
1027
+ 'IMMEDIATE MANUAL INTERVENTION REQUIRED');
1028
+ })
1029
+ .finally(() => {
1030
+ this.wave9ProtectionLossInFlight.delete(key);
1031
+ const pending = this.pendingWave9ProtectionLossBySymbol.get(key);
1032
+ if (pending) {
1033
+ this.pendingWave9ProtectionLossBySymbol.delete(key);
1034
+ this.notifyWave9ProtectionLoss(pending);
1035
+ }
1036
+ });
1037
+ }
860
1038
  /**
861
1039
  * AUTHORITATIVE bracket-lifecycle handler — Binance's ALGO_UPDATE push.
862
1040
  *
@@ -896,6 +1074,11 @@ export class LiveAdapter extends EventEmitter {
896
1074
  const symbol = normalizeBracketSymbol(ev.symbol);
897
1075
  const row = this.bracketLedger?.getBySymbol(symbol);
898
1076
  const status = ev.status.toUpperCase();
1077
+ const rowIsRecoverable = !!row
1078
+ && (row.state === 'active' || row.state === 'partial' || row.state === 'attaching');
1079
+ const isCurrentStop = parsed.role === 'stop'
1080
+ && row?.bracketId === parsed.bracketId
1081
+ && (!row.slCid || row.slCid === cid);
899
1082
  // Record the authoritative per-leg WS status for the Tier-1 fast-path of
900
1083
  // resolveBracketLegLiveness(). Every parsed rc-* lifecycle event, every
901
1084
  // branch — including CANCELED, which the ledger deliberately does NOT
@@ -989,12 +1172,34 @@ export class LiveAdapter extends EventEmitter {
989
1172
  else {
990
1173
  logger.info(TAG, `ALGO_UPDATE CANCELED on ${symbol} (${cid}) — ledger=${row?.state ?? 'none'} (benign: cancel of an already-terminal/absent bracket).`);
991
1174
  }
1175
+ if (rowIsRecoverable && isCurrentStop && row) {
1176
+ this.notifyWave9ProtectionLoss({
1177
+ source: 'algo_canceled',
1178
+ symbol,
1179
+ bracketId: row.bracketId,
1180
+ entryClientOrderId: row.entryCid,
1181
+ entrySide: row.entrySide,
1182
+ stopClientOrderId: cid,
1183
+ detail: `exact native stop reported CANCELED (reason=${ev.reason ?? 'none'})`,
1184
+ });
1185
+ }
992
1186
  return;
993
1187
  }
994
1188
  // NEW / TRIGGERING / REJECTED — informational. REJECTED is worth a WARN
995
1189
  // (Binance refused the conditional order — e.g. margin check).
996
1190
  if (status === 'REJECTED') {
997
1191
  logger.warn(TAG, `ALGO_UPDATE REJECTED on ${symbol} (${cid}) reason=${ev.reason ?? 'none'} — bracket leg refused by Binance; position may be unprotected.`);
1192
+ if (rowIsRecoverable && isCurrentStop && row) {
1193
+ this.notifyWave9ProtectionLoss({
1194
+ source: 'algo_rejected',
1195
+ symbol,
1196
+ bracketId: row.bracketId,
1197
+ entryClientOrderId: row.entryCid,
1198
+ entrySide: row.entrySide,
1199
+ stopClientOrderId: cid,
1200
+ detail: `exact native stop reported REJECTED (reason=${ev.reason ?? 'none'})`,
1201
+ });
1202
+ }
998
1203
  }
999
1204
  }
1000
1205
  /** Expose the bracket manager for agent-facing tools (modify_stop, etc.).
@@ -1062,6 +1267,12 @@ export class LiveAdapter extends EventEmitter {
1062
1267
  // Tier 3 — no answer anywhere.
1063
1268
  return 'unknown';
1064
1269
  }
1270
+ /** Immediate full-shape proof for one exact native conditional order. */
1271
+ async resolveBracketLegProof(cid) {
1272
+ if (!cid)
1273
+ return null;
1274
+ return this.api.queryAlgoOrderProof(cid);
1275
+ }
1065
1276
  /** Symbol-scoped generalization of the trusted-WS fast-path — the
1066
1277
  * authoritative action-layer guard for the 2026-05-16 INJ bracket spiral.
1067
1278
  *
@@ -1273,6 +1484,14 @@ export class LiveAdapter extends EventEmitter {
1273
1484
  const order = await this.api.createOrder(symbol, closeSide, 'market', position.contracts, undefined, ccxtParams);
1274
1485
  logger.info(TAG, `FLATTEN: ${symbol} close order ${order.id} status=${order.status}`);
1275
1486
  this.emit('emergency_progress', { action: 'flatten', status: 'completed', symbol });
1487
+ // Auto-flatten (bracket_attach_failed) bypasses the close_position tool,
1488
+ // so onClosePositionFilled never journals its close and the position row
1489
+ // orphans as status='open' (issue #196). Journal it here from the real
1490
+ // close fill. Other reasons (operator_command, agent close_position) are
1491
+ // journaled by their own paths — don't double-capture.
1492
+ if (closeReason === 'bracket_attach_failed') {
1493
+ this.captureAutoFlattenClose(symbol, order);
1494
+ }
1276
1495
  return order;
1277
1496
  }
1278
1497
  catch (err) {
@@ -1350,12 +1569,17 @@ export class LiveAdapter extends EventEmitter {
1350
1569
  emitUnprotected(`threw: ${formatError(err)}`);
1351
1570
  }
1352
1571
  }
1353
- async getBalance() {
1572
+ /** Null-honest balance read: null = the fetch FAILED (429 / weight-paced /
1573
+ * banned) — the caller MUST treat it as UNKNOWN, never as a zero balance.
1574
+ * Same null≠empty contract as getPositionsOrNull. Decision paths (sizing,
1575
+ * pre-trade risk) must use this: the legacy getBalance() collapse below
1576
+ * reads as walletTotal=0 → ~−100% drawdown → RED zone. */
1577
+ async getBalanceOrNull() {
1354
1578
  const balance = await this.api.fetchBalance();
1355
1579
  this.syncRateLimits();
1356
1580
  this.rateLimiter.recordQuery(ENDPOINT_WEIGHTS.fetchBalance ?? 5);
1357
1581
  if (!balance) {
1358
- return { free: {}, used: {}, total: {} };
1582
+ return null;
1359
1583
  }
1360
1584
  // Binance USDⓈ-M quirk: `availableBalance` is only populated for the primary
1361
1585
  // margin asset (USDT). Secondary collaterals like USDC come back with
@@ -1393,6 +1617,17 @@ export class LiveAdapter extends EventEmitter {
1393
1617
  const cachedPositions = this.reconciler.getLastExchangePositions();
1394
1618
  return this.enricher.enrich(balance, cachedPositions);
1395
1619
  }
1620
+ /** Legacy display-path read. Collapses a FAILED fetch to an empty balance
1621
+ * object — acceptable for read-only surfaces (fetch_balance tool,
1622
+ * risk-summary display), a phantom-zero hazard for anything that decides.
1623
+ * Decision paths use getBalanceOrNull(). */
1624
+ async getBalance() {
1625
+ const balance = await this.getBalanceOrNull();
1626
+ if (!balance) {
1627
+ return { free: {}, used: {}, total: {} };
1628
+ }
1629
+ return balance;
1630
+ }
1396
1631
  /** Re-anchor the realized-today seed + sessionStartNav from /fapi/v1/income.
1397
1632
  * Throttled to {@link INCOME_REFRESH_MIN_INTERVAL_MS}; UTC date rollover
1398
1633
  * forces an immediate refresh. Best-effort — failures are logged and
@@ -1534,6 +1769,9 @@ export class LiveAdapter extends EventEmitter {
1534
1769
  out.push({
1535
1770
  ...p,
1536
1771
  setupType: p.setupType ?? freshMeta.setupType,
1772
+ missionId: p.missionId ?? freshMeta.missionId,
1773
+ candidateId: p.candidateId ?? freshMeta.candidateId,
1774
+ strategy: p.strategy ?? freshMeta.strategy,
1537
1775
  thesis: p.thesis ?? freshMeta.thesis,
1538
1776
  stopPrice: p.stopPrice ?? freshMeta.stopPrice,
1539
1777
  targetPrice: p.targetPrice ?? freshMeta.targetPrice,
@@ -1618,6 +1856,9 @@ export class LiveAdapter extends EventEmitter {
1618
1856
  async fetchOrder(orderId, symbol) {
1619
1857
  return this.api.fetchOrder(orderId, symbol);
1620
1858
  }
1859
+ async resolveOrderByClientId(clientOrderId, symbol) {
1860
+ return this.api.resolveOrderByClientId(clientOrderId, symbol);
1861
+ }
1621
1862
  async getLastPrice(symbol) {
1622
1863
  const ticker = await this.api.fetchTicker(symbol);
1623
1864
  return ticker?.last ?? null;
@@ -16,6 +16,9 @@ import { FactSubscriber } from './fact-subscriber.js';
16
16
  import { LocalStrategyEvaluator, DEFAULT_EVALUATOR_PARAMS } from './local-strategy-evaluator.js';
17
17
  const RECENT_RING = 50;
18
18
  const DEFAULT_FACT_STALENESS_MS = 60_000;
19
+ function isDeclarativeConfig(config) {
20
+ return config.executionModel === undefined || config.executionModel === 'declarative';
21
+ }
19
22
  export class LocalSignalService {
20
23
  opts;
21
24
  subscriber;
@@ -124,12 +127,14 @@ export class LocalSignalService {
124
127
  if (!resp.ok)
125
128
  return;
126
129
  const body = (await resp.json());
127
- const configs = (body.strategies ?? [])
130
+ if (!Array.isArray(body.strategies))
131
+ return;
132
+ const configs = body.strategies
128
133
  .map((r) => r.config)
129
- .filter((c) => !!c);
130
- // Keep last-known strategies if the fetch returned an empty/garbage set.
131
- if (configs.length > 0)
132
- this.strategies = configs;
134
+ .filter((c) => !!c && isDeclarativeConfig(c));
135
+ // A valid empty list means all generic strategies are off (or only a
136
+ // dedicated portfolio bundle is active), so clear stale generic rows.
137
+ this.strategies = configs;
133
138
  }
134
139
  catch {
135
140
  /* keep last-known strategies — fail-open */
@@ -137,7 +142,7 @@ export class LocalSignalService {
137
142
  }
138
143
  /** Test seam — set strategies without an HTTP fetch. */
139
144
  __setStrategiesForTest(s) {
140
- this.strategies = s;
145
+ this.strategies = s.filter((config) => isDeclarativeConfig(config));
141
146
  }
142
147
  /** Test seam — feed a raw SSE chunk to the subscriber. */
143
148
  __ingestForTest(chunk) {
@@ -42,6 +42,10 @@ export class LocalStrategyEvaluator {
42
42
  const states = [];
43
43
  const signals = [];
44
44
  for (const config of strategies) {
45
+ const executionModel = config.executionModel;
46
+ if (executionModel !== undefined && executionModel !== 'declarative') {
47
+ continue;
48
+ }
45
49
  const def = adaptStrategy(config);
46
50
  // Regime gate (engine.ts): effective floor is the LOWER of the strategy's
47
51
  // own minConfidence and the user's regimeConfidenceFloor.
@@ -10,6 +10,10 @@ export interface PositionStateEntry {
10
10
  webappPositionId?: string;
11
11
  /** exchangeTradeId of the opening fill (audit trail). */
12
12
  openedFromExchangeTradeId?: string;
13
+ /** setup_type from the entry metadata (e.g. 'pullback_trend_short_4h').
14
+ * Carried so close paths can record the exit into the re-entry tracker
15
+ * keyed by (symbol, setup) — issue #204. Optional; older entries lack it. */
16
+ setupType?: string;
13
17
  /** Last record_position_reviews call timestamp (epoch ms). Drives stale-gate. */
14
18
  lastReviewAt?: number;
15
19
  lastVerdict?: 'hold' | 'add_on' | 'close_recommended';
@@ -1,6 +1,8 @@
1
1
  import type { BinancePublicApi } from '../ccxt/binance-public.js';
2
2
  import type { IExchangeAdapter } from '../exchange-adapter.js';
3
3
  import type { AutoCaptureContext } from '../ingest/position-auto-capture.js';
4
+ import type { TradingOperationLock } from '../lifecycle/trading-operation-lock.js';
5
+ import type { Wave9LiveSymbolOwnershipCheck } from '../wave9/live-symbol-ownership.js';
4
6
  export interface ProposalDecisionListenerOptions {
5
7
  baseUrl: string;
6
8
  ingestToken: string;
@@ -8,6 +10,10 @@ export interface ProposalDecisionListenerOptions {
8
10
  adapter: IExchangeAdapter;
9
11
  binanceApi: BinancePublicApi;
10
12
  autoCapture?: AutoCaptureContext;
13
+ /** Shared mutation serialization and durable Wave 9 ownership check for
14
+ * listener-side approved-order execution. */
15
+ operationLock?: TradingOperationLock;
16
+ checkWave9LiveSymbolOwnership?: Wave9LiveSymbolOwnershipCheck;
11
17
  /** Active poll interval — used while there are pending proposals (or the
12
18
  * previous tick fetched any). Default 3 s. Keeps approval-to-fire latency
13
19
  * low when the operator is actively working through proposals. */
@@ -56,6 +56,8 @@ export class ProposalDecisionListener {
56
56
  adapter: options.adapter,
57
57
  binanceApi: options.binanceApi,
58
58
  autoCapture: options.autoCapture,
59
+ operationLock: options.operationLock,
60
+ checkWave9LiveSymbolOwnership: options.checkWave9LiveSymbolOwnership,
59
61
  pollIntervalMs: options.pollIntervalMs ?? 3_000,
60
62
  pollIntervalIdleMs: options.pollIntervalIdleMs ?? 15_000,
61
63
  driftAbandonR: options.driftAbandonR ?? 0.3,
@@ -221,6 +223,8 @@ export class ProposalDecisionListener {
221
223
  binanceApi: this.opts.binanceApi,
222
224
  adapter: this.opts.adapter,
223
225
  autoCapture: this.opts.autoCapture,
226
+ operationLock: this.opts.operationLock,
227
+ checkWave9LiveSymbolOwnership: this.opts.checkWave9LiveSymbolOwnership,
224
228
  // proposalManager + userId deliberately omitted — see above.
225
229
  });
226
230
  if ('error' in result) {