@reefclaw/openclaw-plugin 0.1.12 → 0.1.14

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 (67) hide show
  1. package/bridge/gateway/event-parser.d.ts +19 -0
  2. package/bridge/gateway/event-parser.js +52 -0
  3. package/bridge/gateway/gateway-config.d.ts +16 -5
  4. package/bridge/gateway/gateway-config.js +68 -12
  5. package/bridge/gateway/heartbeat-cron.d.ts +1 -0
  6. package/bridge/gateway/heartbeat-cron.js +22 -1
  7. package/bridge/gateway/poller.js +18 -8
  8. package/bridge/providers/emergency-commands.d.ts +9 -1
  9. package/bridge/providers/emergency-commands.js +38 -1
  10. package/bridge/providers/gateway.d.ts +72 -1
  11. package/bridge/providers/gateway.js +241 -29
  12. package/bridge/providers/onboarding-commands.d.ts +8 -0
  13. package/bridge/providers/onboarding-commands.js +4 -4
  14. package/bridge/types.d.ts +30 -0
  15. package/bridge/utils/identity-name.d.ts +24 -0
  16. package/bridge/utils/identity-name.js +54 -0
  17. package/ccxt/binance-public.d.ts +21 -7
  18. package/ccxt/binance-public.js +70 -6
  19. package/config/operator-provenance.d.ts +6 -0
  20. package/config/operator-provenance.js +50 -0
  21. package/config/plugin-config-io.d.ts +15 -1
  22. package/config/plugin-config-io.js +24 -0
  23. package/index.js +216 -173
  24. package/ingest/event-loop-monitor.d.ts +11 -0
  25. package/ingest/event-loop-monitor.js +113 -0
  26. package/ingest/position-auto-capture.d.ts +5 -0
  27. package/ingest/position-auto-capture.js +14 -5
  28. package/ingest/readiness-reporter.d.ts +17 -6
  29. package/ingest/readiness-reporter.js +88 -9
  30. package/ingest/skill-version-reader.d.ts +16 -0
  31. package/ingest/skill-version-reader.js +64 -0
  32. package/live/approval-lifecycle.d.ts +30 -0
  33. package/live/approval-lifecycle.js +80 -0
  34. package/live/bracket-types.d.ts +9 -0
  35. package/live/live-adapter.d.ts +0 -1
  36. package/live/proposal-decision-listener.d.ts +20 -0
  37. package/live/proposal-decision-listener.js +211 -48
  38. package/onboarding/runtime.d.ts +34 -1
  39. package/onboarding/runtime.js +56 -5
  40. package/openclaw.plugin.json +1 -1
  41. package/package.json +2 -2
  42. package/simulator/exchange-simulator.d.ts +45 -2
  43. package/simulator/exchange-simulator.js +96 -4
  44. package/simulator/types.d.ts +17 -0
  45. package/tools/attach-brackets.js +50 -1
  46. package/tools/create-order.d.ts +11 -0
  47. package/tools/create-order.js +23 -2
  48. package/tools/get-risk-summary.d.ts +4 -0
  49. package/tools/get-risk-summary.js +62 -23
  50. package/venues/hyperliquid/hl-bracket-coordinator.d.ts +29 -1
  51. package/venues/hyperliquid/hl-bracket-coordinator.js +59 -2
  52. package/venues/hyperliquid/hl-brackets.d.ts +10 -0
  53. package/venues/hyperliquid/hl-brackets.js +45 -13
  54. package/venues/hyperliquid/hl-fill-ingest.d.ts +18 -0
  55. package/venues/hyperliquid/hl-fill-ingest.js +69 -0
  56. package/venues/hyperliquid/hl-live-adapter.d.ts +36 -0
  57. package/venues/hyperliquid/hl-live-adapter.js +155 -12
  58. package/venues/hyperliquid/hl-order.d.ts +35 -0
  59. package/venues/hyperliquid/hl-order.js +123 -0
  60. package/venues/hyperliquid/hl-position.d.ts +36 -0
  61. package/venues/hyperliquid/hl-position.js +127 -0
  62. package/venues/hyperliquid/hl-private.d.ts +20 -3
  63. package/venues/hyperliquid/hl-private.js +37 -6
  64. package/venues/hyperliquid/hl-public.d.ts +12 -5
  65. package/venues/hyperliquid/hl-public.js +24 -3
  66. package/venues/hyperliquid/hl-user-stream.d.ts +13 -1
  67. package/venues/hyperliquid/hl-user-stream.js +4 -1
