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.
Files changed (154) hide show
  1. package/README.ja.md +243 -0
  2. package/README.ko.md +243 -0
  3. package/README.md +129 -535
  4. package/README.zh.md +243 -0
  5. package/dist/api/public/lighter.d.ts +5 -0
  6. package/dist/api/public/lighter.js +5 -0
  7. package/dist/api/public/pacifica.js +2 -2
  8. package/dist/arb/sizing.js +4 -2
  9. package/dist/arb/state.d.ts +6 -0
  10. package/dist/balance-by-address.d.ts +39 -0
  11. package/dist/balance-by-address.js +44 -0
  12. package/dist/bot/quote-rules.js +1 -1
  13. package/dist/bot/strategies/funding-arb-strategy.js +10 -3
  14. package/dist/bot/strategies/funding-arb-utils.d.ts +14 -2
  15. package/dist/bot/strategies/funding-arb-utils.js +48 -15
  16. package/dist/bot/strategies/spot-perp-arb-strategy.d.ts +1 -1
  17. package/dist/bot/strategies/spot-perp-arb-strategy.js +4 -2
  18. package/dist/bot/strategies/spot-perp-executor.js +2 -2
  19. package/dist/cli-program.js +1 -1
  20. package/dist/commands/account.js +168 -24
  21. package/dist/commands/arb/index.js +19 -77
  22. package/dist/commands/arb-auto.d.ts +24 -0
  23. package/dist/commands/arb-auto.js +155 -81
  24. package/dist/commands/arb.d.ts +8 -0
  25. package/dist/commands/arb.js +49 -23
  26. package/dist/commands/backtest.js +23 -21
  27. package/dist/commands/market.d.ts +48 -0
  28. package/dist/commands/market.js +241 -78
  29. package/dist/commands/options.d.ts +5 -0
  30. package/dist/commands/options.js +20 -3
  31. package/dist/commands/pacifica-earn.js +8 -21
  32. package/dist/commands/portfolio.d.ts +16 -0
  33. package/dist/commands/portfolio.js +51 -8
  34. package/dist/commands/trade.js +4 -4
  35. package/dist/cross-chain-margin.d.ts +3 -1
  36. package/dist/cross-chain-margin.js +4 -2
  37. package/dist/dashboard/server.js +2 -2
  38. package/dist/dashboard/ui.js +9 -6
  39. package/dist/errors.d.ts +5 -0
  40. package/dist/errors.js +10 -0
  41. package/dist/exchanges/address.d.ts +8 -0
  42. package/dist/exchanges/address.js +47 -0
  43. package/dist/exchanges/arcus/adapter.d.ts +10 -6
  44. package/dist/exchanges/arcus/adapter.js +33 -6
  45. package/dist/exchanges/arcus/public.js +3 -2
  46. package/dist/exchanges/aster.d.ts +27 -7
  47. package/dist/exchanges/aster.js +124 -42
  48. package/dist/exchanges/canonical-symbols.d.ts +7 -0
  49. package/dist/exchanges/canonical-symbols.js +20 -0
  50. package/dist/exchanges/capabilities.d.ts +2 -0
  51. package/dist/exchanges/capabilities.js +2 -1
  52. package/dist/exchanges/derive/options.d.ts +5 -0
  53. package/dist/exchanges/derive/options.js +20 -1
  54. package/dist/exchanges/extended/adapter.d.ts +16 -2
  55. package/dist/exchanges/extended/adapter.js +96 -34
  56. package/dist/exchanges/extended/public.d.ts +3 -0
  57. package/dist/exchanges/extended/public.js +9 -2
  58. package/dist/exchanges/grvt/adapter.d.ts +16 -2
  59. package/dist/exchanges/grvt/adapter.js +98 -21
  60. package/dist/exchanges/hyperliquid-outcome.d.ts +2 -0
  61. package/dist/exchanges/hyperliquid-outcome.js +29 -1
  62. package/dist/exchanges/hyperliquid-spot.d.ts +8 -0
  63. package/dist/exchanges/hyperliquid-spot.js +20 -12
  64. package/dist/exchanges/hyperliquid.d.ts +180 -13
  65. package/dist/exchanges/hyperliquid.js +487 -185
  66. package/dist/exchanges/interface.d.ts +64 -8
  67. package/dist/exchanges/interface.js +31 -0
  68. package/dist/exchanges/lighter-public.d.ts +66 -11
  69. package/dist/exchanges/lighter-public.js +249 -81
  70. package/dist/exchanges/lighter-spot.d.ts +7 -1
  71. package/dist/exchanges/lighter-spot.js +99 -90
  72. package/dist/exchanges/lighter.d.ts +70 -11
  73. package/dist/exchanges/lighter.js +285 -92
  74. package/dist/exchanges/ondo/adapter.d.ts +34 -8
  75. package/dist/exchanges/ondo/adapter.js +73 -16
  76. package/dist/exchanges/ondo/public.d.ts +27 -2
  77. package/dist/exchanges/ondo/public.js +63 -31
  78. package/dist/exchanges/outcome-interface.d.ts +4 -0
  79. package/dist/exchanges/pacifica-spot.d.ts +6 -0
  80. package/dist/exchanges/pacifica-spot.js +23 -15
  81. package/dist/exchanges/pacifica-vaults.d.ts +9 -0
  82. package/dist/exchanges/pacifica-vaults.js +14 -1
  83. package/dist/exchanges/pacifica.d.ts +77 -6
  84. package/dist/exchanges/pacifica.js +321 -130
  85. package/dist/exchanges/public-balances.d.ts +25 -0
  86. package/dist/exchanges/public-balances.js +158 -0
  87. package/dist/exchanges/public-positions.d.ts +43 -0
  88. package/dist/exchanges/public-positions.js +154 -0
  89. package/dist/exchanges/qfex/adapter.d.ts +52 -9
  90. package/dist/exchanges/qfex/adapter.js +74 -9
  91. package/dist/exchanges/qfex/public.d.ts +62 -9
  92. package/dist/exchanges/qfex/public.js +128 -22
  93. package/dist/exchanges/qfex/ws-transport.d.ts +1 -1
  94. package/dist/exchanges/risex/adapter.d.ts +19 -7
  95. package/dist/exchanges/risex/adapter.js +58 -12
  96. package/dist/exchanges/spot-factory.js +1 -1
  97. package/dist/exchanges/txflow/adapter.d.ts +14 -7
  98. package/dist/exchanges/txflow/adapter.js +51 -19
  99. package/dist/exchanges/txflow/client.js +3 -1
  100. package/dist/exchanges/txflow/public.d.ts +12 -1
  101. package/dist/exchanges/txflow/public.js +82 -11
  102. package/dist/exchanges/unknown-symbol.d.ts +6 -1
  103. package/dist/exchanges/unknown-symbol.js +7 -2
  104. package/dist/exchanges/variational/adapter.d.ts +2 -6
  105. package/dist/exchanges/venue-types.d.ts +46 -3
  106. package/dist/exchanges/venue-types.js +80 -9
  107. package/dist/exchanges/venue.d.ts +99 -3
  108. package/dist/exchanges/venue.js +72 -0
  109. package/dist/exchanges/venues/hyperliquid.js +9 -0
  110. package/dist/exchanges/venues/lighter-rh.js +13 -3
  111. package/dist/exchanges/venues/lighter.js +13 -3
  112. package/dist/exchanges/venues/pacifica.js +9 -0
  113. package/dist/exchanges/venues/risex.js +15 -0
  114. package/dist/funding/legacy-public.d.ts +5 -3
  115. package/dist/funding/legacy-public.js +33 -37
  116. package/dist/funding/rates.d.ts +54 -5
  117. package/dist/funding/rates.js +49 -13
  118. package/dist/liquidity.d.ts +3 -1
  119. package/dist/liquidity.js +8 -4
  120. package/dist/mcp-pacifica-earn.js +11 -5
  121. package/dist/mcp-server.js +104 -15
  122. package/dist/pacifica/client.d.ts +2 -1
  123. package/dist/pacifica/client.js +11 -0
  124. package/dist/positions-by-address.d.ts +34 -0
  125. package/dist/positions-by-address.js +46 -0
  126. package/dist/strategies/funding-arb.js +35 -62
  127. package/dist/utils.d.ts +11 -1
  128. package/dist/venue-health.js +1 -1
  129. package/dist/ws/arcus-market.d.ts +9 -0
  130. package/dist/ws/arcus-market.js +9 -0
  131. package/dist/ws/aster-market.d.ts +33 -0
  132. package/dist/ws/aster-market.js +30 -0
  133. package/dist/ws/index.d.ts +30 -9
  134. package/dist/ws/index.js +180 -15
  135. package/dist/ws/lighter-market.d.ts +14 -0
  136. package/dist/ws/lighter-market.js +13 -0
  137. package/dist/ws/node.d.ts +30 -9
  138. package/dist/ws/node.js +204 -16
  139. package/dist/ws/unclassified.d.ts +16 -0
  140. package/dist/ws/unclassified.js +43 -0
  141. package/package.json +15 -2
  142. package/skills/perp-cli/SKILL.md +97 -33
  143. package/skills/perp-cli/package.json +15 -2
  144. package/skills/perp-cli/references/agent-operations.md +15 -12
  145. package/skills/perp-cli/references/commands.md +67 -12
  146. package/skills/perp-cli/references/strategies.md +38 -3
  147. package/skills/perp-cli/references/ws-library.md +55 -24
  148. package/skills/perp-cli/scripts/arb-monitor.sh +13 -10
  149. package/skills/perp-cli/scripts/funding-analysis.sh +57 -29
  150. package/skills/perp-cli/scripts/preflight.sh +27 -19
  151. package/skills/perp-cli/scripts/spot-perp-scan.sh +58 -38
  152. package/skills/perp-cli/scripts/validate-arb.sh +56 -39
  153. package/dist/cli-spec.d.ts +0 -46
  154. 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
