@reefclaw/openclaw-plugin 0.1.23 → 0.1.24

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 (57) hide show
  1. package/bridge/bridge.js +72 -5
  2. package/bridge/connector.js +14 -2
  3. package/bridge/gateway/heartbeat-cron.js +30 -7
  4. package/bridge/gateway/poller.d.ts +5 -0
  5. package/bridge/gateway/poller.js +9 -0
  6. package/bridge/provider.d.ts +15 -0
  7. package/bridge/providers/connector-update.d.ts +89 -0
  8. package/bridge/providers/connector-update.js +212 -0
  9. package/bridge/providers/emergency-commands.d.ts +36 -0
  10. package/bridge/providers/emergency-commands.js +91 -0
  11. package/bridge/providers/gateway.d.ts +26 -1
  12. package/bridge/providers/gateway.js +159 -8
  13. package/bridge/providers/mock.js +1 -0
  14. package/bridge/types.d.ts +1 -1
  15. package/bridge/types.js +5 -0
  16. package/ccxt/binance-private.js +2 -1
  17. package/ccxt/binance-public.js +6 -1
  18. package/config/agent-config-client.d.ts +5 -2
  19. package/config/agent-config-client.js +13 -0
  20. package/config/agent-config-poller.js +5 -1
  21. package/config/gate-store.d.ts +9 -0
  22. package/config/gate-store.js +17 -2
  23. package/config/plugin-config-io.js +24 -2
  24. package/http/keepalive-fetch.d.ts +5 -0
  25. package/http/keepalive-fetch.js +50 -0
  26. package/index.js +48 -6
  27. package/ingest/position-decisions-client.d.ts +6 -0
  28. package/ingest/position-decisions-client.js +27 -9
  29. package/live/approval-lifecycle.d.ts +10 -0
  30. package/live/approval-lifecycle.js +16 -2
  31. package/live/microstructure-assembler.js +11 -2
  32. package/live/proposal-decision-listener.d.ts +21 -0
  33. package/live/proposal-decision-listener.js +39 -0
  34. package/live/proposal-manager.d.ts +12 -0
  35. package/live/proposal-manager.js +47 -0
  36. package/live/stop-watcher.d.ts +16 -1
  37. package/live/stop-watcher.js +48 -8
  38. package/openclaw.plugin.json +1 -1
  39. package/package.json +38 -38
  40. package/persistence/state-manager.d.ts +7 -0
  41. package/persistence/state-manager.js +28 -1
  42. package/simulator/exchange-simulator.d.ts +22 -0
  43. package/simulator/exchange-simulator.js +74 -32
  44. package/tools/audit-bracket-protection.js +11 -7
  45. package/tools/create-order.js +49 -7
  46. package/tools/get-funding-context.js +6 -1
  47. package/tools/get-liquidation-levels.js +5 -1
  48. package/tools/get-liquidation-pulse.js +7 -1
  49. package/tools/get-market-intel.js +2 -1
  50. package/tools/get-relevant-learnings.js +20 -1
  51. package/tools/get-resting-liquidity.js +6 -1
  52. package/tools/get-wave9-status.js +17 -0
  53. package/tools/intel-api.d.ts +9 -0
  54. package/tools/intel-api.js +32 -1
  55. package/tools/record-position-reviews.js +2 -2
  56. package/tools/scan-pairs.js +20 -11
  57. package/types.d.ts +7 -0