@@ -4,6 +4,9 @@ import type { CcxtOrder, CcxtBalance, CcxtPosition, CcxtTicker } from '../types.
4
4
  export declare class ExchangeSimulator extends EventEmitter {
5
5
  private state;
6
6
  private lastTicker;
7
+ /** Symbols with a take-profit close in flight — suppresses a re-entrant
8
+ * tick firing a second close on the same position. */
9
+ private takeProfitPending;
7
10
  private lastOrderBook;
8
11
  private simulationConfig;
9
12
  /** Metadata for pending limit orders, keyed by order ID. Cleaned up on fill/cancel. */
@@ -46,10 +49,21 @@ export declare class ExchangeSimulator extends EventEmitter {
46
49
  /** Cache the latest order book snapshot for a symbol. */
47
50
  updateOrderBook(symbol: string, orderbook: OrderBookDepth): void;
48
51
  getLastOrderBook(symbol: string): OrderBookDepth | undefined;
49
- createOrder(symbol: string, side: 'buy' | 'sell', type: 'market' | 'limit', amount: number, price?: number, metadata?: PositionMetadata): CcxtOrder;
52
+ createOrder(symbol: string, side: 'buy' | 'sell', type: 'market' | 'limit', amount: number, price?: number, metadata?: PositionMetadata,
53
+ /** Paper-only market-fill price override (take-profit leg). When set, the
54
+ * market branch prices off THIS instead of the last tick, and skips the
55
+ * stale-quote guard — the caller supplied the price, so quote age is
56
+ * irrelevant, and a protective exit must never be blocked (issue #202). */
57
+ referencePrice?: number): CcxtOrder;
50
58
  cancelOrder(orderId: string): CcxtOrder;
51
59
  cancelAllOrders(symbol?: string): CcxtOrder[];
52
- closePosition(symbol: string, closeReason?: CloseReason): CcxtOrder;
60
+ /**
61
+ * @param referencePrice Paper-only fill-price override. Used by the
62
+ * take-profit leg to fill AT the target level instead of the (possibly
63
+ * gapped-past) tick price — see `checkTakeProfitLegs`. Omitted everywhere
64
+ * else, which keeps the normal market-close path byte-identical.
65
+ */
66
+ closePosition(symbol: string, closeReason?: CloseReason, referencePrice?: number): CcxtOrder;
53
67
  /** Paper-only: move an open position's MUTABLE protective levels (stopPrice /
54
68
  * targetPrice) in place and persist, WITHOUT the close+reopen round-trip
55
69
  * (which pays an extra taker fee and resets the R/MFE denominators). The
@@ -63,6 +77,35 @@ export declare class ExchangeSimulator extends EventEmitter {
63
77
  }): void;
64
78
  updateTicker(ticker: CcxtTicker): void;
65
79
  getLastTicker(symbol: string): CcxtTicker | undefined;
80
+ /**
81
+ * Take-profit legs — the paper analog of the exchange-native
82
+ * `TAKE_PROFIT_MARKET` order live attaches at entry.
83
+ *
84
+ * ★ Why this exists: paper STORED `metadata.targetPrice` and surfaced it
85
+ * (chart line, positions table) but nothing ever closed on it, so the TP was
86
+ * a drawing rather than an order. Every paper winner ran straight past its
87
+ * exit — observed live 2026-07-27 on an OP/USDT short that reached +2.07R
88
+ * against a 1.0R target. That made paper the odd one out of three: the
89
+ * BACKTEST exits at target (`backtest/engine.ts` exitReason 'target') and
90
+ * LIVE exits at target (Binance TP_MARKET leg / HL coordinator TP leg), so
91
+ * a strategy forward-validated on paper was being measured on a book that
92
+ * let every winner run.
93
+ *
94
+ * Fill convention: the TARGET LEVEL is the decision price, and the normal
95
+ * realistic-fill engine applies its own adverse slippage around it (book
96
+ * VWAP + vol factor + taker fee) — the same model every other paper fill
97
+ * uses, rather than a second hand-rolled slippage constant. When a tick gaps
98
+ * past the target we deliberately do NOT credit the gap: filling at the
99
+ * level is worse for us than filling at the gapped tick, so this stays
100
+ * conservative against both the backtest and a real TP_MARKET (which would
101
+ * fill at the gapped price).
102
+ *
103
+ * Stops stay with the PositionWatcher: it is the safety floor and re-homing
104
+ * it is a separate, riskier change. A single tick can only breach one leg
105
+ * (stop and target sit on opposite sides of entry), so there is no
106
+ * stop-vs-target ordering ambiguity to resolve here.
107
+ */
108
+ private checkTakeProfitLegs;
66
109
  /** Walk every position for `symbol` and refresh MFE / give-back from the
67
110
  * latest mark. Idempotent — pure update of `metadata.mfePeakPrice` (only
68
111
  * ratchets favourably) plus derived `mfeR` and `giveBackRatio`. Safe to
@@ -6,7 +6,7 @@
6
6
  // via SimulationConfig and OrderBookDepth.
7
7
  import { EventEmitter } from 'node:events';
8
8
  import { randomUUID } from 'node:crypto';
9
- import { logger } from '../logger.js';
9
+ import { logger, formatError } from '../logger.js';
10
10
  import { MAX_TRADE_HISTORY, DEFAULT_SIMULATION_CONFIG } from './types.js';
11
11
  import { fillMarketOrder, fillLimitOrder, parseSymbol } from './fill-engine.js';
12
12
  import { updateMfe } from '../mfe.js';
@@ -15,6 +15,9 @@ const TAG = 'simulator';
15
15
  export class ExchangeSimulator extends EventEmitter {
16
16
  state;
17
17
  lastTicker = new Map();
18
+ /** Symbols with a take-profit close in flight — suppresses a re-entrant
19
+ * tick firing a second close on the same position. */
20
+ takeProfitPending = new Set();
18
21
  lastOrderBook = new Map();
19
22
  simulationConfig;
20
23
  /** Metadata for pending limit orders, keyed by order ID. Cleaned up on fill/cancel. */
@@ -247,7 +250,12 @@ export class ExchangeSimulator extends EventEmitter {
247
250
  return this.lastOrderBook.get(symbol);
248
251
  }
249
252
  // ---- Write operations (for tools) ----
250
- createOrder(symbol, side, type, amount, price, metadata) {
253
+ createOrder(symbol, side, type, amount, price, metadata,
254
+ /** Paper-only market-fill price override (take-profit leg). When set, the
255
+ * market branch prices off THIS instead of the last tick, and skips the
256
+ * stale-quote guard — the caller supplied the price, so quote age is
257
+ * irrelevant, and a protective exit must never be blocked (issue #202). */
258
+ referencePrice) {
251
259
  // ---- Startup trade lockout ----
252
260
  // Block trades during the first 15s after gateway restart IF there were
253
261
  // existing positions at startup. This prevents stale agent sessions from
@@ -281,6 +289,10 @@ export class ExchangeSimulator extends EventEmitter {
281
289
  createdAt: now,
282
290
  };
283
291
  if (type === 'market') {
292
+ // Explicit fill price (take-profit leg) — see the param doc above.
293
+ if (referencePrice !== undefined && Number.isFinite(referencePrice) && referencePrice > 0) {
294
+ return this.executeMarketFill(order, referencePrice, metadata);
295
+ }
284
296
  // Market orders fill immediately at current price
285
297
  const ticker = this.lastTicker.get(symbol);
286
298
  if (!ticker) {
@@ -345,7 +357,13 @@ export class ExchangeSimulator extends EventEmitter {
345
357
  }
346
358
  return cancelled.map(o => this.toCcxtOrder(o));
347
359
  }
348
- closePosition(symbol, closeReason) {
360
+ /**
361
+ * @param referencePrice Paper-only fill-price override. Used by the
362
+ * take-profit leg to fill AT the target level instead of the (possibly
363
+ * gapped-past) tick price — see `checkTakeProfitLegs`. Omitted everywhere
364
+ * else, which keeps the normal market-close path byte-identical.
365
+ */
366
+ closePosition(symbol, closeReason, referencePrice) {
349
367
  const position = this.state.positions.find(p => p.symbol === symbol);
350
368
  if (!position) {
351
369
  throw new Error(`No open position for ${symbol}`);
@@ -359,7 +377,7 @@ export class ExchangeSimulator extends EventEmitter {
359
377
  }
360
378
  // Create opposing market order to close the position
361
379
  const closeSide = position.side === 'long' ? 'sell' : 'buy';
362
- return this.createOrder(symbol, closeSide, 'market', position.quantity);
380
+ return this.createOrder(symbol, closeSide, 'market', position.quantity, undefined, undefined, referencePrice);
363
381
  }
364
382
  /** Paper-only: move an open position's MUTABLE protective levels (stopPrice /
365
383
  * targetPrice) in place and persist, WITHOUT the close+reopen round-trip
@@ -385,6 +403,10 @@ export class ExchangeSimulator extends EventEmitter {
385
403
  updateTicker(ticker) {
386
404
  this.lastTicker.set(ticker.symbol, ticker);
387
405
  this.refreshMfeForSymbol(ticker.symbol, ticker.last);
406
+ // MFE is refreshed FIRST so the peak this tick reached is recorded before a
407
+ // target close reads it — otherwise every TP exit would understate its own
408
+ // MFE and skew the capture-ratio analysis.
409
+ this.checkTakeProfitLegs(ticker.symbol, ticker.last);
388
410
  // Check if any pending limit orders should fill
389
411
  const toFill = [];
390
412
  const remaining = [];
@@ -429,6 +451,76 @@ export class ExchangeSimulator extends EventEmitter {
429
451
  getLastTicker(symbol) {
430
452
  return this.lastTicker.get(symbol);
431
453
  }
454
+ /**
455
+ * Take-profit legs — the paper analog of the exchange-native
456
+ * `TAKE_PROFIT_MARKET` order live attaches at entry.
457
+ *
458
+ * ★ Why this exists: paper STORED `metadata.targetPrice` and surfaced it
459
+ * (chart line, positions table) but nothing ever closed on it, so the TP was
460
+ * a drawing rather than an order. Every paper winner ran straight past its
461
+ * exit — observed live 2026-07-27 on an OP/USDT short that reached +2.07R
462
+ * against a 1.0R target. That made paper the odd one out of three: the
463
+ * BACKTEST exits at target (`backtest/engine.ts` exitReason 'target') and
464
+ * LIVE exits at target (Binance TP_MARKET leg / HL coordinator TP leg), so
465
+ * a strategy forward-validated on paper was being measured on a book that
466
+ * let every winner run.
467
+ *
468
+ * Fill convention: the TARGET LEVEL is the decision price, and the normal
469
+ * realistic-fill engine applies its own adverse slippage around it (book
470
+ * VWAP + vol factor + taker fee) — the same model every other paper fill
471
+ * uses, rather than a second hand-rolled slippage constant. When a tick gaps
472
+ * past the target we deliberately do NOT credit the gap: filling at the
473
+ * level is worse for us than filling at the gapped tick, so this stays
474
+ * conservative against both the backtest and a real TP_MARKET (which would
475
+ * fill at the gapped price).
476
+ *
477
+ * Stops stay with the PositionWatcher: it is the safety floor and re-homing
478
+ * it is a separate, riskier change. A single tick can only breach one leg
479
+ * (stop and target sit on opposite sides of entry), so there is no
480
+ * stop-vs-target ordering ambiguity to resolve here.
481
+ */
482
+ checkTakeProfitLegs(symbol, price) {
483
+ if (!Number.isFinite(price) || price <= 0)
484
+ return;
485
+ // Snapshot: closing mutates state.positions mid-iteration.
486
+ const candidates = this.state.positions.filter((p) => p.symbol === symbol);
487
+ for (const position of candidates) {
488
+ const target = position.metadata?.targetPrice;
489
+ if (target === undefined || !Number.isFinite(target) || target <= 0)
490
+ continue;
491
+ const breached = position.side === 'long' ? price >= target : price <= target;
492
+ if (!breached)
493
+ continue;
494
+ // 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))
497
+ continue;
498
+ this.takeProfitPending.add(symbol);
499
+ try {
500
+ logger.info(TAG, `TARGET REACHED: ${symbol} ${position.side} price=${price} target=${target} — closing (exchange_target)`);
501
+ // 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);
504
+ this.emit('target_closed', {
505
+ symbol,
506
+ side: position.side,
507
+ targetPrice: target,
508
+ markPrice: price,
509
+ fillPrice: typeof order.average === 'number' ? order.average : target,
510
+ quantity: position.quantity,
511
+ order,
512
+ });
513
+ }
514
+ catch (err) {
515
+ // Never let a failed protective close kill the tick loop — the next
516
+ // tick retries, and the position is still visible to the agent.
517
+ logger.error(TAG, `Target close failed for ${symbol}: ${formatError(err)}`);
518
+ }
519
+ finally {
520
+ this.takeProfitPending.delete(symbol);
521
+ }
522
+ }
523
+ }
432
524
  /** Walk every position for `symbol` and refresh MFE / give-back from the
433
525
  * latest mark. Idempotent — pure update of `metadata.mfePeakPrice` (only
434
526
  * ratchets favourably) plus derived `mfeR` and `giveBackRatio`. Safe to
@@ -1,3 +1,4 @@
1
+ import type { CcxtOrder } from '../types.js';
1
2
  export interface FillError {
2
3
  orderId: string;
3
4
  symbol: string;
@@ -76,6 +77,22 @@ export interface ExecutionStats {
76
77
  export interface Wallet {
77
78
  [currency: string]: CurrencyBalance;
78
79
  }
80
+ /** Emitted when the paper take-profit leg fires (`ExchangeSimulator`
81
+ * 'target_closed'). The live analog is an exchange-native TAKE_PROFIT_MARKET
82
+ * fill; index.ts journals this the same way it journals a stop-watcher close,
83
+ * so a target exit can never leave a phantom-open journal row (issue #199). */
84
+ export interface TargetClosedEvent {
85
+ symbol: string;
86
+ side: 'long' | 'short';
87
+ /** The pinned target level that was breached. */
88
+ targetPrice: number;
89
+ /** The tick price that breached it (may have gapped past the target). */
90
+ markPrice: number;
91
+ /** Where we actually filled — the target level with adverse slippage. */
92
+ fillPrice: number;
93
+ quantity: number;
94
+ order: CcxtOrder;
95
+ }
79
96
  export interface CurrencyBalance {
80
97
  total: number;
81
98
  available: number;
@@ -323,13 +323,28 @@ async function attachBracketsHl(args, adapter) {
323
323
  let clearedStaleLedgerRow = false;
324
324
  if (existing && !isTerminalBracketState(existing.state)) {
325
325
  let cls = 'unknown';
326
+ // Per-LEG liveness (audit 2026-07-26 F4): "either cid survives ⇒ live"
327
+ // let a TP-only position no-op as "already protected" indefinitely while
328
+ // its stop was gone. A registered leg positively absent from a NON-EMPTY
329
+ // order set is a protection gap this tool must repair, not paper over.
330
+ const missingLegs = [];
326
331
  const hasCids = Boolean(existing.slCid || existing.tpCid);
327
332
  if (hasCids) {
328
333
  try {
329
334
  const open = await adapter.getOpenOrders(position.symbol);
330
335
  const liveCids = new Set(open.map(o => o.clientOrderId).filter(Boolean));
331
- if ((existing.slCid && liveCids.has(existing.slCid)) || (existing.tpCid && liveCids.has(existing.tpCid))) {
336
+ const stopLive = Boolean(existing.slCid && liveCids.has(existing.slCid));
337
+ const tpLive = Boolean(existing.tpCid && liveCids.has(existing.tpCid));
338
+ if (stopLive || tpLive) {
332
339
  cls = 'live';
340
+ if (open.length > 0) {
341
+ // Non-empty set = positive evidence for absence (d59e51b) — a
342
+ // registered-but-absent sibling is a repairable gap.
343
+ if (existing.slCid && !stopLive)
344
+ missingLegs.push('stop');
345
+ if (existing.tpCid && !tpLive)
346
+ missingLegs.push('target');
347
+ }
333
348
  }
334
349
  else if (open.length > 0) {
335
350
  cls = 'stale'; // non-empty set positively lacking our cids
@@ -351,6 +366,40 @@ async function attachBracketsHl(args, adapter) {
351
366
  if (cls === 'live') {
352
367
  if (pricesMatch(args.stop_price, existing.stopPrice)
353
368
  && pricesMatch(args.target_price, existing.targetPrice)) {
369
+ if (missingLegs.length > 0) {
370
+ // One registered leg is positively gone (a stop-less position is a
371
+ // safety-floor breach). Rebuild it at the registered price via the
372
+ // resize path — planResize submits ONLY the missing leg and leaves
373
+ // the healthy sibling untouched (F4).
374
+ try {
375
+ const coordinator = adapter.getHlBracketCoordinator();
376
+ await coordinator.resizeToPosition(position.symbol, contracts);
377
+ const healed = ledger.getBySymbol(position.symbol);
378
+ return {
379
+ ok: true,
380
+ symbol: position.symbol,
381
+ bracket_id: existing.bracketId,
382
+ entry_side: entrySide,
383
+ stop_price: existing.stopPrice,
384
+ target_price: existing.targetPrice,
385
+ sl_cid: healed?.slCid ?? existing.slCid,
386
+ tp_cid: healed?.tpCid ?? existing.tpCid,
387
+ attach_latency_ms: 0,
388
+ cancelled_stale_bracket_orders: 0,
389
+ cleared_stale_ledger_row: false,
390
+ attempts: 1,
391
+ idempotent_no_op: false,
392
+ note: `Missing ${missingLegs.join('+')} leg re-attached at the registered price(s); sibling leg untouched.`,
393
+ };
394
+ }
395
+ catch (err) {
396
+ return {
397
+ error: `Registered ${missingLegs.join('+')} leg is GONE from the exchange and the rebuild ` +
398
+ `failed (${formatError(err)}). The position is under-protected — retry attach_brackets ` +
399
+ `next heartbeat; the 60s truth sweep also retries. Do NOT record a protected review.`,
400
+ };
401
+ }
402
+ }
354
403
  return {
355
404
  ok: true,
356
405
  symbol: position.symbol,
@@ -17,6 +17,14 @@ type Wave9LiveResidualProtector = (adapter: IExchangeAdapter, ledger: Wave9LiveE
17
17
  * buildHlOrderCloid() mints the 0x0d… ORDER prefix, which the bracket parser
18
18
  * deliberately never recognises (cross-scheme cancels are destructive). */
19
19
  export declare function mintEntryStashCid(adapter: IExchangeAdapter): string;
20
+ /** Stable exchange idempotency key for one approved proposal.
21
+ *
22
+ * A listener can crash after exchange acceptance but before fire-result is
23
+ * persisted. Reconciliation must query the exact same client-order ID instead
24
+ * of submitting with a freshly generated one. Binance permits 36-character
25
+ * ASCII IDs; Hyperliquid requires exactly 128 bits of hex.
26
+ */
27
+ export declare function proposalEntryClientOrderId(adapter: IExchangeAdapter, proposalUuid: string): string;
20
28
  export declare function createOrderTool(args: {
21
29
  symbol: string;
22
30
  side: string;
@@ -61,6 +69,9 @@ export declare function createOrderTool(args: {
61
69
  proposalManager?: ProposalManager;
62
70
  userId?: string;
63
71
  approvalMode?: 'off' | 'shadow' | 'per_trade';
72
+ /** Internal listener-only override. Keeps one approved proposal bound to a
73
+ * deterministic exchange idempotency key across reconciliation/restart. */
74
+ entryClientOrderId?: string;
64
75
  wave9AdmissionGuard?: Wave9PaperAdmissionGuard;
65
76
  wave9ActivationCheck?: () => Promise<{
66
77
  available: boolean;
@@ -1,6 +1,6 @@
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
- import { randomUUID } from 'node:crypto';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
4
  import { formatError } from '../logger.js';
5
5
  import { getQuoteBalance, getQuoteWalletBalance } from '../balance-utils.js';
6
6
  import { fetchCurrentPrice, fetchOrderBook, isError } from './helpers.js';
@@ -242,6 +242,27 @@ function candidateBoundFoundOrder(resolution, clientOrderId, symbol, side) {
242
242
  export function mintEntryStashCid(adapter) {
243
243
  return adapter instanceof HyperliquidLiveAdapter ? buildHlOrderCloid() : randomUUID();
244
244
  }
245
+ /** Stable exchange idempotency key for one approved proposal.
246
+ *
247
+ * A listener can crash after exchange acceptance but before fire-result is
248
+ * persisted. Reconciliation must query the exact same client-order ID instead
249
+ * of submitting with a freshly generated one. Binance permits 36-character
250
+ * ASCII IDs; Hyperliquid requires exactly 128 bits of hex.
251
+ */
252
+ export function proposalEntryClientOrderId(adapter, proposalUuid) {
253
+ const hex = proposalUuid.replace(/-/g, '').toLowerCase();
254
+ if (!/^[0-9a-f]{32}$/.test(hex)) {
255
+ throw new Error('proposalUuid must be a UUID');
256
+ }
257
+ if (adapter instanceof HyperliquidLiveAdapter) {
258
+ // Keep deterministic entries in the same 0x0d plain-order namespace as
259
+ // buildHlOrderCloid(). Using the UUID bytes directly could begin with
260
+ // bc7[e57] and be misclassified as a managed bracket leg.
261
+ const digest = createHash('sha256').update(hex).digest('hex');
262
+ return `0x0d${digest.slice(0, 30)}`;
263
+ }
264
+ return `rcp-${hex}`;
265
+ }
245
266
  async function resolveWave9EntryCid(adapter, clientOrderId, symbol) {
246
267
  if (!adapter.resolveOrderByClientId) {
247
268
  return { status: 'unknown', detail: 'deterministic client-order resolver is unavailable' };
@@ -1142,7 +1163,7 @@ export async function createOrderTool(args, deps) {
1142
1163
  // throws, the unused stash entry simply expires (24h TTL, pruned).
1143
1164
  let stashCid = wave9Claimed && initialWave9Mode === 'LIVE'
1144
1165
  ? wave9ClientOrderId(args.candidate_id)
1145
- : undefined;
1166
+ : deps.entryClientOrderId;
1146
1167
  if (deps.autoCapture?.pendingEntries && metadata) {
1147
1168
  stashCid ??= mintEntryStashCid(deps.adapter);
1148
1169
  deps.autoCapture.pendingEntries.put({
@@ -41,6 +41,10 @@ export interface RiskSummaryResult {
41
41
  readyForUpgrade: boolean;
42
42
  upgradeBlockers: string[];
43
43
  };
44
+ /** Present only when an input field was missing/non-finite and a substitute
45
+ * was used. Honest degradation: the numbers above still render, and the
46
+ * agent is TOLD which ones are not straight from the exchange. */
47
+ dataWarnings?: string[];
44
48
  timestamp: string;
45
49
  }
46
50
  export declare function getRiskSummaryTool(_args: Record<string, never>, deps: {
@@ -2,6 +2,26 @@
2
2
  // Returns exposure, drawdown, heat score, position count — everything
3
3
  // the agent needs for risk-aware decision making.
4
4
  import { getQuoteBalance, getQuoteWalletBalance } from '../balance-utils.js';
5
+ /** Finite number or undefined — never NaN, never a silent 0. */
6
+ function fin(v) {
7
+ if (v === undefined || v === null || v === '')
8
+ return undefined;
9
+ const n = typeof v === 'number' ? v : Number(v);
10
+ return Number.isFinite(n) ? n : undefined;
11
+ }
12
+ /** Round for display without ever throwing.
13
+ *
14
+ * ★ This tool is read-only observability — it must DEGRADE, never crash. It used
15
+ * to call `.toFixed()` straight on adapter fields, and on Hyperliquid live
16
+ * (where ccxt leaves `markPrice` undefined) that took the whole tool down with
17
+ * `Cannot read properties of undefined (reading 'toFixed')` — the agent lost its
18
+ * entire risk read because one display field was absent. The venue-side root
19
+ * cause is fixed in `venues/hyperliquid/hl-position.ts`; this is the backstop
20
+ * for every other adapter/field. */
21
+ function round(v, dp) {
22
+ const n = fin(v);
23
+ return n === undefined ? 0 : +n.toFixed(dp);
24
+ }
5
25
  export async function getRiskSummaryTool(_args, deps) {
6
26
  // In live modes, use real exchange data instead of simulator
7
27
  let balance;
@@ -27,23 +47,41 @@ export async function getRiskSummaryTool(_args, deps) {
27
47
  let longExposure = 0;
28
48
  let shortExposure = 0;
29
49
  let totalUnrealizedPnl = 0;
50
+ const dataWarnings = [];
30
51
  const positionDetails = positions.map((pos) => {
31
- const notional = Math.abs(pos.notional);
52
+ const entryPrice = fin(pos.entryPrice);
53
+ const rawMark = fin(pos.markPrice);
54
+ // Same convention as pre-trade-check / create-order: an absent mark falls
55
+ // back to entry (a $0 mark would read as a total loss), and we say so.
56
+ const markPrice = rawMark ?? entryPrice;
57
+ if (rawMark === undefined) {
58
+ dataWarnings.push(`${pos.symbol}: mark price unavailable from the exchange — entry price substituted (uPnL/exposure may be stale)`);
59
+ }
60
+ if (entryPrice === undefined) {
61
+ dataWarnings.push(`${pos.symbol}: entry price unavailable from the exchange`);
62
+ }
63
+ const quantity = fin(pos.contracts) ?? 0;
64
+ const rawNotional = fin(pos.notional);
65
+ const notional = rawNotional !== undefined ? Math.abs(rawNotional) : Math.abs(quantity * (markPrice ?? 0));
66
+ const unrealizedPnl = fin(pos.unrealizedPnl);
67
+ if (unrealizedPnl === undefined) {
68
+ dataWarnings.push(`${pos.symbol}: unrealized PnL unavailable from the exchange — counted as 0`);
69
+ }
32
70
  grossExposure += notional;
33
71
  if (pos.side === 'long')
34
72
  longExposure += notional;
35
73
  else
36
74
  shortExposure += notional;
37
- totalUnrealizedPnl += pos.unrealizedPnl;
75
+ totalUnrealizedPnl += unrealizedPnl ?? 0;
38
76
  return {
39
77
  symbol: pos.symbol,
40
78
  side: pos.side,
41
- quantity: pos.contracts,
42
- entryPrice: +pos.entryPrice.toFixed(2),
43
- markPrice: +pos.markPrice.toFixed(2),
44
- notional: +notional.toFixed(2),
45
- unrealizedPnl: +pos.unrealizedPnl.toFixed(2),
46
- portfolioPercent: totalEquity > 0 ? +((notional / totalEquity) * 100).toFixed(1) : 0,
79
+ quantity,
80
+ entryPrice: round(entryPrice, 2),
81
+ markPrice: round(markPrice, 2),
82
+ notional: round(notional, 2),
83
+ unrealizedPnl: round(unrealizedPnl, 2),
84
+ portfolioPercent: totalEquity > 0 ? round((notional / totalEquity) * 100, 1) : 0,
47
85
  };
48
86
  });
49
87
  const netExposure = longExposure - shortExposure;
@@ -76,10 +114,10 @@ export async function getRiskSummaryTool(_args, deps) {
76
114
  const executionStats = execStats && execStats.totalTrades > 0
77
115
  ? {
78
116
  totalTrades: execStats.totalTrades,
79
- avgSlippageBps: +execStats.avgSlippageBps.toFixed(2),
80
- worstSlippageBps: +execStats.worstSlippageBps.toFixed(2),
81
- totalFeesPaid: +execStats.totalFeesPaid.toFixed(2),
82
- avgLatencyMs: +execStats.avgLatencyMs.toFixed(0),
117
+ avgSlippageBps: round(execStats.avgSlippageBps, 2),
118
+ worstSlippageBps: round(execStats.worstSlippageBps, 2),
119
+ totalFeesPaid: round(execStats.totalFeesPaid, 2),
120
+ avgLatencyMs: round(execStats.avgLatencyMs, 0),
83
121
  }
84
122
  : undefined;
85
123
  // Shadow metrics (Phase 9b)
@@ -89,8 +127,8 @@ export async function getRiskSummaryTool(_args, deps) {
89
127
  const upgrade = deps.shadowTracker.isReadyForUpgrade();
90
128
  shadowMetrics = {
91
129
  totalShadowTrades: sm.totalShadowTrades,
92
- avgDeltaBps: +sm.avgDeltaBps.toFixed(2),
93
- worstDeltaBps: +sm.worstDeltaBps.toFixed(2),
130
+ avgDeltaBps: round(sm.avgDeltaBps, 2),
131
+ worstDeltaBps: round(sm.worstDeltaBps, 2),
94
132
  tradingDays: sm.tradingDays,
95
133
  readyForUpgrade: upgrade.ready,
96
134
  upgradeBlockers: upgrade.reasons,
@@ -98,21 +136,22 @@ export async function getRiskSummaryTool(_args, deps) {
98
136
  }
99
137
  return {
100
138
  tradingMode: deps.tradingMode ?? 'PAPER',
101
- totalEquity: +totalEquity.toFixed(2),
102
- availableBalance: +availableBalance.toFixed(2),
103
- lockedBalance: +lockedBalance.toFixed(2),
104
- grossExposure: +grossExposure.toFixed(2),
105
- grossExposurePercent: +grossExposurePercent.toFixed(1),
106
- netExposure: +netExposure.toFixed(2),
107
- netExposurePercent: +netExposurePercent.toFixed(1),
139
+ totalEquity: round(totalEquity, 2),
140
+ availableBalance: round(availableBalance, 2),
141
+ lockedBalance: round(lockedBalance, 2),
142
+ grossExposure: round(grossExposure, 2),
143
+ grossExposurePercent: round(grossExposurePercent, 1),
144
+ netExposure: round(netExposure, 2),
145
+ netExposurePercent: round(netExposurePercent, 1),
108
146
  positionCount: positions.length,
109
- unrealizedPnl: +totalUnrealizedPnl.toFixed(2),
110
- unrealizedPnlPercent: +unrealizedPnlPercent.toFixed(2),
147
+ unrealizedPnl: round(totalUnrealizedPnl, 2),
148
+ unrealizedPnlPercent: round(unrealizedPnlPercent, 2),
111
149
  positions: positionDetails,
112
150
  heatScore,
113
151
  heatLevel,
114
152
  executionStats,
115
153
  shadowMetrics,
154
+ ...(dataWarnings.length > 0 ? { dataWarnings } : {}),
116
155
  timestamp: new Date().toISOString(),
117
156
  };
118
157
  }
@@ -1,7 +1,7 @@
1
1
  import { EventEmitter } from 'node:events';
2
2
  import type { CcxtOrder, CcxtPosition } from '../../types.js';
3
3
  import type { CloseReason } from '../../simulator/types.js';
4
- import type { BracketId, BracketRequest, BracketState } from '../../live/bracket-types.js';
4
+ import type { BracketId, BracketLedgerEntry, BracketRequest, BracketState } from '../../live/bracket-types.js';
5
5
  import type { BracketLedger } from '../../live/bracket-ledger.js';
6
6
  import type { HlOrderUpdateEvent } from './hl-user-stream.js';
7
7
  /** Narrow execution surface the coordinator needs — the HyperliquidLiveAdapter
@@ -24,6 +24,11 @@ export interface HlBracketExecutor {
24
24
  symbol: string;
25
25
  positionSide: 'long' | 'short';
26
26
  positionSize: number;
27
+ /** Ledger-registered prices — lets resize REBUILD a vanished leg (F4). */
28
+ registeredPrices?: {
29
+ stop?: number;
30
+ target?: number;
31
+ };
27
32
  }): Promise<{
28
33
  resized: boolean;
29
34
  slCid?: string;
@@ -58,6 +63,10 @@ export interface HlCoordinatorOpts {
58
63
  sleep?: (ms: number) => Promise<void>;
59
64
  }
60
65
  export declare function isTerminalBracketState(state: BracketState): boolean;
66
+ /** A batch item HL rejected. ccxt types status as open|closed|canceled, but a
67
+ * per-item rejection surfaces as an `error` entry in the raw batch response
68
+ * (`info.error`) — check both shapes rather than trust the narrow type. */
69
+ export declare function isRejectedOrder(o: CcxtOrder | undefined | null): boolean;
61
70
  export declare class HlBracketCoordinator extends EventEmitter {
62
71
  private readonly executor;
63
72
  private readonly ledger;
@@ -84,6 +93,25 @@ export declare class HlBracketCoordinator extends EventEmitter {
84
93
  * fill signal against a non-terminal row is a warned NO-OP, never a fresh
85
94
  * bracketId that orphans the live legs' ledger identity. */
86
95
  registerEntry(req: BracketRequest, bracketId: BracketId, entryCid: string): void;
96
+ /** ★ Audit 2026-07-26 F5 — track a SECOND entry order placed against a live
97
+ * bracket row (a scale-in, or another entry while the first still rests).
98
+ *
99
+ * Each submission gets a fresh cloid, but the row keeps the ORIGINAL
100
+ * `entryCid`. The user-stream fill handler matches fills to rows by cid, so
101
+ * an unrecorded cid meant the later fill matched NOTHING: no attach, no
102
+ * resize. On HL that is naked exposure, not cosmetic drift — legs are FIXED
103
+ * SIZE (T-2), so the added contracts stayed unprotected until the 60s
104
+ * truth-check sweep happened to catch them.
105
+ *
106
+ * Idempotent; a terminal/absent row or a repeat of the primary cid is a
107
+ * no-op. Recording is deliberately CHEAP and local — the sweep remains the
108
+ * backstop, this just stops it being the only line of defence. */
109
+ registerAdditionalEntryCid(symbol: string, entryCid: string): void;
110
+ /** The bracket row a user-stream fill belongs to: the primary `entryCid` OR
111
+ * any cid recorded by `registerAdditionalEntryCid`. Terminal rows never
112
+ * match. Single home for the matching rule so the adapter's fill handler
113
+ * and the ledger can never disagree about what "our entry" means. */
114
+ findRowByEntryCid(cloid: string | undefined | null): BracketLedgerEntry | undefined;
87
115
  /** Attach both legs (ONE batched signed action) with retries. Idempotent on
88
116
  * a non-pending row. On exhaustion: ledger 'failed' + attach_failed event —
89
117
  * the ADAPTER escalates to auto-flatten (it owns closePosition). */