@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
@@ -9,12 +9,18 @@
9
9
  // api.hyperliquid-testnet.xyz). CCXT@4.5.37 wraps everything we need.
10
10
  // - `exchangeStatus` (weight 2) returns `{specialStatuses, time}` — the
11
11
  // reachability probe AND a clock-drift source in one call.
12
- // - metaAndAssetCtxs-class info requests are weight 20 and return data for
13
- // ALL assets at once fetchTicker is served from one cached fetchTickers
14
- // upstream call (TTL below), so N symbols cost the same as one. The full
15
- // address/IP rate gate is a Phase 3 concern (live cadences); paper-mode
16
- // cadence here is bounded by the cache: (60s/TTL) weight-20 calls/min
17
- // (~300/1200 IP budget at the 4s default).
12
+ // - `allMids` (weight 2) returns the mid price for EVERY coin in one call —
13
+ // the docs' recommended price read (plan §3: ccxt fetchTicker is
14
+ // 'emulated' prefer allMids). fetchTicker serves its PRICE from one
15
+ // TTL-cached allMids call; volume/change context rides the last full
16
+ // fetchTickers snapshot, refreshed in the background at most every
17
+ // FULL_SNAPSHOT_REFRESH_MS. Why the split: ccxt@4.5.37's hyperliquid
18
+ // fetchTickers() literally calls fetchMarkets() — the full perp+spot
19
+ // meta/ctx pipeline — measured ~13s on the first HL soak box, which
20
+ // starved every 10s-timeout skill-poller call 100% of the time. allMids
21
+ // measured ~1.4s on the same box. The full address/IP rate gate is a
22
+ // Phase 3 concern (live cadences); paper-mode cadence here is bounded by
23
+ // the caches: ≤ (60s/TTL) weight-2 calls/min + ≤ weight-20 every 5 min.
18
24
  // - CCXT auto-monetization landmine (plan §3.8): initializeClient() only
19
25
  // fires on AUTHENTICATED clients, and this class is keyless — but we pin
20
26
  // `builderFee:false, refSet:true` in options anyway so a future
@@ -25,8 +31,8 @@
25
31
  // never throw to consumers. Binance-shaped symbols (…/USDT) are a caller bug
26
32
  // on this venue — logged clearly, null returned (never silently translated).
27
33
  import { createRequire } from 'node:module';
28
- import { toCcxtSymbol } from '@reefclaw/shared';
29
34
  import { logger } from '../../logger.js';
35
+ import { toCcxtSymbol, toHyperliquidCoin } from '../symbols.js';
30
36
  const TAG = 'hl-public';
31
37
  // Load ccxt via CJS require — OpenClaw's ESM loader gives wrong module shape
32
38
  // (same pattern as binance-public.ts / binance-private.ts).
