@reefclaw/openclaw-plugin 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/bridge/gateway/event-parser.d.ts +6 -1
  2. package/bridge/gateway/event-parser.js +19 -2
  3. package/bridge/gateway/poller.d.ts +1 -0
  4. package/bridge/gateway/poller.js +14 -2
  5. package/bridge/providers/gateway.d.ts +22 -2
  6. package/bridge/providers/gateway.js +67 -9
  7. package/ccxt/binance-private.d.ts +21 -0
  8. package/ccxt/binance-private.js +132 -22
  9. package/ccxt/public-market-data-api.d.ts +14 -0
  10. package/ccxt/public-market-data-api.js +15 -1
  11. package/config/plugin-config-io.d.ts +13 -0
  12. package/config/plugin-config-io.js +15 -0
  13. package/config/tool-gate.js +3 -0
  14. package/exchange-adapter.d.ts +16 -0
  15. package/index.js +658 -83
  16. package/ingest/position-auto-capture.d.ts +68 -0
  17. package/ingest/position-auto-capture.js +321 -23
  18. package/ingest/position-decisions-client.d.ts +7 -2
  19. package/ingest/position-decisions-client.js +13 -3
  20. package/ingest/reconcile-db-vs-exchange.d.ts +39 -1
  21. package/ingest/reconcile-db-vs-exchange.js +66 -10
  22. package/lifecycle/trading-operation-lock.d.ts +17 -0
  23. package/lifecycle/trading-operation-lock.js +14 -0
  24. package/live/bracket-id.d.ts +2 -3
  25. package/live/bracket-id.js +22 -9
  26. package/live/fill-price.d.ts +13 -0
  27. package/live/fill-price.js +37 -0
  28. package/live/live-adapter.d.ts +57 -2
  29. package/live/live-adapter.js +290 -49
  30. package/live/local-signal-service.js +11 -6
  31. package/live/local-strategy-evaluator.js +4 -0
  32. package/live/position-state-store.d.ts +4 -0
  33. package/live/proposal-decision-listener.d.ts +6 -0
  34. package/live/proposal-decision-listener.js +4 -0
  35. package/live/stop-watcher.d.ts +35 -2
  36. package/live/stop-watcher.js +63 -3
  37. package/onboarding/runtime.d.ts +19 -0
  38. package/onboarding/runtime.js +34 -3
  39. package/openclaw.plugin.json +1 -0
  40. package/package.json +2 -2
  41. package/portfolio/reentry-tracker.d.ts +36 -0
  42. package/portfolio/reentry-tracker.js +127 -0
  43. package/portfolio/wave9-admission.d.ts +67 -0
  44. package/portfolio/wave9-admission.js +262 -0
  45. package/portfolio/wave9-policy.d.ts +36 -0
  46. package/portfolio/wave9-policy.js +183 -0
  47. package/signals/conditions/registry.js +61 -2
  48. package/signals/strategy-adapter.js +17 -7
  49. package/simulator/exchange-simulator.d.ts +12 -0
  50. package/simulator/exchange-simulator.js +78 -3
  51. package/simulator/fill-engine.js +5 -1
  52. package/simulator/types.d.ts +10 -1
  53. package/skills/reefclaw/SKILL.md +2 -0
  54. package/strategy/evaluator.d.ts +3 -0
  55. package/strategy/evaluator.js +5 -0
  56. package/tools/assessment-validation.d.ts +23 -0
  57. package/tools/assessment-validation.js +58 -0
  58. package/tools/attach-brackets.d.ts +7 -2
  59. package/tools/attach-brackets.js +201 -0
  60. package/tools/audit-bracket-protection.js +157 -1
  61. package/tools/bracket-control.d.ts +12 -0
  62. package/tools/bracket-control.js +35 -0
  63. package/tools/cancel-all-orders.d.ts +2 -0
  64. package/tools/cancel-all-orders.js +4 -1
  65. package/tools/cancel-order.d.ts +4 -0
  66. package/tools/cancel-order.js +49 -3
  67. package/tools/close-position.d.ts +23 -0
  68. package/tools/close-position.js +286 -13
  69. package/tools/create-order.d.ts +28 -0
  70. package/tools/create-order.js +1390 -190
  71. package/tools/get-analytics.js +2 -2
  72. package/tools/get-basis.js +2 -2
  73. package/tools/get-cascade-risk.js +2 -2
  74. package/tools/get-crypto-metrics.js +14 -4
  75. package/tools/get-cvd.js +2 -2
  76. package/tools/get-divergences.js +2 -2
  77. package/tools/get-funding-context.js +2 -2
  78. package/tools/get-liquidation-levels.js +2 -2
  79. package/tools/get-liquidation-pulse.js +2 -2
  80. package/tools/get-pattern-scan.js +2 -2
  81. package/tools/get-regime.js +2 -2
  82. package/tools/get-resting-liquidity.js +2 -2
  83. package/tools/get-risk-scenario.js +2 -2
  84. package/tools/get-session-review.js +2 -2
  85. package/tools/get-setup-detail.js +21 -2
  86. package/tools/get-signals.js +2 -2
  87. package/tools/get-sizing.js +2 -2
  88. package/tools/get-trade-feedback.js +2 -2
  89. package/tools/get-trade-flow.js +2 -2
  90. package/tools/get-volume-profile.js +2 -2
  91. package/tools/get-wave9-status.d.ts +127 -0
  92. package/tools/get-wave9-status.js +796 -0
  93. package/tools/intel-api.d.ts +20 -0
  94. package/tools/intel-api.js +67 -0
  95. package/tools/intel-cache.d.ts +1 -1
  96. package/tools/intel-cache.js +20 -5
  97. package/tools/list-strategies.d.ts +11 -1
  98. package/tools/list-strategies.js +17 -0
  99. package/tools/modify-stop.d.ts +4 -0
  100. package/tools/modify-stop.js +63 -24
  101. package/tools/modify-target.d.ts +4 -0
  102. package/tools/modify-target.js +62 -23
  103. package/tools/scan-pairs.d.ts +4 -0
  104. package/tools/scan-pairs.js +22 -8
  105. package/tools/toggle-strategy.js +7 -0
  106. package/types.d.ts +5 -0
  107. package/venues/hyperliquid/hl-balance.d.ts +116 -0
  108. package/venues/hyperliquid/hl-balance.js +145 -0
  109. package/venues/hyperliquid/hl-bracket-coordinator.d.ts +123 -0
  110. package/venues/hyperliquid/hl-bracket-coordinator.js +533 -0
  111. package/venues/hyperliquid/hl-brackets.d.ts +102 -0
  112. package/venues/hyperliquid/hl-brackets.js +172 -0
  113. package/venues/hyperliquid/hl-cloid.d.ts +22 -0
  114. package/venues/hyperliquid/hl-cloid.js +82 -0
  115. package/venues/hyperliquid/hl-info-cache.d.ts +46 -0
  116. package/venues/hyperliquid/hl-info-cache.js +125 -0
  117. package/venues/hyperliquid/hl-live-adapter.d.ts +146 -0
  118. package/venues/hyperliquid/hl-live-adapter.js +728 -0
  119. package/venues/hyperliquid/hl-precision.d.ts +61 -0
  120. package/venues/hyperliquid/hl-precision.js +176 -0
  121. package/venues/hyperliquid/hl-private.d.ts +88 -0
  122. package/venues/hyperliquid/hl-private.js +357 -0
  123. package/venues/hyperliquid/hl-public.d.ts +31 -4
  124. package/venues/hyperliquid/hl-public.js +163 -12
  125. package/venues/hyperliquid/hl-rate-gate.d.ts +57 -0
  126. package/venues/hyperliquid/hl-rate-gate.js +220 -0
  127. package/venues/hyperliquid/hl-user-stream.d.ts +90 -0
  128. package/venues/hyperliquid/hl-user-stream.js +220 -0
  129. package/venues/registry.d.ts +23 -9
  130. package/venues/registry.js +12 -13
  131. package/venues/symbols.d.ts +43 -0
  132. package/venues/symbols.js +107 -0
  133. package/wave9/live-account-capture.d.ts +67 -0
  134. package/wave9/live-account-capture.js +435 -0
  135. package/wave9/live-autonomous-protection.d.ts +39 -0
  136. package/wave9/live-autonomous-protection.js +112 -0
  137. package/wave9/live-durable-reconciliation-scheduler.d.ts +33 -0
  138. package/wave9/live-durable-reconciliation-scheduler.js +115 -0
  139. package/wave9/live-execution-ledger.d.ts +107 -0
  140. package/wave9/live-execution-ledger.js +498 -0
  141. package/wave9/live-position-confirmation.d.ts +18 -0
  142. package/wave9/live-position-confirmation.js +111 -0
  143. package/wave9/live-residual-protection.d.ts +18 -0
  144. package/wave9/live-residual-protection.js +250 -0
  145. package/wave9/live-startup-reconciliation.d.ts +38 -0
  146. package/wave9/live-startup-reconciliation.js +454 -0
  147. package/wave9/live-symbol-ownership.d.ts +20 -0
  148. package/wave9/live-symbol-ownership.js +132 -0
  149. package/wave9/paper-admission-guard.d.ts +199 -0
  150. package/wave9/paper-admission-guard.js +650 -0
  151. package/wave9/usdm-evidence-provider.d.ts +42 -0
  152. package/wave9/usdm-evidence-provider.js +133 -0
