@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.
- package/ccxt/binance-private.d.ts +21 -0
- package/ccxt/binance-private.js +132 -22
- package/config/plugin-config-io.d.ts +6 -0
- package/config/tool-gate.js +3 -0
- package/exchange-adapter.d.ts +16 -0
- package/index.js +551 -54
- package/lifecycle/trading-operation-lock.d.ts +17 -0
- package/lifecycle/trading-operation-lock.js +14 -0
- package/live/bracket-id.d.ts +2 -3
- package/live/bracket-id.js +22 -9
- package/live/live-adapter.d.ts +24 -1
- package/live/live-adapter.js +114 -2
- package/live/local-signal-service.js +11 -6
- package/live/local-strategy-evaluator.js +4 -0
- package/live/proposal-decision-listener.d.ts +6 -0
- package/live/proposal-decision-listener.js +4 -0
- package/live/stop-watcher.d.ts +27 -1
- package/live/stop-watcher.js +59 -2
- package/onboarding/runtime.d.ts +13 -0
- package/onboarding/runtime.js +22 -2
- package/openclaw.plugin.json +1 -0
- package/package.json +1 -1
- package/portfolio/wave9-admission.d.ts +67 -0
- package/portfolio/wave9-admission.js +262 -0
- package/portfolio/wave9-policy.d.ts +36 -0
- package/portfolio/wave9-policy.js +183 -0
- package/signals/conditions/registry.js +50 -0
- package/simulator/exchange-simulator.js +5 -0
- package/simulator/fill-engine.js +5 -1
- package/simulator/types.d.ts +6 -1
- package/strategy/evaluator.d.ts +3 -0
- package/strategy/evaluator.js +5 -0
- package/tools/assessment-validation.d.ts +2 -0
- package/tools/attach-brackets.d.ts +7 -2
- package/tools/attach-brackets.js +36 -0
- package/tools/cancel-all-orders.d.ts +2 -0
- package/tools/cancel-all-orders.js +4 -1
- package/tools/cancel-order.d.ts +4 -0
- package/tools/cancel-order.js +49 -3
- package/tools/close-position.d.ts +23 -0
- package/tools/close-position.js +286 -13
- package/tools/create-order.d.ts +28 -0
- package/tools/create-order.js +1364 -192
- package/tools/get-analytics.js +2 -2
- package/tools/get-basis.js +2 -2
- package/tools/get-cascade-risk.js +2 -2
- package/tools/get-crypto-metrics.js +14 -4
- package/tools/get-cvd.js +2 -2
- package/tools/get-divergences.js +2 -2
- package/tools/get-funding-context.js +2 -2
- package/tools/get-liquidation-levels.js +2 -2
- package/tools/get-liquidation-pulse.js +2 -2
- package/tools/get-pattern-scan.js +2 -2
- package/tools/get-regime.js +2 -2
- package/tools/get-resting-liquidity.js +2 -2
- package/tools/get-risk-scenario.js +2 -2
- package/tools/get-session-review.js +2 -2
- package/tools/get-setup-detail.js +9 -1
- package/tools/get-signals.js +2 -2
- package/tools/get-sizing.js +2 -2
- package/tools/get-trade-feedback.js +2 -2
- package/tools/get-trade-flow.js +2 -2
- package/tools/get-volume-profile.js +2 -2
- package/tools/get-wave9-status.d.ts +127 -0
- package/tools/get-wave9-status.js +796 -0
- package/tools/intel-api.d.ts +20 -0
- package/tools/intel-api.js +67 -0
- package/tools/intel-cache.d.ts +1 -1
- package/tools/intel-cache.js +20 -5
- package/tools/list-strategies.d.ts +11 -1
- package/tools/list-strategies.js +17 -0
- package/tools/modify-stop.d.ts +4 -0
- package/tools/modify-stop.js +63 -24
- package/tools/modify-target.d.ts +4 -0
- package/tools/modify-target.js +62 -23
- package/tools/scan-pairs.js +19 -8
- package/tools/toggle-strategy.js +7 -0
- package/types.d.ts +5 -0
- package/venues/hyperliquid/hl-balance.d.ts +116 -0
- package/venues/hyperliquid/hl-balance.js +145 -0
- package/venues/hyperliquid/hl-brackets.d.ts +102 -0
- package/venues/hyperliquid/hl-brackets.js +172 -0
- package/venues/hyperliquid/hl-cloid.d.ts +22 -0
- package/venues/hyperliquid/hl-cloid.js +82 -0
- package/venues/hyperliquid/hl-info-cache.d.ts +46 -0
- package/venues/hyperliquid/hl-info-cache.js +125 -0
- package/venues/hyperliquid/hl-live-adapter.d.ts +88 -0
- package/venues/hyperliquid/hl-live-adapter.js +353 -0
- package/venues/hyperliquid/hl-precision.d.ts +61 -0
- package/venues/hyperliquid/hl-precision.js +176 -0
- package/venues/hyperliquid/hl-private.d.ts +88 -0
- package/venues/hyperliquid/hl-private.js +357 -0
- package/venues/hyperliquid/hl-public.d.ts +31 -4
- package/venues/hyperliquid/hl-public.js +155 -11
- package/venues/hyperliquid/hl-rate-gate.d.ts +57 -0
- package/venues/hyperliquid/hl-rate-gate.js +220 -0
- package/venues/hyperliquid/hl-user-stream.d.ts +90 -0
- package/venues/hyperliquid/hl-user-stream.js +220 -0
- package/venues/registry.d.ts +23 -9
- package/venues/registry.js +12 -13
- package/venues/symbols.d.ts +43 -0
- package/venues/symbols.js +107 -0
- package/wave9/live-account-capture.d.ts +67 -0
- package/wave9/live-account-capture.js +435 -0
- package/wave9/live-autonomous-protection.d.ts +39 -0
- package/wave9/live-autonomous-protection.js +112 -0
- package/wave9/live-durable-reconciliation-scheduler.d.ts +33 -0
- package/wave9/live-durable-reconciliation-scheduler.js +115 -0
- package/wave9/live-execution-ledger.d.ts +107 -0
- package/wave9/live-execution-ledger.js +498 -0
- package/wave9/live-position-confirmation.d.ts +18 -0
- package/wave9/live-position-confirmation.js +111 -0
- package/wave9/live-residual-protection.d.ts +18 -0
- package/wave9/live-residual-protection.js +250 -0
- package/wave9/live-startup-reconciliation.d.ts +38 -0
- package/wave9/live-startup-reconciliation.js +454 -0
- package/wave9/live-symbol-ownership.d.ts +20 -0
- package/wave9/live-symbol-ownership.js +132 -0
- package/wave9/paper-admission-guard.d.ts +199 -0
- package/wave9/paper-admission-guard.js +650 -0
- package/wave9/usdm-evidence-provider.d.ts +42 -0
- 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
|
-
// -
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
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
|
-
/**
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
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
|
+
}
|