@reefclaw/openclaw-plugin 0.1.23 → 0.1.25

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 (95) hide show
  1. package/bridge/bridge.js +72 -5
  2. package/bridge/connector.d.ts +3 -1
  3. package/bridge/connector.js +51 -4
  4. package/bridge/gateway/heartbeat-cron.js +31 -7
  5. package/bridge/gateway/poller.d.ts +5 -0
  6. package/bridge/gateway/poller.js +9 -0
  7. package/bridge/index.js +21 -0
  8. package/bridge/provider.d.ts +15 -0
  9. package/bridge/providers/connector-update.d.ts +89 -0
  10. package/bridge/providers/connector-update.js +212 -0
  11. package/bridge/providers/emergency-commands.d.ts +36 -0
  12. package/bridge/providers/emergency-commands.js +91 -0
  13. package/bridge/providers/gateway.d.ts +26 -1
  14. package/bridge/providers/gateway.js +159 -8
  15. package/bridge/providers/mock.js +1 -0
  16. package/bridge/shock-wake.d.ts +80 -0
  17. package/bridge/shock-wake.js +291 -0
  18. package/bridge/types.d.ts +5 -1
  19. package/bridge/types.js +5 -0
  20. package/bridge/utils/instance-id.d.ts +3 -0
  21. package/bridge/utils/instance-id.js +48 -0
  22. package/ccxt/binance-private.js +2 -1
  23. package/ccxt/binance-public.js +6 -1
  24. package/config/agent-config-client.d.ts +7 -2
  25. package/config/agent-config-client.js +17 -0
  26. package/config/agent-config-poller.js +5 -1
  27. package/config/brackets-config.d.ts +2 -1
  28. package/config/brackets-config.js +25 -3
  29. package/config/gate-store.d.ts +12 -0
  30. package/config/gate-store.js +26 -2
  31. package/config/loss-streak-config.d.ts +2 -0
  32. package/config/loss-streak-config.js +33 -0
  33. package/config/plugin-config-io.d.ts +19 -0
  34. package/config/plugin-config-io.js +24 -2
  35. package/config/reentry-cooldown-config.d.ts +7 -0
  36. package/config/reentry-cooldown-config.js +59 -0
  37. package/http/keepalive-fetch.d.ts +5 -0
  38. package/http/keepalive-fetch.js +50 -0
  39. package/index.js +77 -8
  40. package/ingest/position-auto-capture.js +49 -4
  41. package/ingest/position-decisions-client.d.ts +6 -0
  42. package/ingest/position-decisions-client.js +27 -9
  43. package/ingest/readiness-reporter.d.ts +23 -2
  44. package/ingest/readiness-reporter.js +56 -1
  45. package/live/approval-lifecycle.d.ts +10 -0
  46. package/live/approval-lifecycle.js +16 -2
  47. package/live/microstructure-assembler.js +11 -2
  48. package/live/proposal-decision-listener.d.ts +21 -0
  49. package/live/proposal-decision-listener.js +39 -0
  50. package/live/proposal-manager.d.ts +12 -0
  51. package/live/proposal-manager.js +47 -0
  52. package/live/stop-watcher.d.ts +16 -1
  53. package/live/stop-watcher.js +48 -8
  54. package/onboarding/runtime.js +4 -0
  55. package/openclaw.plugin.json +1 -1
  56. package/package.json +38 -38
  57. package/persistence/state-manager.d.ts +7 -0
  58. package/persistence/state-manager.js +28 -1
  59. package/portfolio/directional-scoreboard.d.ts +17 -0
  60. package/portfolio/directional-scoreboard.js +71 -0
  61. package/portfolio/reentry-tracker.d.ts +38 -1
  62. package/portfolio/reentry-tracker.js +49 -0
  63. package/signals/change-of-character.d.ts +38 -0
  64. package/signals/change-of-character.js +93 -0
  65. package/simulator/exchange-simulator.d.ts +27 -1
  66. package/simulator/exchange-simulator.js +98 -38
  67. package/simulator/types.d.ts +11 -0
  68. package/skills/reefclaw/SKILL.md +2 -2
  69. package/strategy/evaluator.d.ts +4 -0
  70. package/tools/audit-bracket-protection.js +11 -7
  71. package/tools/close-position.js +10 -1
  72. package/tools/create-order.js +121 -9
  73. package/tools/get-funding-context.js +6 -1
  74. package/tools/get-liquidation-levels.js +5 -1
  75. package/tools/get-liquidation-pulse.js +7 -1
  76. package/tools/get-market-intel.js +2 -1
  77. package/tools/get-relevant-learnings.js +20 -1
  78. package/tools/get-resting-liquidity.js +6 -1
  79. package/tools/get-wave9-status.js +17 -0
  80. package/tools/hl-provision-agent-wallet.js +29 -11
  81. package/tools/intel-api.d.ts +9 -0
  82. package/tools/intel-api.js +32 -1
  83. package/tools/record-position-reviews.js +2 -2
  84. package/tools/reentry-cooldown.d.ts +33 -0
  85. package/tools/reentry-cooldown.js +74 -0
  86. package/tools/scan-pairs.d.ts +7 -0
  87. package/tools/scan-pairs.js +67 -11
  88. package/tools/set-exchange-credentials.js +19 -0
  89. package/tools/set-trading-mode.d.ts +6 -0
  90. package/tools/set-trading-mode.js +48 -1
  91. package/types.d.ts +7 -0
  92. package/venues/hyperliquid/hl-agent-wallet.d.ts +26 -0
  93. package/venues/hyperliquid/hl-agent-wallet.js +32 -0
  94. package/venues/hyperliquid/hl-live-adapter.d.ts +27 -2
  95. package/venues/hyperliquid/hl-live-adapter.js +101 -13
@@ -12,6 +12,34 @@ import { fillMarketOrder, fillLimitOrder, parseSymbol } from './fill-engine.js';
12
12
  import { updateMfe } from '../mfe.js';
13
13
  import { computeInvalidationHit } from '../pinned-plan.js';
14
14
  const TAG = 'simulator';
