@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.
- package/bridge/bridge.js +72 -5
- package/bridge/connector.d.ts +3 -1
- package/bridge/connector.js +51 -4
- package/bridge/gateway/heartbeat-cron.js +31 -7
- package/bridge/gateway/poller.d.ts +5 -0
- package/bridge/gateway/poller.js +9 -0
- package/bridge/index.js +21 -0
- package/bridge/provider.d.ts +15 -0
- package/bridge/providers/connector-update.d.ts +89 -0
- package/bridge/providers/connector-update.js +212 -0
- package/bridge/providers/emergency-commands.d.ts +36 -0
- package/bridge/providers/emergency-commands.js +91 -0
- package/bridge/providers/gateway.d.ts +26 -1
- package/bridge/providers/gateway.js +159 -8
- package/bridge/providers/mock.js +1 -0
- package/bridge/shock-wake.d.ts +80 -0
- package/bridge/shock-wake.js +291 -0
- package/bridge/types.d.ts +5 -1
- package/bridge/types.js +5 -0
- package/bridge/utils/instance-id.d.ts +3 -0
- package/bridge/utils/instance-id.js +48 -0
- package/ccxt/binance-private.js +2 -1
- package/ccxt/binance-public.js +6 -1
- package/config/agent-config-client.d.ts +7 -2
- package/config/agent-config-client.js +17 -0
- package/config/agent-config-poller.js +5 -1
- package/config/brackets-config.d.ts +2 -1
- package/config/brackets-config.js +25 -3
- package/config/gate-store.d.ts +12 -0
- package/config/gate-store.js +26 -2
- package/config/loss-streak-config.d.ts +2 -0
- package/config/loss-streak-config.js +33 -0
- package/config/plugin-config-io.d.ts +19 -0
- package/config/plugin-config-io.js +24 -2
- package/config/reentry-cooldown-config.d.ts +7 -0
- package/config/reentry-cooldown-config.js +59 -0
- package/http/keepalive-fetch.d.ts +5 -0
- package/http/keepalive-fetch.js +50 -0
- package/index.js +77 -8
- package/ingest/position-auto-capture.js +49 -4
- package/ingest/position-decisions-client.d.ts +6 -0
- package/ingest/position-decisions-client.js +27 -9
- package/ingest/readiness-reporter.d.ts +23 -2
- package/ingest/readiness-reporter.js +56 -1
- package/live/approval-lifecycle.d.ts +10 -0
- package/live/approval-lifecycle.js +16 -2
- package/live/microstructure-assembler.js +11 -2
- package/live/proposal-decision-listener.d.ts +21 -0
- package/live/proposal-decision-listener.js +39 -0
- package/live/proposal-manager.d.ts +12 -0
- package/live/proposal-manager.js +47 -0
- package/live/stop-watcher.d.ts +16 -1
- package/live/stop-watcher.js +48 -8
- package/onboarding/runtime.js +4 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +38 -38
- package/persistence/state-manager.d.ts +7 -0
- package/persistence/state-manager.js +28 -1
- package/portfolio/directional-scoreboard.d.ts +17 -0
- package/portfolio/directional-scoreboard.js +71 -0
- package/portfolio/reentry-tracker.d.ts +38 -1
- package/portfolio/reentry-tracker.js +49 -0
- package/signals/change-of-character.d.ts +38 -0
- package/signals/change-of-character.js +93 -0
- package/simulator/exchange-simulator.d.ts +27 -1
- package/simulator/exchange-simulator.js +98 -38
- package/simulator/types.d.ts +11 -0
- package/skills/reefclaw/SKILL.md +2 -2
- package/strategy/evaluator.d.ts +4 -0
- package/tools/audit-bracket-protection.js +11 -7
- package/tools/close-position.js +10 -1
- package/tools/create-order.js +121 -9
- package/tools/get-funding-context.js +6 -1
- package/tools/get-liquidation-levels.js +5 -1
- package/tools/get-liquidation-pulse.js +7 -1
- package/tools/get-market-intel.js +2 -1
- package/tools/get-relevant-learnings.js +20 -1
- package/tools/get-resting-liquidity.js +6 -1
- package/tools/get-wave9-status.js +17 -0
- package/tools/hl-provision-agent-wallet.js +29 -11
- package/tools/intel-api.d.ts +9 -0
- package/tools/intel-api.js +32 -1
- package/tools/record-position-reviews.js +2 -2
- package/tools/reentry-cooldown.d.ts +33 -0
- package/tools/reentry-cooldown.js +74 -0
- package/tools/scan-pairs.d.ts +7 -0
- package/tools/scan-pairs.js +67 -11
- package/tools/set-exchange-credentials.js +19 -0
- package/tools/set-trading-mode.d.ts +6 -0
- package/tools/set-trading-mode.js +48 -1
- package/types.d.ts +7 -0
- package/venues/hyperliquid/hl-agent-wallet.d.ts +26 -0
- package/venues/hyperliquid/hl-agent-wallet.js +32 -0
- package/venues/hyperliquid/hl-live-adapter.d.ts +27 -2
- 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
|
|
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
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
700
|
-
|
|
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
|
|
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
|
-
|
|
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;
|
package/simulator/types.d.ts
CHANGED
|
@@ -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
|
package/skills/reefclaw/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: reefclaw
|
|
3
|
-
version: 0.0.
|
|
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**
|
|
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
|
|
package/strategy/evaluator.d.ts
CHANGED
|
@@ -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
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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) {
|
package/tools/close-position.js
CHANGED
|
@@ -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)
|
package/tools/create-order.js
CHANGED
|
@@ -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:
|
|
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
|
-
//
|
|
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
|
-
//
|
|
1091
|
-
//
|
|
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 {
|