package/package.json CHANGED
@@ -1,38 +1,38 @@
1
- {
2
- "name": "@reefclaw/openclaw-plugin",
3
- "version": "0.1.23",
4
- "description": "ReefClaw supervised trading plugin for OpenClaw. Runs entirely on YOUR machine and starts in PAPER mode \u00e2\u20ac\u201d it cannot trade real funds until you supply exchange credentials and walk the PAPER\u00e2\u2020\u2019MICRO_LIVE\u00e2\u2020\u2019LIVE ladder yourself from the ReefClaw dashboard (the agent cannot make that change; it is refused without operator provenance). Your exchange API keys stay on your machine to sign requests to the exchange and are NEVER sent to ReefClaw \u00e2\u20ac\u201d a test in the package asserts this. What does reach ReefClaw is trading telemetry for the dashboard (positions, fills, decision journal). Live trading always carries exchange-native protective stops. Trading instructions can be updated remotely, and every update must carry a valid Ed25519 signature verified against a key pinned in this build before it is applied. Install: /plugins install clawhub:@reefclaw/openclaw-plugin",
5
- "type": "module",
6
- "main": "index.js",
7
- "openclaw": {
8
- "extensions": [
9
- "./index.js"
10
- ],
11
- "compat": {
12
- "pluginApi": ">=2026.6.0"
13
- },
14
- "build": {
15
- "openclawVersion": "2026.6.11"
16
- }
17
- },
18
- "files": [
19
- "**/*",
20
- "!scripts/**"
21
- ],
22
- "engines": {
23
- "node": ">=20"
24
- },
25
- "dependencies": {
26
- "@reefclaw/shared": "0.1.4",
27
- "ccxt": "4.5.37",
28
- "json5": "2.2.3",
29
- "ws": "8.21.1"
30
- },
31
- "scripts": {
32
- "build": "node scripts/assemble.mjs",
33
- "verify": "node scripts/verify-shared-contract.mjs",
34
- "prepublishOnly": "node scripts/verify-shared-contract.mjs"
35
- },
36
- "license": "MIT",
37
- "homepage": "https://reefclaw.com"
38
- }
1
+ {
2
+ "name": "@reefclaw/openclaw-plugin",
3
+ "version": "0.1.24",
4
+ "description": "ReefClaw supervised trading plugin for OpenClaw. Runs entirely on YOUR machine and starts in PAPER mode — it cannot trade real funds until you supply exchange credentials and walk the PAPER→MICRO_LIVE→LIVE ladder yourself from the ReefClaw dashboard (the agent cannot make that change; it is refused without operator provenance). Your exchange API keys stay on your machine to sign requests to the exchange and are NEVER sent to ReefClaw — a test in the package asserts this. What does reach ReefClaw is trading telemetry for the dashboard (positions, fills, decision journal). Live trading always carries exchange-native protective stops. Trading instructions can be updated remotely, and every update must carry a valid Ed25519 signature verified against a key pinned in this build before it is applied. Install: /plugins install clawhub:@reefclaw/openclaw-plugin",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "openclaw": {
8
+ "extensions": [
9
+ "./index.js"
10
+ ],
11
+ "compat": {
12
+ "pluginApi": ">=2026.6.0"
13
+ },
14
+ "build": {
15
+ "openclawVersion": "2026.6.11"
16
+ }
17
+ },
18
+ "files": [
19
+ "**/*",
20
+ "!scripts/**"
21
+ ],
22
+ "engines": {
23
+ "node": ">=20"
24
+ },
25
+ "dependencies": {
26
+ "@reefclaw/shared": "0.1.4",
27
+ "ccxt": "4.5.37",
28
+ "json5": "2.2.3",
29
+ "ws": "8.21.1"
30
+ },
31
+ "scripts": {
32
+ "build": "node scripts/assemble.mjs",
33
+ "verify": "node scripts/verify-shared-contract.mjs",
34
+ "prepublishOnly": "node scripts/verify-shared-contract.mjs"
35
+ },
36
+ "license": "MIT",
37
+ "homepage": "https://reefclaw.com"
38
+ }
@@ -1,6 +1,7 @@
1
1
  import type { SimulatorState } from '../simulator/types.js';
