@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
@@ -0,0 +1,57 @@
1
+ /** Info-request weights, from the docs (§3.3). Anything unlisted defaults to 20 —
2
+ * the conservative side. `/exchange` actions are `1 + floor(batch/40)`. */
3
+ export declare const HL_INFO_WEIGHTS: Record<string, number>;
4
+ export declare class HyperliquidRateLimitedError extends Error {
5
+ constructor(message: string);
6
+ }
7
+ /** Thrown by the proactive pacer BEFORE a call goes out. Subclass so every
8
+ * existing `catch (HyperliquidRateLimitedError)` absorbs it — the same
9
+ * relationship `BinanceWeightPacedError` has to `BinanceBannedError`. */
10
+ export declare class HyperliquidPacedError extends HyperliquidRateLimitedError {
11
+ constructor(message: string);
12
+ }
13
+ /** Current 60s IP-weight estimate. */
14
+ export declare function currentIpWeight(now?: number): number;
15
+ /** Weight for an info context (defaults to the conservative 20). */
16
+ export declare function infoWeight(ctx: string): number;
17
+ /** `/exchange` IP weight for a batch of n actions: 1 + floor(n/40). */
18
+ export declare function exchangeIpWeight(batchLen: number): number;
19
+ /** FIRST LINE of every gated read's try-block.
20
+ *
21
+ * Throws when:
22
+ * - a 429 backoff is active (hard stop — like an active Binance ban), or
23
+ * - the IP weight window is over its soft ceiling (proactive pacing), or
24
+ * - the address budget is below the floor AND this context is shed-able.
25
+ *
26
+ * Order/cancel/close paths must NOT call this — they arm the gate via
27
+ * `noteError` only, so a limit can never block an exit. */
28
+ export declare function assertNotLimited(ctx: string): void;
29
+ /** Record a successful call's cost. `addressActions` is 0 for pure info reads
30
+ * (they cost IP weight only) and n for an `/exchange` batch of n actions. */
31
+ export declare function noteSuccess(ctx: string, cost?: {
32
+ ipWeight?: number;
33
+ addressActions?: number;
34
+ }): void;
35
+ /** Arm the gate from a caught error. Called in EVERY catch — including on the
36
+ * order/cancel paths that are never pre-gated (so a burst of rejects still
37
+ * backs the gate off). */
38
+ export declare function noteError(err: unknown, ctx?: string): void;
39
+ /** HL surfaces rate limiting as HTTP 429 and/or a text body; match on shape, not
40
+ * on a venue flag, so the skill-side breaker can reuse this predicate. */
41
+ export declare function isRateLimitError(err: unknown): boolean;
42
+ /** Feed the address budget from info `userRateLimit` (`nRequestsCap −
43
+ * nRequestsUsed`). Poll it every ~5 min — it costs weight 20. */
44
+ export declare function updateAddressBudget(args: {
45
+ nRequestsUsed: number;
46
+ nRequestsCap: number;
47
+ }): void;
48
+ /** Snapshot for tests + the readiness/diagnostic surfaces. */
49
+ export declare function getGateState(): {
50
+ ipWeight60s: number;
51
+ weightCeiling: number;
52
+ addressRemaining?: number;
53
+ limited: boolean;
54
+ limitedForMs: number;
55
+ };
56
+ /** Test-only reset. */
57
+ export declare function __resetGateForTests(): void;
@@ -0,0 +1,220 @@
1
+ // Hyperliquid rate gate — the HL analog of `ccxt/binance-ban-gate.ts` (plan §5.6).
2
+ //
3
+ // SAME SHAPE as the Binance gate (process-wide singleton; `assertNotLimited(ctx)`
4
+ // first line in the try, `noteError(err)` in the catch, `noteSuccess()` after a
5
+ // clean call; order/cancel paths are NEVER pre-gated but DO arm the gate).
6
+ // DIFFERENT MODEL inside — read twice before tuning:
7
+ //
8
+ // Binance: per-IP weight window; abuse ⇒ a 418 BAN that EXTENDS on every further
9
+ // request. The danger is a poller hammering through a ban.
10
+ // HL: TWO independent budgets.
11
+ // (1) IP weight: 1200/min (no response header to reconcile against —
12
+ // the local estimate IS the authority; calibrate in shadow).
13
+ // (2) ★ ADDRESS action budget: 10,000 initial + 1 per 1 USDC of
14
+ // cumulative traded volume. Exhausted ⇒ 1 request / 10 s. A
15
+ // low-volume, chatty heartbeat agent STARVES ITSELF — the exact
16
+ // opposite failure mode from Binance, and the one Binance-tuned
17
+ // intuition will miss.
18
+ //
19
+ // Batches count as **1** for the IP window but **n** for the address budget, so
20
+ // every `note*` call takes both numbers — brackets (2 legs, one batch) are counted
21
+ // honestly.
22
+ import { logger } from '../../logger.js';
23
+ const TAG = 'hl-rate-gate';
24
+ /** Documented IP limit is 1200 weight/min; we pace against a soft ceiling so a
25
+ * burst never reaches the real one. Env override for calibration. */
26
+ const DEFAULT_WEIGHT_CEILING = 900;
27
+ /** Below this many remaining address actions we start shedding SHED_FIRST reads
28
+ * and warn loudly (the remedy is operator-level: trade volume accrues budget, or
29
+ * `reserveRequestWeight` purchases more — never automatic). */
30
+ const DEFAULT_ADDRESS_FLOOR = 2000;
31
+ const WINDOW_MS = 60_000;
32
+ /** 429 backoff ladder (mirrors the Binance gate's shape; HL has no
33
+ * 418-with-timestamp analog, so there is no stated window to parse). */
34
+ const BACKOFF_LADDER_MS = [5_000, 15_000, 45_000, 90_000, 120_000];
35
+ /** Info-request weights, from the docs (§3.3). Anything unlisted defaults to 20 —
36
+ * the conservative side. `/exchange` actions are `1 + floor(batch/40)`. */
37
+ export const HL_INFO_WEIGHTS = {
38
+ l2Book: 2,
39
+ allMids: 2,
40
+ clearinghouseState: 2,
41
+ orderStatus: 2,
42
+ exchangeStatus: 2,
43
+ spotClearinghouseState: 2,
44
+ userRole: 60,
45
+ // everything else (meta, metaAndAssetCtxs, userFills, frontendOpenOrders,
46
+ // userFunding, userNonFundingLedgerUpdates, candleSnapshot, userRateLimit …)
47
+ default: 20,
48
+ };
49
+ /** Read contexts that are safe to SHED when the address budget runs low — they are
50
+ * refreshable/derivable, unlike the safety-floor reads (positions, open orders,
51
+ * order status) which must always pass. */
52
+ const SHED_FIRST = new Set([
53
+ 'candleSnapshot',
54
+ 'metaAndAssetCtxs',
55
+ 'allMids',
56
+ 'l2Book',
57
+ 'userFunding',
58
+ 'userFees',
59
+ 'portfolio',
60
+ ]);
61
+ export class HyperliquidRateLimitedError extends Error {
62
+ constructor(message) {
63
+ super(message);
64
+ this.name = 'HyperliquidRateLimitedError';
65
+ }
66
+ }
67
+ /** Thrown by the proactive pacer BEFORE a call goes out. Subclass so every
68
+ * existing `catch (HyperliquidRateLimitedError)` absorbs it — the same
69
+ * relationship `BinanceWeightPacedError` has to `BinanceBannedError`. */
70
+ export class HyperliquidPacedError extends HyperliquidRateLimitedError {
71
+ constructor(message) {
72
+ super(message);
73
+ this.name = 'HyperliquidPacedError';
74
+ }
75
+ }
76
+ const state = {
77
+ samples: [],
78
+ limitedUntil: 0,
79
+ consecutive429: 0,
80
+ windowIpWeight: 0,
81
+ windowAddressActions: 0,
82
+ windowStart: Date.now(),
83
+ };
84
+ function weightCeiling() {
85
+ const raw = Number(process.env.RC_HL_WEIGHT_CEILING);
86
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_WEIGHT_CEILING;
87
+ }
88
+ function addressFloor() {
89
+ const raw = Number(process.env.RC_HL_ADDRESS_BUDGET_FLOOR);
90
+ return Number.isFinite(raw) && raw >= 0 ? raw : DEFAULT_ADDRESS_FLOOR;
91
+ }
92
+ function pruneWindow(now) {
93
+ const cutoff = now - WINDOW_MS;
94
+ while (state.samples.length > 0 && state.samples[0].at < cutoff) {
95
+ state.samples.shift();
96
+ }
97
+ }
98
+ /** Current 60s IP-weight estimate. */
99
+ export function currentIpWeight(now = Date.now()) {
100
+ pruneWindow(now);
101
+ return state.samples.reduce((sum, s) => sum + s.weight, 0);
102
+ }
103
+ /** Weight for an info context (defaults to the conservative 20). */
104
+ export function infoWeight(ctx) {
105
+ return HL_INFO_WEIGHTS[ctx] ?? HL_INFO_WEIGHTS.default;
106
+ }
107
+ /** `/exchange` IP weight for a batch of n actions: 1 + floor(n/40). */
108
+ export function exchangeIpWeight(batchLen) {
109
+ return 1 + Math.floor(Math.max(batchLen, 1) / 40);
110
+ }
111
+ /** FIRST LINE of every gated read's try-block.
112
+ *
113
+ * Throws when:
114
+ * - a 429 backoff is active (hard stop — like an active Binance ban), or
115
+ * - the IP weight window is over its soft ceiling (proactive pacing), or
116
+ * - the address budget is below the floor AND this context is shed-able.
117
+ *
118
+ * Order/cancel/close paths must NOT call this — they arm the gate via
119
+ * `noteError` only, so a limit can never block an exit. */
120
+ export function assertNotLimited(ctx) {
121
+ const now = Date.now();
122
+ if (state.limitedUntil > now) {
123
+ const secs = Math.ceil((state.limitedUntil - now) / 1000);
124
+ throw new HyperliquidRateLimitedError(`Hyperliquid rate limit active (${secs}s remaining) — ${ctx} shed`);
125
+ }
126
+ const weight = infoWeight(ctx);
127
+ const used = currentIpWeight(now);
128
+ if (used + weight > weightCeiling()) {
129
+ throw new HyperliquidPacedError(`Hyperliquid IP weight pacer: ${used}+${weight} > ${weightCeiling()}/min ceiling — ${ctx} shed`);
130
+ }
131
+ const remaining = state.addressRemaining;
132
+ if (remaining !== undefined && remaining < addressFloor() && SHED_FIRST.has(ctx)) {
133
+ throw new HyperliquidPacedError(`Hyperliquid address action budget low (${remaining} left, floor ${addressFloor()}) — ` +
134
+ `shedding ${ctx}. Budget accrues with traded volume; reserveRequestWeight can purchase more.`);
135
+ }
136
+ }
137
+ /** Record a successful call's cost. `addressActions` is 0 for pure info reads
138
+ * (they cost IP weight only) and n for an `/exchange` batch of n actions. */
139
+ export function noteSuccess(ctx, cost) {
140
+ const now = Date.now();
141
+ const ipWeight = cost?.ipWeight ?? infoWeight(ctx);
142
+ const addressActions = cost?.addressActions ?? 0;
143
+ state.samples.push({ at: now, weight: ipWeight });
144
+ pruneWindow(now);
145
+ state.consecutive429 = 0;
146
+ state.limitedUntil = 0;
147
+ state.windowIpWeight += ipWeight;
148
+ state.windowAddressActions += addressActions;
149
+ if (addressActions > 0 && state.addressRemaining !== undefined) {
150
+ state.addressRemaining = Math.max(0, state.addressRemaining - addressActions);
151
+ }
152
+ maybeLogWindow(now);
153
+ }
154
+ /** Arm the gate from a caught error. Called in EVERY catch — including on the
155
+ * order/cancel paths that are never pre-gated (so a burst of rejects still
156
+ * backs the gate off). */
157
+ export function noteError(err, ctx = 'unknown') {
158
+ if (!isRateLimitError(err))
159
+ return;
160
+ state.consecutive429 += 1;
161
+ const idx = Math.min(state.consecutive429 - 1, BACKOFF_LADDER_MS.length - 1);
162
+ const backoff = BACKOFF_LADDER_MS[idx];
163
+ state.limitedUntil = Date.now() + backoff;
164
+ logger.warn(TAG, `Hyperliquid rate limit hit on ${ctx} (#${state.consecutive429}) — backing off ${backoff / 1000}s`);
165
+ }
166
+ /** HL surfaces rate limiting as HTTP 429 and/or a text body; match on shape, not
167
+ * on a venue flag, so the skill-side breaker can reuse this predicate. */
168
+ export function isRateLimitError(err) {
169
+ if (err instanceof HyperliquidRateLimitedError)
170
+ return true;
171
+ const msg = err instanceof Error ? err.message : String(err ?? '');
172
+ return /429|rate limit|too many requests/i.test(msg);
173
+ }
174
+ /** Feed the address budget from info `userRateLimit` (`nRequestsCap −
175
+ * nRequestsUsed`). Poll it every ~5 min — it costs weight 20. */
176
+ export function updateAddressBudget(args) {
177
+ const remaining = Math.max(0, args.nRequestsCap - args.nRequestsUsed);
178
+ const prev = state.addressRemaining;
179
+ state.addressRemaining = remaining;
180
+ state.addressUpdatedAt = Date.now();
181
+ if (remaining < addressFloor() && (prev === undefined || prev >= addressFloor())) {
182
+ logger.warn(TAG, `★ Hyperliquid ADDRESS action budget low: ${remaining} remaining (cap ${args.nRequestsCap}). ` +
183
+ 'Exhaustion throttles the account to 1 request/10s. Budget accrues at 1 action per 1 USDC ' +
184
+ 'of traded volume; an operator can purchase more via reserveRequestWeight.');
185
+ }
186
+ }
187
+ /** Snapshot for tests + the readiness/diagnostic surfaces. */
188
+ export function getGateState() {
189
+ const now = Date.now();
190
+ return {
191
+ ipWeight60s: currentIpWeight(now),
192
+ weightCeiling: weightCeiling(),
193
+ addressRemaining: state.addressRemaining,
194
+ limited: state.limitedUntil > now,
195
+ limitedForMs: Math.max(0, state.limitedUntil - now),
196
+ };
197
+ }
198
+ /** Permanent instrumentation — the `weight window summary` analog. Attribute load
199
+ * from THESE lines, never from a guess. */
200
+ function maybeLogWindow(now) {
201
+ if (now - state.windowStart < WINDOW_MS)
202
+ return;
203
+ logger.info(TAG, `hl window summary: ipWeight=${state.windowIpWeight}/${weightCeiling()} ` +
204
+ `addressActions=${state.windowAddressActions} ` +
205
+ `addressRemaining=${state.addressRemaining ?? 'unknown'}`);
206
+ state.windowIpWeight = 0;
207
+ state.windowAddressActions = 0;
208
+ state.windowStart = now;
209
+ }
210
+ /** Test-only reset. */
211
+ export function __resetGateForTests() {
212
+ state.samples = [];
213
+ state.limitedUntil = 0;
214
+ state.consecutive429 = 0;
215
+ state.addressRemaining = undefined;
216
+ state.addressUpdatedAt = undefined;
217
+ state.windowIpWeight = 0;
218
+ state.windowAddressActions = 0;
219
+ state.windowStart = Date.now();
220
+ }
@@ -0,0 +1,90 @@
1
+ export interface HlFillEvent {
2
+ coin: string;
3
+ px: string;
4
+ sz: string;
5
+ side: 'A' | 'B';
6
+ time: number;
7
+ dir: string;
8
+ closedPnl: string;
9
+ fee: string;
10
+ feeToken?: string;
11
+ builderFee?: string;
12
+ crossed: boolean;
13
+ oid: number;
14
+ tid: number;
15
+ cloid?: string;
16
+ startPosition?: string;
17
+ /** Present ONLY on a liquidation fill — an authoritative close-bypass signal. */
18
+ liquidation?: {
19
+ liquidatedUser?: string;
20
+ markPx?: string;
21
+ method?: string;
22
+ };
23
+ }
24
+ export interface HlOrderUpdateEvent {
25
+ order: {
26
+ coin: string;
27
+ side: 'A' | 'B';
28
+ limitPx: string;
29
+ sz: string;
30
+ oid: number;
31
+ timestamp: number;
32
+ origSz: string;
33
+ cloid?: string;
34
+ isTrigger?: boolean;
35
+ triggerPx?: string;
36
+ isPositionTpsl?: boolean;
37
+ reduceOnly?: boolean;
38
+ orderType?: string;
39
+ };
40
+ status: string;
41
+ statusTimestamp: number;
42
+ }
43
+ export interface HlUserStreamCallbacks {
44
+ onFill: (fill: HlFillEvent) => void;
45
+ onOrderUpdate: (update: HlOrderUpdateEvent) => void;
46
+ /** userEvents: liquidation / funding / non-user-cancel — the close-bypass feed. */
47
+ onUserEvent: (event: Record<string, unknown>) => void;
48
+ /**
49
+ * ★ MANDATORY. Fired after every (re)connect, carrying the window during which
50
+ * we were blind. T-5 proved the WS does NOT backfill it, so the caller MUST
51
+ * reconcile via REST (positions, open orders, fills since `sinceMs`) before
52
+ * trusting local state again. A caller that ignores this WILL miss fills.
53
+ */
54
+ onResyncNeeded: (window: {
55
+ sinceMs: number;
56
+ wasDisconnectedMs: number;
57
+ }) => void;
58
+ }
59
+ export declare class HyperliquidUserStream {
60
+ private readonly opts;
61
+ private ws;
62
+ private pingTimer;
63
+ private staleTimer;
64
+ private reconnectTimer;
65
+ private reconnectAttempts;
66
+ private stopped;
67
+ private lastFrameAt;
68
+ /** Last event we actually processed — the low-water mark for the REST gap-fill. */
69
+ private lastEventAt;
70
+ private disconnectedAt;
71
+ constructor(opts: {
72
+ /** MASTER address — subscriptions are keyed by the account, never the agent. */
73
+ walletAddress: string;
74
+ testnet?: boolean;
75
+ callbacks: HlUserStreamCallbacks;
76
+ });
77
+ get isConnected(): boolean;
78
+ /** Epoch-ms of the last processed event (0 = none yet). */
79
+ get lastEventTimestamp(): number;
80
+ start(): void;
81
+ stop(): void;
82
+ private url;
83
+ private connect;
84
+ private subscribeAll;
85
+ private handle;
86
+ private send;
87
+ private armTimers;
88
+ private clearTimers;
89
+ private onDisconnected;
90
+ }
@@ -0,0 +1,220 @@
1
+ // Hyperliquid user data stream (plan §5.4/§5.9) — the HL analog of
2
+ // `live/user-data-stream.ts`.
3
+ //
4
+ // ★ THE MEASURED FACT THAT SHAPES THIS FILE (T-5, testnet 2026-07-12):
5
+ // **HL's WS replays NOTHING on reconnect.** We killed the socket, placed an
6
+ // order inside the gap, re-subscribed — and received ZERO events for it, while
7
+ // REST showed it plainly. There is no `isSnapshot` backfill for the gap.
8
+ //
9
+ // ⇒ WS is a FAST PATH, never the ledger. Every reconnect MUST be followed by a
10
+ // REST truth-check (positions + open orders + fills since the disconnect), and
11
+ // the caller is REQUIRED to wire `onResyncNeeded`. This class refuses to
12
+ // pretend otherwise: it tracks `lastEventAt`/`disconnectedAt` and hands them
13
+ // to the resync callback so the gap window is explicit.
14
+ //
15
+ // Other HL-specific rules honoured here:
16
+ // - No listenKey. Auth is by SUBSCRIBING with the MASTER address (agent wallets
17
+ // sign; queries always name the master).
18
+ // - Server closes an idle socket after 60s of silence ⇒ app-level ping every 30s
19
+ // (same cadence the intel HL client uses).
20
+ // - Subscriptions are NOT auto-restored by the server: they must be re-sent on
21
+ // every (re)connect.
22
+ // - `orderUpdates` carries trigger orders natively (statuses incl. `triggered`,
23
+ // `siblingFilledCanceled`, `liquidatedCanceled`, `delistedCanceled`,
24
+ // `marginCanceled`) — simpler than Binance, where brackets needed a separate
25
+ // ALGO_UPDATE channel.
26
+ import WebSocket from 'ws';
27
+ import { logger } from '../../logger.js';
28
+ const TAG = 'hl-user-stream';
29
+ const PING_INTERVAL_MS = 30_000; // server cuts idle sockets at 60s
30
+ const STALE_AFTER_MS = 90_000; // no frame at all for 90s ⇒ terminate + reconnect
31
+ const RECONNECT_BASE_MS = 1_000;
32
+ const RECONNECT_MAX_MS = 60_000;
33
+ export class HyperliquidUserStream {
34
+ opts;
35
+ ws = null;
36
+ pingTimer = null;
37
+ staleTimer = null;
38
+ reconnectTimer = null;
39
+ reconnectAttempts = 0;
40
+ stopped = false;
41
+ lastFrameAt = 0;
42
+ /** Last event we actually processed — the low-water mark for the REST gap-fill. */
43
+ lastEventAt = 0;
44
+ disconnectedAt = 0;
45
+ constructor(opts) {
46
+ this.opts = opts;
47
+ }
48
+ get isConnected() {
49
+ return this.ws?.readyState === WebSocket.OPEN;
50
+ }
51
+ /** Epoch-ms of the last processed event (0 = none yet). */
52
+ get lastEventTimestamp() {
53
+ return this.lastEventAt;
54
+ }
55
+ start() {
56
+ this.stopped = false;
57
+ this.connect();
58
+ }
59
+ stop() {
60
+ this.stopped = true;
61
+ this.clearTimers();
62
+ if (this.reconnectTimer)
63
+ clearTimeout(this.reconnectTimer);
64
+ this.reconnectTimer = null;
65
+ try {
66
+ this.ws?.close();
67
+ }
68
+ catch {
69
+ /* best-effort */
70
+ }
71
+ this.ws = null;
72
+ }
73
+ url() {
74
+ return this.opts.testnet
75
+ ? 'wss://api.hyperliquid-testnet.xyz/ws'
76
+ : 'wss://api.hyperliquid.xyz/ws';
77
+ }
78
+ connect() {
79
+ if (this.stopped)
80
+ return;
81
+ const url = this.url();
82
+ logger.info(TAG, `connecting ${url}`);
83
+ const ws = new WebSocket(url);
84
+ this.ws = ws;
85
+ ws.on('open', () => {
86
+ this.reconnectAttempts = 0;
87
+ this.lastFrameAt = Date.now();
88
+ this.subscribeAll();
89
+ this.armTimers();
90
+ // ★ T-5: the server sends NO backfill for the gap. Tell the caller exactly
91
+ // how blind we were so it can REST-reconcile that window. `lastEventAt || 0`
92
+ // makes the first connect a full-snapshot request too.
93
+ const wasDisconnectedMs = this.disconnectedAt ? Date.now() - this.disconnectedAt : 0;
94
+ const sinceMs = this.lastEventAt || Date.now() - 60_000;
95
+ logger.info(TAG, `connected — REST resync required (blind ${Math.round(wasDisconnectedMs / 1000)}s; ` +
96
+ 'HL WS does not replay missed events — verified testnet 2026-07-12)');
97
+ this.opts.callbacks.onResyncNeeded({ sinceMs, wasDisconnectedMs });
98
+ this.disconnectedAt = 0;
99
+ });
100
+ ws.on('message', (raw) => {
101
+ this.lastFrameAt = Date.now();
102
+ let msg;
103
+ try {
104
+ msg = JSON.parse(raw.toString());
105
+ }
106
+ catch {
107
+ return;
108
+ }
109
+ this.handle(msg);
110
+ });
111
+ ws.on('close', (code, reason) => {
112
+ logger.warn(TAG, `closed code=${code} reason=${reason?.toString().slice(0, 80)}`);
113
+ this.onDisconnected();
114
+ });
115
+ ws.on('error', (err) => {
116
+ logger.warn(TAG, `socket error: ${err.message}`);
117
+ // 'close' follows — reconnect is handled there.
118
+ });
119
+ }
120
+ subscribeAll() {
121
+ const user = this.opts.walletAddress;
122
+ // Subscriptions are NOT restored by the server after a reconnect — always re-send.
123
+ const subs = [
124
+ { type: 'orderUpdates', user },
125
+ { type: 'userFills', user },
126
+ { type: 'userEvents', user },
127
+ { type: 'userFundings', user },
128
+ ];
129
+ for (const subscription of subs) {
130
+ this.send({ method: 'subscribe', subscription });
131
+ }
132
+ }
133
+ handle(msg) {
134
+ const { channel, data } = msg;
135
+ if (!channel || channel === 'subscriptionResponse' || channel === 'pong')
136
+ return;
137
+ switch (channel) {
138
+ case 'userFills': {
139
+ const payload = data;
140
+ for (const fill of payload?.fills ?? []) {
141
+ this.lastEventAt = Math.max(this.lastEventAt, fill.time ?? Date.now());
142
+ this.opts.callbacks.onFill(fill);
143
+ }
144
+ break;
145
+ }
146
+ case 'orderUpdates': {
147
+ const updates = (Array.isArray(data) ? data : []);
148
+ for (const u of updates) {
149
+ this.lastEventAt = Math.max(this.lastEventAt, u.statusTimestamp ?? Date.now());
150
+ this.opts.callbacks.onOrderUpdate(u);
151
+ }
152
+ break;
153
+ }
154
+ case 'userEvents': {
155
+ this.lastEventAt = Date.now();
156
+ this.opts.callbacks.onUserEvent((data ?? {}));
157
+ break;
158
+ }
159
+ case 'userFundings': {
160
+ this.lastEventAt = Date.now();
161
+ this.opts.callbacks.onUserEvent({ fundings: data });
162
+ break;
163
+ }
164
+ default:
165
+ break;
166
+ }
167
+ }
168
+ send(payload) {
169
+ if (this.ws?.readyState !== WebSocket.OPEN)
170
+ return;
171
+ try {
172
+ this.ws.send(JSON.stringify(payload));
173
+ }
174
+ catch (err) {
175
+ logger.warn(TAG, `send failed: ${err instanceof Error ? err.message : String(err)}`);
176
+ }
177
+ }
178
+ armTimers() {
179
+ this.clearTimers();
180
+ // App-level ping — the server cuts an idle socket at 60s.
181
+ this.pingTimer = setInterval(() => this.send({ method: 'ping' }), PING_INTERVAL_MS);
182
+ // Frame-staleness watchdog — a silent socket is worse than a closed one
183
+ // (we would believe stale positions). Terminate ⇒ reconnect ⇒ REST resync.
184
+ this.staleTimer = setInterval(() => {
185
+ if (!this.lastFrameAt)
186
+ return;
187
+ const silentMs = Date.now() - this.lastFrameAt;
188
+ if (silentMs > STALE_AFTER_MS) {
189
+ logger.warn(TAG, `no frames for ${Math.round(silentMs / 1000)}s — terminating socket`);
190
+ try {
191
+ this.ws?.terminate();
192
+ }
193
+ catch {
194
+ /* the close handler drives the reconnect */
195
+ }
196
+ }
197
+ }, 15_000);
198
+ }
199
+ clearTimers() {
200
+ if (this.pingTimer)
201
+ clearInterval(this.pingTimer);
202
+ if (this.staleTimer)
203
+ clearInterval(this.staleTimer);
204
+ this.pingTimer = null;
205
+ this.staleTimer = null;
206
+ }
207
+ onDisconnected() {
208
+ this.clearTimers();
209
+ this.ws = null;
210
+ if (this.stopped)
211
+ return;
212
+ if (!this.disconnectedAt)
213
+ this.disconnectedAt = Date.now();
214
+ const delay = Math.min(RECONNECT_BASE_MS * 2 ** this.reconnectAttempts, RECONNECT_MAX_MS);
215
+ const jittered = delay * (0.8 + Math.random() * 0.4);
216
+ this.reconnectAttempts += 1;
217
+ logger.info(TAG, `reconnecting in ${Math.round(jittered / 1000)}s (attempt ${this.reconnectAttempts})`);
218
+ this.reconnectTimer = setTimeout(() => this.connect(), jittered);
219
+ }
220
+ }
@@ -1,5 +1,6 @@
1
- import { fillExchangeId, parseVenue, type VenueId } from '@reefclaw/shared';
2
1
  import { LiveAdapter } from '../live/live-adapter.js';