- // Rebuild asset map for the new dex
413
- this._assetMap.clear();
414
- this._assetMapReverse.clear();
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: sym,
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
- stepSize: null,
819
- // HL funding is uniform (venue definition), not per-market.
820
- fundingHours: null,
821
- marginTiers: tierMap.get(Number(asset.marginTableId)) ?? null,
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 it to say "delisted" rather than
834
- * "not found" — the asset map still resolves it for orders / positions / history.
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
- if (!this._delisted.has(resolved))
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
- async getOrderbook(symbol) {
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 new Error("No private key configured — account data unavailable. Run: perp setup");
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
- // dexAbstraction (discontinued): USDC (token 0) lives in the NATIVE perp balance, any other
1008
- // collateral token in spot. Native perps → the perp state below. A HIP-3 dex with non-USDC
1009
- // collateral → its spot row. A USDC-collateral HIP-3 dex is refused: its dex state is not the
1010
- // balance ("Cross margin on HIP-3 DEXs does not behave intuitively for DEX abstraction users").
1011
- let spotIsTruth = mode === "unified" || mode === "portfolio";
1012
- if (mode === "dexAbstraction" && this._dex) {
1013
- if (await this._getCollateralToken() === 0) {
1014
- throw new PerpError("NOT_IMPLEMENTED", `Hyperliquid account mode "dexAbstraction" (discontinued by the venue): the USDC balance of HIP-3 dex "${this._dex}" cannot be read reliably`, {
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
- const state = await this._getClearinghouseState();
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
- * (getOrderbook passes the dex). Either side missing / non-positive → PRICE_STALE.
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.getOrderbook(symbol);
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
- async getRecentTrades(symbol, limit = 20) {
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.getAssetIndex(symbol);
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, limit)
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
- await this.getAssetIndex(symbol); // unlisted coin: SYMBOL_NOT_FOUND (candleSnapshot answers HTTP 500 "null")
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
- time: Number(c.t ?? 0),
1834
- open: String(c.o ?? "0"),
1835
- high: String(c.h ?? "0"),
1836
- low: String(c.l ?? "0"),
1837
- close: String(c.c ?? "0"),
1838
- // absent / non-numeric → null (never "")
1839
- volume: typeof c.v === "string" && c.v.trim() !== "" && Number.isFinite(Number(c.v)) ? c.v
1840
- : typeof c.v === "number" && Number.isFinite(c.v) ? String(c.v) : null,
1841
- quoteVolume: null, // candleSnapshot carries base volume only
1842
- trades: Number(c.n ?? 0),
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
- await this.getAssetIndex(symbol); // unlisted coin: SYMBOL_NOT_FOUND (fundingHistory answers HTTP 500)
1992
- const now = Date.now();
1993
- const history = await this._infoPost({ type: "fundingHistory", coin: this._venueCoin(symbol), startTime: now - 24 * 60 * 60 * 1000 });
1994
- return (history ?? []).slice(-limit).map((h) => ({
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 all available HIP-3 deployed perp dexes.
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 this._infoPost({ type: "allPerpMetas" });
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 meta = allMetas[i];
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 dexName = colonIdx > 0 ? firstAsset.slice(0, colonIdx) : `dex-${i}`;
2548
- dexes.push({
2549
- name: dexName,
2550
- deployer: "", // not exposed by API
2551
- assets: universe.map(a => a.name),
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();