perp-cli 0.15.0 → 0.16.0
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/README.ja.md +243 -0
- package/README.ko.md +243 -0
- package/README.md +129 -535
- package/README.zh.md +243 -0
- package/dist/api/public/lighter.d.ts +5 -0
- package/dist/api/public/lighter.js +5 -0
- package/dist/api/public/pacifica.js +2 -2
- package/dist/arb/sizing.js +4 -2
- package/dist/arb/state.d.ts +6 -0
- package/dist/balance-by-address.d.ts +39 -0
- package/dist/balance-by-address.js +44 -0
- package/dist/bot/quote-rules.js +1 -1
- package/dist/bot/strategies/funding-arb-strategy.js +10 -3
- package/dist/bot/strategies/funding-arb-utils.d.ts +14 -2
- package/dist/bot/strategies/funding-arb-utils.js +48 -15
- package/dist/bot/strategies/spot-perp-arb-strategy.d.ts +1 -1
- package/dist/bot/strategies/spot-perp-arb-strategy.js +4 -2
- package/dist/bot/strategies/spot-perp-executor.js +2 -2
- package/dist/cli-program.js +1 -1
- package/dist/commands/account.js +168 -24
- package/dist/commands/arb/index.js +19 -77
- package/dist/commands/arb-auto.d.ts +24 -0
- package/dist/commands/arb-auto.js +155 -81
- package/dist/commands/arb.d.ts +8 -0
- package/dist/commands/arb.js +49 -23
- package/dist/commands/backtest.js +23 -21
- package/dist/commands/market.d.ts +48 -0
- package/dist/commands/market.js +241 -78
- package/dist/commands/options.d.ts +5 -0
- package/dist/commands/options.js +20 -3
- package/dist/commands/pacifica-earn.js +8 -21
- package/dist/commands/portfolio.d.ts +16 -0
- package/dist/commands/portfolio.js +51 -8
- package/dist/commands/trade.js +4 -4
- package/dist/cross-chain-margin.d.ts +3 -1
- package/dist/cross-chain-margin.js +4 -2
- package/dist/dashboard/server.js +2 -2
- package/dist/dashboard/ui.js +9 -6
- package/dist/errors.d.ts +5 -0
- package/dist/errors.js +10 -0
- package/dist/exchanges/address.d.ts +8 -0
- package/dist/exchanges/address.js +47 -0
- package/dist/exchanges/arcus/adapter.d.ts +10 -6
- package/dist/exchanges/arcus/adapter.js +33 -6
- package/dist/exchanges/arcus/public.js +3 -2
- package/dist/exchanges/aster.d.ts +27 -7
- package/dist/exchanges/aster.js +124 -42
- package/dist/exchanges/canonical-symbols.d.ts +7 -0
- package/dist/exchanges/canonical-symbols.js +20 -0
- package/dist/exchanges/capabilities.d.ts +2 -0
- package/dist/exchanges/capabilities.js +2 -1
- package/dist/exchanges/derive/options.d.ts +5 -0
- package/dist/exchanges/derive/options.js +20 -1
- package/dist/exchanges/extended/adapter.d.ts +16 -2
- package/dist/exchanges/extended/adapter.js +96 -34
- package/dist/exchanges/extended/public.d.ts +3 -0
- package/dist/exchanges/extended/public.js +9 -2
- package/dist/exchanges/grvt/adapter.d.ts +16 -2
- package/dist/exchanges/grvt/adapter.js +98 -21
- package/dist/exchanges/hyperliquid-outcome.d.ts +2 -0
- package/dist/exchanges/hyperliquid-outcome.js +29 -1
- package/dist/exchanges/hyperliquid-spot.d.ts +8 -0
- package/dist/exchanges/hyperliquid-spot.js +20 -12
- package/dist/exchanges/hyperliquid.d.ts +180 -13
- package/dist/exchanges/hyperliquid.js +487 -185
- package/dist/exchanges/interface.d.ts +64 -8
- package/dist/exchanges/interface.js +31 -0
- package/dist/exchanges/lighter-public.d.ts +66 -11
- package/dist/exchanges/lighter-public.js +249 -81
- package/dist/exchanges/lighter-spot.d.ts +7 -1
- package/dist/exchanges/lighter-spot.js +99 -90
- package/dist/exchanges/lighter.d.ts +70 -11
- package/dist/exchanges/lighter.js +285 -92
- package/dist/exchanges/ondo/adapter.d.ts +34 -8
- package/dist/exchanges/ondo/adapter.js +73 -16
- package/dist/exchanges/ondo/public.d.ts +27 -2
- package/dist/exchanges/ondo/public.js +63 -31
- package/dist/exchanges/outcome-interface.d.ts +4 -0
- package/dist/exchanges/pacifica-spot.d.ts +6 -0
- package/dist/exchanges/pacifica-spot.js +23 -15
- package/dist/exchanges/pacifica-vaults.d.ts +9 -0
- package/dist/exchanges/pacifica-vaults.js +14 -1
- package/dist/exchanges/pacifica.d.ts +77 -6
- package/dist/exchanges/pacifica.js +321 -130
- package/dist/exchanges/public-balances.d.ts +25 -0
- package/dist/exchanges/public-balances.js +158 -0
- package/dist/exchanges/public-positions.d.ts +43 -0
- package/dist/exchanges/public-positions.js +154 -0
- package/dist/exchanges/qfex/adapter.d.ts +52 -9
- package/dist/exchanges/qfex/adapter.js +74 -9
- package/dist/exchanges/qfex/public.d.ts +62 -9
- package/dist/exchanges/qfex/public.js +128 -22
- package/dist/exchanges/qfex/ws-transport.d.ts +1 -1
- package/dist/exchanges/risex/adapter.d.ts +19 -7
- package/dist/exchanges/risex/adapter.js +58 -12
- package/dist/exchanges/spot-factory.js +1 -1
- package/dist/exchanges/txflow/adapter.d.ts +14 -7
- package/dist/exchanges/txflow/adapter.js +51 -19
- package/dist/exchanges/txflow/client.js +3 -1
- package/dist/exchanges/txflow/public.d.ts +12 -1
- package/dist/exchanges/txflow/public.js +82 -11
- package/dist/exchanges/unknown-symbol.d.ts +6 -1
- package/dist/exchanges/unknown-symbol.js +7 -2
- package/dist/exchanges/variational/adapter.d.ts +2 -6
- package/dist/exchanges/venue-types.d.ts +46 -3
- package/dist/exchanges/venue-types.js +80 -9
- package/dist/exchanges/venue.d.ts +99 -3
- package/dist/exchanges/venue.js +72 -0
- package/dist/exchanges/venues/hyperliquid.js +9 -0
- package/dist/exchanges/venues/lighter-rh.js +13 -3
- package/dist/exchanges/venues/lighter.js +13 -3
- package/dist/exchanges/venues/pacifica.js +9 -0
- package/dist/exchanges/venues/risex.js +15 -0
- package/dist/funding/legacy-public.d.ts +5 -3
- package/dist/funding/legacy-public.js +33 -37
- package/dist/funding/rates.d.ts +54 -5
- package/dist/funding/rates.js +49 -13
- package/dist/liquidity.d.ts +3 -1
- package/dist/liquidity.js +8 -4
- package/dist/mcp-pacifica-earn.js +11 -5
- package/dist/mcp-server.js +104 -15
- package/dist/pacifica/client.d.ts +2 -1
- package/dist/pacifica/client.js +11 -0
- package/dist/positions-by-address.d.ts +34 -0
- package/dist/positions-by-address.js +46 -0
- package/dist/strategies/funding-arb.js +35 -62
- package/dist/utils.d.ts +11 -1
- package/dist/venue-health.js +1 -1
- package/dist/ws/arcus-market.d.ts +9 -0
- package/dist/ws/arcus-market.js +9 -0
- package/dist/ws/aster-market.d.ts +33 -0
- package/dist/ws/aster-market.js +30 -0
- package/dist/ws/index.d.ts +30 -9
- package/dist/ws/index.js +180 -15
- package/dist/ws/lighter-market.d.ts +14 -0
- package/dist/ws/lighter-market.js +13 -0
- package/dist/ws/node.d.ts +30 -9
- package/dist/ws/node.js +204 -16
- package/dist/ws/unclassified.d.ts +16 -0
- package/dist/ws/unclassified.js +43 -0
- package/package.json +15 -2
- package/skills/perp-cli/SKILL.md +97 -33
- package/skills/perp-cli/package.json +15 -2
- package/skills/perp-cli/references/agent-operations.md +15 -12
- package/skills/perp-cli/references/commands.md +67 -12
- package/skills/perp-cli/references/strategies.md +38 -3
- package/skills/perp-cli/references/ws-library.md +55 -24
- package/skills/perp-cli/scripts/arb-monitor.sh +13 -10
- package/skills/perp-cli/scripts/funding-analysis.sh +57 -29
- package/skills/perp-cli/scripts/preflight.sh +27 -19
- package/skills/perp-cli/scripts/spot-perp-scan.sh +58 -38
- package/skills/perp-cli/scripts/validate-arb.sh +56 -39
- package/dist/cli-spec.d.ts +0 -46
- package/dist/cli-spec.js +0 -81
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { assertCandleInterval } from "./interface.js";
|
|
1
2
|
import { LocalEvmSigner } from "../signer/index.js";
|
|
2
|
-
import { DEFINITE_ORDER_FAILURE_CODES, PerpError, classifyError, extractErrorMessage, orderNotSent, orderOutcomeUnknown, sendFailure } from "../errors.js";
|
|
3
|
+
import { DEFINITE_ORDER_FAILURE_CODES, PerpError, classifyError, extractErrorMessage, orderNotSent, orderOutcomeUnknown, sendFailure, noAccountKeyError } from "../errors.js";
|
|
3
4
|
import { parseFiniteVenueNumber, venueRateField } from "../utils/numeric.js";
|
|
4
5
|
import { alignProtectionPrice, floorSize, formatComputedPrice, slippagePrice, subDecimal } from "./precision.js";
|
|
5
6
|
import { unsupported } from "./trading-v2.js";
|
|
@@ -270,6 +271,18 @@ export const HL_ABSTRACTION_TTL_MS = 60_000;
|
|
|
270
271
|
export function hlAbstractionCachePrefix(network) {
|
|
271
272
|
return `mode:hl:${network}:abstraction:`;
|
|
272
273
|
}
|
|
274
|
+
/**
|
|
275
|
+
* Margin tiers of a universe entry. Docs "Margin tiers": "For IDs less than 50, there is a single
|
|
276
|
+
* tier with max leverage equal to the ID" — those tables are not in `marginTables`, so the
|
|
277
|
+
* implicit tier is emitted instead of null. Unknown ids ≥ 50 → null.
|
|
278
|
+
*/
|
|
279
|
+
export function hlMarginTiersOf(marginTableId, tierMap) {
|
|
280
|
+
const id = Number(marginTableId);
|
|
281
|
+
const tiers = tierMap.get(id);
|
|
282
|
+
if (tiers !== undefined)
|
|
283
|
+
return tiers;
|
|
284
|
+
return Number.isInteger(id) && id > 0 && id < 50 ? [{ lowerBound: 0, maxLeverage: id }] : null;
|
|
285
|
+
}
|
|
273
286
|
/** Venue margin restriction of a universe entry: strictIsolated (no margin removal) / noCross; null = unrestricted. */
|
|
274
287
|
export function hlMarginModeOf(asset) {
|
|
275
288
|
if (asset.marginMode === "strictIsolated")
|
|
@@ -343,6 +356,32 @@ export function hlL1ActionRejection(type, e) {
|
|
|
343
356
|
const HL_FUNDING_PAGE = 500;
|
|
344
357
|
/** 7 days of hourly funding = 168 rows per coin; 20 pages = 10 000 rows (~60 coins). */
|
|
345
358
|
const HL_FUNDING_MAX_PAGES = 20;
|
|
359
|
+
/**
|
|
360
|
+
* candleSnapshot intervals (docs "Candle snapshot": "Supported intervals") with their nominal
|
|
361
|
+
* length. Anything else is answered 422 "Failed to deserialize" (live 2026-10-19, "7m"), so it is
|
|
362
|
+
* refused as INVALID_PARAMS before sending. "1M" buckets are 30 days, not calendar months
|
|
363
|
+
* (live: consecutive 1M opens 30 d apart); "1w" buckets open on Thursday 00:00 UTC.
|
|
364
|
+
*/
|
|
365
|
+
export const HL_CANDLE_INTERVAL_MS = {
|
|
366
|
+
"1m": 60_000, "3m": 180_000, "5m": 300_000, "15m": 900_000, "30m": 1_800_000,
|
|
367
|
+
"1h": 3_600_000, "2h": 7_200_000, "4h": 14_400_000, "8h": 28_800_000, "12h": 43_200_000,
|
|
368
|
+
"1d": 86_400_000, "3d": 259_200_000, "1w": 604_800_000, "1M": 2_592_000_000,
|
|
369
|
+
};
|
|
370
|
+
/** Docs "Candle snapshot": "Only the most recent 5000 candles are available". */
|
|
371
|
+
export const HL_CANDLE_CAP = 5000;
|
|
372
|
+
/** Earliest candleSnapshot start (unix ms) for `interval` at `now`: now − 5000 × interval. Unknown interval → NaN. */
|
|
373
|
+
export function hlEarliestCandleStart(interval, now = Date.now()) {
|
|
374
|
+
return now - HL_CANDLE_CAP * (HL_CANDLE_INTERVAL_MS[interval] ?? Number.NaN);
|
|
375
|
+
}
|
|
376
|
+
/** recentTrades returns the last 10 trades, with no count parameter (live 2026-10-19). */
|
|
377
|
+
export const HL_RECENT_TRADES_CAP = 10;
|
|
378
|
+
/** l2Book returns at most 20 levels per side (docs l2Book; live 2026-10-19). */
|
|
379
|
+
export const HL_BOOK_LEVELS_CAP = 20;
|
|
380
|
+
/** Time-range info responses carry at most 500 elements (docs "Info endpoint" pagination note). */
|
|
381
|
+
const HL_INFO_PAGE = 500;
|
|
382
|
+
/** Largest `market funding --limit` (hourly rows ≈ 208 days) and the page guard of one read. */
|
|
383
|
+
export const HL_FUNDING_HISTORY_MAX_LIMIT = 5000;
|
|
384
|
+
const HL_FUNDING_HISTORY_MAX_PAGES = 20;
|
|
346
385
|
/** A required field of an HL info row as a string; missing / empty → EXCHANGE_ERROR. */
|
|
347
386
|
function hlField(row, key, endpoint) {
|
|
348
387
|
const v = row[key];
|
|
@@ -362,6 +401,203 @@ function hlSide(v, endpoint) {
|
|
|
362
401
|
return "sell";
|
|
363
402
|
throw new PerpError("EXCHANGE_ERROR", `Hyperliquid ${endpoint} row with unknown side ${JSON.stringify(v)}`, { exchange: "hyperliquid" });
|
|
364
403
|
}
|
|
404
|
+
/**
|
|
405
|
+
* Open positions of one clearinghouseState (info `clearinghouseState` {user[, dex]}): rows with
|
|
406
|
+
* szi 0 dropped. The ONE mapper of the keyed `getPositions` and the public-address read
|
|
407
|
+
* (`account positions --address`); `symbolOf` names the row (keyed: the adapter's display name,
|
|
408
|
+
* public: the plain coin — "BTC", HIP-3 "xyz:GOLD").
|
|
409
|
+
*/
|
|
410
|
+
export function hlPositionRows(state, symbolOf) {
|
|
411
|
+
const positions = (state?.assetPositions ?? []);
|
|
412
|
+
return positions
|
|
413
|
+
.filter((p) => {
|
|
414
|
+
const pos = (p.position ?? p);
|
|
415
|
+
return parseFiniteVenueNumber(pos.szi, "position.szi", "hyperliquid") !== 0;
|
|
416
|
+
})
|
|
417
|
+
.map((p) => {
|
|
418
|
+
const pos = (p.position ?? p);
|
|
419
|
+
const szi = parseFiniteVenueNumber(pos.szi, "position.szi", "hyperliquid");
|
|
420
|
+
const positionValue = pos.positionValue !== undefined && pos.positionValue !== null
|
|
421
|
+
? parseFiniteVenueNumber(pos.positionValue, "position.positionValue", "hyperliquid")
|
|
422
|
+
: 0;
|
|
423
|
+
return {
|
|
424
|
+
symbol: symbolOf(String(pos.coin ?? "")),
|
|
425
|
+
side: szi > 0 ? "long" : "short",
|
|
426
|
+
size: String(Math.abs(szi)),
|
|
427
|
+
entryPrice: String(pos.entryPx ?? "0"),
|
|
428
|
+
// clearinghouseState has no per-position mark: positionValue / |szi| is computed, so it is
|
|
429
|
+
// formatted (no "2727.2000000000003").
|
|
430
|
+
markPrice: szi !== 0 && positionValue !== 0
|
|
431
|
+
? formatComputedPrice(positionValue / Math.abs(szi))
|
|
432
|
+
: String(pos.markPx ?? "0"),
|
|
433
|
+
liquidationPrice: pos.liquidationPx != null ? String(pos.liquidationPx) : null,
|
|
434
|
+
unrealizedPnl: String(pos.unrealizedPnl ?? "0"),
|
|
435
|
+
leverage: parseFiniteVenueNumber(pos.leverage?.value, "position.leverage.value", "hyperliquid", { defaultValue: 1 }),
|
|
436
|
+
};
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* info `userAbstraction` answer → mode. Per SSOT Rule #2: an unrecognized venue response throws —
|
|
441
|
+
* no silent default.
|
|
442
|
+
*/
|
|
443
|
+
export function hlAbstractionModeOf(raw) {
|
|
444
|
+
if (raw === "unifiedAccount")
|
|
445
|
+
return "unified";
|
|
446
|
+
if (raw === "portfolioMargin")
|
|
447
|
+
return "portfolio";
|
|
448
|
+
if (raw === "disabled")
|
|
449
|
+
return "standard";
|
|
450
|
+
if (raw === "default")
|
|
451
|
+
return "default";
|
|
452
|
+
// "dexAbstraction" is HL's discontinued fourth mode, still returned for some users. It is
|
|
453
|
+
// NOT unified: "USDC balances default to perps balance, all other collateral defaults to
|
|
454
|
+
// spot balance" (docs trading/account-abstraction-modes). Handled explicitly in hlBalanceOf().
|
|
455
|
+
if (raw === "dexAbstraction")
|
|
456
|
+
return "dexAbstraction";
|
|
457
|
+
throw new PerpError("INVALID_PARAMS", `UNKNOWN_ACCOUNT_MODE: HL returned unrecognized userAbstraction mode: ${JSON.stringify(raw)}`, { remediation: "Check app.hyperliquid.xyz Settings → Account Mode" });
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Portfolio-margin collateral rows: the venue returns an `ltv` on exactly the eligible collateral
|
|
461
|
+
* tokens (live 2026-10-26: USDC, HYPE, USDT0 carry it, memecoins do not; unified-mode rows never
|
|
462
|
+
* do), plus the dex's collateral token itself.
|
|
463
|
+
*/
|
|
464
|
+
export function hlIsPortfolioCollateral(row, collateralToken) {
|
|
465
|
+
return Number(row.token) === collateralToken || Object.prototype.hasOwnProperty.call(row, "ltv");
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* USDC price of each spot token (index → price) from info `spotMetaAndAssetCtxs`: the mark of its
|
|
469
|
+
* USDC-quoted pair (quote token 0); several pairs for one token → the one with the larger 24h
|
|
470
|
+
* notional. USDC itself is 1. Malformed payload → EXCHANGE_ERROR (never a guessed price).
|
|
471
|
+
*/
|
|
472
|
+
export function hlSpotUsdPricesOf(raw) {
|
|
473
|
+
const arr = raw;
|
|
474
|
+
const universe = Array.isArray(arr) ? arr[0]?.universe : undefined;
|
|
475
|
+
const ctxs = Array.isArray(arr) ? arr[1] : undefined;
|
|
476
|
+
if (!Array.isArray(universe) || !Array.isArray(ctxs)) {
|
|
477
|
+
throw new PerpError("EXCHANGE_ERROR", "Hyperliquid spotMetaAndAssetCtxs: expected [meta, ctxs]", { exchange: "hyperliquid" });
|
|
478
|
+
}
|
|
479
|
+
const best = new Map();
|
|
480
|
+
for (const pair of universe) {
|
|
481
|
+
const tokens = pair.tokens;
|
|
482
|
+
if (!Array.isArray(tokens) || tokens[1] !== 0 || typeof pair.index !== "number")
|
|
483
|
+
continue;
|
|
484
|
+
const ctx = ctxs[pair.index];
|
|
485
|
+
const price = Number(ctx?.markPx);
|
|
486
|
+
if (!Number.isFinite(price) || price <= 0)
|
|
487
|
+
continue;
|
|
488
|
+
const vol = Number(ctx?.dayNtlVlm);
|
|
489
|
+
const base = Number(tokens[0]);
|
|
490
|
+
const prev = best.get(base);
|
|
491
|
+
if (prev === undefined || (Number.isFinite(vol) ? vol : 0) > prev.vol)
|
|
492
|
+
best.set(base, { price, vol: Number.isFinite(vol) ? vol : 0 });
|
|
493
|
+
}
|
|
494
|
+
const out = new Map([[0, 1]]);
|
|
495
|
+
for (const [t, v] of best)
|
|
496
|
+
out.set(t, v.price);
|
|
497
|
+
return out;
|
|
498
|
+
}
|
|
499
|
+
export async function hlBalanceOf(state, mode, dex, reads) {
|
|
500
|
+
const s = state;
|
|
501
|
+
const notes = [];
|
|
502
|
+
const margin = (s?.marginSummary ?? {});
|
|
503
|
+
const cross = (s?.crossMarginSummary ?? {});
|
|
504
|
+
const marginUsed = parseFiniteVenueNumber(margin.totalMarginUsed ?? cross.totalMarginUsed, "marginSummary.totalMarginUsed", "hyperliquid");
|
|
505
|
+
// Sum unrealized PnL directly from positions (reliable for both main + dex accounts)
|
|
506
|
+
const positions = (s?.assetPositions ?? []);
|
|
507
|
+
let unrealizedPnl = 0;
|
|
508
|
+
for (const entry of positions) {
|
|
509
|
+
const pos = (entry.position ?? entry);
|
|
510
|
+
unrealizedPnl += parseFiniteVenueNumber(pos.unrealizedPnl, "position.unrealizedPnl", "hyperliquid");
|
|
511
|
+
}
|
|
512
|
+
let equity;
|
|
513
|
+
let available;
|
|
514
|
+
let portfolio = null;
|
|
515
|
+
// dexAbstraction (discontinued): USDC (token 0) lives in the NATIVE perp balance, any other
|
|
516
|
+
// collateral token in spot. Native perps → the perp state below. A HIP-3 dex with non-USDC
|
|
517
|
+
// collateral → its spot row. A USDC-collateral HIP-3 dex is refused: its dex state is not the
|
|
518
|
+
// balance ("Cross margin on HIP-3 DEXs does not behave intuitively for DEX abstraction users").
|
|
519
|
+
let spotIsTruth = mode === "unified" || mode === "portfolio";
|
|
520
|
+
if (mode === "dexAbstraction" && dex) {
|
|
521
|
+
if (await reads.collateralToken() === 0) {
|
|
522
|
+
throw new PerpError("NOT_IMPLEMENTED", `Hyperliquid account mode "dexAbstraction" (discontinued by the venue): the USDC balance of HIP-3 dex "${dex}" cannot be read reliably`, {
|
|
523
|
+
exchange: "hyperliquid",
|
|
524
|
+
remediation: "Switch the account mode first: perp -e hyperliquid wallet manage account-mode unified (or standard)",
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
spotIsTruth = true;
|
|
528
|
+
}
|
|
529
|
+
if (spotIsTruth) {
|
|
530
|
+
const { row, balances, token } = await reads.spotCollateralRow();
|
|
531
|
+
const coin = row === null ? `token#${token}` : String(row.coin);
|
|
532
|
+
const spotTotal = parseFiniteVenueNumber(row?.total, `spotBalance.${coin}.total`, "hyperliquid");
|
|
533
|
+
const spotHold = parseFiniteVenueNumber(row?.hold, `spotBalance.${coin}.hold`, "hyperliquid");
|
|
534
|
+
equity = spotTotal;
|
|
535
|
+
available = spotTotal - spotHold;
|
|
536
|
+
if (mode === "portfolio") {
|
|
537
|
+
const prices = await reads.spotUsdPrices();
|
|
538
|
+
const rows = [];
|
|
539
|
+
let value = 0;
|
|
540
|
+
for (const b of balances) {
|
|
541
|
+
if (!hlIsPortfolioCollateral(b, token))
|
|
542
|
+
continue;
|
|
543
|
+
const c = String(b.coin);
|
|
544
|
+
const total = parseFiniteVenueNumber(b.total, `spotBalance.${c}.total`, "hyperliquid");
|
|
545
|
+
if (total === 0)
|
|
546
|
+
continue; // an empty row adds nothing (USDT0 0 with a hold: the hold is in available)
|
|
547
|
+
const isCollateral = Number(b.token) === token;
|
|
548
|
+
let priceUsd;
|
|
549
|
+
if (isCollateral)
|
|
550
|
+
priceUsd = 1;
|
|
551
|
+
else {
|
|
552
|
+
const p = prices.get(Number(b.token));
|
|
553
|
+
if (p === undefined) {
|
|
554
|
+
throw new PerpError("PRICE_STALE", `Hyperliquid portfolio margin: no USDC spot price for collateral ${c} (token ${String(b.token)}) — equity unknown`, {
|
|
555
|
+
exchange: "hyperliquid", remediation: "Retry shortly; the spot market context read may have failed",
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
priceUsd = p;
|
|
559
|
+
}
|
|
560
|
+
const valueUsd = total * priceUsd;
|
|
561
|
+
value += valueUsd;
|
|
562
|
+
rows.push({
|
|
563
|
+
token: c, total: String(b.total), priceUsd: String(priceUsd), valueUsd: String(valueUsd),
|
|
564
|
+
ltv: typeof b.ltv === "string" ? b.ltv : null,
|
|
565
|
+
borrowed: typeof b.borrowed === "string" ? b.borrowed : null,
|
|
566
|
+
supplied: typeof b.supplied === "string" ? b.supplied : null,
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
equity = value;
|
|
570
|
+
const ratio = (await reads.spotCollateralRow()).portfolioMarginRatio;
|
|
571
|
+
portfolio = { collateral: rows, portfolioMarginRatio: typeof ratio === "string" ? ratio : null };
|
|
572
|
+
notes.push(`[hl] portfolio margin: equity = Σ collateral total × spot USDC price (${rows.map(r => `${r.token} ${r.total}${r.priceUsd !== "1" ? ` × ${r.priceUsd}` : ""}`).join(" + ")}); non-collateral spot tokens are listed in spot, vault equity is not included\n`);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
else {
|
|
576
|
+
equity = parseFiniteVenueNumber(margin.accountValue ?? cross.accountValue, "marginSummary.accountValue", "hyperliquid");
|
|
577
|
+
available = parseFiniteVenueNumber(s?.withdrawable, "withdrawable", "hyperliquid");
|
|
578
|
+
if (mode === "default" && equity === 0) {
|
|
579
|
+
// Unset mode: do not assume unified. If the perp side reads 0 while spot holds
|
|
580
|
+
// the collateral token, say so instead of reporting an empty account silently.
|
|
581
|
+
const { row, token } = await reads.spotCollateralRow();
|
|
582
|
+
const spotTotal = parseFiniteVenueNumber(row?.total, `spotBalance.token#${token}.total`, "hyperliquid");
|
|
583
|
+
if (spotTotal > 0) {
|
|
584
|
+
notes.push(`[hl] account mode is unset ("default"): perp equity${dex ? ` on dex ${dex}` : ""} is 0 while spot holds ${spotTotal} ${String(row?.coin)}. ` +
|
|
585
|
+
`That spot balance is not perp collateral here. Either switch the account to unified ` +
|
|
586
|
+
`(perp wallet manage account-mode unified) or move it: perp -e hyperliquid trade spot-transfer to-perp <amount>\n`);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
return {
|
|
591
|
+
balance: {
|
|
592
|
+
equity: String(equity),
|
|
593
|
+
available: String(available),
|
|
594
|
+
marginUsed: String(marginUsed),
|
|
595
|
+
unrealizedPnl: String(unrealizedPnl),
|
|
596
|
+
...(portfolio ?? {}),
|
|
597
|
+
},
|
|
598
|
+
notes,
|
|
599
|
+
};
|
|
600
|
+
}
|
|
365
601
|
export class HyperliquidAdapter {
|
|
366
602
|
name = "hyperliquid";
|
|
367
603
|
chain = "evm";
|
|
@@ -375,6 +611,8 @@ export class HyperliquidAdapter {
|
|
|
375
611
|
* semantics for accounts that have never opted in. Codex v0.12.12 final QA #1.
|
|
376
612
|
*/
|
|
377
613
|
isUnifiedAccount = false;
|
|
614
|
+
/** The account abstraction mode resolved in init() (null until then / without an address). */
|
|
615
|
+
accountMode = null;
|
|
378
616
|
_address;
|
|
379
617
|
_privateKey;
|
|
380
618
|
_testnet;
|
|
@@ -400,6 +638,8 @@ export class HyperliquidAdapter {
|
|
|
400
638
|
* configured. Set by createHyperliquid — the adapter never reads settings.
|
|
401
639
|
*/
|
|
402
640
|
builderFee = null;
|
|
641
|
+
/** recentTrades' fixed tape size (no paging): `market trades` defaults to min(20, this). */
|
|
642
|
+
recentTradesCap = HL_RECENT_TRADES_CAP;
|
|
403
643
|
constructor(privateKey, testnet = false) {
|
|
404
644
|
// No SDK: /info reads go through _infoPost, /exchange actions are signed here (hl-signing.ts).
|
|
405
645
|
this._address = "";
|
|
@@ -409,9 +649,39 @@ export class HyperliquidAdapter {
|
|
|
409
649
|
/** Set the HIP-3 deployed perp dex to query/trade on. */
|
|
410
650
|
setDex(dex) {
|
|
411
651
|
this._dex = dex;
|
|
412
|
-
//
|
|
413
|
-
|
|
414
|
-
this.
|
|
652
|
+
// Fresh market state for the new dex — NEW objects, never .clear(): a map shared with another
|
|
653
|
+
// adapter (prototype copy) must not be emptied under it. Rebuilt lazily (_loadAssetMap).
|
|
654
|
+
this._assetMap = new Map();
|
|
655
|
+
this._assetMapReverse = new Map();
|
|
656
|
+
this._szDecimalsMap = new Map();
|
|
657
|
+
this._marginModeMap = new Map();
|
|
658
|
+
this._assetMapError = null;
|
|
659
|
+
this._marketsCache = null;
|
|
660
|
+
this._marketsCacheTime = 0;
|
|
661
|
+
this._delisted = new Set();
|
|
662
|
+
this._delistedAssets = new Set();
|
|
663
|
+
this._chsInFlight = null;
|
|
664
|
+
this._collateralTokenIdx = null;
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* An independent adapter for HIP-3 dex `dex` ("" = native perps) with this adapter's account
|
|
668
|
+
* configuration (key / signers / address / network / builder gate) and its OWN market state
|
|
669
|
+
* (asset maps, markets cache, delisted sets, in-flight reads). Fan-outs over dexes use this:
|
|
670
|
+
* Object.create(adapter) + setDex() shared the prototype's maps, so setDex cleared the native
|
|
671
|
+
* adapter's asset map and the dex copies served the native markets cache.
|
|
672
|
+
*/
|
|
673
|
+
forDex(dex) {
|
|
674
|
+
const a = new HyperliquidAdapter(this._privateKey || undefined, this._testnet);
|
|
675
|
+
a._address = this._address;
|
|
676
|
+
a._evmSigner = this._evmSigner;
|
|
677
|
+
a._agentSigner = this._agentSigner;
|
|
678
|
+
a._agentMeta = this._agentMeta;
|
|
679
|
+
a._useNoAgent = this._useNoAgent;
|
|
680
|
+
a.isUnifiedAccount = this.isUnifiedAccount;
|
|
681
|
+
a.accountMode = this.accountMode;
|
|
682
|
+
a.builderFee = this.builderFee;
|
|
683
|
+
a.setDex(dex);
|
|
684
|
+
return a;
|
|
415
685
|
}
|
|
416
686
|
get dex() {
|
|
417
687
|
return this._dex;
|
|
@@ -544,6 +814,7 @@ export class HyperliquidAdapter {
|
|
|
544
814
|
// every caller that branches on the account mode.
|
|
545
815
|
if (this._address) {
|
|
546
816
|
const mode = await this._getAbstractionMode();
|
|
817
|
+
this.accountMode = mode;
|
|
547
818
|
// dexAbstraction is not unified (USDC stays in the perp balance).
|
|
548
819
|
this.isUnifiedAccount = mode === "unified" || mode === "portfolio";
|
|
549
820
|
}
|
|
@@ -562,6 +833,7 @@ export class HyperliquidAdapter {
|
|
|
562
833
|
/** Load asset index map — supports native and HIP-3 dex. */
|
|
563
834
|
async _loadAssetMap() {
|
|
564
835
|
this._assetMapError = null;
|
|
836
|
+
this._delistedAssets = new Set();
|
|
565
837
|
try {
|
|
566
838
|
if (this._dex) {
|
|
567
839
|
// HIP-3 deployed dex: asset indices use global offset scheme
|
|
@@ -598,6 +870,10 @@ export class HyperliquidAdapter {
|
|
|
598
870
|
this._assetMap.set(fullName, globalIdx);
|
|
599
871
|
this._assetMap.set(baseName, globalIdx);
|
|
600
872
|
this._assetMapReverse.set(globalIdx, fullName);
|
|
873
|
+
if (asset.isDelisted === true) {
|
|
874
|
+
this._delistedAssets.add(fullName);
|
|
875
|
+
this._delistedAssets.add(baseName);
|
|
876
|
+
}
|
|
601
877
|
// Cache szDecimals for dex assets (critical for order sizing)
|
|
602
878
|
if (asset.szDecimals !== undefined) {
|
|
603
879
|
this._szDecimalsMap.set(fullName, asset.szDecimals);
|
|
@@ -620,6 +896,8 @@ export class HyperliquidAdapter {
|
|
|
620
896
|
const sym = `${asset.name}-PERP`;
|
|
621
897
|
this._assetMap.set(sym, idx);
|
|
622
898
|
this._assetMapReverse.set(idx, sym);
|
|
899
|
+
if (asset.isDelisted === true)
|
|
900
|
+
this._delistedAssets.add(sym);
|
|
623
901
|
if (asset.szDecimals !== undefined) {
|
|
624
902
|
this._szDecimalsMap.set(sym, asset.szDecimals);
|
|
625
903
|
}
|
|
@@ -806,19 +1084,27 @@ export class HyperliquidAdapter {
|
|
|
806
1084
|
const sym = String(asset.name);
|
|
807
1085
|
const szDec = this._szDecimalsMap.get(sym) ?? (asset.szDecimals !== undefined ? Number(asset.szDecimals) : null);
|
|
808
1086
|
return [{
|
|
809
|
-
symbol:
|
|
1087
|
+
// One symbol shape on every venue: the plain asset ("BTC"; HIP-3 "km:US500"), the
|
|
1088
|
+
// venue's wire coin in venueSymbol. The internal "<COIN>-PERP" key is not shown.
|
|
1089
|
+
symbol: sym.replace(/-PERP$/, ""),
|
|
1090
|
+
// The coin exactly as the venue names it on the wire (native "kPEPE", HIP-3 "xyz:TSLA" —
|
|
1091
|
+
// lower-case dex prefix); _venueCoin upper-cases its input, so it is not used here.
|
|
1092
|
+
venueSymbol: sym.replace(/-PERP$/, ""),
|
|
810
1093
|
markPrice: String(ctx.markPx ?? mids[sym] ?? "0"),
|
|
811
1094
|
indexPrice: ctx.oraclePx != null ? String(ctx.oraclePx) : null,
|
|
812
1095
|
fundingRate: ctx.funding != null ? String(ctx.funding) : null,
|
|
1096
|
+
// metaAndAssetCtxs `funding` = the running hour's rate (WS allDexsAssetCtxs: accruing).
|
|
1097
|
+
fundingRateKind: ctx.funding != null ? "accruing" : null,
|
|
813
1098
|
volume24h: ctx.dayNtlVlm != null ? String(ctx.dayNtlVlm) : null,
|
|
814
1099
|
openInterest: ctx.openInterest != null ? String(ctx.openInterest) : null,
|
|
815
1100
|
maxLeverage: Number(asset.maxLeverage ?? 50),
|
|
816
1101
|
sizeDecimals: szDec,
|
|
817
|
-
// HL has no separate lot-size field; the step is implied by szDecimals
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
1102
|
+
// HL has no separate lot-size field; the step is implied by szDecimals (10^-szDecimals,
|
|
1103
|
+
// the same value the keyless MCP get_markets path states).
|
|
1104
|
+
stepSize: szDec !== null && Number.isInteger(szDec) && szDec >= 0 ? (szDec === 0 ? "1" : `0.${"0".repeat(szDec - 1)}1`) : null,
|
|
1105
|
+
// HL funding is uniform: a fraction per 1 h (venue fundingRateBasis), core and HIP-3.
|
|
1106
|
+
fundingHours: 1,
|
|
1107
|
+
marginTiers: hlMarginTiersOf(asset.marginTableId, tierMap),
|
|
822
1108
|
}];
|
|
823
1109
|
});
|
|
824
1110
|
this._delisted = delisted;
|
|
@@ -828,18 +1114,62 @@ export class HyperliquidAdapter {
|
|
|
828
1114
|
}
|
|
829
1115
|
/** Display names of the delisted assets seen by the last getMarkets() (meta isDelisted). */
|
|
830
1116
|
_delisted = new Set();
|
|
1117
|
+
/** Asset-map keys of the delisted assets in `meta` (_loadAssetMap): "MATIC-PERP"; HIP-3 "km:US500" and "US500". */
|
|
1118
|
+
_delistedAssets = new Set();
|
|
831
1119
|
/**
|
|
832
1120
|
* Why `symbol` is not in getMarkets(): it is delisted (message), or null when it is not a
|
|
833
|
-
* delisted asset. Read after getMarkets(). `market info` uses
|
|
834
|
-
* "not found" — the asset map still resolves it for orders /
|
|
1121
|
+
* delisted asset. Read after getMarkets() (or once the asset map is loaded). `market info` uses
|
|
1122
|
+
* it to say "delisted" rather than "not found" — the asset map still resolves it for orders /
|
|
1123
|
+
* positions / history. A HIP-3 bare name ("US500" on --dex km) is matched by its venue coin.
|
|
835
1124
|
*/
|
|
836
1125
|
delistedMessage(symbol) {
|
|
837
1126
|
const resolved = this.resolveSymbol(symbol.toUpperCase());
|
|
838
|
-
|
|
1127
|
+
const coin = this._venueCoin(symbol).replace(/^([^:]+):/, (_m, dex) => `${dex.toLowerCase()}:`); // "KM:US500" → "km:US500"
|
|
1128
|
+
const isDelisted = this._delisted.has(resolved) || this._delisted.has(coin) || this._delistedAssets.has(resolved);
|
|
1129
|
+
if (!isDelisted)
|
|
839
1130
|
return null;
|
|
840
|
-
return `${resolved.replace(/-PERP$/, "")} is delisted on Hyperliquid (positions / history still readable)`;
|
|
1131
|
+
return `${this._dex ? coin : resolved.replace(/-PERP$/, "")} is delisted on Hyperliquid (positions / history still readable)`;
|
|
841
1132
|
}
|
|
842
|
-
|
|
1133
|
+
/** SYMBOL_NOT_FOUND (details.reason "delisted") for a delisted `symbol`, with a dex-aware remediation; null when listed. */
|
|
1134
|
+
delistedError(symbol) {
|
|
1135
|
+
const msg = this.delistedMessage(symbol);
|
|
1136
|
+
if (msg === null)
|
|
1137
|
+
return null;
|
|
1138
|
+
return new PerpError("SYMBOL_NOT_FOUND", msg, {
|
|
1139
|
+
exchange: "hyperliquid",
|
|
1140
|
+
reason: "delisted",
|
|
1141
|
+
remediation: `List live markets: perp -e hyperliquid${this._dex ? ` --dex ${this._dex}` : ""} market list`,
|
|
1142
|
+
});
|
|
1143
|
+
}
|
|
1144
|
+
/**
|
|
1145
|
+
* Per-symbol MARKET-DATA reads (trades, funding, book, klines): the symbol must resolve
|
|
1146
|
+
* (SYMBOL_NOT_FOUND) and must not be delisted — HL keeps serving a delisted coin's last tape
|
|
1147
|
+
* (MATIC: trades from 2024-09-09) and zero-rate funding rows stamped every hour (live
|
|
1148
|
+
* 2026-10-19), which read as a live market. Orders / positions / history do not call this.
|
|
1149
|
+
*/
|
|
1150
|
+
async _listedMarket(symbol) {
|
|
1151
|
+
await this.getAssetIndex(symbol);
|
|
1152
|
+
const err = this.delistedError(symbol);
|
|
1153
|
+
if (err !== null)
|
|
1154
|
+
throw err;
|
|
1155
|
+
}
|
|
1156
|
+
/**
|
|
1157
|
+
* Public order-book read (`market book`, bots, MCP): l2Book serves at most HL_BOOK_LEVELS_CAP
|
|
1158
|
+
* (20) levels per side and cannot be paged, so a `depth` above it is INVALID_PARAMS naming the
|
|
1159
|
+
* cap. Refuses delisted markets (_listedMarket). Order paths use _l2Book (no delisted gate):
|
|
1160
|
+
* a reduce-only close of a delisted asset must still get its reference price.
|
|
1161
|
+
*/
|
|
1162
|
+
async getOrderbook(symbol, depth) {
|
|
1163
|
+
if (depth !== undefined && !(Number.isInteger(depth) && depth > 0 && depth <= HL_BOOK_LEVELS_CAP)) {
|
|
1164
|
+
throw new PerpError("INVALID_PARAMS", `Hyperliquid order book: depth must be an integer 1..${HL_BOOK_LEVELS_CAP} (got ${depth}) — l2Book serves at most ${HL_BOOK_LEVELS_CAP} levels per side`, {
|
|
1165
|
+
exchange: "hyperliquid", remediation: `Use -d ${HL_BOOK_LEVELS_CAP} or less`,
|
|
1166
|
+
});
|
|
1167
|
+
}
|
|
1168
|
+
await this._listedMarket(symbol);
|
|
1169
|
+
return this._l2Book(symbol);
|
|
1170
|
+
}
|
|
1171
|
+
/** l2Book of `symbol` (native or this adapter's dex), no listing gate. Unlisted coin → SYMBOL_NOT_FOUND. */
|
|
1172
|
+
async _l2Book(symbol) {
|
|
843
1173
|
let levels;
|
|
844
1174
|
// l2Book answers `null` for a coin the venue does not list (live 2026-10-14, coin "NOPE"):
|
|
845
1175
|
// SYMBOL_NOT_FOUND, never an empty book (which reads as "no liquidity").
|
|
@@ -887,7 +1217,7 @@ export class HyperliquidAdapter {
|
|
|
887
1217
|
}
|
|
888
1218
|
ensureAddress() {
|
|
889
1219
|
if (!this._address) {
|
|
890
|
-
throw
|
|
1220
|
+
throw noAccountKeyError(this.name);
|
|
891
1221
|
}
|
|
892
1222
|
}
|
|
893
1223
|
/** The in-flight clearinghouseState read: parallel callers (getBalance + getPositions) share one request. */
|
|
@@ -930,23 +1260,7 @@ export class HyperliquidAdapter {
|
|
|
930
1260
|
async _getAbstractionMode(opts = {}) {
|
|
931
1261
|
this.ensureAddress();
|
|
932
1262
|
const key = `${hlAbstractionCachePrefix(this._network)}${this._address.toLowerCase()}`;
|
|
933
|
-
return (opts.fresh === true ? fetchAndCache : withCache)(key, HL_ABSTRACTION_TTL_MS, async () => {
|
|
934
|
-
const raw = (await this._infoPost({ type: "userAbstraction", user: this._address }));
|
|
935
|
-
if (raw === "unifiedAccount")
|
|
936
|
-
return "unified";
|
|
937
|
-
if (raw === "portfolioMargin")
|
|
938
|
-
return "portfolio";
|
|
939
|
-
if (raw === "disabled")
|
|
940
|
-
return "standard";
|
|
941
|
-
if (raw === "default")
|
|
942
|
-
return "default";
|
|
943
|
-
// "dexAbstraction" is HL's discontinued fourth mode, still returned for some users. It is
|
|
944
|
-
// NOT unified: "USDC balances default to perps balance, all other collateral defaults to
|
|
945
|
-
// spot balance" (docs trading/account-abstraction-modes). Handled explicitly in getBalance().
|
|
946
|
-
if (raw === "dexAbstraction")
|
|
947
|
-
return "dexAbstraction";
|
|
948
|
-
throw new PerpError("INVALID_PARAMS", `UNKNOWN_ACCOUNT_MODE: HL returned unrecognized userAbstraction mode: ${JSON.stringify(raw)}`, { remediation: "Check app.hyperliquid.xyz Settings → Account Mode" });
|
|
949
|
-
});
|
|
1263
|
+
return (opts.fresh === true ? fetchAndCache : withCache)(key, HL_ABSTRACTION_TTL_MS, async () => hlAbstractionModeOf(await this._infoPost({ type: "userAbstraction", user: this._address })));
|
|
950
1264
|
}
|
|
951
1265
|
/** Collateral token index of this adapter's perp dex (info `meta{dex}.collateralToken`; 0 = USDC on native perps). */
|
|
952
1266
|
_collateralTokenIdx = null;
|
|
@@ -977,127 +1291,22 @@ export class HyperliquidAdapter {
|
|
|
977
1291
|
const [spotState, token] = await Promise.all([this._getSpotClearinghouseState(), this._getCollateralToken()]);
|
|
978
1292
|
const balances = (spotState?.balances ?? []);
|
|
979
1293
|
const row = balances.find((b) => Number(b.token) === token) ?? null;
|
|
980
|
-
return { row, balances, token };
|
|
1294
|
+
return { row, balances, token, portfolioMarginRatio: spotState?.portfolioMarginRatio };
|
|
981
1295
|
}
|
|
982
1296
|
async getBalance() {
|
|
983
1297
|
const state = await this._getClearinghouseState();
|
|
984
|
-
const s = state;
|
|
985
|
-
const margin = (s?.marginSummary ?? {});
|
|
986
|
-
const cross = (s?.crossMarginSummary ?? {});
|
|
987
|
-
const marginUsed = parseFiniteVenueNumber(margin.totalMarginUsed ?? cross.totalMarginUsed, "marginSummary.totalMarginUsed", "hyperliquid");
|
|
988
|
-
// Sum unrealized PnL directly from positions (reliable for both main + dex accounts)
|
|
989
|
-
const positions = (s?.assetPositions ?? []);
|
|
990
|
-
let unrealizedPnl = 0;
|
|
991
|
-
for (const entry of positions) {
|
|
992
|
-
const pos = (entry.position ?? entry);
|
|
993
|
-
unrealizedPnl += parseFiniteVenueNumber(pos.unrealizedPnl, "position.unrealizedPnl", "hyperliquid");
|
|
994
|
-
}
|
|
995
|
-
let equity;
|
|
996
|
-
let available;
|
|
997
|
-
// Branch by the user's account abstraction mode (Rule #2: no silent fallback).
|
|
998
|
-
// - "unified" / "portfolio": per HL docs, API users see all balances and holds
|
|
999
|
-
// in the spot clearinghouse state; the dex's collateral token row (by token
|
|
1000
|
-
// index from meta{dex}.collateralToken) is the equity. Adding perp margin
|
|
1001
|
-
// would double-count.
|
|
1002
|
-
// - "standard" / "default": perp clearinghouse (per dex) is the collateral source.
|
|
1003
|
-
//
|
|
1004
|
-
// Portfolio-margin mode: eligible collateral includes HYPE, BTC, USDT besides
|
|
1005
|
-
// USDC. Only the collateral token is counted; a stderr note lists the rest.
|
|
1006
1298
|
const mode = await this._getAbstractionMode();
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
exchange: "hyperliquid",
|
|
1016
|
-
remediation: "Switch the account mode first: perp -e hyperliquid wallet manage account-mode unified (or standard)",
|
|
1017
|
-
});
|
|
1018
|
-
}
|
|
1019
|
-
spotIsTruth = true;
|
|
1020
|
-
}
|
|
1021
|
-
if (spotIsTruth) {
|
|
1022
|
-
const { row, balances, token } = await this._spotCollateralRow();
|
|
1023
|
-
const coin = row === null ? `token#${token}` : String(row.coin);
|
|
1024
|
-
const spotTotal = parseFiniteVenueNumber(row?.total, `spotBalance.${coin}.total`, "hyperliquid");
|
|
1025
|
-
const spotHold = parseFiniteVenueNumber(row?.hold, `spotBalance.${coin}.hold`, "hyperliquid");
|
|
1026
|
-
equity = spotTotal;
|
|
1027
|
-
available = spotTotal - spotHold;
|
|
1028
|
-
if (mode === "portfolio") {
|
|
1029
|
-
// Eligible collateral per trading/account-abstraction-modes.md ("eligible
|
|
1030
|
-
// assets, which are currently HYPE, BTC, USDC, USDT").
|
|
1031
|
-
const PORTFOLIO_COLLATERAL = ["HYPE", "BTC", "USDT"];
|
|
1032
|
-
const others = balances.filter((b) => {
|
|
1033
|
-
if (Number(b.token) === token)
|
|
1034
|
-
return false;
|
|
1035
|
-
const c = String(b.coin);
|
|
1036
|
-
// Reject NaN/empty here too — venue payload corruption shouldn't
|
|
1037
|
-
// silently filter a token in or out.
|
|
1038
|
-
const total = parseFiniteVenueNumber(b.total, `spotBalance.${c}.total`, "hyperliquid");
|
|
1039
|
-
if (total <= 0)
|
|
1040
|
-
return false;
|
|
1041
|
-
return PORTFOLIO_COLLATERAL.some((p) => c === p || c.startsWith(`${p}-`));
|
|
1042
|
-
});
|
|
1043
|
-
if (others.length > 0) {
|
|
1044
|
-
const summary = others.map((b) => `${b.coin}=${b.total}`).join(", ");
|
|
1045
|
-
process.stderr.write(`[hl] portfolio mode: non-${coin} collateral present but not counted in equity (${summary}). ` +
|
|
1046
|
-
`Equity reflects ${coin} only; full multi-asset accounting pending.\n`);
|
|
1047
|
-
}
|
|
1048
|
-
}
|
|
1049
|
-
}
|
|
1050
|
-
else {
|
|
1051
|
-
equity = parseFiniteVenueNumber(margin.accountValue ?? cross.accountValue, "marginSummary.accountValue", "hyperliquid");
|
|
1052
|
-
available = parseFiniteVenueNumber(s?.withdrawable, "withdrawable", "hyperliquid");
|
|
1053
|
-
if (mode === "default" && equity === 0) {
|
|
1054
|
-
// Unset mode: do not assume unified. If the perp side reads 0 while spot holds
|
|
1055
|
-
// the collateral token, say so instead of reporting an empty account silently.
|
|
1056
|
-
const { row, token } = await this._spotCollateralRow();
|
|
1057
|
-
const spotTotal = parseFiniteVenueNumber(row?.total, `spotBalance.token#${token}.total`, "hyperliquid");
|
|
1058
|
-
if (spotTotal > 0) {
|
|
1059
|
-
process.stderr.write(`[hl] account mode is unset ("default"): perp equity${this._dex ? ` on dex ${this._dex}` : ""} is 0 while spot holds ${spotTotal} ${String(row?.coin)}. ` +
|
|
1060
|
-
`That spot balance is not perp collateral here. Either switch the account to unified ` +
|
|
1061
|
-
`(perp wallet manage account-mode unified) or move it: perp -e hyperliquid trade spot-transfer to-perp <amount>\n`);
|
|
1062
|
-
}
|
|
1063
|
-
}
|
|
1064
|
-
}
|
|
1065
|
-
return {
|
|
1066
|
-
equity: String(equity),
|
|
1067
|
-
available: String(available),
|
|
1068
|
-
marginUsed: String(marginUsed),
|
|
1069
|
-
unrealizedPnl: String(unrealizedPnl),
|
|
1070
|
-
};
|
|
1299
|
+
const { balance, notes } = await hlBalanceOf(state, mode, this._dex, {
|
|
1300
|
+
spotCollateralRow: () => this._spotCollateralRow(),
|
|
1301
|
+
collateralToken: () => this._getCollateralToken(),
|
|
1302
|
+
spotUsdPrices: async () => hlSpotUsdPricesOf(await this._infoPost({ type: "spotMetaAndAssetCtxs" })),
|
|
1303
|
+
});
|
|
1304
|
+
for (const n of notes)
|
|
1305
|
+
process.stderr.write(n);
|
|
1306
|
+
return balance;
|
|
1071
1307
|
}
|
|
1072
1308
|
async getPositions() {
|
|
1073
|
-
|
|
1074
|
-
const positions = (state?.assetPositions ?? []);
|
|
1075
|
-
return positions
|
|
1076
|
-
.filter((p) => {
|
|
1077
|
-
const pos = (p.position ?? p);
|
|
1078
|
-
return parseFiniteVenueNumber(pos.szi, "position.szi", "hyperliquid") !== 0;
|
|
1079
|
-
})
|
|
1080
|
-
.map((p) => {
|
|
1081
|
-
const pos = (p.position ?? p);
|
|
1082
|
-
const szi = parseFiniteVenueNumber(pos.szi, "position.szi", "hyperliquid");
|
|
1083
|
-
const positionValue = pos.positionValue !== undefined && pos.positionValue !== null
|
|
1084
|
-
? parseFiniteVenueNumber(pos.positionValue, "position.positionValue", "hyperliquid")
|
|
1085
|
-
: 0;
|
|
1086
|
-
return {
|
|
1087
|
-
symbol: this._displaySymbol(String(pos.coin ?? "")),
|
|
1088
|
-
side: szi > 0 ? "long" : "short",
|
|
1089
|
-
size: String(Math.abs(szi)),
|
|
1090
|
-
entryPrice: String(pos.entryPx ?? "0"),
|
|
1091
|
-
// clearinghouseState has no per-position mark: positionValue / |szi| is computed, so it is
|
|
1092
|
-
// formatted (no "2727.2000000000003").
|
|
1093
|
-
markPrice: szi !== 0 && positionValue !== 0
|
|
1094
|
-
? formatComputedPrice(positionValue / Math.abs(szi))
|
|
1095
|
-
: String(pos.markPx ?? "0"),
|
|
1096
|
-
liquidationPrice: pos.liquidationPx != null ? String(pos.liquidationPx) : null,
|
|
1097
|
-
unrealizedPnl: String(pos.unrealizedPnl ?? "0"),
|
|
1098
|
-
leverage: parseFiniteVenueNumber(pos.leverage?.value, "position.leverage.value", "hyperliquid", { defaultValue: 1 }),
|
|
1099
|
-
};
|
|
1100
|
-
});
|
|
1309
|
+
return hlPositionRows(await this._getClearinghouseState(), (coin) => this._displaySymbol(coin));
|
|
1101
1310
|
}
|
|
1102
1311
|
/**
|
|
1103
1312
|
* The leverage this account applies to `symbol`, position or not — info `activeAssetData`
|
|
@@ -1323,10 +1532,11 @@ export class HyperliquidAdapter {
|
|
|
1323
1532
|
/**
|
|
1324
1533
|
* The single declared reference price for market-type protection prices:
|
|
1325
1534
|
* l2Book mid = (best bid + best ask) / 2. Works for native perps and HIP-3
|
|
1326
|
-
* (
|
|
1535
|
+
* (_l2Book passes the dex). Either side missing / non-positive → PRICE_STALE. Not gated on
|
|
1536
|
+
* delisting: a reduce-only market close of a delisted asset is sent (the venue decides).
|
|
1327
1537
|
*/
|
|
1328
1538
|
async referencePrice(symbol) {
|
|
1329
|
-
const book = await this.
|
|
1539
|
+
const book = await this._l2Book(symbol);
|
|
1330
1540
|
const bid = Number(book.bids[0]?.[0]);
|
|
1331
1541
|
const ask = Number(book.asks[0]?.[0]);
|
|
1332
1542
|
if (!(bid > 0) || !(ask > 0) || !Number.isFinite(bid) || !Number.isFinite(ask)) {
|
|
@@ -1805,15 +2015,30 @@ export class HyperliquidAdapter {
|
|
|
1805
2015
|
reduceOnly: opts?.reduceOnly ?? true,
|
|
1806
2016
|
});
|
|
1807
2017
|
}
|
|
1808
|
-
|
|
2018
|
+
/**
|
|
2019
|
+
* recentTrades: the venue's last HL_RECENT_TRADES_CAP (10) trades — no count parameter, no
|
|
2020
|
+
* paging. `limit` undefined = the venue max (10); an explicit `limit` above 10 is INVALID_PARAMS
|
|
2021
|
+
* naming the cap (never fewer rows than asked, silently). Row `symbol` is the raw venue coin
|
|
2022
|
+
* ("BTC", "km:US500"), as in the WS codec — not the "BTC-PERP" display name of `market list`.
|
|
2023
|
+
*/
|
|
2024
|
+
async getRecentTrades(symbol, limit) {
|
|
2025
|
+
const n = limit ?? HL_RECENT_TRADES_CAP;
|
|
2026
|
+
if (!(Number.isInteger(n) && n > 0)) {
|
|
2027
|
+
throw new PerpError("INVALID_PARAMS", `Hyperliquid trades: limit must be a positive integer (got ${limit})`, { exchange: "hyperliquid" });
|
|
2028
|
+
}
|
|
2029
|
+
if (n > HL_RECENT_TRADES_CAP) {
|
|
2030
|
+
throw new PerpError("INVALID_PARAMS", `Hyperliquid trades: limit ${n} exceeds the venue cap of ${HL_RECENT_TRADES_CAP} (recentTrades serves only the last ${HL_RECENT_TRADES_CAP} trades, no paging)`, {
|
|
2031
|
+
exchange: "hyperliquid", remediation: `Use --limit ${HL_RECENT_TRADES_CAP} or less`,
|
|
2032
|
+
});
|
|
2033
|
+
}
|
|
1809
2034
|
// recentTrades answers HTTP 500 "null" for an unlisted coin (live 2026-10-14), which would read
|
|
1810
2035
|
// as a retryable venue error: resolve the coin against the asset map first (SYMBOL_NOT_FOUND).
|
|
1811
|
-
await this.
|
|
2036
|
+
await this._listedMarket(symbol);
|
|
1812
2037
|
const trades = await this._infoPost({ type: "recentTrades", coin: this._venueCoin(symbol) });
|
|
1813
2038
|
if (trades === null)
|
|
1814
2039
|
throw this._unknownSymbol(symbol);
|
|
1815
2040
|
return trades
|
|
1816
|
-
.slice(0,
|
|
2041
|
+
.slice(0, n)
|
|
1817
2042
|
.map((t) => ({
|
|
1818
2043
|
time: Number(t.time ?? 0),
|
|
1819
2044
|
symbol: String(t.coin ?? symbol.toUpperCase()),
|
|
@@ -1823,24 +2048,51 @@ export class HyperliquidAdapter {
|
|
|
1823
2048
|
fee: null, // the public tape carries no fee
|
|
1824
2049
|
}));
|
|
1825
2050
|
}
|
|
2051
|
+
/**
|
|
2052
|
+
* candleSnapshot. Only the most recent HL_CANDLE_CAP (5000) candles of an interval exist (docs)
|
|
2053
|
+
* and older ones cannot be paged to: a `startTime` before hlEarliestCandleStart() is refused
|
|
2054
|
+
* up front (INVALID_PARAMS naming the earliest allowed start) — never a silently shortened
|
|
2055
|
+
* range. `closed` (additive) is false on the in-progress candle (close time `T`, or open +
|
|
2056
|
+
* interval when `T` is absent, still in the future). "1M" = 30-day buckets; "1w" opens
|
|
2057
|
+
* Thursday 00:00 UTC.
|
|
2058
|
+
*/
|
|
1826
2059
|
async getKlines(symbol, interval, startTime, endTime) {
|
|
1827
|
-
|
|
2060
|
+
const ivMs = HL_CANDLE_INTERVAL_MS[interval];
|
|
2061
|
+
if (ivMs === undefined) {
|
|
2062
|
+
throw new PerpError("INVALID_PARAMS", `Hyperliquid has no ${JSON.stringify(interval)} candles`, {
|
|
2063
|
+
exchange: "hyperliquid", remediation: `Use one of: ${Object.keys(HL_CANDLE_INTERVAL_MS).join(", ")}`,
|
|
2064
|
+
});
|
|
2065
|
+
}
|
|
2066
|
+
const now = Date.now();
|
|
2067
|
+
const earliest = hlEarliestCandleStart(interval, now);
|
|
2068
|
+
if (startTime < earliest) {
|
|
2069
|
+
throw new PerpError("INVALID_PARAMS", `Hyperliquid serves only the most recent ${HL_CANDLE_CAP} ${interval} candles: the earliest allowed start is ${new Date(earliest).toISOString()} (${earliest}); requested ${new Date(startTime).toISOString()}`, {
|
|
2070
|
+
exchange: "hyperliquid", remediation: `Use --start ${earliest} or later, or a longer interval`,
|
|
2071
|
+
});
|
|
2072
|
+
}
|
|
2073
|
+
await this._listedMarket(symbol); // unlisted coin: SYMBOL_NOT_FOUND (candleSnapshot answers HTTP 500 "null")
|
|
1828
2074
|
const candles = await this._infoPost({
|
|
1829
2075
|
type: "candleSnapshot",
|
|
1830
2076
|
req: { coin: this._venueCoin(symbol), interval, startTime, endTime },
|
|
1831
2077
|
});
|
|
1832
|
-
return (candles ?? []).map((c) =>
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
:
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
2078
|
+
return (candles ?? []).map((c) => {
|
|
2079
|
+
assertCandleInterval(this.name, interval, c.i);
|
|
2080
|
+
const time = Number(c.t ?? 0);
|
|
2081
|
+
const closeAt = Number.isFinite(Number(c.T)) && c.T !== undefined && c.T !== null ? Number(c.T) : time + ivMs;
|
|
2082
|
+
return {
|
|
2083
|
+
time,
|
|
2084
|
+
open: String(c.o ?? "0"),
|
|
2085
|
+
high: String(c.h ?? "0"),
|
|
2086
|
+
low: String(c.l ?? "0"),
|
|
2087
|
+
close: String(c.c ?? "0"),
|
|
2088
|
+
// absent / non-numeric → null (never "")
|
|
2089
|
+
volume: typeof c.v === "string" && c.v.trim() !== "" && Number.isFinite(Number(c.v)) ? c.v
|
|
2090
|
+
: typeof c.v === "number" && Number.isFinite(c.v) ? String(c.v) : null,
|
|
2091
|
+
quoteVolume: null, // candleSnapshot carries base volume only
|
|
2092
|
+
trades: Number(c.n ?? 0),
|
|
2093
|
+
closed: closeAt < now,
|
|
2094
|
+
};
|
|
2095
|
+
});
|
|
1844
2096
|
}
|
|
1845
2097
|
/**
|
|
1846
2098
|
* Order history: POST /info {type:"historicalOrders"} ("Retrieve a user's historical orders",
|
|
@@ -1987,16 +2239,59 @@ export class HyperliquidAdapter {
|
|
|
1987
2239
|
return coin.startsWith(`${this._dex}:`);
|
|
1988
2240
|
return !/[:@#/]/.test(coin);
|
|
1989
2241
|
}
|
|
2242
|
+
/**
|
|
2243
|
+
* The last `limit` hourly funding settlements. HL funding is hourly, so the window starts
|
|
2244
|
+
* `limit + 1` hours back (one spare hour for a settlement not yet published), and fundingHistory
|
|
2245
|
+
* pages are read forward from the last row's time (docs: at most 500 elements per response;
|
|
2246
|
+
* "use the last returned timestamp as the next startTime") — deduped by time, oldest first.
|
|
2247
|
+
*/
|
|
1990
2248
|
async getFundingHistory(symbol, limit = 10) {
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
2249
|
+
if (!(Number.isInteger(limit) && limit > 0)) {
|
|
2250
|
+
throw new PerpError("INVALID_PARAMS", `Hyperliquid funding history: limit must be a positive integer (got ${limit})`, { exchange: "hyperliquid" });
|
|
2251
|
+
}
|
|
2252
|
+
if (limit > HL_FUNDING_HISTORY_MAX_LIMIT) {
|
|
2253
|
+
throw new PerpError("INVALID_PARAMS", `Hyperliquid funding history: limit ${limit} exceeds ${HL_FUNDING_HISTORY_MAX_LIMIT} hourly rows`, { exchange: "hyperliquid", remediation: `Use --limit ${HL_FUNDING_HISTORY_MAX_LIMIT} or less` });
|
|
2254
|
+
}
|
|
2255
|
+
await this._listedMarket(symbol); // unlisted coin: SYMBOL_NOT_FOUND (fundingHistory answers HTTP 500)
|
|
2256
|
+
const history = await this._fundingHistorySince(this._venueCoin(symbol), Date.now() - (limit + 1) * 60 * 60 * 1000);
|
|
2257
|
+
return history.slice(-limit).map((h) => ({
|
|
1995
2258
|
time: Number(h.time ?? 0),
|
|
1996
2259
|
rate: venueRateField(h.fundingRate, "fundingHistory fundingRate", "hyperliquid"),
|
|
1997
2260
|
price: null,
|
|
2261
|
+
intervalHours: 1,
|
|
2262
|
+
rateKind: "lastSettled",
|
|
1998
2263
|
}));
|
|
1999
2264
|
}
|
|
2265
|
+
/** Every fundingHistory row of `coin` since `startTime`, paged forward (500 per response), deduped by time, oldest first. */
|
|
2266
|
+
async _fundingHistorySince(coin, startTime) {
|
|
2267
|
+
const byTime = new Map();
|
|
2268
|
+
let from = startTime;
|
|
2269
|
+
for (let page = 0;; page++) {
|
|
2270
|
+
if (page >= HL_FUNDING_HISTORY_MAX_PAGES) {
|
|
2271
|
+
throw new PerpError("EXCHANGE_ERROR", `Hyperliquid fundingHistory needs more than ${HL_FUNDING_HISTORY_MAX_PAGES} pages — the result would be incomplete`, { exchange: "hyperliquid" });
|
|
2272
|
+
}
|
|
2273
|
+
const rows = await this._infoPost({ type: "fundingHistory", coin, startTime: from });
|
|
2274
|
+
if (!Array.isArray(rows)) {
|
|
2275
|
+
throw new PerpError("EXCHANGE_ERROR", `Hyperliquid fundingHistory returned no list (${JSON.stringify(rows).slice(0, 120)})`, { exchange: "hyperliquid" });
|
|
2276
|
+
}
|
|
2277
|
+
let last = -Infinity;
|
|
2278
|
+
let fresh = 0;
|
|
2279
|
+
for (const h of rows) {
|
|
2280
|
+
const t = Number(h.time);
|
|
2281
|
+
if (!Number.isFinite(t))
|
|
2282
|
+
throw new PerpError("EXCHANGE_ERROR", `Hyperliquid fundingHistory row without a numeric time: ${JSON.stringify(h).slice(0, 160)}`, { exchange: "hyperliquid" });
|
|
2283
|
+
last = Math.max(last, t);
|
|
2284
|
+
if (!byTime.has(t)) {
|
|
2285
|
+
byTime.set(t, h);
|
|
2286
|
+
fresh++;
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
if (rows.length < HL_INFO_PAGE || fresh === 0)
|
|
2290
|
+
break;
|
|
2291
|
+
from = last;
|
|
2292
|
+
}
|
|
2293
|
+
return [...byTime.entries()].sort((a, b) => a[0] - b[0]).map(([, h]) => h);
|
|
2294
|
+
}
|
|
2000
2295
|
// ────────────────────────────────────────────────────────────
|
|
2001
2296
|
// Extended methods (from Python SDK / nktkas TS SDK analysis)
|
|
2002
2297
|
// ────────────────────────────────────────────────────────────
|
|
@@ -2525,31 +2820,33 @@ export class HyperliquidAdapter {
|
|
|
2525
2820
|
return this._sendExchangeAction({ type: "claimRewards" });
|
|
2526
2821
|
}
|
|
2527
2822
|
/**
|
|
2528
|
-
* List
|
|
2823
|
+
* List the HIP-3 deployed perp dexes: allPerpMetas (universe per dex; index 0 = native perps)
|
|
2824
|
+
* joined by index with perpDexs (name, deployer — live 2026-10-19 both list the dexes in the
|
|
2825
|
+
* same order). `assets` holds the LIVE (non-delisted) assets only; `delistedAssets` the rest and
|
|
2826
|
+
* `delisted` is true when every asset of the dex is delisted (km, flx, hyna, cash, vntl, abcd on
|
|
2827
|
+
* 2026-10-19). Delisted dexes stay listed: accounts may still hold positions there.
|
|
2529
2828
|
*/
|
|
2530
2829
|
async listDeployedDexes() {
|
|
2531
|
-
const allMetas = await
|
|
2830
|
+
const [allMetas, dexList] = await Promise.all([
|
|
2831
|
+
this._infoPost({ type: "allPerpMetas" }),
|
|
2832
|
+
withCache(`mkt:hl:${this._network}:perpDexs`, HL_MARKET_META_TTL_MS, () => this._infoPost({ type: "perpDexs" })),
|
|
2833
|
+
]);
|
|
2532
2834
|
if (!Array.isArray(allMetas))
|
|
2533
2835
|
return [];
|
|
2534
2836
|
const dexes = [];
|
|
2535
|
-
// allPerpMetas returns an array: [nativePerps, dex1, dex2, ...]
|
|
2536
|
-
// Each entry has { universe, marginTables, collateralToken }
|
|
2537
|
-
// Dex name is derived from the asset prefix (e.g., "xyz:TSLA" → "xyz")
|
|
2538
|
-
// Skip index 0 (native/validator perps — no prefix)
|
|
2539
2837
|
for (let i = 1; i < allMetas.length; i++) {
|
|
2540
|
-
const
|
|
2541
|
-
const universe = (meta.universe ?? []);
|
|
2838
|
+
const universe = (allMetas[i]?.universe ?? []);
|
|
2542
2839
|
if (universe.length === 0)
|
|
2543
2840
|
continue;
|
|
2544
|
-
// Extract dex name from the first asset's prefix
|
|
2545
2841
|
const firstAsset = universe[0].name;
|
|
2546
2842
|
const colonIdx = firstAsset.indexOf(":");
|
|
2547
|
-
const
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2843
|
+
const prefix = colonIdx > 0 ? firstAsset.slice(0, colonIdx) : `dex-${i}`;
|
|
2844
|
+
const entry = Array.isArray(dexList) ? dexList[i] : null;
|
|
2845
|
+
// The deployer is read from the perpDexs entry of the same dex (by index, checked by name).
|
|
2846
|
+
const deployer = entry?.name === prefix && typeof entry.deployer === "string" ? entry.deployer : "";
|
|
2847
|
+
const live = universe.filter(a => a.isDelisted !== true).map(a => a.name);
|
|
2848
|
+
const gone = universe.filter(a => a.isDelisted === true).map(a => a.name);
|
|
2849
|
+
dexes.push({ name: prefix, deployer, assets: live, delistedAssets: gone, delisted: live.length === 0 });
|
|
2553
2850
|
}
|
|
2554
2851
|
return dexes;
|
|
2555
2852
|
}
|
|
@@ -2620,6 +2917,11 @@ export class HyperliquidAdapter {
|
|
|
2620
2917
|
// share an aggregated weight limit of 1200 per minute") → typed RATE_LIMITED.
|
|
2621
2918
|
if (res.status === 429)
|
|
2622
2919
|
throw rateLimitedError("hyperliquid", res.headers.get("retry-after"), `info ${String(body.type)}: ${text}`);
|
|
2920
|
+
// HTTP 422 "Failed to deserialize the JSON body into the target type" = the request is
|
|
2921
|
+
// malformed (bad interval / field type, live 2026-10-19): a caller error, not retryable.
|
|
2922
|
+
if (res.status === 422) {
|
|
2923
|
+
throw new PerpError("INVALID_PARAMS", `Hyperliquid info ${String(body.type)} rejected the request (422): ${text.slice(0, 200)}`, { exchange: "hyperliquid" });
|
|
2924
|
+
}
|
|
2623
2925
|
throw new PerpError("EXCHANGE_ERROR", `Hyperliquid info ${String(body.type)} failed (${res.status}): ${text.slice(0, 200)}`, { exchange: "hyperliquid" });
|
|
2624
2926
|
}
|
|
2625
2927
|
return res.json();
|