2
+ import { HyperliquidLiveAdapter, type HlLiveAdapterOptions } from './hyperliquid/hl-live-adapter.js';
3
+ import { fillExchangeId, parseVenue, type VenueId } from './symbols.js';
3
4
  export type { VenueId };
4
5
  export { fillExchangeId, parseVenue };
5
6
  /** Venues this build can construct a LIVE adapter for. PAPER mode is
@@ -13,12 +14,25 @@ export declare function isLiveVenueSupported(venue: VenueId): boolean;
13
14
  * USDC venue reads as $0 equity and every order fails "Insufficient USDC". */
14
15
  export declare function venueQuoteCurrency(venue: VenueId): 'USDT' | 'USDC';
15
16
  type BinanceLiveAdapterArgs = ConstructorParameters<typeof LiveAdapter>;
16
- /** Construct the live adapter for a venue.
17
+ /** Every live adapter this build can construct. Both are EventEmitters exposing
18
+ * the same lifecycle events (`drift_detected`) and both implement
19
+ * IExchangeAdapter — the boot path treats them identically. */
20
+ export type AnyLiveAdapter = LiveAdapter | HyperliquidLiveAdapter;
21
+ /** Venue-typed construction spec. A discriminated union so the compiler — not a
22
+ * runtime branch in the boot path — guarantees each venue gets ITS OWN args
23
+ * (Binance takes API key/secret; Hyperliquid takes a master address + agent key,
24
+ * a completely different credential shape). */
25
+ export type LiveAdapterSpec = {
26
+ venue: 'binance';
27
+ args: BinanceLiveAdapterArgs;
28
+ } | {
29
+ venue: 'hyperliquid';
30
+ args: HlLiveAdapterOptions;
31
+ };
32
+ /** Construct the live adapter for a venue — the ONLY place either class is newed.
17
33
  *
18
- * Binance: a pure pass-through to `new LiveAdapter(...)` — byte-identical to
19
- * the inline construction this factory replaced (Phase 0 no-behavior-change
20
- * rule; the args tuple is derived from the constructor so the two can never
21
- * drift). Hyperliquid: throws reaching this arm means the boot-time
22
- * isLiveVenueSupported() fallback-to-PAPER gate was bypassed, which is a bug,
23
- * not a user state. */
24
- export declare function createLiveAdapter(venue: VenueId, ...args: BinanceLiveAdapterArgs): LiveAdapter;
34
+ * Binance: a pure pass-through to `new LiveAdapter(...)` — byte-identical to the
35
+ * inline construction this factory replaced (the args tuple is derived from the
36
+ * constructor so the two can never drift).
37
+ * Hyperliquid: `new HyperliquidLiveAdapter(...)` (Phase 3). */
38
+ export declare function createLiveAdapter(spec: LiveAdapterSpec): AnyLiveAdapter;
@@ -12,13 +12,14 @@
12
12
  // rule: it holds keys + is part of the safety floor) — it is read from