@@ -205,6 +205,11 @@ export class ExchangeSimulator extends EventEmitter {
205
205
  // Surface entry metadata (if available)
206
206
  ...(p.metadata && {
207
207
  setupType: p.metadata.setupType,
208
+ missionId: p.metadata.missionId,
209
+ candidateId: p.metadata.candidateId,
210
+ strategy: p.metadata.strategy,
211
+ accruedFundingUsd: p.metadata.accruedFundingUsd,
212
+ fundingThroughTime: p.metadata.fundingThroughTime,
208
213
  thesis: p.metadata.thesis,
209
214
  stopPrice: p.metadata.stopPrice,
210
215
  targetPrice: p.metadata.targetPrice,
@@ -281,11 +286,18 @@ export class ExchangeSimulator extends EventEmitter {
281
286
  if (!ticker) {
282
287
  throw new Error(`No ticker data for ${symbol}. Call updateTicker() first.`);
283
288
  }
289
+ // Stale-quote guard (issue #202): a market fill priced off an aged quote
290
+ // books phantom P&L the moment a fresh price arrives (measured up to
291
+ // ~0.6% off on the HL paper book — a real −$22.99 in 4 seconds). Reject
292
+ // instead; the agent refreshes via fetch_ticker and retries.
293
+ this.assertQuoteFresh(symbol, ticker);
284
294
  return this.executeMarketFill(order, ticker.last, metadata);
285
295
  }
286
- // Limit order — check if it crosses the current price
296
+ // Limit order — check if it crosses the current price. A stale quote must
297
+ // not price an immediate cross-fill (same hazard as market fills); the
298
+ // order RESTS instead and fills on the next fresh tick via updateTicker.
287
299
  const ticker = this.lastTicker.get(symbol);
288
- if (ticker && this.shouldFillLimit(order, ticker.last)) {
300
+ if (ticker && this.quoteAgeMs(ticker) <= this.maxQuoteAgeMs() && this.shouldFillLimit(order, ticker.last)) {
289
301
  return this.executeLimitFill(order, ticker.last, metadata);
290
302
  }
291
303
  // Limit order doesn't cross — add to open orders. Pin the entry metadata
@@ -543,6 +555,45 @@ export class ExchangeSimulator extends EventEmitter {
543
555
  };
544
556
  }
545
557
  // ---- Private helpers ----
558
+ /** Default max quote age a NEW-exposure fill may price against (issue #202).
559
+ * Generous vs the 5s paper feed cadence; env RC_PAPER_MAX_QUOTE_AGE_MS
560
+ * overrides. */
561
+ static DEFAULT_MAX_QUOTE_AGE_MS = 45_000;
562
+ maxQuoteAgeMs() {
563
+ const raw = Number(process.env.RC_PAPER_MAX_QUOTE_AGE_MS);
564
+ return Number.isFinite(raw) && raw > 0 ? raw : ExchangeSimulator.DEFAULT_MAX_QUOTE_AGE_MS;
565
+ }
566
+ /** Quote age from the ticker's own timestamp. A missing/invalid timestamp
567
+ * reads as age 0 (fail-open — the guard cannot fire on it). */
568
+ quoteAgeMs(ticker) {
569
+ const ts = ticker.timestamp;
570
+ if (!Number.isFinite(ts) || ts <= 0)
571
+ return 0;
572
+ return Math.max(0, Date.now() - ts);
573
+ }
574
+ /** Reject fills that would OPEN or GROW exposure on a stale quote. Risk-
575
+ * reducing fills (closes/partials against an existing position) are always
576
+ * allowed — blocking a close on a broken feed compounds the risk. */
577
+ assertQuoteFresh(symbol, ticker) {
578
+ const age = this.quoteAgeMs(ticker);
579
+ const max = this.maxQuoteAgeMs();
580
+ if (age <= max)
581
+ return;
582
+ const pos = this.state.positions.find(p => p.symbol === symbol);
583
+ // The order side reaching here is the one being filled — derive reduce vs
584
+ // grow from the position side at the call site instead? The market path
585
+ // calls this before fill with the order side unavailable; use position
586
+ // presence: any existing position keeps closes flowing, and a stale-quote
587
+ // scale-in on an open position is bounded by the feed refreshing open
588
+ // symbols every 5s (only NEW symbols go minutes without a tick).
589
+ if (pos) {
590
+ logger.warn(TAG, `Stale quote for ${symbol} (${Math.round(age / 1000)}s old) — allowing fill because an open ` +
591
+ `position exists (risk-reducing paths are never blocked)`);
592
+ return;
593
+ }
594
+ throw new Error(`Order rejected: market data for ${symbol} is stale (${Math.round(age / 1000)}s old, ` +
595
+ `max ${Math.round(max / 1000)}s). Refresh the price (fetch_ticker) and retry.`);
596
+ }
546
597
  shouldFillLimit(order, currentPrice) {
547
598
  if (order.price === null)
548
599
  return false;
@@ -562,6 +613,12 @@ export class ExchangeSimulator extends EventEmitter {
562
613
  metadata,
563
614
  };
564
615
  const result = fillMarketOrder(order, currentPrice, this.state.wallet, position, realistic);
616
+ // Observability for issue #202: stamp the quote's age onto the fill's
617
+ // execution-quality record so staleness is visible in trade history.
618
+ const tickerAtFill = this.lastTicker.get(order.symbol);
619
+ if (result.executionQuality && tickerAtFill) {
620
+ result.executionQuality.quoteAgeMs = this.quoteAgeMs(tickerAtFill);
621
+ }
565
622
  const ccxtOrder = this.applyFillResult(result);
566
623
  const eq = result.executionQuality;
567
624
  if (eq) {
@@ -608,7 +665,25 @@ export class ExchangeSimulator extends EventEmitter {
608
665
  fillPrice: result.order.average ?? 0,
609
666
  fee: result.order.fee.cost,
610
667
  });
611
- return this.toCcxtOrder(result.order);
668
+ const ccxtOrder = this.toCcxtOrder(result.order);
669
+ // Reducing fills carry a Trade record — attach its engine-exact economics
670
+ // (net-of-fee P&L + both fee legs) so the journal close capture records
671
+ // NET, matching the live book's convention (issue #201). The wallet/NAV
672
+ // was already net; only the journal was blind to fees.
673
+ if (result.trade) {
674
+ const t = result.trade;
675
+ ccxtOrder.info = {
676
+ ...(ccxtOrder.info ?? {}),
677
+ paperTrade: {
678
+ grossRealizedPnl: t.realizedPnl,
679
+ netRealizedPnl: t.realizedPnl - (t.fee + (t.openFee ?? 0)),
680
+ openFee: t.openFee ?? 0,
681
+ closeFee: t.fee,
682
+ ...(typeof t.metadata?.setupType === 'string' ? { setupType: t.metadata.setupType } : {}),
683
+ },
684
+ };
685
+ }
686
+ return ccxtOrder;
612
687
  }
613
688
  updatePosition(symbol, newPosition) {
614
689
  const idx = this.state.positions.findIndex(p => p.symbol === symbol);
@@ -53,7 +53,11 @@ export function parseSymbol(symbol) {
53
53
  if (parts.length !== 2) {
54
54
  throw new Error(`Invalid symbol format: ${symbol}. Expected BASE/QUOTE`);
55
55
  }
56
- return { base: parts[0], quote: parts[1] };
56
+ const [quote, settle, ...extra] = parts[1].split(':');
57
+ if (!quote || extra.length > 0 || (settle !== undefined && settle !== quote)) {
58
+ throw new Error(`Invalid symbol format: ${symbol}. Expected BASE/QUOTE or BASE/QUOTE:QUOTE`);
59
+ }
60
+ return { base: parts[0], quote };
57
61
  }
58
62
  /**
59
63
  * Ensure a currency exists in the wallet with at least zero balance.
@@ -38,6 +38,10 @@ export interface ExecutionQuality {
38
38
  feePaid: number;
39
39
  bookLevelsConsumed: number;
40
40
  bookDepthAvailable: boolean;
41
+ /** Age of the quote the fill priced against (fill time − ticker.timestamp).
42
+ * Surfaces feed staleness (issue #202); absent on records from before the
43
+ * field existed or when the ticker carried no usable timestamp. */
44
+ quoteAgeMs?: number;
41
45
  }
42
46
  export interface SimulatorState {
43
47
  version: string;
@@ -83,7 +87,7 @@ export interface CurrencyBalance {
83
87
  * - `exchange_stop` / `exchange_target`: exchange-native STOP_MARKET / TAKE_PROFIT_MARKET triggered on Binance
84
88
  * - `cancelled_auto`: bracket reconciler cleaned up after detecting the position closed externally
85
89
  * - `operator` / `agent` / `emergency`: explicit initiator */
86
- export type CloseReason = 'agent' | 'stop_watcher' | 'emergency' | 'operator' | 'bracket_attach_failed' | 'exchange_stop' | 'exchange_target' | 'cancelled_auto';
90
+ export type CloseReason = 'agent' | 'stop_watcher' | 'emergency' | 'operator' | 'bracket_attach_failed' | 'exchange_stop' | 'exchange_target' | 'cancelled_auto' | 'wave9_signal_reversal' | 'liquidated' | 'adl' | 'venue_delisted';
87
91
  /** The agent's stated profit-realization plan, pinned at entry. PURE DATA /
88
92
  * indication — the plugin does NOT enforce it; it is surfaced back so the agent
89
93
  * can hold to its own plan instead of re-litigating. The agent fills the parts
@@ -107,6 +111,11 @@ export interface RealizationRule {
107
111
  * moment of close. */
108
112
  export interface PositionMetadata {
109
113
  setupType?: string;
114
+ missionId?: string;
115
+ candidateId?: string;
116
+ strategy?: 'tsmom_28d_long_flat_reversal_1d' | 'tsmom_28d_short_flat_reversal_1d';
117
+ accruedFundingUsd?: number;
118
+ fundingThroughTime?: string;
110
119
  thesis?: string;
111
120
  stopPrice?: number;
112
121
  targetPrice?: number;
@@ -80,6 +80,8 @@ OpenClaw can install the ReefClaw plugin through its own owner chat command —
80
80
  2. Tell the user to type `/restart` in this chat.
81
81
  3. Then the user types: `/plugins install clawhub:@reefclaw/openclaw-plugin` — the install restarts OpenClaw automatically; the bundled connector starts by itself and the dashboard flips to Connected.
82
82
 
83
+ If that install is **cancelled asking for a ClawHub risk acknowledgement** (OpenClaw 2026.7+ does this; older versions never ask and reject the flag), rerun the identical command with the acknowledgement appended: `/plugins install clawhub:@reefclaw/openclaw-plugin --acknowledge-clawhub-risk`. If it is still refused, use the `npx @reefclaw/connect` fallback — it does not go through ClawHub.
84
+
83
85
  Only fall back to `npx @reefclaw/connect` (below) when the chat install is unavailable.
84
86
 
85
87
  ## If you cannot run the installer (elevated exec disabled)
@@ -27,6 +27,8 @@ export interface StrategyConfig {
27
27
  params: Record<string, number>;
28
28
  };
29
29
  targetRMultiples: number[];
30
+ /** Dedicated portfolio catalog rows are not declarative generic strategies. */
31
+ executionModel?: string;
30
32
  }
31
33
  export interface StrategyRow {
32
34
  id: number;
@@ -55,6 +57,7 @@ export interface StrategyEvalResult {
55
57
  regime_confidence: number;
56
58
  summary: string;
57
59
  }
60
+ export declare function isDeclarativeStrategyConfig(strategy: StrategyConfig): boolean;
58
61
  /**
59
62
  * Evaluate a strategy against a symbol's fact object.
60
63
  * Returns null if the strategy is regime-gated and the symbol's regime doesn't match.
@@ -4,12 +4,17 @@
4
4
  // and optionally checks threshold-based conditions against indicator values.
5
5
  import { CONDITION_EVALUATORS } from './condition-registry.js';
6
6
  import { getCachedTradingParams } from '../trading-params-cache.js';
7
+ export function isDeclarativeStrategyConfig(strategy) {
8
+ return strategy.executionModel === undefined || strategy.executionModel === 'declarative';
9
+ }
7
10
  // ─── Evaluator ──────────────────────────────────────────────────────────
8
11
  /**
9
12
  * Evaluate a strategy against a symbol's fact object.
10
13
  * Returns null if the strategy is regime-gated and the symbol's regime doesn't match.
11
14
  */
12
15
  export function evaluateStrategy(strategy, fact) {
16
+ if (!isDeclarativeStrategyConfig(strategy))
17
+ return null;
13
18
  // Check regime gate
14
19
  if (strategy.regimeWhitelist.length > 0) {
15
20
  const matchesRegime = strategy.regimeWhitelist.some(r => r === fact.regime || r === 'ANY');
@@ -1,4 +1,25 @@
1
1
  import type { RealizationRule } from '../simulator/types.js';
2
+ /** Minimum pre-entry stop distance in basis points (the SKILL v2.10.0
3
+ * discipline floor's fixed component; the ATR component is enforced by the
4
+ * live bracket path where ATR is authoritative). */
5
+ export declare const MIN_STOP_DISTANCE_BPS = 30;
6
+ export interface ProtectiveGeometryArgs {
7
+ side: 'buy' | 'sell';
8
+ /** Reference entry price: the limit price, or last trade for market orders. */
9
+ refPrice: number;
10
+ stopPrice?: number;
11
+ invalidationPrice?: number;
12
+ targetPrice?: number;
13
+ }
14
+ /** Validate stop / invalidation / target geometry against the entry side.
15
+ *
16
+ * Before this existed the wrong-side check ran ONLY on the wave9 path
17
+ * (create-order.ts) — a generic short entered with its stop BELOW entry
18
+ * passed validation and was closed by the stop-watcher seconds later
19
+ * (7 sub-5-minute kills on the 2026-07 HL soak, issue #200). Returns an
20
+ * error string, or null when the geometry is sound. Fields left undefined
21
+ * are not judged (stop-required policy stays with the caller). */
22
+ export declare function validateProtectiveGeometry(args: ProtectiveGeometryArgs): string | null;
2
23
  /** The five legitimate reasons to manually close an otherwise-bracketed
3
24
  * position (see SKILL.md v2.10.0 Position Management Discipline). Any other
4
25
  * value is rejected. */
@@ -31,6 +52,8 @@ export interface ClosePositionArgs {
31
52
  symbol: string;
32
53
  reason?: string;
33
54
  assessment?: CloseAssessment;
55
+ candidate_id?: string;
56
+ wave9_exit_token?: string;
34
57
  }
35
58
  export interface ValidationResult {
36
59
  ok: boolean;
@@ -13,6 +13,64 @@
13
13
  // live mode.
14
14
  // Tested in: assessment-validation.test.ts.
15
15
  import { normalizeBracketSymbol } from '../live/bracket-ledger.js';
16
+ // ---- protective geometry (create_order, issue #200) ----
17
+ /** Minimum pre-entry stop distance in basis points (the SKILL v2.10.0
18
+ * discipline floor's fixed component; the ATR component is enforced by the
19
+ * live bracket path where ATR is authoritative). */
20
+ export const MIN_STOP_DISTANCE_BPS = 30;
21
+ /** Validate stop / invalidation / target geometry against the entry side.
22
+ *
23
+ * Before this existed the wrong-side check ran ONLY on the wave9 path
24
+ * (create-order.ts) — a generic short entered with its stop BELOW entry
25
+ * passed validation and was closed by the stop-watcher seconds later
26
+ * (7 sub-5-minute kills on the 2026-07 HL soak, issue #200). Returns an
27
+ * error string, or null when the geometry is sound. Fields left undefined
28
+ * are not judged (stop-required policy stays with the caller). */
29
+ export function validateProtectiveGeometry(args) {
30
+ const { side, refPrice } = args;
31
+ if (!Number.isFinite(refPrice) || refPrice <= 0)
32
+ return null; // no reference — cannot judge
33
+ const dirWord = side === 'buy' ? 'long' : 'short';
34
+ if (args.stopPrice !== undefined) {
35
+ const stop = args.stopPrice;
36
+ if (!Number.isFinite(stop) || stop <= 0) {
37
+ return 'stopPrice must be a positive finite number.';
38
+ }
39
+ if ((side === 'buy' && stop >= refPrice) || (side === 'sell' && stop <= refPrice)) {
40
+ return (`stopPrice ${stop} is on the WRONG SIDE of entry ${refPrice} for a ${dirWord}: a ${dirWord}'s ` +
41
+ `protective stop must be ${side === 'buy' ? 'below' : 'above'} entry. A wrong-side stop is ` +
42
+ `instantly "breached" and the stop-watcher closes the position seconds after entry.`);
43
+ }
44
+ const distanceBps = (Math.abs(refPrice - stop) / refPrice) * 10_000;
45
+ if (distanceBps < MIN_STOP_DISTANCE_BPS) {
46
+ return (`stopPrice ${stop} is only ${distanceBps.toFixed(1)}bps from entry ${refPrice} — below the ` +
47
+ `${MIN_STOP_DISTANCE_BPS}bps discipline floor. Place the stop at a structural level ` +
48
+ `(≥ max(0.3×ATR, ${MIN_STOP_DISTANCE_BPS}bps) from entry).`);
49
+ }
50
+ }
51
+ if (args.invalidationPrice !== undefined) {
52
+ const inv = args.invalidationPrice;
53
+ if (!Number.isFinite(inv) || inv <= 0) {
54
+ return 'invalidation_price must be a positive finite number.';
55
+ }
56
+ if ((side === 'buy' && inv >= refPrice) || (side === 'sell' && inv <= refPrice)) {
57
+ return (`invalidation_price ${inv} is on the WRONG SIDE of entry ${refPrice} for a ${dirWord}: ` +
58
+ `invalidation must be ${side === 'buy' ? 'below' : 'above'} entry (it marks where the thesis ` +
59
+ `is WRONG, not where it profits). Fix the pinned plan before entering.`);
60
+ }
61
+ }
62
+ if (args.targetPrice !== undefined) {
63
+ const target = args.targetPrice;
64
+ if (!Number.isFinite(target) || target <= 0) {
65
+ return 'target_price must be a positive finite number.';
66
+ }
67
+ if ((side === 'buy' && target <= refPrice) || (side === 'sell' && target >= refPrice)) {
68
+ return (`target_price ${target} is on the WRONG SIDE of entry ${refPrice} for a ${dirWord}: ` +
69
+ `the profit target must be ${side === 'buy' ? 'above' : 'below'} entry.`);
70
+ }
71
+ }
72
+ return null;
73
+ }
16
74
  // ---- close_position ----
17
75
  /** The five legitimate reasons to manually close an otherwise-bracketed
18
76
  * position (see SKILL.md v2.10.0 Position Management Discipline). Any other
@@ -1,4 +1,6 @@
1
1
  import type { IExchangeAdapter } from '../exchange-adapter.js';
2
+ import type { TradingOperationLock } from '../lifecycle/trading-operation-lock.js';
3
+ import type { Wave9LiveSymbolOwnershipCheck } from '../wave9/live-symbol-ownership.js';
2
4
  interface AttachBracketsArgs {
3
5
  symbol: string;
4
6
  stop_price?: number;
@@ -26,9 +28,12 @@ interface AttachBracketsSuccess {
26
28
  idempotent_no_op?: boolean;
27
29
  note?: string;
28
30
  }
29
- export declare function attachBracketsTool(args: AttachBracketsArgs, deps: {
31
+ interface AttachBracketsDeps {
30
32
  adapter: IExchangeAdapter;
31
- }): Promise<AttachBracketsSuccess | {
33
+ operationLock?: TradingOperationLock;
34
+ checkWave9LiveSymbolOwnership?: Wave9LiveSymbolOwnershipCheck;
35
+ }
36
+ export declare function attachBracketsTool(args: AttachBracketsArgs, deps: AttachBracketsDeps): Promise<AttachBracketsSuccess | {
32
37
  error: string;
33
38
  }>;
34
39
  export {};
@@ -22,6 +22,9 @@
22
22
  import { generateBracketId, buildBracketCid } from '../live/bracket-id.js';
23
23
  import { validateStopDirection, validateTargetDirection } from '../live/bracket-params.js';
24
24
  import { formatError, logger } from '../logger.js';
25
+ import { HyperliquidLiveAdapter } from '../venues/hyperliquid/hl-live-adapter.js';
26
+ import { isTerminalBracketState } from '../venues/hyperliquid/hl-bracket-coordinator.js';
27
+ import { buildHlBracketCloid } from '../venues/hyperliquid/hl-cloid.js';
25
28
  const TAG = 'attach-brackets';
26
29
  export async function attachBracketsTool(args, deps) {
27
30
  // ---- 1. Argument validation ----
@@ -41,6 +44,48 @@ export async function attachBracketsTool(args, deps) {
41
44
  if (!deps.adapter.isLive) {
42
45
  return { error: 'attach_brackets is live-mode only (it places exchange-side STOP/TP orders). In paper mode, use modify_stop / modify_target to set or move the position\'s protective levels in place — the paper stop-watcher enforces metadata.stopPrice.' };
43
46
  }
47
+ if (deps.checkWave9LiveSymbolOwnership && !deps.operationLock) {
48
+ return {
49
+ error: 'Live attach_brackets refused: the shared trading operation lock is required ' +
50
+ 'while Wave 9 LIVE ownership checks are enabled.',
51
+ };
52
+ }
53
+ const attachLive = () => attachBracketsLive(args, deps);
54
+ try {
55
+ return deps.operationLock
56
+ ? await deps.operationLock.withAccountLock(`generic-attach-brackets-final:${args.symbol}`, attachLive)
57
+ : await attachLive();
58
+ }
59
+ catch (err) {
60
+ return { error: formatError(err) };
61
+ }
62
+ }
63
+ async function attachBracketsLive(args, deps) {
64
+ if (deps.checkWave9LiveSymbolOwnership) {
65
+ let ownership;
66
+ try {
67
+ ownership = await deps.checkWave9LiveSymbolOwnership(args.symbol);
68
+ }
69
+ catch (err) {
70
+ return {
71
+ error: `attach_brackets refused at mutation boundary: Wave 9 LIVE ownership check failed ` +
72
+ `for ${args.symbol} (${formatError(err)}).`,
73
+ };
74
+ }
75
+ if (ownership.status === 'owned' || ownership.status === 'ambiguous') {
76
+ return {
77
+ error: `attach_brackets refused at mutation boundary: ${args.symbol} has ` +
78
+ `${ownership.status} Wave 9 LIVE ownership (${ownership.reason}). ` +
79
+ 'Wave 9 protection is candidate-bound and cannot be replaced by the generic bracket tool.',
80
+ };
81
+ }
82
+ }
83
+ // Venue dispatch (issue #209): the HL arm drives the HlBracketCoordinator —
84
+ // the Binance-shaped path below would silently no-op ("not enabled") on HL,
85
+ // which is exactly the recovery gap the boot guard existed for.
86
+ if (deps.adapter instanceof HyperliquidLiveAdapter) {
87
+ return attachBracketsHl(args, deps.adapter);
88
+ }
44
89
  const live = deps.adapter;
45
90
  const mgr = live.getBracketManager?.();
46
91
  const ledger = live.getBracketLedger?.();
@@ -231,6 +276,162 @@ export async function attachBracketsTool(args, deps) {
231
276
  attempts: attachResult.attempts,
232
277
  };
233
278
  }
279
+ /**
280
+ * Hyperliquid recovery attach (issue #209). Same safety contract as the
281
+ * Binance path — null ≠ empty, positive-evidence-only destructive decisions,
282
+ * idempotent no-op on a live matching bracket — expressed against the
283
+ * HlBracketCoordinator. Classification is the d59e51b floor: a stored cid
284
+ * positively present in a successful getOpenOrders is 'live'; a NON-EMPTY set
285
+ * lacking every stored cid is 'stale'; an EMPTY set or a thrown fetch is
286
+ * 'unknown' (empty is never positive disconfirmation).
287
+ */
288
+ async function attachBracketsHl(args, adapter) {
289
+ const coordinator = adapter.getHlBracketCoordinator();
290
+ const ledger = coordinator.getLedger();
291
+ // ---- Position must exist (null ≠ empty) ----
292
+ const positions = await adapter.getPositionsOrNull(args.symbol);
293
+ if (positions === null) {
294
+ return {
295
+ error: `Exchange data unavailable for ${args.symbol} (Hyperliquid fetch failed / rate-gated) — ` +
296
+ `position state UNKNOWN. NOT treating this as "no position"; bracket attach skipped ` +
297
+ `this cycle. Retry on next heartbeat.`,
298
+ };
299
+ }
300
+ const base = args.symbol.split(':')[0];
301
+ const position = positions.find(p => p.symbol?.startsWith(base)) ?? positions[0];
302
+ const contracts = Math.abs(Number(position?.contracts ?? 0));
303
+ if (!position || contracts <= 0) {
304
+ return { error: `No open position for ${args.symbol}.` };
305
+ }
306
+ const entrySide = position.side === 'long' ? 'buy' : 'sell';
307
+ // ---- Direction sanity vs entry price ----
308
+ const refPrice = position.entryPrice > 0 ? position.entryPrice : await adapter.getLastPrice(position.symbol);
309
+ if (refPrice != null && refPrice > 0) {
310
+ if (args.stop_price !== undefined) {
311
+ const msg = validateStopDirection(entrySide, refPrice, args.stop_price);
312
+ if (msg)
313
+ return { error: msg };
314
+ }
315
+ if (args.target_price !== undefined) {
316
+ const msg = validateTargetDirection(entrySide, refPrice, args.target_price);
317
+ if (msg)
318
+ return { error: msg };
319
+ }
320
+ }
321
+ // ---- Existing ledger row: classify against the exchange ----
322
+ const existing = ledger.getBySymbol(position.symbol);
323
+ let clearedStaleLedgerRow = false;
324
+ if (existing && !isTerminalBracketState(existing.state)) {
325
+ let cls = 'unknown';
326
+ const hasCids = Boolean(existing.slCid || existing.tpCid);
327
+ if (hasCids) {
328
+ try {
329
+ const open = await adapter.getOpenOrders(position.symbol);
330
+ 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))) {
332
+ cls = 'live';
333
+ }
334
+ else if (open.length > 0) {
335
+ cls = 'stale'; // non-empty set positively lacking our cids
336
+ } // empty set → 'unknown' (d59e51b: empty is never disconfirmation)
337
+ }
338
+ catch {
339
+ cls = 'unknown';
340
+ }
341
+ }
342
+ if (cls === 'unknown') {
343
+ return {
344
+ error: `Cannot confirm whether ${position.symbol} brackets are live on Hyperliquid ` +
345
+ `(open-orders unavailable, the set came back empty, or the row is in-flight with no ` +
346
+ `cids yet). NOT assuming protected and NOT reattaching this cycle. Retry next ` +
347
+ `heartbeat; if this persists treat the position as potentially NAKED and verify on ` +
348
+ `the exchange. Do NOT record a hold/protected review on the basis of this call.`,
349
+ };
350
+ }
351
+ if (cls === 'live') {
352
+ if (pricesMatch(args.stop_price, existing.stopPrice)
353
+ && pricesMatch(args.target_price, existing.targetPrice)) {
354
+ return {
355
+ ok: true,
356
+ symbol: position.symbol,
357
+ bracket_id: existing.bracketId,
358
+ entry_side: entrySide,
359
+ stop_price: existing.stopPrice,
360
+ target_price: existing.targetPrice,
361
+ sl_cid: existing.slCid,
362
+ tp_cid: existing.tpCid,
363
+ attach_latency_ms: 0,
364
+ cancelled_stale_bracket_orders: 0,
365
+ cleared_stale_ledger_row: false,
366
+ attempts: 0,
367
+ idempotent_no_op: true,
368
+ note: `Bracket already ${existing.state} on exchange with matching prices — no-op.`,
369
+ };
370
+ }
371
+ return {
372
+ error: `Bracket already ${existing.state} for ${position.symbol} at ` +
373
+ `stop=${existing.stopPrice ?? 'none'} target=${existing.targetPrice ?? 'none'}. ` +
374
+ `Use modify_stop / modify_target to change levels, or close_position to exit.`,
375
+ };
376
+ }
377
+ logger.warn(TAG, `HL ledger row for ${position.symbol} is stale (state=${existing.state}; stored cloids not ` +
378
+ `on exchange, non-empty order set) — forcing terminal to re-attach`);
379
+ ledger.markState(position.symbol, 'cancelled', {
380
+ closeReason: 'cancelled_auto',
381
+ lastError: 'stale_ledger_detected_by_attach_brackets',
382
+ });
383
+ clearedStaleLedgerRow = true;
384
+ }
385
+ // ---- Orphan cleanup (best-effort; protection beats hygiene) ----
386
+ let cancelledOrphans = 0;
387
+ try {
388
+ cancelledOrphans = await adapter.cancelSymbolBracketLegs(position.symbol);
389
+ }
390
+ catch (err) {
391
+ logger.warn(TAG, `HL orphan cleanup failed for ${position.symbol}: ${formatError(err)}`);
392
+ }
393
+ // ---- Register + attach ----
394
+ const bracketId = generateBracketId();
395
+ try {
396
+ coordinator.registerEntry({
397
+ symbol: position.symbol,
398
+ side: entrySide,
399
+ stopPrice: args.stop_price,
400
+ targetPrice: args.target_price,
401
+ }, bracketId, buildHlBracketCloid(bracketId, 'entry'));
402
+ }
403
+ catch (err) {
404
+ return { error: `registerEntry failed: ${formatError(err)}` };
405
+ }
406
+ let attachResult;
407
+ try {
408
+ attachResult = await coordinator.attachOnFill(position.symbol, contracts);
409
+ }
410
+ catch (err) {
411
+ return { error: `attach failed: ${formatError(err)}` };
412
+ }
413
+ if (!attachResult.ok) {
414
+ return {
415
+ error: `Bracket attach failed after ${attachResult.attempts} attempt(s): ` +
416
+ `${attachResult.error ?? 'unknown error'}. Position remains open. Try again with ` +
417
+ `adjusted levels, or close_position to exit.`,
418
+ };
419
+ }
420
+ return {
421
+ ok: true,
422
+ symbol: position.symbol,
423
+ bracket_id: bracketId,
424
+ entry_side: entrySide,
425
+ stop_price: args.stop_price,
426
+ target_price: args.target_price,
427
+ sl_cid: attachResult.slCid,
428
+ tp_cid: attachResult.tpCid,
429
+ attach_latency_ms: attachResult.latencyMs,
430
+ cancelled_stale_bracket_orders: cancelledOrphans,
431
+ cleared_stale_ledger_row: clearedStaleLedgerRow,
432
+ attempts: attachResult.attempts,
433
+ };
434
+ }
234
435
  // Non-terminal bracket states — a row in any of these means brackets are
235
436
  // either in-flight or fully live; attach_brackets refuses to clobber them
236
437
  // UNLESS the ledger cids don't match any live exchange order (stale row).