@@ -44,6 +50,11 @@ catch {
44
50
  const HL_MAINNET_API = 'https://api.hyperliquid.xyz';
45
51
  const HL_TESTNET_API = 'https://api.hyperliquid-testnet.xyz';
46
52
  const DEFAULT_TICKER_TTL_MS = 4_000;
53
+ /** How stale the full fetchTickers snapshot (volume/change context) may get
54
+ * before fetchTicker kicks a background refresh. Deliberately long: the
55
+ * snapshot's ccxt call re-runs the whole fetchMarkets pipeline (weight ~60,
56
+ * ~13s on a slow host) and nothing price-critical reads these fields. */
57
+ const FULL_SNAPSHOT_REFRESH_MS = 300_000;
47
58
  function resolveTickerTtlMs() {
48
59
  const raw = Number(process.env.RC_HL_TICKER_TTL_MS);
49
60
  if (!Number.isFinite(raw) || raw < 500)
@@ -59,6 +70,9 @@ export class HyperliquidPublicApi {
59
70
  /** One upstream fetchTickers call serves every symbol within the TTL. */
60
71
  tickersCache = null;
61
72
  tickersInflight = null;
73
+ /** Coin → mid from one weight-2 allMids call (fetchTicker's price source). */
74
+ midsCache = null;
75
+ midsInflight = null;
62
76
  constructor(opts = {}) {
63
77
  this.testnet = opts.testnet === true;
64
78
  this.fetchImpl = opts.fetchImpl ?? fetch;
@@ -127,6 +141,56 @@ export class HyperliquidPublicApi {
127
141
  })();
128
142
  return this.tickersInflight;
129
143
  }
144
+ /** Coin → mid price from one weight-2 `allMids` call (TTL-cached, inflight-
145
+ * deduped; docs: hyperliquid.gitbook.io → Info endpoint → "Retrieve mids
146
+ * for all coins"). Null on ANY failure — callers fall back to the full
147
+ * snapshot path. Spot entries (`@<idx>` keys) parse fine and are simply
148
+ * never looked up (we key by perp coin name). */
149
+ async getMids() {
150
+ const now = Date.now();
151
+ if (this.midsCache && now - this.midsCache.at < this.tickerTtlMs) {
152
+ return this.midsCache.byCoin;
153
+ }
154
+ if (this.midsInflight)
155
+ return this.midsInflight;
156
+ this.midsInflight = (async () => {
157
+ try {
158
+ const raw = await this.exchange.publicPostInfo({ type: 'allMids' });
159
+ const byCoin = new Map();
160
+ for (const [coin, mid] of Object.entries(raw ?? {})) {
161
+ const n = Number(mid);
162
+ if (Number.isFinite(n) && n > 0)
163
+ byCoin.set(coin, n);
164
+ }
165
+ if (byCoin.size === 0) {
166
+ logger.warn(TAG, 'allMids returned no parseable mids');
167
+ return null;
168
+ }
169
+ this.midsCache = { at: Date.now(), byCoin };
170
+ return byCoin;
171
+ }
172
+ catch (err) {
173
+ logger.error(TAG, `allMids failed: ${err instanceof Error ? err.message : String(err)}`);
174
+ return null;
175
+ }
176
+ finally {
177
+ this.midsInflight = null;
178
+ }
179
+ })();
180
+ return this.midsInflight;
181
+ }
182
+ /** Fire-and-forget full-snapshot refresh once it ages past
183
+ * FULL_SNAPSHOT_REFRESH_MS — fetchTicker must never block on the heavy
184
+ * fetchTickers call when a fresh mid is available. getTickers' own
185
+ * inflight dedup + error handling make this safe to kick repeatedly. */
186
+ maybeRefreshSnapshot() {
187
+ const at = this.tickersCache?.at ?? 0;
188
+ if (Date.now() - at < FULL_SNAPSHOT_REFRESH_MS)
189
+ return;
190
+ if (this.tickersInflight)
191
+ return;
192
+ void this.getTickers();
193
+ }
130
194
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
131
195
  async fetchTickerRaw(symbol) {
132
196
  const vs = this.venueSymbol(symbol, 'fetchTickerRaw');
@@ -135,14 +199,36 @@ export class HyperliquidPublicApi {
135
199
  const tickers = await this.getTickers();
136
200
  return tickers?.get(vs) ?? null;
137
201
  }
138
- /** Ticker from the cached all-assets snapshot. Hyperliquid's asset contexts
139
- * carry mark/mid rather than a trade-tape bid/ask; absent fields fall back
140
- * to `last` with zero modeled spread the paper fill engine models
141
- * slippage itself (same convention as IntelPublicApi). */
202
+ /** Price from the weight-2 allMids fast path; volume/change context from
203
+ * the last full snapshot (background-refreshed). Hyperliquid's asset
204
+ * contexts carry mark/mid rather than a trade-tape bid/ask; absent fields
205
+ * fall back to `last` with zero modeled spread — the paper fill engine
206
+ * models slippage itself (same convention as IntelPublicApi). Falls back
207
+ * to the original blocking full-snapshot path when allMids fails. */
142
208
  async fetchTicker(symbol) {
143
209
  const vs = this.venueSymbol(symbol, 'fetchTicker');
144
210
  if (!vs)
145
211
  return null;
212
+ const coin = toHyperliquidCoin(symbol);
213
+ const mids = await this.getMids();
214
+ const mid = mids?.get(coin);
215
+ if (mid !== undefined) {
216
+ this.maybeRefreshSnapshot();
217
+ const base = this.tickersCache?.bySymbol.get(vs);
218
+ const timestamp = this.midsCache?.at ?? Date.now();
219
+ return {
220
+ symbol,
221
+ last: mid,
222
+ bid: mid,
223
+ ask: mid,
224
+ baseVolume: Number(base?.baseVolume ?? 0) || 0,
225
+ quoteVolume: Number(base?.quoteVolume ?? 0) || 0,
226
+ change: Number(base?.change ?? 0) || 0,
227
+ percentage: Number(base?.percentage ?? 0) || 0,
228
+ timestamp,
229
+ datetime: new Date(timestamp).toISOString(),
230
+ };
231
+ }
146
232
  const tickers = await this.getTickers();
147
233
  const raw = tickers?.get(vs);
148
234
  if (!raw) {
@@ -282,4 +368,62 @@ export class HyperliquidPublicApi {
282
368
  clearTimeout(tid);
283
369
  }
284
370
  }
371
+ /** `meta` — the asset universe (szDecimals, maxLeverage, positional assetIndex).
372
+ * Keyless info read, weight 20. Feeds HyperliquidInfoCache. Returns null on any
373
+ * failure (never a partial universe — a half-loaded rules table would silently
374
+ * reject orders). */
375
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
376
+ async fetchMeta() {
377
+ try {
378
+ const res = await this.fetchImpl(`${this.baseUrl()}/info`, {
379
+ method: 'POST',
380
+ headers: { 'content-type': 'application/json' },
381
+ body: JSON.stringify({ type: 'meta' }),
382
+ signal: AbortSignal.timeout(15_000),
383
+ });
384
+ if (!res.ok) {
385
+ logger.warn(TAG, `fetchMeta: HTTP ${res.status}`);
386
+ return null;
387
+ }
388
+ return await res.json();
389
+ }
390
+ catch (err) {
391
+ logger.warn(TAG, `fetchMeta failed: ${err instanceof Error ? err.message : String(err)}`);
392
+ return null;
393
+ }
394
+ }
395
+ /** ★ MARK price — the price HL trigger orders fire on (there is no workingType
396
+ * choice on this venue). Stop-distance validation and every trigger MUST use
397
+ * this, never `last`: the mark is a CEX-composite median that can diverge from
398
+ * HL's own last trade. Sourced from `metaAndAssetCtxs` (one weight-20 call
399
+ * covers every asset). Returns null on failure — never a stale guess. */
400
+ async fetchMarkPrice(symbol) {
401
+ try {
402
+ const res = await this.fetchImpl(`${this.baseUrl()}/info`, {
403
+ method: 'POST',
404
+ headers: { 'content-type': 'application/json' },
405
+ body: JSON.stringify({ type: 'metaAndAssetCtxs' }),
406
+ signal: AbortSignal.timeout(15_000),
407
+ });
408
+ if (!res.ok) {
409
+ logger.warn(TAG, `fetchMarkPrice: HTTP ${res.status}`);
410
+ return null;
411
+ }
412
+ const body = (await res.json());
413
+ const universe = body?.[0]?.universe ?? [];
414
+ const ctxs = body?.[1] ?? [];
415
+ const coin = toHyperliquidCoin(symbol);
416
+ const idx = universe.findIndex((u) => u?.name === coin);
417
+ if (idx < 0) {
418
+ logger.warn(TAG, `fetchMarkPrice: ${coin} not in meta.universe`);
419
+ return null;
420
+ }
421
+ const mark = Number(ctxs[idx]?.markPx);
422
+ return Number.isFinite(mark) && mark > 0 ? mark : null;
423
+ }
424
+ catch (err) {
425
+ logger.warn(TAG, `fetchMarkPrice failed: ${err instanceof Error ? err.message : String(err)}`);
426
+ return null;
427
+ }
428
+ }
285
429
  }
@@ -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
+ }