13
13
  // ~/.reefclaw/plugin-config.json `exchange.venue` and MUST never be settable
14
14
  // from the central config channel.
15
- import { fillExchangeId, parseVenue } from '@reefclaw/shared';
16
15
  import { LiveAdapter } from '../live/live-adapter.js';
16
+ import { HyperliquidLiveAdapter } from './hyperliquid/hl-live-adapter.js';
17
+ import { fillExchangeId, parseVenue } from './symbols.js';
17
18
  export { fillExchangeId, parseVenue };
18
19
  /** Venues this build can construct a LIVE adapter for. PAPER mode is
19
20
  * venue-flavored only by its market-data source (PaperMarketFeed / chart) and
20
21
  * is not gated here. */
21
- export const SUPPORTED_LIVE_VENUES = new Set(['binance']);
22
+ export const SUPPORTED_LIVE_VENUES = new Set(['binance', 'hyperliquid']);
22
23
  export function isLiveVenueSupported(venue) {
23
24
  return SUPPORTED_LIVE_VENUES.has(venue);
24
25
  }
@@ -29,19 +30,17 @@ export function isLiveVenueSupported(venue) {
29
30
  export function venueQuoteCurrency(venue) {
30
31
  return venue === 'hyperliquid' ? 'USDC' : 'USDT';
31
32
  }
32
- /** Construct the live adapter for a venue.
33
+ /** Construct the live adapter for a venue — the ONLY place either class is newed.
33
34
  *
34
- * Binance: a pure pass-through to `new LiveAdapter(...)` — byte-identical to
35
- * the inline construction this factory replaced (Phase 0 no-behavior-change
36
- * rule; the args tuple is derived from the constructor so the two can never
37
- * drift). Hyperliquid: throws reaching this arm means the boot-time
38
- * isLiveVenueSupported() fallback-to-PAPER gate was bypassed, which is a bug,
39
- * not a user state. */
40
- export function createLiveAdapter(venue, ...args) {
41
- switch (venue) {
35
+ * Binance: a pure pass-through to `new LiveAdapter(...)` — byte-identical to the
36
+ * inline construction this factory replaced (the args tuple is derived from the
37
+ * constructor so the two can never drift).
38
+ * Hyperliquid: `new HyperliquidLiveAdapter(...)` (Phase 3). */
39
+ export function createLiveAdapter(spec) {
40
+ switch (spec.venue) {
42
41
  case 'binance':
43
- return new LiveAdapter(...args);
42
+ return new LiveAdapter(...spec.args);
44
43
  case 'hyperliquid':
45
- throw new Error("venue 'hyperliquid' has no live adapter in this build — arrives in Phase 3 of docs/HYPERLIQUID_INTEGRATION_PLAN.md");
44
+ return new HyperliquidLiveAdapter(spec.args);
46
45
  }
47
46
  }