@reefclaw/openclaw-plugin 0.1.5 → 0.1.6

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 (122) hide show
  1. package/ccxt/binance-private.d.ts +21 -0
  2. package/ccxt/binance-private.js +132 -22
  3. package/config/plugin-config-io.d.ts +6 -0
  4. package/config/tool-gate.js +3 -0
  5. package/exchange-adapter.d.ts +16 -0
  6. package/index.js +551 -54
  7. package/lifecycle/trading-operation-lock.d.ts +17 -0
  8. package/lifecycle/trading-operation-lock.js +14 -0
  9. package/live/bracket-id.d.ts +2 -3
  10. package/live/bracket-id.js +22 -9
  11. package/live/live-adapter.d.ts +24 -1
  12. package/live/live-adapter.js +114 -2
  13. package/live/local-signal-service.js +11 -6
  14. package/live/local-strategy-evaluator.js +4 -0
  15. package/live/proposal-decision-listener.d.ts +6 -0
  16. package/live/proposal-decision-listener.js +4 -0
  17. package/live/stop-watcher.d.ts +27 -1
  18. package/live/stop-watcher.js +59 -2
  19. package/onboarding/runtime.d.ts +13 -0
  20. package/onboarding/runtime.js +22 -2
  21. package/openclaw.plugin.json +1 -0
  22. package/package.json +1 -1
  23. package/portfolio/wave9-admission.d.ts +67 -0
  24. package/portfolio/wave9-admission.js +262 -0
  25. package/portfolio/wave9-policy.d.ts +36 -0
  26. package/portfolio/wave9-policy.js +183 -0
  27. package/signals/conditions/registry.js +50 -0
  28. package/simulator/exchange-simulator.js +5 -0
  29. package/simulator/fill-engine.js +5 -1
  30. package/simulator/types.d.ts +6 -1
  31. package/strategy/evaluator.d.ts +3 -0
  32. package/strategy/evaluator.js +5 -0
  33. package/tools/assessment-validation.d.ts +2 -0
  34. package/tools/attach-brackets.d.ts +7 -2
  35. package/tools/attach-brackets.js +36 -0
  36. package/tools/cancel-all-orders.d.ts +2 -0
  37. package/tools/cancel-all-orders.js +4 -1
  38. package/tools/cancel-order.d.ts +4 -0
  39. package/tools/cancel-order.js +49 -3
  40. package/tools/close-position.d.ts +23 -0
  41. package/tools/close-position.js +286 -13
  42. package/tools/create-order.d.ts +28 -0
  43. package/tools/create-order.js +1364 -192
  44. package/tools/get-analytics.js +2 -2
  45. package/tools/get-basis.js +2 -2
  46. package/tools/get-cascade-risk.js +2 -2
  47. package/tools/get-crypto-metrics.js +14 -4
  48. package/tools/get-cvd.js +2 -2
  49. package/tools/get-divergences.js +2 -2
  50. package/tools/get-funding-context.js +2 -2
  51. package/tools/get-liquidation-levels.js +2 -2
  52. package/tools/get-liquidation-pulse.js +2 -2
  53. package/tools/get-pattern-scan.js +2 -2
  54. package/tools/get-regime.js +2 -2
  55. package/tools/get-resting-liquidity.js +2 -2
  56. package/tools/get-risk-scenario.js +2 -2
  57. package/tools/get-session-review.js +2 -2
  58. package/tools/get-setup-detail.js +9 -1
  59. package/tools/get-signals.js +2 -2
  60. package/tools/get-sizing.js +2 -2
  61. package/tools/get-trade-feedback.js +2 -2
  62. package/tools/get-trade-flow.js +2 -2
  63. package/tools/get-volume-profile.js +2 -2
  64. package/tools/get-wave9-status.d.ts +127 -0
  65. package/tools/get-wave9-status.js +796 -0
  66. package/tools/intel-api.d.ts +20 -0
  67. package/tools/intel-api.js +67 -0
  68. package/tools/intel-cache.d.ts +1 -1
  69. package/tools/intel-cache.js +20 -5
  70. package/tools/list-strategies.d.ts +11 -1
  71. package/tools/list-strategies.js +17 -0
  72. package/tools/modify-stop.d.ts +4 -0
  73. package/tools/modify-stop.js +63 -24
  74. package/tools/modify-target.d.ts +4 -0
  75. package/tools/modify-target.js +62 -23
  76. package/tools/scan-pairs.js +19 -8
  77. package/tools/toggle-strategy.js +7 -0
  78. package/types.d.ts +5 -0
  79. package/venues/hyperliquid/hl-balance.d.ts +116 -0
  80. package/venues/hyperliquid/hl-balance.js +145 -0
  81. package/venues/hyperliquid/hl-brackets.d.ts +102 -0
  82. package/venues/hyperliquid/hl-brackets.js +172 -0
  83. package/venues/hyperliquid/hl-cloid.d.ts +22 -0
  84. package/venues/hyperliquid/hl-cloid.js +82 -0
  85. package/venues/hyperliquid/hl-info-cache.d.ts +46 -0
  86. package/venues/hyperliquid/hl-info-cache.js +125 -0
  87. package/venues/hyperliquid/hl-live-adapter.d.ts +88 -0
  88. package/venues/hyperliquid/hl-live-adapter.js +353 -0
  89. package/venues/hyperliquid/hl-precision.d.ts +61 -0
  90. package/venues/hyperliquid/hl-precision.js +176 -0
  91. package/venues/hyperliquid/hl-private.d.ts +88 -0
  92. package/venues/hyperliquid/hl-private.js +357 -0
  93. package/venues/hyperliquid/hl-public.d.ts +31 -4
  94. package/venues/hyperliquid/hl-public.js +155 -11
  95. package/venues/hyperliquid/hl-rate-gate.d.ts +57 -0
  96. package/venues/hyperliquid/hl-rate-gate.js +220 -0
  97. package/venues/hyperliquid/hl-user-stream.d.ts +90 -0
  98. package/venues/hyperliquid/hl-user-stream.js +220 -0
  99. package/venues/registry.d.ts +23 -9
  100. package/venues/registry.js +12 -13
  101. package/venues/symbols.d.ts +43 -0
  102. package/venues/symbols.js +107 -0
  103. package/wave9/live-account-capture.d.ts +67 -0
  104. package/wave9/live-account-capture.js +435 -0
  105. package/wave9/live-autonomous-protection.d.ts +39 -0
  106. package/wave9/live-autonomous-protection.js +112 -0
  107. package/wave9/live-durable-reconciliation-scheduler.d.ts +33 -0
  108. package/wave9/live-durable-reconciliation-scheduler.js +115 -0
  109. package/wave9/live-execution-ledger.d.ts +107 -0
  110. package/wave9/live-execution-ledger.js +498 -0
  111. package/wave9/live-position-confirmation.d.ts +18 -0
  112. package/wave9/live-position-confirmation.js +111 -0
  113. package/wave9/live-residual-protection.d.ts +18 -0
  114. package/wave9/live-residual-protection.js +250 -0
  115. package/wave9/live-startup-reconciliation.d.ts +38 -0
  116. package/wave9/live-startup-reconciliation.js +454 -0
  117. package/wave9/live-symbol-ownership.d.ts +20 -0
  118. package/wave9/live-symbol-ownership.js +132 -0
  119. package/wave9/paper-admission-guard.d.ts +199 -0
  120. package/wave9/paper-admission-guard.js +650 -0
  121. package/wave9/usdm-evidence-provider.d.ts +42 -0
  122. package/wave9/usdm-evidence-provider.js +133 -0