15
+ /** Canonical key for the per-symbol quote/orderbook caches and for matching a
16
+ * ticker against stored positions/orders.
17
+ *
18
+ * CCXT echoes the VENUE-UNIFIED symbol back from fetchTicker: ask Binance USDM
19
+ * for 'ETH/USDT' and the ticker returns as 'ETH/USDT:USDT'. Positions and
20
+ * orders, however, are stored under whatever form the caller opened them with,
21
+ * so `lastTicker.get(position.symbol)` silently missed for every position held
22
+ * in the un-suffixed form. The consequences were all silent:
23
+ * - getPositions()/computeEquity() fell back to entryPrice, so the mark was
24
+ * FROZEN at entry — the stop-watcher compared a frozen mark, never saw a
25
+ * breach, and the position ran unprotected past its stop indefinitely;
26
+ * - createOrder() threw 'No ticker data for X' on the market leg, so the
27
+ * agent could not close by hand either (both automatic and manual exits
28
+ * were dead at once);
29
+ * - MFE, take-profit legs and resting-limit fills never advanced.
30
+ * Keying both sides through this normalizer makes the caches format-agnostic,
31
+ * which also repairs books already persisted with a mix of both forms.
32
+ *
33
+ * Same regex as webapp/src/lib/symbols.ts normalizeSymbol and
34
+ * plugin/src/venues/symbols.ts stripSettleSuffix — kept inline so the
35
+ * simulator keeps its zero-import-for-hot-path shape. */
36
+ export function tickerKey(symbol) {
37
+ return symbol.replace(/:[A-Z]+$/, '');
38
+ }
39
+ /** True when two symbols denote the same market regardless of settle suffix. */
40
+ function sameMarket(a, b) {
41
+ return tickerKey(a) === tickerKey(b);
42
+ }
15
43
  export class ExchangeSimulator extends EventEmitter {
16
44
  state;
17
45
  lastTicker = new Map();
@@ -112,7 +140,7 @@ export class ExchangeSimulator extends EventEmitter {
112
140
  const walletTotal = this.state.wallet[quote]?.total ?? 0;
113
141
  let positionEquity = 0;
114
142
  for (const pos of this.state.positions) {
115
- const ticker = this.lastTicker.get(pos.symbol);
143
+ const ticker = this.lastTicker.get(tickerKey(pos.symbol));
116
144
  const mark = ticker?.last ?? pos.entryPrice;
117
145
  const entryNotional = pos.entryPrice * pos.quantity;
118
146
  const unrealized = pos.side === 'long'
@@ -182,10 +210,16 @@ export class ExchangeSimulator extends EventEmitter {
182
210
  }
183
211
  getPositions(symbol) {
184
212
  const positions = symbol
185
- ? this.state.positions.filter(p => p.symbol === symbol)
213
+ ? this.state.positions.filter(p => sameMarket(p.symbol, symbol))
186
214
  : this.state.positions;
187
215
  return positions.map(p => {
188
- const ticker = this.lastTicker.get(p.symbol);
216
+ const ticker = this.lastTicker.get(tickerKey(p.symbol));
217
+ // No quote for this symbol means we CANNOT mark this position. Falling
218
+ // back to entryPrice fabricates "flat since entry", which reads as a
219
+ // perfectly healthy position to every consumer — that is how a stop
220
+ // breach stayed invisible for 35h. Keep the fallback (callers need a
221
+ // number) but flag it so the stop-watcher can alarm instead of skipping.
222
+ const markPriceStale = ticker === undefined;
189
223
  const markPrice = ticker?.last ?? p.entryPrice;
190
224
  const notional = p.quantity * markPrice;
191
225
  const pnlMultiplier = p.side === 'long' ? 1 : -1;
@@ -200,6 +234,7 @@ export class ExchangeSimulator extends EventEmitter {
200
234
  contractSize: 1,
201
235
  entryPrice: p.entryPrice,
202
236
  markPrice,
237
+ markPriceStale,
203
238
  notional,
204
239
  unrealizedPnl,
205
240
  percentage,
@@ -233,7 +268,7 @@ export class ExchangeSimulator extends EventEmitter {
233
268
  }
234
269
  getOpenOrders(symbol) {
235
270
  const orders = symbol
236
- ? this.state.openOrders.filter(o => o.symbol === symbol)
271
+ ? this.state.openOrders.filter(o => sameMarket(o.symbol, symbol))
237
272
  : this.state.openOrders;
238
273
  return orders.map(o => this.toCcxtOrder(o));
239
274
  }
@@ -244,10 +279,10 @@ export class ExchangeSimulator extends EventEmitter {
244
279
  // ---- Order book ----
245
280
  /** Cache the latest order book snapshot for a symbol. */
246
281
  updateOrderBook(symbol, orderbook) {
247
- this.lastOrderBook.set(symbol, orderbook);
282
+ this.lastOrderBook.set(tickerKey(symbol), orderbook);
248
283
  }
249
284
  getLastOrderBook(symbol) {
250
- return this.lastOrderBook.get(symbol);
285
+ return this.lastOrderBook.get(tickerKey(symbol));
251
286
  }
252
287
  // ---- Write operations (for tools) ----
253
288
  createOrder(symbol, side, type, amount, price, metadata,
@@ -255,17 +290,29 @@ export class ExchangeSimulator extends EventEmitter {
255
290
  * market branch prices off THIS instead of the last tick, and skips the
256
291
  * stale-quote guard — the caller supplied the price, so quote age is
257
292
  * irrelevant, and a protective exit must never be blocked (issue #202). */
258
- referencePrice) {
293
+ referencePrice,
294
+ /** Set ONLY by closePosition for mechanical/operator-initiated exits —
295
+ * exempts them from the startup lockout. Never reachable from the
296
+ * agent-facing create_order path. */
297
+ protectiveExit) {
259
298
  // ---- Startup trade lockout ----
260
299
  // Block trades during the first 15s after gateway restart IF there were
261
300
  // existing positions at startup. This prevents stale agent sessions from
262
301
  // selling positions before the session is cleared and the agent re-reads SKILL.md.
263
302
  // Only activates when positions exist (nothing to protect if starting empty).
303
+ // Protective exits pass through: a stop breached 3s after a restart must
304
+ // close NOW — blocking the watcher here left positions unprotected for
305
+ // the whole window (open item since 2026-07-27).
264
306
  const elapsed = Date.now() - this.startupTime;
265
307
  if (this.hadPositionsAtStartup && elapsed < ExchangeSimulator.STARTUP_LOCKOUT_MS) {
266
- const remaining = Math.ceil((ExchangeSimulator.STARTUP_LOCKOUT_MS - elapsed) / 1000);
267
- logger.warn(TAG, `STARTUP LOCKOUT: Blocked ${side} ${amount} ${symbol} — ${remaining}s remaining. This prevents stale session trades during restart.`);
268
- throw new Error(`Trade blocked: startup lockout (${remaining}s remaining). The gateway just restarted — wait for the agent to re-read its instructions and check positions before trading.`);
308
+ if (protectiveExit) {
309
+ logger.info(TAG, `Startup lockout bypassed for protective exit: ${side} ${amount} ${symbol}`);
310
+ }
311
+ else {
312
+ const remaining = Math.ceil((ExchangeSimulator.STARTUP_LOCKOUT_MS - elapsed) / 1000);
313
+ logger.warn(TAG, `STARTUP LOCKOUT: Blocked ${side} ${amount} ${symbol} — ${remaining}s remaining. This prevents stale session trades during restart.`);
314
+ throw new Error(`Trade blocked: startup lockout (${remaining}s remaining). The gateway just restarted — wait for the agent to re-read its instructions and check positions before trading.`);
315
+ }
269
316
  }
270
317
  if (amount <= 0) {
271
318
  throw new Error('Order amount must be positive');
@@ -294,7 +341,7 @@ export class ExchangeSimulator extends EventEmitter {
294
341
  return this.executeMarketFill(order, referencePrice, metadata);
295
342
  }
296
343
  // Market orders fill immediately at current price
297
- const ticker = this.lastTicker.get(symbol);
344
+ const ticker = this.lastTicker.get(tickerKey(symbol));
298
345
  if (!ticker) {
299
346
  throw new Error(`No ticker data for ${symbol}. Call updateTicker() first.`);
300
347
  }
@@ -308,7 +355,7 @@ export class ExchangeSimulator extends EventEmitter {
308
355
  // Limit order — check if it crosses the current price. A stale quote must
309
356
  // not price an immediate cross-fill (same hazard as market fills); the
310
357
  // order RESTS instead and fills on the next fresh tick via updateTicker.
311
- const ticker = this.lastTicker.get(symbol);
358
+ const ticker = this.lastTicker.get(tickerKey(symbol));
312
359
  if (ticker && this.quoteAgeMs(ticker) <= this.maxQuoteAgeMs() && this.shouldFillLimit(order, ticker.last)) {
313
360
  return this.executeLimitFill(order, ticker.last, metadata);
314
361
  }
@@ -341,7 +388,7 @@ export class ExchangeSimulator extends EventEmitter {
341
388
  const cancelled = [];
342
389
  const remaining = [];
343
390
  for (const order of this.state.openOrders) {
344
- if (!symbol || order.symbol === symbol) {
391
+ if (!symbol || sameMarket(order.symbol, symbol)) {
345
392
  order.status = 'canceled';
346
393
  cancelled.push(order);
347
394
  this.pendingOrderMetadata.delete(order.id);
@@ -364,7 +411,7 @@ export class ExchangeSimulator extends EventEmitter {
364
411
  * else, which keeps the normal market-close path byte-identical.
365
412
  */
366
413
  closePosition(symbol, closeReason, referencePrice) {
367
- const position = this.state.positions.find(p => p.symbol === symbol);
414
+ const position = this.state.positions.find(p => sameMarket(p.symbol, symbol));
368
415
  if (!position) {
369
416
  throw new Error(`No open position for ${symbol}`);
370
417
  }
@@ -375,9 +422,15 @@ export class ExchangeSimulator extends EventEmitter {
375
422
  if (closeReason) {
376
423
  position.metadata = { ...(position.metadata ?? {}), closeReason };
377
424
  }
378
- // Create opposing market order to close the position
425
+ // Create opposing market order to close the position.
426
+ // Mechanical / operator-initiated exits (stop_watcher, exchange_target,
427
+ // emergency, operator, bracket_attach_failed, …) must never wait out the
428
+ // startup lockout — a stop breached seconds after a restart has to close
429
+ // immediately. Only discretionary closes ('agent' or reason-less) keep
430
+ // the stale-session guard.
431
+ const protectiveExit = closeReason !== undefined && closeReason !== 'agent';
379
432
  const closeSide = position.side === 'long' ? 'sell' : 'buy';
380
- return this.createOrder(symbol, closeSide, 'market', position.quantity, undefined, undefined, referencePrice);
433
+ return this.createOrder(symbol, closeSide, 'market', position.quantity, undefined, undefined, referencePrice, protectiveExit);
381
434
  }
382
435
  /** Paper-only: move an open position's MUTABLE protective levels (stopPrice /
383
436
  * targetPrice) in place and persist, WITHOUT the close+reopen round-trip
@@ -387,7 +440,7 @@ export class ExchangeSimulator extends EventEmitter {
387
440
  * and getPositions both read metadata.stopPrice, so a moved stop takes
388
441
  * effect on the next watcher tick. Throws if there is no open position. (M9) */
389
442
  updatePositionMetadata(symbol, patch) {
390
- const pos = this.state.positions.find(p => p.symbol === symbol);
443
+ const pos = this.state.positions.find(p => sameMarket(p.symbol, symbol));
391
444
  if (!pos) {
392
445
  throw new Error(`No open position for ${symbol}`);
393
446
  }
@@ -401,7 +454,7 @@ export class ExchangeSimulator extends EventEmitter {
401
454
  }
402
455
  // ---- Ticker updates (checks pending limit fills) ----
403
456
  updateTicker(ticker) {
404
- this.lastTicker.set(ticker.symbol, ticker);
457
+ this.lastTicker.set(tickerKey(ticker.symbol), ticker);
405
458
  this.refreshMfeForSymbol(ticker.symbol, ticker.last);
406
459
  // MFE is refreshed FIRST so the peak this tick reached is recorded before a
407
460
  // target close reads it — otherwise every TP exit would understate its own
@@ -411,7 +464,7 @@ export class ExchangeSimulator extends EventEmitter {
411
464
  const toFill = [];
412
465
  const remaining = [];
413
466
  for (const order of this.state.openOrders) {
414
- if (order.symbol === ticker.symbol && this.shouldFillLimit(order, ticker.last)) {
467
+ if (sameMarket(order.symbol, ticker.symbol) && this.shouldFillLimit(order, ticker.last)) {
415
468
  toFill.push(order);
416
469
  }
417
470
  else {
@@ -449,7 +502,7 @@ export class ExchangeSimulator extends EventEmitter {
449
502
  }
450
503
  }
451
504
  getLastTicker(symbol) {
452
- return this.lastTicker.get(symbol);
505
+ return this.lastTicker.get(tickerKey(symbol));
453
506
  }
454
507
  /**
455
508
  * Take-profit legs — the paper analog of the exchange-native
@@ -483,7 +536,7 @@ export class ExchangeSimulator extends EventEmitter {
483
536
  if (!Number.isFinite(price) || price <= 0)
484
537
  return;
485
538
  // Snapshot: closing mutates state.positions mid-iteration.
486
- const candidates = this.state.positions.filter((p) => p.symbol === symbol);
539
+ const candidates = this.state.positions.filter((p) => sameMarket(p.symbol, symbol));
487
540
  for (const position of candidates) {
488
541
  const target = position.metadata?.targetPrice;
489
542
  if (target === undefined || !Number.isFinite(target) || target <= 0)
@@ -492,17 +545,20 @@ export class ExchangeSimulator extends EventEmitter {
492
545
  if (!breached)
493
546
  continue;
494
547
  // Guard against a re-entrant tick firing a second close on the same
495
- // symbol while the first is still settling.
496
- if (this.takeProfitPending.has(symbol))
548
+ // symbol while the first is still settling. Keyed canonically so the
549
+ // guard still holds when the tick and the position disagree on suffix.
550
+ const pendingKey = tickerKey(position.symbol);
551
+ if (this.takeProfitPending.has(pendingKey))
497
552
  continue;
498
- this.takeProfitPending.add(symbol);
553
+ this.takeProfitPending.add(pendingKey);
499
554
  try {
500
- logger.info(TAG, `TARGET REACHED: ${symbol} ${position.side} price=${price} target=${target} — closing (exchange_target)`);
555
+ logger.info(TAG, `TARGET REACHED: ${position.symbol} ${position.side} price=${price} target=${target} — closing (exchange_target)`);
501
556
  // Target = decision price; the fill engine adds realistic adverse
502
- // slippage on top (see the fill-convention note above).
503
- const order = this.closePosition(symbol, 'exchange_target', target);
557
+ // slippage on top (see the fill-convention note above). Address the
558
+ // close with the POSITION's own symbol, never the ticker's form.
559
+ const order = this.closePosition(position.symbol, 'exchange_target', target);
504
560
  this.emit('target_closed', {
505
- symbol,
561
+ symbol: position.symbol,
506
562
  side: position.side,
507
563
  targetPrice: target,
508
564
  markPrice: price,
@@ -514,10 +570,10 @@ export class ExchangeSimulator extends EventEmitter {
514
570
  catch (err) {
515
571
  // Never let a failed protective close kill the tick loop — the next
516
572
  // tick retries, and the position is still visible to the agent.
517
- logger.error(TAG, `Target close failed for ${symbol}: ${formatError(err)}`);
573
+ logger.error(TAG, `Target close failed for ${position.symbol}: ${formatError(err)}`);
518
574
  }
519
575
  finally {
520
- this.takeProfitPending.delete(symbol);
576
+ this.takeProfitPending.delete(pendingKey);
521
577
  }
522
578
  }
523
579
  }
@@ -530,7 +586,7 @@ export class ExchangeSimulator extends EventEmitter {
530
586
  return;
531
587
  let peakAdvanced = false;
532
588
  for (const p of this.state.positions) {
533
- if (p.symbol !== symbol)
589
+ if (!sameMarket(p.symbol, symbol))
534
590
  continue;
535
591
  const meta = { ...(p.metadata ?? {}) };
536
592
  const priorPeak = meta.mfePeakPrice;
@@ -602,7 +658,7 @@ export class ExchangeSimulator extends EventEmitter {
602
658
  const keptPeak = diskPeak === undefined
603
659
  ? priorPeak
604
660
  : (p.side === 'long' ? Math.max(priorPeak, diskPeak) : Math.min(priorPeak, diskPeak));
605
- const mark = this.lastTicker.get(p.symbol)?.last ?? meta.originalEntryPrice ?? p.entryPrice;
661
+ const mark = this.lastTicker.get(tickerKey(p.symbol))?.last ?? meta.originalEntryPrice ?? p.entryPrice;
606
662
  const out = updateMfe({
607
663
  side: p.side,
608
664
  entryPrice: meta.originalEntryPrice ?? p.entryPrice,
@@ -671,7 +727,7 @@ export class ExchangeSimulator extends EventEmitter {
671
727
  const max = this.maxQuoteAgeMs();
672
728
  if (age <= max)
673
729
  return;
674
- const pos = this.state.positions.find(p => p.symbol === symbol);
730
+ const pos = this.state.positions.find(p => sameMarket(p.symbol, symbol));
675
731
  // The order side reaching here is the one being filled — derive reduce vs
676
732
  // grow from the position side at the call site instead? The market path
677
733
  // calls this before fill with the order side unavailable; use position
@@ -696,8 +752,10 @@ export class ExchangeSimulator extends EventEmitter {
696
752
  : currentPrice >= order.price;
697
753
  }
698
754
  executeMarketFill(order, currentPrice, metadata) {
699
- const position = this.state.positions.find(p => p.symbol === order.symbol) ?? null;
700
- const orderbook = this.lastOrderBook.get(order.symbol) ?? null;
755
+ // Match canonically: a miss here would hand fillMarketOrder position=null,
756
+ // turning a CLOSE into a brand-new opposing position.
757
+ const position = this.state.positions.find(p => sameMarket(p.symbol, order.symbol)) ?? null;
758
+ const orderbook = this.lastOrderBook.get(tickerKey(order.symbol)) ?? null;
701
759
  const realistic = {
702
760
  orderbook,
703
761
  config: this.simulationConfig,
@@ -707,7 +765,7 @@ export class ExchangeSimulator extends EventEmitter {
707
765
  const result = fillMarketOrder(order, currentPrice, this.state.wallet, position, realistic);
708
766
  // Observability for issue #202: stamp the quote's age onto the fill's
709
767
  // execution-quality record so staleness is visible in trade history.
710
- const tickerAtFill = this.lastTicker.get(order.symbol);
768
+ const tickerAtFill = this.lastTicker.get(tickerKey(order.symbol));
711
769
  if (result.executionQuality && tickerAtFill) {
712
770
  result.executionQuality.quoteAgeMs = this.quoteAgeMs(tickerAtFill);
713
771
  }
@@ -725,7 +783,7 @@ export class ExchangeSimulator extends EventEmitter {
725
783
  return ccxtOrder;
726
784
  }
727
785
  executeLimitFill(order, decisionPrice, metadata) {
728
- const position = this.state.positions.find(p => p.symbol === order.symbol) ?? null;
786
+ const position = this.state.positions.find(p => sameMarket(p.symbol, order.symbol)) ?? null;
729
787
  const result = fillLimitOrder(order, this.state.wallet, position, this.simulationConfig, decisionPrice, metadata);
730
788
  const ccxtOrder = this.applyFillResult(result);
731
789
  const eq = result.executionQuality;
@@ -778,7 +836,9 @@ export class ExchangeSimulator extends EventEmitter {
778
836
  return ccxtOrder;
779
837
  }
780
838
  updatePosition(symbol, newPosition) {
781
- const idx = this.state.positions.findIndex(p => p.symbol === symbol);
839
+ // Canonical match: an exact-compare miss here would append a DUPLICATE
840
+ // position row instead of replacing/removing the existing one.
841
+ const idx = this.state.positions.findIndex(p => sameMarket(p.symbol, symbol));
782
842
  if (newPosition) {
783
843
  if (idx >= 0) {
784
844
  this.state.positions[idx] = newPosition;
@@ -150,6 +150,17 @@ export interface PositionMetadata {
150
150
  /** The agent's stated profit-realization plan, pinned at entry (indication,
151
151
  * not an enforced mechanic). */
152
152
  realizationRule?: RealizationRule;
153
+ /** Re-entry cooldown gate eval, present ONLY when the gate TRIGGERED on this
154
+ * entry (a same-symbol loss within the window) and the order fired anyway
155
+ * (shadow/observe). Journaled to position_entries.metadata.reentry_cooldown
156
+ * so the shadow soak can measure the would-block cohort's forward outcomes
157
+ * against untagged entries (tools/reentry-cooldown.ts). */
158
+ reentryCooldown?: {
159
+ mode: 'shadow' | 'observe' | 'enforce';
160
+ minutesSinceLoss: number;
161
+ cooldownMinutes: number;
162
+ wouldBlock: boolean;
163
+ };
153
164
  /** Entry price as it was at the moment of the FIRST fill, frozen. MFE-in-R is
154
165
  * denominated against this (and originalStopPrice), never against the
155
166
  * position's running averaged entryPrice — otherwise a scale-in would
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: reefclaw
3
- version: 0.0.9
3
+ version: 0.0.10
4
4
  description: ReefClaw trading control room — bootstrap (connects your agent; the full trading instructions arrive after first connect as a signed, signature-verified update)
5
5
  author: ReefClaw
6
6
  homepage: https://reefclaw.com
@@ -108,7 +108,7 @@ That is the whole install. It places the plugin, sets up the connector, restarts
108
108
 
109
109
  **Do not attempt to perform this install yourself, and do not work around it if it is unavailable to you.** Specifically: do not edit OpenClaw's configuration to enable chat install commands, do not add anyone to an owner or elevated-exec allowlist, do not enable elevated mode, and do not restart the gateway to widen your own permissions. If you cannot run the command, the correct outcome is to hand the user the one line above and let them run it deliberately. A trading integration is exactly the kind of software whose installation a human should perform knowingly.
110
110
 
111
- **If the user installs via ClawHub instead** and sees a security warning ("Security scan: suspicious"): that is expected and honest — ReefClaw is a trading plugin, and it discloses live-trading capability, exchange-credential storage, and remote instruction updates, which ClawHub flags for review (its linked security page has the details; VirusTotal reports the artifact clean). The chat command cannot acknowledge that warning, so the user should either use the `npx` line above, orhaving read the warning run `openclaw plugins install clawhub:@reefclaw/openclaw-plugin --acknowledge-clawhub-risk` themselves in a terminal.
111
+ **If the user installs via ClawHub instead** no terminal needed on OpenClaw 2026.8.1 or newer: either OpenClaw's Control UI (Plugins → Discover → search "reefclaw" → Install → accept the listed capabilities), or the chat command `/plugins install clawhub:@reefclaw/openclaw-plugin` followed, after reading the capability list it replies with, by the same command plus `--accept-capabilities` (the chat command needs `commands.plugins: true` in openclaw.json, which only the user sets). ClawHub labels the plugin "Security scan: suspicious". That is expected and honest — ReefClaw discloses live-trading capability, exchange-credential storage, and remote instruction updates, which ClawHub flags for review (its linked security page has the details; VirusTotal reports the artifact clean). OpenClaw 2026.8.1+ shows that audit and continues once the capabilities are accepted; OpenClaw 2026.7.x still blocks the chat command on that label there the user runs the `npx` line above, or `openclaw plugins install clawhub:@reefclaw/openclaw-plugin --acknowledge-clawhub-risk` in a terminal.
112
112
 
113
113
  Whatever the install path, **save the connection settings anyway** — the connector picks them up automatically the moment the plugin is running.
114
114
 
@@ -5,6 +5,10 @@ export interface SymbolFact {
5
5
  conditions: Record<string, boolean>;
6
6
  regime: string;
7
7
  regime_confidence: number;
8
+ /** Change-of-character block computed by the intel fact-computer (WS2,
9
+ * docs/MARKET_ADAPTIVITY_PLAN.md §3). Absent on older intel builds or when
10
+ * inputs were insufficient — consumers treat absence as "no signal". */
11
+ changeOfCharacter?: import('../signals/change-of-character.js').ChangeOfCharacter;
8
12
  }
9
13
  export interface ConditionConfig {
10
14
  type: string;
@@ -566,13 +566,16 @@ async function auditHlLive(adapter) {
566
566
  };
567
567
  }
568
568
  const entries = [];
569
- for (const p of activePositions) {
570
- let symbolOrders;
571
- try {
572
- symbolOrders = await adapter.getOpenOrders(p.symbol);
573
- }
574
- catch (err) {
575
- const m = formatError(err);
569
+ // Fan the per-symbol open-orders reads out (HL has no batched read; the
570
+ // serial loop cost N round-trips per heartbeat), then walk the results in
571
+ // the ORIGINAL position order — the first failing symbol still produces
572
+ // the identical error return, and no verdict logic changes.
573
+ const orderFetches = await Promise.allSettled(activePositions.map((pos) => adapter.getOpenOrders(pos.symbol)));
574
+ for (let i = 0; i < activePositions.length; i++) {
575
+ const p = activePositions[i];
576
+ const fetched = orderFetches[i];
577
+ if (fetched.status === 'rejected') {
578
+ const m = formatError(fetched.reason);
576
579
  logger.warn(TAG, `HL audit skipped — open orders unavailable for ${p.symbol}: ${m}`);
577
580
  return {
578
581
  error: `Exchange data unavailable for ${p.symbol} (Hyperliquid open-orders fetch failed). ` +
@@ -580,6 +583,7 @@ async function auditHlLive(adapter) {
580
583
  `close_position(reason='bracket_integrity') on the basis of this call. Underlying: ${m}`,
581
584
  };
582
585
  }
586
+ const symbolOrders = fetched.value;
583
587
  const ledgerRow = ledger.getBySymbol(p.symbol) ?? null;
584
588
  const rowNonTerminalWithCids = !!ledgerRow && !isTerminalBracketState(ledgerRow.state) && Boolean(ledgerRow.slCid || ledgerRow.tpCid);
585
589
  if (symbolOrders.length === 0 && rowNonTerminalWithCids) {
@@ -317,9 +317,18 @@ export async function closePositionTool(args, deps) {
317
317
  'Use get_wave9_status for a reversal authorization or operator_command for an emergency close.');
318
318
  }
319
319
  }
320
+ // Thread the validated reason to the adapter as its CloseReason:
321
+ // 'operator_command' maps to 'operator' so an operator-driven close is
322
+ // never caught by the paper startup lockout (whose protective-exit
323
+ // carve-out keys on the reason — before this, the tool dropped the
324
+ // reason entirely and EVERY generic close arrived as discretionary).
325
+ // All other reasons are agent-discretionary by design and stay subject
326
+ // to the lockout; the rich reason still reaches the journal via
327
+ // close_reason, this only fixes the adapter-level class + metadata.
328
+ const adapterCloseReason = args.reason === 'operator_command' ? 'operator' : 'agent';
320
329
  return wave9ExitClaimed
321
330
  ? deps.adapter.closePosition(args.symbol, 'wave9_signal_reversal')
322
- : deps.adapter.closePosition(args.symbol);
331
+ : deps.adapter.closePosition(args.symbol, adapterCloseReason);
323
332
  };
324
333
  const closeWave9 = async () => {
325
334
  if (!wave9Lease)
@@ -1,12 +1,16 @@
1
1
  // Tool: create_order — order execution with real price data + pre-trade risk gate
2
2
  // Readiness gate: BLOCKED unless adapter.readiness === 'READY'.
3
3
  import { createHash, randomUUID } from 'node:crypto';
4
- import { formatError } from '../logger.js';
4
+ import { formatError, logger } from '../logger.js';
5
5
  import { getQuoteBalance, getQuoteWalletBalance } from '../balance-utils.js';
6
6
  import { fetchCurrentPrice, fetchOrderBook, isError } from './helpers.js';
7
7
  import { validateCreateOrder, sanitizeRealizationRule, validateProtectiveGeometry } from './assessment-validation.js';
8
8
  import { preTradeRiskCheck, getDefaultPreTradeLimits, computeConsecutiveLosses } from '../risk/pre-trade-check.js';
9
9
  import { bracketsEnabled, loadBracketMode, loadBracketRequirements } from '../config/brackets-config.js';
10
+ import { loadReentryCooldownMinutes, loadReentryCooldownMode, } from '../config/reentry-cooldown-config.js';
11
+ import { resolveLossStreakSizingMode } from '../config/loss-streak-config.js';
12
+ import { buildReentryCooldownRejection, evaluateReentryCooldown, } from './reentry-cooldown.js';
13
+ import { normalizeBracketSymbol } from '../live/bracket-ledger.js';
10
14
  import { onCreateOrderFilled } from '../ingest/position-auto-capture.js';
11
15
  import { wave9ClientOrderId, } from '../wave9/live-execution-ledger.js';
12
16
  import { inspectWave9LiveSymbolOwnership, } from '../wave9/live-symbol-ownership.js';
@@ -875,6 +879,7 @@ export async function createOrderTool(args, deps) {
875
879
  }
876
880
  // In paper mode, fetch latest ticker + order book for realistic fills
877
881
  let ticker = null;
882
+ let livePortfolioPromise;
878
883
  if (!deps.adapter.isLive) {
879
884
  const paperDeps = { binanceApi: deps.binanceApi, simulator: deps.adapter.getSimulator() };
880
885
  const [priceResult] = await Promise.all([
@@ -886,7 +891,14 @@ export async function createOrderTool(args, deps) {
886
891
  ticker = priceResult;
887
892
  }
888
893
  else {
889
- // Live mode: fetch ticker for risk check reference price
894
+ // Live mode: the risk-gate portfolio snapshot (balance + positions) has
895
+ // no dependency on the ticker — start it here so the two exchange
896
+ // round-trips overlap; awaited at the risk check below. The catch-keeper
897
+ // only silences the unhandled rejection an early return would otherwise
898
+ // leave floating; the later await still surfaces the real error.
899
+ livePortfolioPromise = buildPortfolioSnapshotFromAdapter(deps.adapter);
900
+ livePortfolioPromise.catch(() => { });
901
+ // Fetch ticker for risk check reference price
890
902
  const lastPrice = await deps.adapter.getLastPrice(args.symbol);
891
903
  if (lastPrice != null) {
892
904
  ticker = {
@@ -1003,8 +1015,25 @@ export async function createOrderTool(args, deps) {
1003
1015
  consecutiveLosses = computeConsecutiveLosses(simulator.getState().tradeHistory);
1004
1016
  }
1005
1017
  else {
1006
- portfolio = await buildPortfolioSnapshotFromAdapter(deps.adapter);
1007
- // In live mode, consecutive losses would come from intelligence DB — use 0 for now
1018
+ portfolio = await (livePortfolioPromise ?? buildPortfolioSnapshotFromAdapter(deps.adapter));
1019
+ // WS1 (docs/MARKET_ADAPTIVITY_PLAN.md §3): the graduated loss-streak
1020
+ // sizing brake was fed a hardcoded 0 on live since the beginning. Real
1021
+ // feed = trailing lossy closes on the live book from the ReentryTracker
1022
+ // records. RC_LOSS_STREAK_SIZING = off | log (sizing unaffected) |
1023
+ // enforce (DEFAULT since 2026-09-05 — live/paper parity restored after
1024
+ // the log soak).
1025
+ const streakMode = resolveLossStreakSizingMode();
1026
+ if (streakMode !== 'off' && deps.autoCapture?.reentryTracker) {
1027
+ const liveStreak = deps.autoCapture.reentryTracker.consecutiveLosses('live');
1028
+ if (liveStreak > 0) {
1029
+ logger.info('loss-streak', `live consecutive losses=${liveStreak} (mode=${streakMode}` +
1030
+ (streakMode === 'log'
1031
+ ? ' — sizing UNAFFECTED; RC_LOSS_STREAK_SIZING=enforce applies the graduated reduction)'
1032
+ : ')'));
1033
+ }
1034
+ if (streakMode === 'enforce')
1035
+ consecutiveLosses = liveStreak;
1036
+ }
1008
1037
  }
1009
1038
  // Bracket enforcement only applies in live mode when the feature is enabled.
1010
1039
  // Paper mode uses the stop-watcher and doesn't care about these flags.
@@ -1040,6 +1069,43 @@ export async function createOrderTool(args, deps) {
1040
1069
  const reasons = riskCheck.violations.map(v => v.message).join('; ');
1041
1070
  return rejectWave9Divergence(`Order REJECTED by risk gate [${riskCheck.drawdownZone} zone]: ${reasons}`, `generic_pretrade_divergence:${riskCheck.violations.map((v) => v.rule).join(',')}`);
1042
1071
  }
1072
+ // ---- Re-entry cooldown gate (mode-laddered; ships 'off') ----
1073
+ // Generic NEW entries only: wave9 has its own frozen admission policy,
1074
+ // scale-ins and operator-approved proposal fires are exempt inside the
1075
+ // evaluator, and this branch can only ever suppress a new entry — exits,
1076
+ // closes, stops, and emergency paths never route through it. Mode resolves
1077
+ // PER CALL (central → file → off) so a gate flip hot-applies, same as the
1078
+ // exit gate. See tools/reentry-cooldown.ts for the measurement behind it.
1079
+ let reentryCooldownEval;
1080
+ if (!wave9Claimed) {
1081
+ const rcMode = loadReentryCooldownMode();
1082
+ if (rcMode !== 'off') {
1083
+ const symbolKey = normalizeBracketSymbol(args.symbol);
1084
+ reentryCooldownEval = evaluateReentryCooldown({
1085
+ symbol: args.symbol,
1086
+ mode: rcMode,
1087
+ tracker: deps.autoCapture?.reentryTracker,
1088
+ book: deps.adapter.isLive ? 'live' : 'paper',
1089
+ cooldownMinutes: loadReentryCooldownMinutes(),
1090
+ hasOpenPosition: portfolio.positions.some((p) => normalizeBracketSymbol(p.symbol) === symbolKey && Math.abs(p.quantity) > 0),
1091
+ isListenerFire: deps.entryClientOrderId != null,
1092
+ });
1093
+ if (reentryCooldownEval.triggered) {
1094
+ const detail = `${args.symbol} ${side}: lossy close ${reentryCooldownEval.minutesSinceLoss}m ago ` +
1095
+ `(window=${reentryCooldownEval.cooldownMinutes}m, mode=${rcMode}, ` +
1096
+ `loss_source=${reentryCooldownEval.lastLoss?.lossSource ?? 'unknown'})`;
1097
+ if (rcMode === 'shadow') {
1098
+ logger.info('reentry-cooldown', `WOULD BLOCK (shadow) ${detail}`);
1099
+ }
1100
+ else {
1101
+ logger.warn('reentry-cooldown', `${rcMode === 'enforce' ? 'BLOCKED' : 'WOULD BLOCK (observe)'} ${detail}`);
1102
+ }
1103
+ if (reentryCooldownEval.blocked) {
1104
+ return { error: buildReentryCooldownRejection(reentryCooldownEval, args.symbol) };
1105
+ }
1106
+ }
1107
+ }
1108
+ }
1043
1109
  // Build position metadata from optional args (only if any metadata provided)
1044
1110
  // Sanitize numeric metadata — reject non-finite values, clamp ranges
1045
1111
  const num = (v) => typeof v === 'number' && Number.isFinite(v) ? v : undefined;
@@ -1047,9 +1113,20 @@ export async function createOrderTool(args, deps) {
1047
1113
  const n = num(v);
1048
1114
  return n != null ? Math.max(min, Math.min(max, n)) : undefined;
1049
1115
  };
1116
+ // Journal-tag a TRIGGERED cooldown eval (shadow/observe fire-anyway cohort —
1117
+ // the measurement rows the promote-to-enforce decision reads).
1118
+ const reentryCooldownTag = reentryCooldownEval?.triggered
1119
+ ? {
1120
+ mode: reentryCooldownEval.mode,
1121
+ minutesSinceLoss: reentryCooldownEval.minutesSinceLoss ?? 0,
1122
+ cooldownMinutes: reentryCooldownEval.cooldownMinutes,
1123
+ wouldBlock: true,
1124
+ }
1125
+ : undefined;
1050
1126
  const hasMetadata = args.mission_id || args.setup_type || args.thesis || args.target_price != null
1051
1127
  || args.regime || args.scorecard_verdict || args.confluence_score != null
1052
- || args.invalidation_price != null || args.realization_rule != null;
1128
+ || args.invalidation_price != null || args.realization_rule != null
1129
+ || reentryCooldownTag != null;
1053
1130
  const metadata = hasMetadata ? {
1054
1131
  missionId: args.mission_id,
1055
1132
  setupType: args.setup_type,
@@ -1068,6 +1145,7 @@ export async function createOrderTool(args, deps) {
1068
1145
  confluenceScore: clamp(args.confluence_score, 0, 10),
1069
1146
  invalidationPrice: num(args.invalidation_price),
1070
1147
  realizationRule: sanitizeRealizationRule(args.realization_rule),
1148
+ reentryCooldown: reentryCooldownTag,
1071
1149
  } : undefined;
1072
1150
  // ---- Approval-mode branches ----
1073
1151
  // Both shadow and per_trade need full v2.10.0 metadata so the proposal is
@@ -1083,14 +1161,48 @@ export async function createOrderTool(args, deps) {
1083
1161
  typeof args.confluence_score === 'number');
1084
1162
  const proposalPathWired = !!(deps.proposalManager && deps.userId);
1085
1163
  const approvalMode = deps.approvalMode ?? 'off';
1164
+ // per_trade + proposal path unwired: FAIL CLOSED.
1165
+ //
1166
+ // The operator asked for a human gate on every entry. If we can't deliver
1167
+ // the proposal (no ingest token / no REEFCLAW_USER_ID), the honest failure
1168
+ // is "no trade", not "trade without the gate". This branch previously fell
1169
+ // through to the normal fire path so the agent was never trapped in an
1170
+ // unfireable state — but that turned a misconfiguration into a live order
1171
+ // the operator never approved, silently, with only a boot-time warning as
1172
+ // the signal. A silent bypass of an explicitly-requested safety gate is the
1173
+ // one outcome this feature exists to prevent (Locked Decision #2: never
1174
+ // silent fire). Credentials drift for ordinary reasons — token rotation
1175
+ // touches four files (docs/CLAUDE/token-rotation.md), and a freshly
1176
+ // provisioned box can boot before they land — so this is a reachable state,
1177
+ // not a theoretical one.
1178
+ //
1179
+ // Halting entries is the safe direction: exits, closes, stops and every
1180
+ // operator emergency control are unaffected (they don't route through the
1181
+ // approval branch), so a mis-wired box can still protect and unwind an open
1182
+ // book — it just can't open new risk without the gate the operator asked
1183
+ // for. Logged at ERROR on EVERY occurrence, not once at boot, so the signal
1184
+ // is present at the time the trade is refused.
1185
+ if (approvalMode === 'per_trade' && !proposalPathWired) {
1186
+ logger.error('create-order', `approval.mode=per_trade but the proposal path is not wired — REFUSING to fire ` +
1187
+ `${args.symbol} ${side} (fail-closed). Missing plugin connectionToken/WEBAPP_INGEST_TOKEN ` +
1188
+ `and/or REEFCLAW_USER_ID. Restore the credentials, or set approval.mode=off to trade ` +
1189
+ `autonomously again.`);
1190
+ return {
1191
+ error: 'Order refused: approval mode is per_trade but this box cannot deliver the proposal ' +
1192
+ 'to the operator (missing connection token and/or REEFCLAW_USER_ID), so there is no ' +
1193
+ 'way for the operator to approve it. Refusing to fire un-approved — this is fail-closed ' +
1194
+ 'by design, not a transient error. Tell the operator: either restore the plugin ' +
1195
+ 'connection credentials, or set approval.mode=off to resume autonomous trading. ' +
1196
+ 'Do not retry until they confirm one of those. Closing positions, stops and brackets ' +
1197
+ 'are unaffected.',
1198
+ };
1199
+ }
1086
1200
  // per_trade mode: propose and EARLY-RETURN. The agent's call doesn't fire;
1087
1201
  // the operator approves and ProposalDecisionListener fires via this same
1088
1202
  // tool with proposalManager omitted (which takes the real path below).
1089
1203
  //
1090
- // If proposalManager isn't wired (operator set approval.mode=per_trade in
1091
- // config but ingest creds aren't present), we fall through to the normal
1092
- // fire path — the startup warning is the operator's signal. We never trap
1093
- // the agent inside an unfireable per_trade branch.
1204
+ // NOTE the listener's own fire passes NO approvalMode (defaults to 'off')
1205
+ // and no proposalManager, so it never re-enters this branch.
1094
1206
  if (approvalMode === 'per_trade' && proposalPathWired) {
1095
1207
  if (!argsMetadataComplete) {
1096
1208
  return {