2
2
  export declare class StateManager {
3
3
  private readonly statePath;
4
+ private lastLoadedStat?;
4
5
  private saveTimer;
5
6
  private pendingState;
6
7
  constructor(pluginId: string, baseDir?: string);
@@ -37,6 +38,12 @@ export declare class StateManager {
37
38
  * missing-vs-corrupt distinction as load(): a corrupt file is quarantined
38
39
  * (preserved), never silently overwritten by the default the caller seeds. */
39
40
  loadSync(): SimulatorState | null;
41
+ /** loadSync that returns null when the file is unchanged since the last
42
+ * loadSync. The reload exists for the two-process case (another process
43
+ * wrote the file); an unchanged file means the caller's replaceState would
44
+ * be a no-op re-parse of the whole trade history — which grows with the
45
+ * account's age and was being paid on every paper-mode tool call. */
46
+ loadSyncIfChanged(): SimulatorState | null;
40
47
  /** Synchronous load-or-create-default — for use in synchronous plugin register(). */
41
48
  loadOrDefaultSync(startingBalance: number, quoteCurrency: string): SimulatorState;
42
49
  private saveSyncSafe;
@@ -2,7 +2,7 @@
2
2
  // Saves/loads state to ~/.openclaw/plugins/reefclaw-paper-trading/state.json
3
3
  // Uses atomic writes (temp file + rename) to prevent corruption.
4
4
  import { readFile, writeFile, rename, mkdir, unlink } from 'node:fs/promises';
5
- import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
5
+ import { existsSync, readFileSync, statSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
6
6
  import { homedir } from 'node:os';
7
7
  import { join, dirname } from 'node:path';
8
8
  import { randomUUID } from 'node:crypto';
@@ -11,6 +11,7 @@ import { createDefaultState } from '../simulator/types.js';
11
11
  const TAG = 'state-manager';
12
12
  export class StateManager {
13
13
  statePath;
14
+ lastLoadedStat;
14
15
  saveTimer = null;
15
16
  pendingState = null;
16
17
  constructor(pluginId, baseDir) {
@@ -131,8 +132,17 @@ export class StateManager {
131
132
  return null;
132
133
  }
133
134
  try {
135
+ // Stat BEFORE read: a write racing in between then causes one extra
136
+ // reload next time, never a skipped-but-needed one.
137
+ let stat;
138
+ try {
139
+ const s = statSync(this.statePath, { bigint: true });
140
+ stat = { mtimeNs: s.mtimeNs, size: s.size };
141
+ }
142
+ catch { /* stat raced a delete — plain read below decides */ }
134
143
  const raw = readFileSync(this.statePath, 'utf-8');
135
144
  const state = JSON.parse(raw);
145
+ this.lastLoadedStat = stat;
136
146
  logger.info(TAG, `Loaded state: ${state.positions.length} positions, ${state.openOrders.length} orders, ${state.tradeHistory.length} trades`);
137
147
  return state;
138
148
  }
@@ -141,6 +151,23 @@ export class StateManager {
141
151
  return null;
142
152
  }
143
153
  }
154
+ /** loadSync that returns null when the file is unchanged since the last
155
+ * loadSync. The reload exists for the two-process case (another process
156
+ * wrote the file); an unchanged file means the caller's replaceState would
157
+ * be a no-op re-parse of the whole trade history — which grows with the
158
+ * account's age and was being paid on every paper-mode tool call. */
159
+ loadSyncIfChanged() {
160
+ try {
161
+ const s = statSync(this.statePath, { bigint: true });
162
+ if (this.lastLoadedStat &&
163
+ this.lastLoadedStat.mtimeNs === s.mtimeNs &&
164
+ this.lastLoadedStat.size === s.size) {
165
+ return null;
166
+ }
167
+ }
168
+ catch { /* missing/unstatable — loadSync handles + logs it */ }
169
+ return this.loadSync();
170
+ }
144
171
  /** Synchronous load-or-create-default — for use in synchronous plugin register(). */
145
172
  loadOrDefaultSync(startingBalance, quoteCurrency) {
146
173
  const loaded = this.loadSync();
@@ -1,6 +1,28 @@
1
1
  import { EventEmitter } from 'node:events';
2
2
  import type { SimulatorState, OrderBookDepth, SimulationConfig, ExecutionStats, PositionMetadata, CloseReason } from './types.js';
3
3
  import type { CcxtOrder, CcxtBalance, CcxtPosition, CcxtTicker } from '../types.js';
4
+ /** Canonical key for the per-symbol quote/orderbook caches and for matching a
5
+ * ticker against stored positions/orders.
6
+ *
7
+ * CCXT echoes the VENUE-UNIFIED symbol back from fetchTicker: ask Binance USDM
8
+ * for 'ETH/USDT' and the ticker returns as 'ETH/USDT:USDT'. Positions and
9
+ * orders, however, are stored under whatever form the caller opened them with,
10
+ * so `lastTicker.get(position.symbol)` silently missed for every position held
11
+ * in the un-suffixed form. The consequences were all silent:
12
+ * - getPositions()/computeEquity() fell back to entryPrice, so the mark was
13
+ * FROZEN at entry — the stop-watcher compared a frozen mark, never saw a
14
+ * breach, and the position ran unprotected past its stop indefinitely;
15
+ * - createOrder() threw 'No ticker data for X' on the market leg, so the
16
+ * agent could not close by hand either (both automatic and manual exits
17
+ * were dead at once);
18
+ * - MFE, take-profit legs and resting-limit fills never advanced.
19
+ * Keying both sides through this normalizer makes the caches format-agnostic,
20
+ * which also repairs books already persisted with a mix of both forms.
21
+ *
22
+ * Same regex as webapp/src/lib/symbols.ts normalizeSymbol and
23
+ * plugin/src/venues/symbols.ts stripSettleSuffix — kept inline so the
24
+ * simulator keeps its zero-import-for-hot-path shape. */
25
+ export declare function tickerKey(symbol: string): string;
4
26
  export declare class ExchangeSimulator extends EventEmitter {
5
27
  private state;
6
28
  private lastTicker;
@@ -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,
@@ -294,7 +329,7 @@ export class ExchangeSimulator extends EventEmitter {
294
329
  return this.executeMarketFill(order, referencePrice, metadata);
295
330
  }
296
331
  // Market orders fill immediately at current price
297
- const ticker = this.lastTicker.get(symbol);
332
+ const ticker = this.lastTicker.get(tickerKey(symbol));
298
333
  if (!ticker) {
299
334
  throw new Error(`No ticker data for ${symbol}. Call updateTicker() first.`);
300
335
  }
@@ -308,7 +343,7 @@ export class ExchangeSimulator extends EventEmitter {
308
343
  // Limit order — check if it crosses the current price. A stale quote must
309
344
  // not price an immediate cross-fill (same hazard as market fills); the
310
345
  // order RESTS instead and fills on the next fresh tick via updateTicker.
311
- const ticker = this.lastTicker.get(symbol);
346
+ const ticker = this.lastTicker.get(tickerKey(symbol));
312
347
  if (ticker && this.quoteAgeMs(ticker) <= this.maxQuoteAgeMs() && this.shouldFillLimit(order, ticker.last)) {
313
348
  return this.executeLimitFill(order, ticker.last, metadata);
314
349
  }
@@ -341,7 +376,7 @@ export class ExchangeSimulator extends EventEmitter {
341
376
  const cancelled = [];
342
377
  const remaining = [];
343
378
  for (const order of this.state.openOrders) {
344
- if (!symbol || order.symbol === symbol) {
379
+ if (!symbol || sameMarket(order.symbol, symbol)) {
345
380
  order.status = 'canceled';
346
381
  cancelled.push(order);
347
382
  this.pendingOrderMetadata.delete(order.id);
@@ -364,7 +399,7 @@ export class ExchangeSimulator extends EventEmitter {
364
399
  * else, which keeps the normal market-close path byte-identical.
365
400
  */
366
401
  closePosition(symbol, closeReason, referencePrice) {
367
- const position = this.state.positions.find(p => p.symbol === symbol);
402
+ const position = this.state.positions.find(p => sameMarket(p.symbol, symbol));
368
403
  if (!position) {
369
404
  throw new Error(`No open position for ${symbol}`);
370
405
  }
@@ -387,7 +422,7 @@ export class ExchangeSimulator extends EventEmitter {
387
422
  * and getPositions both read metadata.stopPrice, so a moved stop takes
388
423
  * effect on the next watcher tick. Throws if there is no open position. (M9) */
389
424
  updatePositionMetadata(symbol, patch) {
390
- const pos = this.state.positions.find(p => p.symbol === symbol);
425
+ const pos = this.state.positions.find(p => sameMarket(p.symbol, symbol));
391
426
  if (!pos) {
392
427
  throw new Error(`No open position for ${symbol}`);
393
428
  }
@@ -401,7 +436,7 @@ export class ExchangeSimulator extends EventEmitter {
401
436
  }
402
437
  // ---- Ticker updates (checks pending limit fills) ----
403
438
  updateTicker(ticker) {
404
- this.lastTicker.set(ticker.symbol, ticker);
439
+ this.lastTicker.set(tickerKey(ticker.symbol), ticker);
405
440
  this.refreshMfeForSymbol(ticker.symbol, ticker.last);
406
441
  // MFE is refreshed FIRST so the peak this tick reached is recorded before a
407
442
  // target close reads it — otherwise every TP exit would understate its own
@@ -411,7 +446,7 @@ export class ExchangeSimulator extends EventEmitter {
411
446
  const toFill = [];
412
447
  const remaining = [];
413
448
  for (const order of this.state.openOrders) {
414
- if (order.symbol === ticker.symbol && this.shouldFillLimit(order, ticker.last)) {
449
+ if (sameMarket(order.symbol, ticker.symbol) && this.shouldFillLimit(order, ticker.last)) {
415
450
  toFill.push(order);
416
451
  }
417
452
  else {
@@ -449,7 +484,7 @@ export class ExchangeSimulator extends EventEmitter {
449
484
  }
450
485
  }
451
486
  getLastTicker(symbol) {
452
- return this.lastTicker.get(symbol);
487
+ return this.lastTicker.get(tickerKey(symbol));
453
488
  }
454
489
  /**
455
490
  * Take-profit legs — the paper analog of the exchange-native
@@ -483,7 +518,7 @@ export class ExchangeSimulator extends EventEmitter {
483
518
  if (!Number.isFinite(price) || price <= 0)
484
519
  return;
485
520
  // Snapshot: closing mutates state.positions mid-iteration.
486
- const candidates = this.state.positions.filter((p) => p.symbol === symbol);
521
+ const candidates = this.state.positions.filter((p) => sameMarket(p.symbol, symbol));
487
522
  for (const position of candidates) {
488
523
  const target = position.metadata?.targetPrice;
489
524
  if (target === undefined || !Number.isFinite(target) || target <= 0)
@@ -492,17 +527,20 @@ export class ExchangeSimulator extends EventEmitter {
492
527
  if (!breached)
493
528
  continue;
494
529
  // 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))
530
+ // symbol while the first is still settling. Keyed canonically so the
531
+ // guard still holds when the tick and the position disagree on suffix.
532
+ const pendingKey = tickerKey(position.symbol);
533
+ if (this.takeProfitPending.has(pendingKey))
497
534
  continue;
498
- this.takeProfitPending.add(symbol);
535
+ this.takeProfitPending.add(pendingKey);
499
536
  try {
500
- logger.info(TAG, `TARGET REACHED: ${symbol} ${position.side} price=${price} target=${target} — closing (exchange_target)`);
537
+ logger.info(TAG, `TARGET REACHED: ${position.symbol} ${position.side} price=${price} target=${target} — closing (exchange_target)`);
501
538
  // 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);
539
+ // slippage on top (see the fill-convention note above). Address the
540
+ // close with the POSITION's own symbol, never the ticker's form.
541
+ const order = this.closePosition(position.symbol, 'exchange_target', target);
504
542
  this.emit('target_closed', {
505
- symbol,
543
+ symbol: position.symbol,
506
544
  side: position.side,
507
545
  targetPrice: target,
508
546
  markPrice: price,
@@ -514,10 +552,10 @@ export class ExchangeSimulator extends EventEmitter {
514
552
  catch (err) {
515
553
  // Never let a failed protective close kill the tick loop — the next
516
554
  // tick retries, and the position is still visible to the agent.
517
- logger.error(TAG, `Target close failed for ${symbol}: ${formatError(err)}`);
555
+ logger.error(TAG, `Target close failed for ${position.symbol}: ${formatError(err)}`);
518
556
  }
519
557
  finally {
520
- this.takeProfitPending.delete(symbol);
558
+ this.takeProfitPending.delete(pendingKey);
521
559
  }
522
560
  }
523
561
  }
@@ -530,7 +568,7 @@ export class ExchangeSimulator extends EventEmitter {
530
568
  return;
531
569
  let peakAdvanced = false;
532
570
  for (const p of this.state.positions) {
533
- if (p.symbol !== symbol)
571
+ if (!sameMarket(p.symbol, symbol))
534
572
  continue;
535
573
  const meta = { ...(p.metadata ?? {}) };
536
574
  const priorPeak = meta.mfePeakPrice;
@@ -602,7 +640,7 @@ export class ExchangeSimulator extends EventEmitter {
602
640
  const keptPeak = diskPeak === undefined
603
641
  ? priorPeak
604
642
  : (p.side === 'long' ? Math.max(priorPeak, diskPeak) : Math.min(priorPeak, diskPeak));
605
- const mark = this.lastTicker.get(p.symbol)?.last ?? meta.originalEntryPrice ?? p.entryPrice;
643
+ const mark = this.lastTicker.get(tickerKey(p.symbol))?.last ?? meta.originalEntryPrice ?? p.entryPrice;
606
644
  const out = updateMfe({
607
645
  side: p.side,
608
646
  entryPrice: meta.originalEntryPrice ?? p.entryPrice,
@@ -671,7 +709,7 @@ export class ExchangeSimulator extends EventEmitter {
671
709
  const max = this.maxQuoteAgeMs();
672
710
  if (age <= max)
673
711
  return;
674
- const pos = this.state.positions.find(p => p.symbol === symbol);
712
+ const pos = this.state.positions.find(p => sameMarket(p.symbol, symbol));
675
713
  // The order side reaching here is the one being filled — derive reduce vs
676
714
  // grow from the position side at the call site instead? The market path
677
715
  // calls this before fill with the order side unavailable; use position
@@ -696,8 +734,10 @@ export class ExchangeSimulator extends EventEmitter {
696
734
  : currentPrice >= order.price;
697
735
  }
698
736
  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;
737
+ // Match canonically: a miss here would hand fillMarketOrder position=null,
738
+ // turning a CLOSE into a brand-new opposing position.
739
+ const position = this.state.positions.find(p => sameMarket(p.symbol, order.symbol)) ?? null;
740
+ const orderbook = this.lastOrderBook.get(tickerKey(order.symbol)) ?? null;
701
741
  const realistic = {
702
742
  orderbook,
703
743
  config: this.simulationConfig,
@@ -707,7 +747,7 @@ export class ExchangeSimulator extends EventEmitter {
707
747
  const result = fillMarketOrder(order, currentPrice, this.state.wallet, position, realistic);
708
748
  // Observability for issue #202: stamp the quote's age onto the fill's
709
749
  // execution-quality record so staleness is visible in trade history.
710
- const tickerAtFill = this.lastTicker.get(order.symbol);
750
+ const tickerAtFill = this.lastTicker.get(tickerKey(order.symbol));
711
751
  if (result.executionQuality && tickerAtFill) {
712
752
  result.executionQuality.quoteAgeMs = this.quoteAgeMs(tickerAtFill);
713
753
  }
@@ -725,7 +765,7 @@ export class ExchangeSimulator extends EventEmitter {
725
765
  return ccxtOrder;
726
766
  }
727
767
  executeLimitFill(order, decisionPrice, metadata) {
728
- const position = this.state.positions.find(p => p.symbol === order.symbol) ?? null;
768
+ const position = this.state.positions.find(p => sameMarket(p.symbol, order.symbol)) ?? null;
729
769
  const result = fillLimitOrder(order, this.state.wallet, position, this.simulationConfig, decisionPrice, metadata);
730
770
  const ccxtOrder = this.applyFillResult(result);
731
771
  const eq = result.executionQuality;
@@ -778,7 +818,9 @@ export class ExchangeSimulator extends EventEmitter {
778
818
  return ccxtOrder;
779
819
  }
780
820
  updatePosition(symbol, newPosition) {
781
- const idx = this.state.positions.findIndex(p => p.symbol === symbol);
821
+ // Canonical match: an exact-compare miss here would append a DUPLICATE
822
+ // position row instead of replacing/removing the existing one.
823
+ const idx = this.state.positions.findIndex(p => sameMarket(p.symbol, symbol));
782
824
  if (newPosition) {
783
825
  if (idx >= 0) {
784
826
  this.state.positions[idx] = newPosition;
@@ -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) {
@@ -1,7 +1,7 @@
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';
@@ -875,6 +875,7 @@ export async function createOrderTool(args, deps) {
875
875
  }
876
876
  // In paper mode, fetch latest ticker + order book for realistic fills
877
877
  let ticker = null;
878
+ let livePortfolioPromise;
878
879
  if (!deps.adapter.isLive) {
879
880
  const paperDeps = { binanceApi: deps.binanceApi, simulator: deps.adapter.getSimulator() };
880
881
  const [priceResult] = await Promise.all([
@@ -886,7 +887,14 @@ export async function createOrderTool(args, deps) {
886
887
  ticker = priceResult;
887
888
  }
888
889
  else {
889
- // Live mode: fetch ticker for risk check reference price
890
+ // Live mode: the risk-gate portfolio snapshot (balance + positions) has
891
+ // no dependency on the ticker — start it here so the two exchange
892
+ // round-trips overlap; awaited at the risk check below. The catch-keeper
893
+ // only silences the unhandled rejection an early return would otherwise
894
+ // leave floating; the later await still surfaces the real error.
895
+ livePortfolioPromise = buildPortfolioSnapshotFromAdapter(deps.adapter);
896
+ livePortfolioPromise.catch(() => { });
897
+ // Fetch ticker for risk check reference price
890
898
  const lastPrice = await deps.adapter.getLastPrice(args.symbol);
891
899
  if (lastPrice != null) {
892
900
  ticker = {
@@ -1003,7 +1011,7 @@ export async function createOrderTool(args, deps) {
1003
1011
  consecutiveLosses = computeConsecutiveLosses(simulator.getState().tradeHistory);
1004
1012
  }
1005
1013
  else {
1006
- portfolio = await buildPortfolioSnapshotFromAdapter(deps.adapter);
1014
+ portfolio = await (livePortfolioPromise ?? buildPortfolioSnapshotFromAdapter(deps.adapter));
1007
1015
  // In live mode, consecutive losses would come from intelligence DB — use 0 for now
1008
1016
  }
1009
1017
  // Bracket enforcement only applies in live mode when the feature is enabled.
@@ -1083,14 +1091,48 @@ export async function createOrderTool(args, deps) {
1083
1091
  typeof args.confluence_score === 'number');
1084
1092
  const proposalPathWired = !!(deps.proposalManager && deps.userId);
1085
1093
  const approvalMode = deps.approvalMode ?? 'off';
1094
+ // per_trade + proposal path unwired: FAIL CLOSED.
1095
+ //
1096
+ // The operator asked for a human gate on every entry. If we can't deliver
1097
+ // the proposal (no ingest token / no REEFCLAW_USER_ID), the honest failure
1098
+ // is "no trade", not "trade without the gate". This branch previously fell
1099
+ // through to the normal fire path so the agent was never trapped in an
1100
+ // unfireable state — but that turned a misconfiguration into a live order
1101
+ // the operator never approved, silently, with only a boot-time warning as
1102
+ // the signal. A silent bypass of an explicitly-requested safety gate is the
1103
+ // one outcome this feature exists to prevent (Locked Decision #2: never
1104
+ // silent fire). Credentials drift for ordinary reasons — token rotation
1105
+ // touches four files (docs/CLAUDE/token-rotation.md), and a freshly
1106
+ // provisioned box can boot before they land — so this is a reachable state,
1107
+ // not a theoretical one.
1108
+ //
1109
+ // Halting entries is the safe direction: exits, closes, stops and every
1110
+ // operator emergency control are unaffected (they don't route through the
1111
+ // approval branch), so a mis-wired box can still protect and unwind an open
1112
+ // book — it just can't open new risk without the gate the operator asked
1113
+ // for. Logged at ERROR on EVERY occurrence, not once at boot, so the signal
1114
+ // is present at the time the trade is refused.
1115
+ if (approvalMode === 'per_trade' && !proposalPathWired) {
1116
+ logger.error('create-order', `approval.mode=per_trade but the proposal path is not wired — REFUSING to fire ` +
1117
+ `${args.symbol} ${side} (fail-closed). Missing plugin connectionToken/WEBAPP_INGEST_TOKEN ` +
1118
+ `and/or REEFCLAW_USER_ID. Restore the credentials, or set approval.mode=off to trade ` +
1119
+ `autonomously again.`);
1120
+ return {
1121
+ error: 'Order refused: approval mode is per_trade but this box cannot deliver the proposal ' +
1122
+ 'to the operator (missing connection token and/or REEFCLAW_USER_ID), so there is no ' +
1123
+ 'way for the operator to approve it. Refusing to fire un-approved — this is fail-closed ' +
1124
+ 'by design, not a transient error. Tell the operator: either restore the plugin ' +
1125
+ 'connection credentials, or set approval.mode=off to resume autonomous trading. ' +
1126
+ 'Do not retry until they confirm one of those. Closing positions, stops and brackets ' +
1127
+ 'are unaffected.',
1128
+ };
1129
+ }
1086
1130
  // per_trade mode: propose and EARLY-RETURN. The agent's call doesn't fire;
1087
1131
  // the operator approves and ProposalDecisionListener fires via this same
1088
1132
  // tool with proposalManager omitted (which takes the real path below).
1089
1133
  //
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.
1134
+ // NOTE the listener's own fire passes NO approvalMode (defaults to 'off')
1135
+ // and no proposalManager, so it never re-enters this branch.
1094
1136
  if (approvalMode === 'per_trade' && proposalPathWired) {
1095
1137
  if (!argsMetadataComplete) {
1096
1138
  return {
@@ -12,5 +12,10 @@
12
12
  // FUNDING_OVERLAY flag is off or the symbol has < 100 30d samples.
13
13
  import { fetchIntelApi, enc, resolveIntelSymbol } from './intel-api.js';
14
14
  export async function getFundingContextTool(args, deps) {
15
- return fetchIntelApi(`/api/funding/${enc(resolveIntelSymbol(deps, args.symbol))}`, deps);
15
+ // Server-side percentile context already has a 15-min TTL — 60s here just
16
+ // collapses same-heartbeat repeats.
17
+ return fetchIntelApi(`/api/funding/${enc(resolveIntelSymbol(deps, args.symbol))}`, deps, {
18
+ cacheTtlMs: 60_000,
19
+ timeoutMs: 10_000,
20
+ });
16
21
  }
@@ -3,5 +3,9 @@
3
3
  import { fetchIntelApi, enc, resolveIntelSymbol } from './intel-api.js';
4
4
  export async function getLiquidationLevelsTool(args, deps) {
5
5
  const hours = args.hours ?? 24;
6
- return fetchIntelApi(`/api/liquidation-levels/${enc(resolveIntelSymbol(deps, args.symbol))}?hours=${hours}`, deps);
6
+ // Historical clusters move slowly — 30s TTL collapses per-position repeats.
7
+ return fetchIntelApi(`/api/liquidation-levels/${enc(resolveIntelSymbol(deps, args.symbol))}?hours=${hours}`, deps, {
8
+ cacheTtlMs: 30_000,
9
+ timeoutMs: 10_000,
10
+ });
7
11
  }
@@ -13,7 +13,13 @@ export async function getLiquidationPulseTool(args, deps) {
13
13
  params.set('symbol', resolveIntelSymbol(deps, args.symbol));
14
14
  const windowSeconds = clamp(args.window_seconds ?? 60, 5, 300);
15
15
  params.set('window_seconds', String(windowSeconds));
16
- return fetchIntelApi(`/api/liquidation-pulse?${params.toString()}`, deps);
16
+ // 10s TTL: short enough that an active_cascade classification is never
17
+ // stale-read into the override decision (cascades persist 60-180s), long
18
+ // enough to collapse the per-position repeats within one heartbeat.
19
+ return fetchIntelApi(`/api/liquidation-pulse?${params.toString()}`, deps, {
20
+ cacheTtlMs: 10_000,
21
+ timeoutMs: 10_000,
22
+ });
17
23
  }
18
24
  function clamp(n, lo, hi) {
19
25
  if (!Number.isFinite(n))