@@ -0,0 +1,116 @@
1
+ import type { CcxtBalance } from '../../types.js';
2
+ import type { CloseReason } from '../../simulator/types.js';
3
+ export interface HlClearinghouseState {
4
+ marginSummary?: {
5
+ accountValue?: string | number;
6
+ totalMarginUsed?: string | number;
7
+ totalNtlPos?: string | number;
8
+ };
9
+ withdrawable?: string | number;
10
+ assetPositions?: Array<{
11
+ position?: {
12
+ coin?: string;
13
+ szi?: string | number;
14
+ entryPx?: string | number;
15
+ unrealizedPnl?: string | number;
16
+ positionValue?: string | number;
17
+ liquidationPx?: string | number;
18
+ marginUsed?: string | number;
19
+ };
20
+ }>;
21
+ }
22
+ export interface HlNav {
23
+ /** accountValue — what the HL app's portfolio panel shows. */
24
+ equity: number;
25
+ /** equity − Σ uPnl. The Binance "wallet"/margin-balance analog. */
26
+ wallet: number;
27
+ totalUnrealizedPnl: number;
28
+ /** Free collateral (HL `withdrawable`). */
29
+ available: number;
30
+ }
31
+ /** Derive NAV/wallet from a clearinghouseState payload.
32
+ *
33
+ * ★ Returns null when the payload is UNREADABLE — never a zeroed NAV. A phantom
34
+ * "$0 equity" would trip risk limits and look like a catastrophic loss; null
35
+ * means "unknown", which the caller must refuse to act on (null ≠ empty). */
36
+ export declare function deriveHlNav(state: HlClearinghouseState | null | undefined): HlNav | null;
37
+ /** CcxtBalance shape the rest of the plugin (KPI strip, sizing, LiveBalanceEnricher)
38
+ * already consumes. USDC is HL's settlement asset. */
39
+ export declare function toCcxtBalance(nav: HlNav): CcxtBalance;
40
+ export interface HlFillForAnchor {
41
+ time: number;
42
+ closedPnl?: string | number;
43
+ fee?: string | number;
44
+ builderFee?: string | number;
45
+ }
46
+ export interface HlFundingForAnchor {
47
+ time: number;
48
+ /** HL funding deltas are signed USDC (negative = paid). */
49
+ usdc?: string | number;
50
+ delta?: {
51
+ usdc?: string | number;
52
+ };
53
+ }
54
+ /** Σ(closedPnl − fee − builderFee) + Σ funding, over rows at/after `sinceMs`.
55
+ *
56
+ * This is the exact analog of Binance's `netNonTransfer` (REALIZED_PNL +
57
+ * COMMISSION + FUNDING_FEE), and — critically — it is NET of fees. The Binance
58
+ * lesson (the ledger was systematically gross, causing a visible KPI gap) applies
59
+ * verbatim: a gross anchor WILL disagree with the HL app. */
60
+ export declare function computeNetNonTransfer(args: {
61
+ fills: HlFillForAnchor[];
62
+ fundings: HlFundingForAnchor[];
63
+ sinceMs: number;
64
+ }): {
65
+ netNonTransfer: number;
66
+ realizedPnlGross: number;
67
+ fees: number;
68
+ funding: number;
69
+ };
70
+ /** UTC-midnight epoch ms for a given instant (the Day-P&L boundary). */
71
+ export declare function utcMidnightMs(now?: number): number;
72
+ /** sessionStartNav = wallet_now − netNonTransfer_since_midnight.
73
+ * dayPnl = NAV − sessionStartNav (computed by the caller each tick). */
74
+ export declare function deriveSessionStartNav(args: {
75
+ walletNow: number;
76
+ netNonTransfer: number;
77
+ }): number;
78
+ /** A capital flow (deposit / withdraw / internal transfer) changes the wallet
79
+ * WITHOUT being trading P&L — bump the anchor by the same signed amount so it
80
+ * never shows up as Day-P&L (the DepositTracker rule).
81
+ *
82
+ * T-3 note: the `delta.type` enum is OPEN (docs incomplete; only
83
+ * `internalTransfer` observed so far). We therefore treat any ledger row that
84
+ * carries a usdc delta as a capital flow rather than switch on a closed set — an
85
+ * UNKNOWN type must never be silently dropped, or the anchor drifts. */
86
+ export declare function applyLedgerDelta(args: {
87
+ sessionStartNav: number;
88
+ ledgerRows: Array<{
89
+ time: number;
90
+ delta?: {
91
+ usdc?: string | number;
92
+ type?: string;
93
+ };
94
+ }>;
95
+ sinceMs: number;
96
+ seenHashes?: Set<string>;
97
+ }): {
98
+ sessionStartNav: number;
99
+ applied: number;
100
+ };
101
+ /** ★ Close-bypass mapping (§5.9) — HL signals → journal close reasons.
102
+ *
103
+ * A position can leave our control without our close path running: a bracket
104
+ * triggering, a LIQUIDATION, an ADL, or a delisting. Each must land in the
105
+ * journal honestly (the KPI and the learning loop both read these), never as a
106
+ * silent disappearance. */
107
+ export declare function mapHlCloseReason(signal: {
108
+ /** Fill-level liquidation object (authoritative). */
109
+ liquidation?: {
110
+ method?: string;
111
+ } | null;
112
+ /** Order status from `orderUpdates`. */
113
+ orderStatus?: string;
114
+ /** Our own cloid role, when the closing order was one of our legs. */
115
+ bracketRole?: 'stop' | 'target';
116
+ }): CloseReason | 'liquidated' | 'adl' | 'venue_delisted' | null;
@@ -0,0 +1,145 @@
1
+ // Hyperliquid balance / NAV / Day-P&L anchor (plan §5.8) + close-bypass mapping
2
+ // (§5.9). Pure functions — the adapter feeds them raw HL payloads.
3
+ //
4
+ // ★ THE INVARIANT THIS FILE EXISTS TO KEEP: "KPI must equal the exchange app to
5
+ // the cent." On Binance that meant anchoring Day-P&L to the UTC-midnight income
6
+ // endpoint (a multi-dollar gap there was ALWAYS a bug, never a formula quirk).
7
+ // HL has no income endpoint, so the anchor is RECONSTRUCTED:
8
+ //
9
+ // wallet = accountValue − Σ unrealizedPnl (accountValue INCLUDES uPnl)
10
+ // NAV/equity = accountValue (so equity = wallet + Σ uPnl
11
+ // stays literally true)
12
+ // netNonTransfer(since UTC midnight)
13
+ // = Σ closedPnl − Σ fee − Σ builderFee (fills)
14
+ // + Σ funding (userFunding)
15
+ // sessionStartNav = wallet_now − netNonTransfer
16
+ // dayPnl = NAV − sessionStartNav
17
+ //
18
+ // Deposits/withdrawals/transfers are NOT trading P&L: they bump the anchor
19
+ // instead (the DepositTracker rule), via `applyLedgerDelta`.
20
+ //
21
+ // ★ FILL-HISTORY SHALLOWNESS: only the 10,000 most recent fills exist server-side
22
+ // on HL (deep history is NOT queryable). The anchor must therefore be able to
23
+ // run off WS-captured fills persisted in our own `trades` table — which is why
24
+ // the WS-first audit-trail rule is a hard prerequisite here, not hygiene.
25
+ const num = (v) => {
26
+ const n = typeof v === 'number' ? v : Number(v);
27
+ return Number.isFinite(n) ? n : 0;
28
+ };
29
+ /** Derive NAV/wallet from a clearinghouseState payload.
30
+ *
31
+ * ★ Returns null when the payload is UNREADABLE — never a zeroed NAV. A phantom
32
+ * "$0 equity" would trip risk limits and look like a catastrophic loss; null
33
+ * means "unknown", which the caller must refuse to act on (null ≠ empty). */
34
+ export function deriveHlNav(state) {
35
+ if (!state || typeof state !== 'object')
36
+ return null;
37
+ const accountValueRaw = state.marginSummary?.accountValue;
38
+ if (accountValueRaw === undefined || accountValueRaw === null)
39
+ return null;
40
+ const equity = num(accountValueRaw);
41
+ const totalUnrealizedPnl = (state.assetPositions ?? []).reduce((sum, ap) => sum + num(ap?.position?.unrealizedPnl), 0);
42
+ return {
43
+ equity,
44
+ wallet: equity - totalUnrealizedPnl,
45
+ totalUnrealizedPnl,
46
+ available: num(state.withdrawable),
47
+ };
48
+ }
49
+ /** CcxtBalance shape the rest of the plugin (KPI strip, sizing, LiveBalanceEnricher)
50
+ * already consumes. USDC is HL's settlement asset. */
51
+ export function toCcxtBalance(nav) {
52
+ const free = { USDC: nav.available };
53
+ const used = { USDC: Math.max(0, nav.wallet - nav.available) };
54
+ const total = { USDC: nav.wallet };
55
+ return {
56
+ free,
57
+ used,
58
+ total,
59
+ USDC: { free: nav.available, used: used.USDC, total: nav.wallet },
60
+ };
61
+ }
62
+ /** Σ(closedPnl − fee − builderFee) + Σ funding, over rows at/after `sinceMs`.
63
+ *
64
+ * This is the exact analog of Binance's `netNonTransfer` (REALIZED_PNL +
65
+ * COMMISSION + FUNDING_FEE), and — critically — it is NET of fees. The Binance
66
+ * lesson (the ledger was systematically gross, causing a visible KPI gap) applies
67
+ * verbatim: a gross anchor WILL disagree with the HL app. */
68
+ export function computeNetNonTransfer(args) {
69
+ let realizedPnlGross = 0;
70
+ let fees = 0;
71
+ for (const f of args.fills) {
72
+ if (!Number.isFinite(f.time) || f.time < args.sinceMs)
73
+ continue;
74
+ realizedPnlGross += num(f.closedPnl);
75
+ fees += num(f.fee) + num(f.builderFee);
76
+ }
77
+ let funding = 0;
78
+ for (const f of args.fundings) {
79
+ if (!Number.isFinite(f.time) || f.time < args.sinceMs)
80
+ continue;
81
+ funding += num(f.usdc ?? f.delta?.usdc);
82
+ }
83
+ return {
84
+ netNonTransfer: realizedPnlGross - fees + funding,
85
+ realizedPnlGross,
86
+ fees,
87
+ funding,
88
+ };
89
+ }
90
+ /** UTC-midnight epoch ms for a given instant (the Day-P&L boundary). */
91
+ export function utcMidnightMs(now = Date.now()) {
92
+ const d = new Date(now);
93
+ return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
94
+ }
95
+ /** sessionStartNav = wallet_now − netNonTransfer_since_midnight.
96
+ * dayPnl = NAV − sessionStartNav (computed by the caller each tick). */
97
+ export function deriveSessionStartNav(args) {
98
+ return args.walletNow - args.netNonTransfer;
99
+ }
100
+ /** A capital flow (deposit / withdraw / internal transfer) changes the wallet
101
+ * WITHOUT being trading P&L — bump the anchor by the same signed amount so it
102
+ * never shows up as Day-P&L (the DepositTracker rule).
103
+ *
104
+ * T-3 note: the `delta.type` enum is OPEN (docs incomplete; only
105
+ * `internalTransfer` observed so far). We therefore treat any ledger row that
106
+ * carries a usdc delta as a capital flow rather than switch on a closed set — an
107
+ * UNKNOWN type must never be silently dropped, or the anchor drifts. */
108
+ export function applyLedgerDelta(args) {
109
+ let bump = 0;
110
+ let applied = 0;
111
+ for (const row of args.ledgerRows) {
112
+ if (!Number.isFinite(row.time) || row.time < args.sinceMs)
113
+ continue;
114
+ const usdc = num(row.delta?.usdc);
115
+ if (usdc === 0)
116
+ continue;
117
+ bump += usdc;
118
+ applied += 1;
119
+ }
120
+ return { sessionStartNav: args.sessionStartNav + bump, applied };
121
+ }
122
+ /** ★ Close-bypass mapping (§5.9) — HL signals → journal close reasons.
123
+ *
124
+ * A position can leave our control without our close path running: a bracket
125
+ * triggering, a LIQUIDATION, an ADL, or a delisting. Each must land in the
126
+ * journal honestly (the KPI and the learning loop both read these), never as a
127
+ * silent disappearance. */
128
+ export function mapHlCloseReason(signal) {
129
+ if (signal.liquidation) {
130
+ // HL marks backstop (HLP vault) liquidations distinctly from market ones; ADL
131
+ // arrives as a liquidation of the OPPOSITE side hitting us.
132
+ const method = (signal.liquidation.method ?? '').toLowerCase();
133
+ return method.includes('adl') ? 'adl' : 'liquidated';
134
+ }
135
+ const status = (signal.orderStatus ?? '').toLowerCase();
136
+ if (status === 'liquidatedcanceled')
137
+ return 'liquidated';
138
+ if (status === 'delistedcanceled')
139
+ return 'venue_delisted';
140
+ if (signal.bracketRole === 'stop')
141
+ return 'exchange_stop';
142
+ if (signal.bracketRole === 'target')
143
+ return 'exchange_target';
144
+ return null;
145
+ }
@@ -0,0 +1,102 @@
1
+ import type { BracketId, BracketRole } from '../../live/bracket-types.js';
2
+ import type { HlOrderRequest } from './hl-private.js';
3
+ export type ProtectiveRole = Extract<BracketRole, 'stop' | 'target'>;
4
+ export interface HlBracketLeg {
5
+ role: ProtectiveRole;
6
+ triggerPrice: number;
7
+ /** HL legs are FIXED SIZE (T-2) — this is why resizing exists. */
8
+ size: number;
9
+ cloid: string;
10
+ }
11
+ export interface HlBracketPlan {
12
+ bracketId: BracketId;
13
+ symbol: string;
14
+ /** Side of the CLOSING orders (opposite the position). */
15
+ closeSide: 'buy' | 'sell';
16
+ legs: HlBracketLeg[];
17
+ }
18
+ /** A leg as it currently exists ON the exchange (from `frontendOpenOrders`). */
19
+ export interface LiveLeg {
20
+ role: ProtectiveRole;
21
+ cloid: string;
22
+ size: number;
23
+ triggerPrice: number;
24
+ }
25
+ /** Plan a fresh bracket for a filled entry. */
26
+ export declare function planBracket(args: {
27
+ bracketId: BracketId;
28
+ symbol: string;
29
+ positionSide: 'long' | 'short';
30
+ positionSize: number;
31
+ stopPrice?: number;
32
+ targetPrice?: number;
33
+ }): HlBracketPlan;
34
+ /** Build the submit-requests for a plan.
35
+ *
36
+ * Both legs go out as ONE `createOrders` batch: one signed action, one nonce (a
37
+ * same-ms nonce collision is a real HL failure mode), costing 1 IP weight unit
38
+ * but 2 ADDRESS actions (the rate gate counts both).
39
+ *
40
+ * Legs are reduce-only trigger-market orders. HL triggers fire on MARK price only
41
+ * — stop-distance validation upstream must therefore use mark, never last. */
42
+ export declare function buildBracketOrders(plan: HlBracketPlan): HlOrderRequest[];
43
+ export interface CoverageResult {
44
+ covered: boolean;
45
+ /** Un-protected contracts per role. > 0 ⇒ that much of the position is NAKED. */
46
+ shortfall: Record<ProtectiveRole, number>;
47
+ /** A role with NO live leg at all — a protection gap, not a sizing gap. */
48
+ missing: ProtectiveRole[];
49
+ }
50
+ /** ★ THE T-2 INVARIANT — do the live legs cover the whole position?
51
+ *
52
+ * On HL a protective leg is a FIXED SIZE, so a scale-in silently leaves the added
53
+ * contracts unprotected unless the legs are resized. A `covered:false` result is
54
+ * a safety-floor breach (naked size), not cosmetic drift. */
55
+ export declare function bracketCoversPosition(args: {
56
+ positionSize: number;
57
+ liveLegs: LiveLeg[];
58
+ }): CoverageResult;
59
+ export interface ResizePlan {
60
+ /** Cloids to cancel (the stale, wrongly-sized legs). */
61
+ cancelCloids: string[];
62
+ /** Fresh legs to submit at the correct size. */
63
+ submit: HlOrderRequest[];
64
+ /** Empty when nothing needs doing — the caller must treat this as a no-op and
65
+ * NOT churn the exchange (idempotent-tool contract). */
66
+ noop: boolean;
67
+ }
68
+ /** ★ Resize protective legs after a scale-in (or any position-size change).
69
+ *
70
+ * HL gives us no in-place resize primitive we can trust (`modify` carries an
71
+ * `always_place` footgun the plan forbids, and positionTpsl auto-sizing is
72
+ * unreachable via ccxt — T-2). So the honest sequence is CANCEL + REPLACE, and
73
+ * the order matters:
74
+ *
75
+ * Cancel-then-place opens a brief unprotected window; place-then-cancel would
76
+ * double-protect (two reduce-only legs summing to 2× the position — the excess
77
+ * is harmless because reduce-only can never flip a position, and HL cancels the
78
+ * stragglers when flat). We therefore SUBMIT FIRST, then cancel the stale legs:
79
+ * over-protection for a few hundred ms beats a naked window. The caller is
80
+ * expected to execute `submit` before `cancelCloids`.
81
+ *
82
+ * Fresh cloids are generated for the new legs (a cloid is single-use), which is
83
+ * also why the ledger keys on `bracketId`, not on a specific cloid. */
84
+ export declare function planResize(args: {
85
+ bracketId: BracketId;
86
+ symbol: string;
87
+ positionSide: 'long' | 'short';
88
+ positionSize: number;
89
+ liveLegs: LiveLeg[];
90
+ }): ResizePlan;
91
+ /** Is this exchange order one of OUR protective legs?
92
+ *
93
+ * The kill-switch's second layer (`OrderData.protective`) maps from this. It is
94
+ * deliberately BROADER than "has one of our cloids": an externally-placed stop
95
+ * must also survive a Kill. Mirrors the Binance rule (`reduceOnly || closePosition
96
+ * || STOP/TAKE_PROFIT type`), translated to HL's fields. */
97
+ export declare function isProtectiveHlOrder(order: {
98
+ reduceOnly?: boolean;
99
+ isTrigger?: boolean;
100
+ isPositionTpsl?: boolean;
101
+ orderType?: string;
102
+ }): boolean;
@@ -0,0 +1,172 @@
1
+ // Hyperliquid bracket mechanics (plan §5.7) — written against MEASURED behavior,
2
+ // not assumptions. The testnet run of 2026-07-12 (docs/CLAUDE/hyperliquid.md
3
+ // §"Testnet verification results") settled the two questions this file turns on:
4
+ //
5
+ // ★ T-1 — GOOD NEWS. Standalone (grouping `na`) reduce-only trigger legs
6
+ // AUTO-CANCEL when the position goes flat (~2.7s, zero residuals), and when
7
+ // ONE leg triggers the SIBLING SELF-CANCELS (OCO-like, ≤5.6s). HL cleans up
8
+ // after itself like Binance's algo orders, so BracketManager needs NO bespoke
9
+ // sibling-canceller. A defensive cancel stays harmless (idempotent) but is not
10
+ // load-bearing.
11
+ //
12
+ // ★ T-2 — THE HAZARD. `grouping:'positionTpsl'` is NOT reachable through ccxt's
13
+ // params passthrough (it silently produced an ordinary `isPositionTpsl:false`
14
+ // leg), and leg sizes DO NOT follow a scale-in: after doubling a position the
15
+ // stop stayed at the ORIGINAL size. Binance legs use `closePosition:true`
16
+ // (inherently whole-position); HL legs are FIXED SIZE. Therefore:
17
+ //
18
+ // ⇒ EVERY scale-in MUST resize the protective legs, or the added size is
19
+ // NAKED. `planResize()` is that requirement in code and
20
+ // `bracketCoversPosition()` is the invariant the auditor enforces.
21
+ //
22
+ // Everything here is PURE — it computes plans (what to submit, what to cancel);
23
+ // the adapter executes them. Safety logic stays unit-testable without a network.
24
+ import { buildHlBracketCloid } from './hl-cloid.js';
25
+ /** HL sizes are rounded to szDecimals, so an exact float compare would false-flag
26
+ * a correctly-sized leg. Below this, a gap is rounding dust, not exposure. */
27
+ const COVERAGE_EPSILON = 1e-8;
28
+ /** Plan a fresh bracket for a filled entry. */
29
+ export function planBracket(args) {
30
+ const closeSide = args.positionSide === 'long' ? 'sell' : 'buy';
31
+ const legs = [];
32
+ if (args.stopPrice !== undefined) {
33
+ legs.push({
34
+ role: 'stop',
35
+ triggerPrice: args.stopPrice,
36
+ size: args.positionSize,
37
+ cloid: buildHlBracketCloid(args.bracketId, 'stop'),
38
+ });
39
+ }
40
+ if (args.targetPrice !== undefined) {
41
+ legs.push({
42
+ role: 'target',
43
+ triggerPrice: args.targetPrice,
44
+ size: args.positionSize,
45
+ cloid: buildHlBracketCloid(args.bracketId, 'target'),
46
+ });
47
+ }
48
+ return { bracketId: args.bracketId, symbol: args.symbol, closeSide, legs };
49
+ }
50
+ /** Build the submit-requests for a plan.
51
+ *
52
+ * Both legs go out as ONE `createOrders` batch: one signed action, one nonce (a
53
+ * same-ms nonce collision is a real HL failure mode), costing 1 IP weight unit
54
+ * but 2 ADDRESS actions (the rate gate counts both).
55
+ *
56
+ * Legs are reduce-only trigger-market orders. HL triggers fire on MARK price only
57
+ * — stop-distance validation upstream must therefore use mark, never last. */
58
+ export function buildBracketOrders(plan) {
59
+ return plan.legs.map((leg) => ({
60
+ symbol: plan.symbol,
61
+ side: plan.closeSide,
62
+ type: 'market',
63
+ amount: leg.size,
64
+ // A trigger-market leg still needs a price for ccxt's slippage bound; the
65
+ // trigger price is the natural reference (the leg only executes there).
66
+ price: leg.triggerPrice,
67
+ reduceOnly: true,
68
+ cloid: leg.cloid,
69
+ trigger: {
70
+ triggerPrice: leg.triggerPrice,
71
+ tpsl: leg.role === 'stop' ? 'sl' : 'tp',
72
+ },
73
+ }));
74
+ }
75
+ /** ★ THE T-2 INVARIANT — do the live legs cover the whole position?
76
+ *
77
+ * On HL a protective leg is a FIXED SIZE, so a scale-in silently leaves the added
78
+ * contracts unprotected unless the legs are resized. A `covered:false` result is
79
+ * a safety-floor breach (naked size), not cosmetic drift. */
80
+ export function bracketCoversPosition(args) {
81
+ const shortfall = { stop: 0, target: 0 };
82
+ const missing = [];
83
+ for (const role of ['stop', 'target']) {
84
+ const legs = args.liveLegs.filter((l) => l.role === role);
85
+ if (legs.length === 0) {
86
+ missing.push(role);
87
+ continue;
88
+ }
89
+ const coveredSize = legs.reduce((sum, l) => sum + l.size, 0);
90
+ const gap = args.positionSize - coveredSize;
91
+ if (gap > COVERAGE_EPSILON)
92
+ shortfall[role] = gap;
93
+ }
94
+ const covered = shortfall.stop <= COVERAGE_EPSILON &&
95
+ shortfall.target <= COVERAGE_EPSILON &&
96
+ // A missing STOP is never "covered" — that is the naked-position case. A
97
+ // missing TARGET is a policy choice (requireTakeProfit can be off), so it is
98
+ // reported in `missing` but does not by itself fail coverage.
99
+ !missing.includes('stop');
100
+ return { covered, shortfall, missing };
101
+ }
102
+ /** ★ Resize protective legs after a scale-in (or any position-size change).
103
+ *
104
+ * HL gives us no in-place resize primitive we can trust (`modify` carries an
105
+ * `always_place` footgun the plan forbids, and positionTpsl auto-sizing is
106
+ * unreachable via ccxt — T-2). So the honest sequence is CANCEL + REPLACE, and
107
+ * the order matters:
108
+ *
109
+ * Cancel-then-place opens a brief unprotected window; place-then-cancel would
110
+ * double-protect (two reduce-only legs summing to 2× the position — the excess
111
+ * is harmless because reduce-only can never flip a position, and HL cancels the
112
+ * stragglers when flat). We therefore SUBMIT FIRST, then cancel the stale legs:
113
+ * over-protection for a few hundred ms beats a naked window. The caller is
114
+ * expected to execute `submit` before `cancelCloids`.
115
+ *
116
+ * Fresh cloids are generated for the new legs (a cloid is single-use), which is
117
+ * also why the ledger keys on `bracketId`, not on a specific cloid. */
118
+ export function planResize(args) {
119
+ const coverage = bracketCoversPosition({
120
+ positionSize: args.positionSize,
121
+ liveLegs: args.liveLegs,
122
+ });
123
+ // Nothing to do when every present leg already covers the position exactly.
124
+ const oversized = args.liveLegs.some((l) => l.size - args.positionSize > COVERAGE_EPSILON);
125
+ if (coverage.shortfall.stop <= COVERAGE_EPSILON &&
126
+ coverage.shortfall.target <= COVERAGE_EPSILON &&
127
+ !oversized) {
128
+ return { cancelCloids: [], submit: [], noop: true };
129
+ }
130
+ // Rebuild every PRESENT role at the correct size, preserving its trigger price.
131
+ const legs = [];
132
+ const cancelCloids = [];
133
+ for (const role of ['stop', 'target']) {
134
+ const existing = args.liveLegs.filter((l) => l.role === role);
135
+ if (existing.length === 0)
136
+ continue; // absent leg = attach path's job, not resize's
137
+ // Trigger price is preserved from the live leg (the agent's pinned plan) —
138
+ // resizing must NEVER silently move a stop.
139
+ const triggerPrice = existing[0].triggerPrice;
140
+ legs.push({
141
+ role,
142
+ triggerPrice,
143
+ size: args.positionSize,
144
+ cloid: buildHlBracketCloid(args.bracketId, role),
145
+ });
146
+ cancelCloids.push(...existing.map((l) => l.cloid));
147
+ }
148
+ const closeSide = args.positionSide === 'long' ? 'sell' : 'buy';
149
+ const submit = buildBracketOrders({
150
+ bracketId: args.bracketId,
151
+ symbol: args.symbol,
152
+ closeSide,
153
+ legs,
154
+ });
155
+ return { cancelCloids, submit, noop: submit.length === 0 };
156
+ }
157
+ /** Is this exchange order one of OUR protective legs?
158
+ *
159
+ * The kill-switch's second layer (`OrderData.protective`) maps from this. It is
160
+ * deliberately BROADER than "has one of our cloids": an externally-placed stop
161
+ * must also survive a Kill. Mirrors the Binance rule (`reduceOnly || closePosition
162
+ * || STOP/TAKE_PROFIT type`), translated to HL's fields. */
163
+ export function isProtectiveHlOrder(order) {
164
+ if (order.reduceOnly === true)
165
+ return true;
166
+ if (order.isTrigger === true)
167
+ return true;
168
+ if (order.isPositionTpsl === true)
169
+ return true;
170
+ const t = (order.orderType ?? '').toLowerCase();
171
+ return t.includes('stop') || t.includes('take profit') || t.includes('tp');
172
+ }
@@ -0,0 +1,22 @@
1
+ import type { BracketId, BracketRole } from '../../live/bracket-types.js';
2
+ /** Build the HL cloid for a bracket leg. Reuses the venue-neutral 16-hex
3
+ * bracketId (same value the Binance cid carries) so the BracketLedger,
4
+ * reconciler and journal stay venue-agnostic. */
5
+ export declare function buildHlBracketCloid(bracketId: BracketId, role: BracketRole): string;
6
+ /** Parse an HL cloid. Returns null for any non-bracket cloid — this is called on
7
+ * EVERY order HL returns during reconciliation, so a null result is normal and
8
+ * must never throw. */
9
+ export declare function parseHlBracketCloid(cloid: string): {
10
+ bracketId: BracketId;
11
+ role: BracketRole;
12
+ } | null;
13
+ /** Cheap type-guard: is this one of OUR bracket cloids? */
14
+ export declare function isHlBracketCloid(cloid: string): boolean;
15
+ /** Is this a syntactically valid HL cloid at all (128-bit hex)? Used to reject
16
+ * a Binance-shaped cid before it reaches the wire (HL returns a cryptic error
17
+ * for malformed cloids). */
18
+ export declare function isValidHlCloid(cloid: string): boolean;
19
+ /** Non-bracket cloid for a plain (entry/close) order — lets every HL order carry
20
+ * an idempotency key, exactly like the Binance path's clientOrderId. Distinct
21
+ * magic ("ord") so it never parses as a bracket leg. */
22
+ export declare function buildHlOrderCloid(): string;
@@ -0,0 +1,82 @@
1
+ // Hyperliquid bracket cloid scheme (plan §5.7).
2
+ //
3
+ // HL client order ids are **128-bit hex** (`0x` + exactly 32 hex chars) — NOT
4
+ // free-form ASCII like Binance's `newClientOrderId`. So the Binance `bkt<16hex><role>`
5
+ // scheme cannot be reused verbatim; it is RE-ENCODED into the 128-bit space:
6
+ //
7
+ // 0x bc7 <role> <16hex bracketId> <12hex random>
8
+ // └┬┘ └┬┘ └──┬─┘ └──────┬────────┘ └─────┬──────┘
9
+ // │ │ │ │ └── entropy (collision + opacity)
10
+ // │ │ │ └── the SAME bracketId the Binance scheme uses, so the
11
+ // │ │ │ ledger/reconciler stay venue-agnostic
12
+ // │ │ └── role nibble: e=entry, 5=stop, 7=target (hex-legal; 's'/'t' are not)
13
+ // │ └── magic "bc7" (bracket) — short on purpose, see below
14
+ // └── 0x prefix
15
+ //
16
+ // 3 + 1 + 16 + 12 = 32 hex chars. Exactly a valid cloid.
17
+ //
18
+ // ★ WHY THE MAGIC IS SHORT AND THE ENTROPY IS LONG. On Binance an EXTERNAL actor
19
+ // with API access pattern-matched our `rc-` cids and auto-cancelled every order
20
+ // carrying them (docs/BRACKET_CANCEL_ROOT_CAUSE_2026-05-16.md). The lesson carries
21
+ // to any venue: a long, guessable, human-obvious prefix is an attack surface. Keep
22
+ // the recognizable part minimal (we still need to reassemble brackets from the
23
+ // exchange after a restart, with zero reliance on local state) and let the rest be
24
+ // random.
25
+ //
26
+ // ★ RECOGNITION STAYS CENTRALIZED. docs/CLAUDE/brackets.md forbids a second
27
+ // bracket-cid regex anywhere else in the codebase. This module is the HL half of
28
+ // that rule; `live/bracket-id.ts:parseBracketClientId(venue, cid)` dispatches here.
29
+ // Never inline `/^0xbc7/` in a caller.
30
+ import { randomBytes } from 'node:crypto';
31
+ /** 0x + bc7 + role-nibble + 16-hex bracketId + 12-hex entropy = 34 chars total. */
32
+ const HL_CLOID_REGEX = /^0xbc7([e57])([0-9a-f]{16})([0-9a-f]{12})$/;
33
+ /** Any well-formed HL cloid (128-bit hex), bracket or not. */
34
+ const HL_CLOID_SHAPE = /^0x[0-9a-f]{32}$/;
35
+ const ROLE_TO_NIBBLE = {
36
+ entry: 'e',
37
+ stop: '5',
38
+ target: '7',
39
+ };
40
+ const NIBBLE_TO_ROLE = {
41
+ e: 'entry',
42
+ '5': 'stop',
43
+ '7': 'target',
44
+ };
45
+ /** Build the HL cloid for a bracket leg. Reuses the venue-neutral 16-hex
46
+ * bracketId (same value the Binance cid carries) so the BracketLedger,
47
+ * reconciler and journal stay venue-agnostic. */
48
+ export function buildHlBracketCloid(bracketId, role) {
49
+ if (!/^[0-9a-f]{16}$/.test(bracketId)) {
50
+ throw new Error(`Invalid bracketId: ${bracketId} — expected 16 hex chars`);
51
+ }
52
+ const entropy = randomBytes(6).toString('hex'); // 12 hex chars
53
+ return `0xbc7${ROLE_TO_NIBBLE[role]}${bracketId}${entropy}`;
54
+ }
55
+ /** Parse an HL cloid. Returns null for any non-bracket cloid — this is called on
56
+ * EVERY order HL returns during reconciliation, so a null result is normal and
57
+ * must never throw. */
58
+ export function parseHlBracketCloid(cloid) {
59
+ if (typeof cloid !== 'string')
60
+ return null;
61
+ const m = HL_CLOID_REGEX.exec(cloid.toLowerCase());
62
+ if (!m)
63
+ return null;
64
+ return { bracketId: m[2], role: NIBBLE_TO_ROLE[m[1]] };
65
+ }
66
+ /** Cheap type-guard: is this one of OUR bracket cloids? */
67
+ export function isHlBracketCloid(cloid) {
68
+ return typeof cloid === 'string' && HL_CLOID_REGEX.test(cloid.toLowerCase());
69
+ }
70
+ /** Is this a syntactically valid HL cloid at all (128-bit hex)? Used to reject
71
+ * a Binance-shaped cid before it reaches the wire (HL returns a cryptic error
72
+ * for malformed cloids). */
73
+ export function isValidHlCloid(cloid) {
74
+ return typeof cloid === 'string' && HL_CLOID_SHAPE.test(cloid.toLowerCase());
75
+ }
76
+ /** Non-bracket cloid for a plain (entry/close) order — lets every HL order carry
77
+ * an idempotency key, exactly like the Binance path's clientOrderId. Distinct
78
+ * magic ("ord") so it never parses as a bracket leg. */
79
+ export function buildHlOrderCloid() {
80
+ // 0x + "0d" (order) + 30 hex random = 32 hex chars.
81
+ return `0x0d${randomBytes(15).toString('hex')}`;
82
+ }