perp-cli 0.14.1 → 0.14.2

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 (61) hide show
  1. package/dist/arb/sizing.js +29 -7
  2. package/dist/bot/conditions.d.ts +5 -1
  3. package/dist/bot/conditions.js +16 -11
  4. package/dist/bot/engine.d.ts +12 -0
  5. package/dist/bot/engine.js +55 -44
  6. package/dist/bot/strategies/claude-agent.js +2 -2
  7. package/dist/bot/strategies/funding-arb-strategy.js +16 -20
  8. package/dist/bot/strategies/funding-arb-utils.d.ts +19 -0
  9. package/dist/bot/strategies/funding-arb-utils.js +44 -0
  10. package/dist/bot/strategies/funding-auto.js +61 -37
  11. package/dist/bot/strategies/grid-strategy.d.ts +2 -0
  12. package/dist/bot/strategies/grid-strategy.js +21 -2
  13. package/dist/bot/strategies/spot-perp-arb-strategy.js +28 -20
  14. package/dist/bot/tui/BotDashboard.d.ts +13 -2
  15. package/dist/bot/tui/BotDashboard.js +8 -3
  16. package/dist/bridge-engine.d.ts +2 -0
  17. package/dist/bridge-engine.js +68 -31
  18. package/dist/commands/account.js +2 -2
  19. package/dist/commands/arb/index.d.ts +20 -0
  20. package/dist/commands/arb/index.js +121 -36
  21. package/dist/commands/arb-auto.d.ts +36 -0
  22. package/dist/commands/arb-auto.js +202 -76
  23. package/dist/commands/arb.js +51 -35
  24. package/dist/commands/bot.d.ts +6 -0
  25. package/dist/commands/bot.js +55 -45
  26. package/dist/commands/funds-onchain.js +6 -5
  27. package/dist/commands/funds.js +18 -5
  28. package/dist/commands/history.js +135 -53
  29. package/dist/commands/portfolio.d.ts +5 -0
  30. package/dist/commands/portfolio.js +58 -20
  31. package/dist/commands/rebalance.js +46 -10
  32. package/dist/commands/settings.js +2 -1
  33. package/dist/commands/trade.js +8 -4
  34. package/dist/commands/wallet.js +1 -1
  35. package/dist/cross-chain-margin.d.ts +29 -2
  36. package/dist/cross-chain-margin.js +30 -4
  37. package/dist/dashboard/server.d.ts +45 -8
  38. package/dist/dashboard/server.js +113 -42
  39. package/dist/dashboard/ui.js +46 -27
  40. package/dist/dashboard/ws-feeds.d.ts +25 -3
  41. package/dist/dashboard/ws-feeds.js +219 -82
  42. package/dist/dex-asset-map.d.ts +23 -4
  43. package/dist/dex-asset-map.js +39 -13
  44. package/dist/exchanges/arcus/adapter.js +33 -6
  45. package/dist/exchanges/arcus/public.d.ts +6 -0
  46. package/dist/exchanges/arcus/public.js +12 -4
  47. package/dist/exchanges/aster.d.ts +19 -7
  48. package/dist/exchanges/aster.js +79 -46
  49. package/dist/exchanges/hyperliquid.d.ts +30 -0
  50. package/dist/exchanges/hyperliquid.js +159 -34
  51. package/dist/exchanges/lighter.d.ts +1 -1
  52. package/dist/exchanges/lighter.js +11 -9
  53. package/dist/exchanges/ondo/funds.d.ts +0 -1
  54. package/dist/exchanges/pacifica.d.ts +7 -0
  55. package/dist/exchanges/pacifica.js +52 -26
  56. package/dist/rebalance.d.ts +20 -1
  57. package/dist/rebalance.js +31 -5
  58. package/dist/strategies/funding-arb.js +38 -7
  59. package/package.json +1 -1
  60. package/skills/perp-cli/SKILL.md +2 -2
  61. package/skills/perp-cli/references/commands.md +5 -3
@@ -30,7 +30,7 @@ import { networkConfig, requireCapabilities } from "../venue.js";
30
30
  import { parseOrderRef, toLegacyMarketInfo, } from "../venue-types.js";
31
31
  import { ArcusClient, arcusLeverageRejectError, arcusRejectionError } from "./client.js";
32
32
  import { ed25519PubHexFromBase58 } from "./agent.js";
33
- import { fetchArcusRawMarkets, toArcusVenueMarket } from "./public.js";
33
+ import { fetchArcusRawMarkets, onlineArcusMarkets, toArcusVenueMarket } from "./public.js";
34
34
  import { QUOTE_QUANTUM, assertArcusClientId, canonicalJson, cancelPayload, modifyPayload, nowNs, placePayload, signUtf8Hex, splitEvmSignature, withdrawTypedData, } from "./signing.js";
35
35
  /**
36
36
  * goodTilTime horizon. Arcus rejects anything nearer than one month at
@@ -49,6 +49,18 @@ const CLIENT_ID_HISTORY_SCAN = 200;
49
49
  /** GET /v1/openOrders page cap (default = max = 1000). */
50
50
  const OPEN_ORDERS_PAGE = 1000;
51
51
  const CANDLE_TIMEFRAMES = new Set(["1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "8h", "12h", "1d", "3d", "1w"]);
52
+ /** Bar length (ms) per timeframe — sizes the GET /v1/candles windows. */
53
+ const CANDLE_MS = {
54
+ "1m": 60_000, "3m": 180_000, "5m": 300_000, "15m": 900_000, "30m": 1_800_000, "1h": 3_600_000, "2h": 7_200_000,
55
+ "4h": 14_400_000, "8h": 28_800_000, "12h": 43_200_000, "1d": 86_400_000, "3d": 259_200_000, "1w": 604_800_000,
56
+ };
57
+ /**
58
+ * GET /v1/candles returns at most 1500 candles (newest first; live 2026-10-13: a 48 h 1m request
59
+ * answers `total: 1500` covering the newest ~25 h). Ranges are read in windows of this many bars.
60
+ */
61
+ const CANDLE_WINDOW_BARS = 1450;
62
+ /** Window cap per call (≈ 72 500 bars); a wider range is refused rather than truncated. */
63
+ const CANDLE_MAX_WINDOWS = 50;
52
64
  // ── Helpers ──
53
65
  const STATUS_MAP = {
54
66
  ACK: "accepted",
@@ -447,7 +459,7 @@ export class ArcusAdapter {
447
459
  // ── TradingV2 ──
448
460
  async getVenueMarkets() {
449
461
  const raw = await fetchArcusRawMarkets(this.client);
450
- return raw.map(toArcusVenueMarket);
462
+ return onlineArcusMarkets(raw).map(toArcusVenueMarket);
451
463
  }
452
464
  async placeOrder(input) {
453
465
  // §13.3: GTC is the uniform default for a limit order without a TIF.
@@ -888,10 +900,25 @@ export class ArcusAdapter {
888
900
  });
889
901
  }
890
902
  const m = await this.market(symbol);
891
- const res = await this.client.get("/v1/candles", "candles", { market: m.marketDisplayName, timeframe: interval, from: startTime * 1000, to: endTime * 1000 });
892
- return res.candles
893
- .map(c => ({ time: usToMs(c.openTime), open: c.open, high: c.high, low: c.low, close: c.close, volume: c.volume, trades: c.tradeCount }))
894
- .sort((x, y) => x.time - y.time);
903
+ const span = CANDLE_WINDOW_BARS * CANDLE_MS[interval];
904
+ const windows = Math.max(1, Math.ceil((endTime - startTime) / span));
905
+ if (windows > CANDLE_MAX_WINDOWS) {
906
+ throw new PerpError("INVALID_PARAMS", `Arcus: ${interval} candles over this range need ${windows} requests (max ${CANDLE_MAX_WINDOWS})`, {
907
+ remediation: "Narrow --start / --end or use a longer interval",
908
+ });
909
+ }
910
+ // Disjoint windows [from, to) — the venue caps one response at 1500 candles, so one wide
911
+ // request would silently drop the oldest bars. Merged and deduped by open time.
912
+ const byTime = new Map();
913
+ for (let from = startTime; from < endTime; from += span) {
914
+ const to = Math.min(from + span, endTime);
915
+ const res = await this.client.get("/v1/candles", "candles", { market: m.marketDisplayName, timeframe: interval, from: from * 1000, to: to * 1000 });
916
+ for (const c of res.candles) {
917
+ const time = usToMs(c.openTime);
918
+ byTime.set(time, { time, open: c.open, high: c.high, low: c.low, close: c.close, volume: c.volume, trades: c.tradeCount });
919
+ }
920
+ }
921
+ return [...byTime.values()].sort((x, y) => x.time - y.time);
895
922
  }
896
923
  // ── Legacy: account ──
897
924
  async rawPositions() {
@@ -62,4 +62,10 @@ export interface ArcusRawMarket {
62
62
  export declare function fetchArcusRawMarkets(client: ArcusClient): Promise<ArcusRawMarket[]>;
63
63
  export declare function arcusAssetClass(m: ArcusRawMarket): AssetClass;
64
64
  export declare function toArcusVenueMarket(m: ArcusRawMarket): VenueMarket;
65
+ /**
66
+ * Markets that are not ONLINE carry placeholder values (live 2026-10-13: the OFFLINE markets
67
+ * F, BAC, CCL, VT, SGOV, RVI report markPrice "0" and fundingRate "0") — they are left out of
68
+ * market / funding lists rather than listed at a $0 price and 0 % funding.
69
+ */
70
+ export declare function onlineArcusMarkets(raw: ArcusRawMarket[]): ArcusRawMarket[];
65
71
  export declare const arcusPublicData: VenuePublicData;
@@ -76,8 +76,8 @@ function sessionOf(m) {
76
76
  export function toArcusVenueMarket(m) {
77
77
  const assetClass = arcusAssetClass(m);
78
78
  const mark = numOrNull(m.markPrice);
79
- if (mark === null) {
80
- throw new PerpError("EXCHANGE_ERROR", `Arcus market ${m.marketDisplayName}: missing markPrice`);
79
+ if (mark === null || !(Number(mark) > 0)) {
80
+ throw new PerpError("EXCHANGE_ERROR", `Arcus market ${m.marketDisplayName}: missing / non-positive markPrice (${JSON.stringify(m.markPrice)})`);
81
81
  }
82
82
  return {
83
83
  venue: "arcus",
@@ -104,15 +104,23 @@ export function toArcusVenueMarket(m) {
104
104
  tradable: m.status === "ONLINE",
105
105
  };
106
106
  }
107
+ /**
108
+ * Markets that are not ONLINE carry placeholder values (live 2026-10-13: the OFFLINE markets
109
+ * F, BAC, CCL, VT, SGOV, RVI report markPrice "0" and fundingRate "0") — they are left out of
110
+ * market / funding lists rather than listed at a $0 price and 0 % funding.
111
+ */
112
+ export function onlineArcusMarkets(raw) {
113
+ return raw.filter(m => m.status === "ONLINE");
114
+ }
107
115
  async function fetchMarkets(net) {
108
116
  const raw = await fetchArcusRawMarkets(new ArcusClient(net, null));
109
- return raw.map(toArcusVenueMarket);
117
+ return onlineArcusMarkets(raw).map(toArcusVenueMarket);
110
118
  }
111
119
  async function fetchFundingRates(net) {
112
120
  const raw = await fetchArcusRawMarkets(new ArcusClient(net, null));
113
121
  const out = [];
114
122
  const missing = [];
115
- for (const m of raw) {
123
+ for (const m of onlineArcusMarkets(raw)) {
116
124
  const rate = numOrNull(m.fundingRate);
117
125
  if (rate === null) {
118
126
  missing.push(m.marketDisplayName);
@@ -61,8 +61,13 @@ type ResolvedSigner = {
61
61
  /** EVM address of the owner/master — used as `user` field in Aster requests */
62
62
  userAddress: string;
63
63
  };
64
- /** Max 1000-row pages of 7-day commission income read for symbol discovery (20 000 fills). */
65
- export declare const ASTER_INCOME_MAX_PAGES = 20;
64
+ /**
65
+ * Max GET /fapi/v3/income requests for one 7-day read (7 daily slices + splits of full slices).
66
+ * The endpoint weighs 30 against Aster's 2400/min request weight, so 60 requests = 1800 — inside
67
+ * one minute's budget with room for the per-symbol fan-out. Enough for ~2000 fills/day; beyond
68
+ * that the read refuses (EXCHANGE_ERROR) rather than returning a partial history.
69
+ */
70
+ export declare const ASTER_INCOME_MAX_PAGES = 60;
66
71
  export declare class AsterAdapter implements ExchangeAdapter {
67
72
  readonly name = "aster";
68
73
  readonly chain = "bnb";
@@ -209,13 +214,19 @@ export declare class AsterAdapter implements ExchangeAdapter {
209
214
  */
210
215
  invalidateOpenOrdersCache(): void;
211
216
  getOpenOrders(): Promise<ExchangeOrder[]>;
217
+ /**
218
+ * Every income row of `incomeType` in the last 7 days. The response order of GET /fapi/v3/income
219
+ * is not documented, so nothing assumes it: the window is read as disjoint [start, end] slices
220
+ * (startTime / endTime inclusive; one day each) and a slice that comes back full (1000 rows, the
221
+ * endpoint max) is split in half and re-read. More than ASTER_INCOME_MAX_PAGES requests, or a
222
+ * full slice of one millisecond → EXCHANGE_ERROR, never a partial list.
223
+ */
224
+ private _incomeRows;
212
225
  /**
213
226
  * Symbols with account activity: open positions, open orders, and every symbol with a
214
- * COMMISSION income row in the last 7 days (the venue's default window). Aster's userTrades /
215
- * allOrders both require `symbol`, so this is the per-symbol fan-out set. Income is read in
216
- * pages of 1000 (the endpoint max; startTime / endTime are inclusive) walking back from now;
217
- * more than ASTER_INCOME_MAX_PAGES pages → EXCHANGE_ERROR, never a partial set. Orders on a
218
- * symbol that never filled, is not open and has no position cannot be found (allOrders needs it).
227
+ * COMMISSION income row in the last 7 days (see _incomeRows). Aster's userTrades / allOrders
228
+ * both require `symbol`, so this is the per-symbol fan-out set. Orders on a symbol that never
229
+ * filled, is not open and has no position cannot be found (allOrders needs it).
219
230
  */
220
231
  private _tradedApiSymbols;
221
232
  /**
@@ -230,6 +241,7 @@ export declare class AsterAdapter implements ExchangeAdapter {
230
241
  * A failed read throws (never an empty list); a row without a readable field → EXCHANGE_ERROR.
231
242
  */
232
243
  getTradeHistory(limit?: number): Promise<ExchangeTrade[]>;
244
+ /** FUNDING_FEE income of the last 7 days (see _incomeRows), newest first, at most `limit` rows. */
233
245
  getFundingPayments(limit?: number): Promise<ExchangeFundingPayment[]>;
234
246
  /**
235
247
  * Per-market leverage / margin mode: every row of GET /fapi/v3/positionRisk ("Position
@@ -152,8 +152,13 @@ export function requireAsterClientOrderId(id) {
152
152
  return id;
153
153
  }
154
154
  // ── AsterAdapter ──────────────────────────────────────────────────────────────
155
- /** Max 1000-row pages of 7-day commission income read for symbol discovery (20 000 fills). */
156
- export const ASTER_INCOME_MAX_PAGES = 20;
155
+ /**
156
+ * Max GET /fapi/v3/income requests for one 7-day read (7 daily slices + splits of full slices).
157
+ * The endpoint weighs 30 against Aster's 2400/min request weight, so 60 requests = 1800 — inside
158
+ * one minute's budget with room for the per-symbol fan-out. Enough for ~2000 fills/day; beyond
159
+ * that the read refuses (EXCHANGE_ERROR) rather than returning a partial history.
160
+ */
161
+ export const ASTER_INCOME_MAX_PAGES = 60;
157
162
  export class AsterAdapter {
158
163
  name = "aster";
159
164
  chain = "bnb";
@@ -717,17 +722,70 @@ export class AsterAdapter {
717
722
  this._ordersCache = { data: result, time: Date.now(), epoch: accountCacheEpoch() };
718
723
  return result;
719
724
  }
725
+ /**
726
+ * Every income row of `incomeType` in the last 7 days. The response order of GET /fapi/v3/income
727
+ * is not documented, so nothing assumes it: the window is read as disjoint [start, end] slices
728
+ * (startTime / endTime inclusive; one day each) and a slice that comes back full (1000 rows, the
729
+ * endpoint max) is split in half and re-read. More than ASTER_INCOME_MAX_PAGES requests, or a
730
+ * full slice of one millisecond → EXCHANGE_ERROR, never a partial list.
731
+ */
732
+ async _incomeRows(incomeType, r) {
733
+ const end = Date.now();
734
+ const start = end - 7 * 86_400_000;
735
+ const slices = [];
736
+ for (let s = start; s < end; s += 86_400_000)
737
+ slices.push([s, Math.min(s + 86_400_000 - 1, end)]);
738
+ slices[slices.length - 1][1] = end; // last slice ends exactly at `end` (inclusive), no extra 1-ms request
739
+ const rows = [];
740
+ let requests = 0;
741
+ while (slices.length > 0) {
742
+ const [s, e] = slices.shift();
743
+ if (++requests > ASTER_INCOME_MAX_PAGES) {
744
+ throw new PerpError("EXCHANGE_ERROR", `Aster ${incomeType} history needs more than ${ASTER_INCOME_MAX_PAGES} requests for 7 days — the result would be incomplete`, {
745
+ exchange: "aster", remediation: "Check the history per symbol in the Aster web app",
746
+ });
747
+ }
748
+ const page = await this._signedGetEip712("/fapi/v3/income", { incomeType, startTime: String(s), endTime: String(e), limit: "1000" }, r);
749
+ if (!Array.isArray(page)) {
750
+ throw new PerpError("EXCHANGE_ERROR", `Aster income history is not a list (${JSON.stringify(page).slice(0, 120)})`, { exchange: "aster" });
751
+ }
752
+ if (page.length >= 1000) {
753
+ if (e <= s) {
754
+ throw new PerpError("EXCHANGE_ERROR", `Aster returned 1000+ ${incomeType} rows at one millisecond (${s}) — the list cannot be read completely`, {
755
+ exchange: "aster", remediation: "Check the history per symbol in the Aster web app",
756
+ });
757
+ }
758
+ const mid = Math.floor((s + e) / 2);
759
+ slices.unshift([s, mid], [mid + 1, e]);
760
+ continue;
761
+ }
762
+ for (const row of page) {
763
+ if (!Number.isFinite(Number(row.time))) {
764
+ throw new PerpError("EXCHANGE_ERROR", `Aster income row without a numeric time: ${JSON.stringify(row).slice(0, 160)}`, { exchange: "aster" });
765
+ }
766
+ rows.push(row);
767
+ }
768
+ }
769
+ return rows;
770
+ }
720
771
  /**
721
772
  * Symbols with account activity: open positions, open orders, and every symbol with a
722
- * COMMISSION income row in the last 7 days (the venue's default window). Aster's userTrades /
723
- * allOrders both require `symbol`, so this is the per-symbol fan-out set. Income is read in
724
- * pages of 1000 (the endpoint max; startTime / endTime are inclusive) walking back from now;
725
- * more than ASTER_INCOME_MAX_PAGES pages → EXCHANGE_ERROR, never a partial set. Orders on a
726
- * symbol that never filled, is not open and has no position cannot be found (allOrders needs it).
773
+ * COMMISSION income row in the last 7 days (see _incomeRows). Aster's userTrades / allOrders
774
+ * both require `symbol`, so this is the per-symbol fan-out set. Orders on a symbol that never
775
+ * filled, is not open and has no position cannot be found (allOrders needs it).
727
776
  */
728
777
  async _tradedApiSymbols(r) {
729
- const positions = await this.getPositions();
730
- const apiSymbols = new Set(positions.map(p => this._toApi(p.symbol)));
778
+ // Raw venue symbols (no _fromApi → _toApi round trip, which would invent a USDT suffix for a
779
+ // non-USDT market): open positions of GET /fapi/v3/accountWithJoinMargin.
780
+ const account = await this._signedGetEip712("/fapi/v3/accountWithJoinMargin", {}, r);
781
+ if (!Array.isArray(account?.positions)) {
782
+ throw new PerpError("EXCHANGE_ERROR", "Aster accountWithJoinMargin has no positions list", { exchange: "aster" });
783
+ }
784
+ const apiSymbols = new Set();
785
+ for (const p of account.positions) {
786
+ if (typeof p.symbol === "string" && p.symbol !== "" && parseFiniteVenueNumber(p.positionAmt, "position.positionAmt", "aster") !== 0)
787
+ apiSymbols.add(p.symbol);
788
+ }
731
789
  const open = await this._signedGetEip712("/fapi/v3/openOrders", {}, r);
732
790
  if (!Array.isArray(open)) {
733
791
  throw new PerpError("EXCHANGE_ERROR", `Aster open orders is not a list (${JSON.stringify(open).slice(0, 120)})`, { exchange: "aster" });
@@ -736,33 +794,9 @@ export class AsterAdapter {
736
794
  if (typeof o.symbol === "string" && o.symbol !== "")
737
795
  apiSymbols.add(o.symbol);
738
796
  }
739
- const startTime = Date.now() - 7 * 86_400_000;
740
- let endTime = Date.now();
741
- for (let page = 0;; page++) {
742
- if (page >= ASTER_INCOME_MAX_PAGES) {
743
- throw new PerpError("EXCHANGE_ERROR", `Aster commission history exceeds ${ASTER_INCOME_MAX_PAGES * 1000} rows in 7 days — the symbol list would be incomplete`, {
744
- exchange: "aster", remediation: "Check fills per symbol in the Aster web app",
745
- });
746
- }
747
- const income = await this._signedGetEip712("/fapi/v3/income", {
748
- incomeType: "COMMISSION", startTime: String(startTime), endTime: String(endTime), limit: "1000",
749
- }, r);
750
- if (!Array.isArray(income)) {
751
- throw new PerpError("EXCHANGE_ERROR", `Aster income history is not a list (${JSON.stringify(income).slice(0, 120)})`, { exchange: "aster" });
752
- }
753
- let oldest = Infinity;
754
- for (const row of income) {
755
- if (typeof row.symbol === "string" && row.symbol !== "")
756
- apiSymbols.add(row.symbol);
757
- const t = Number(row.time);
758
- if (!Number.isFinite(t)) {
759
- throw new PerpError("EXCHANGE_ERROR", `Aster income row without a numeric time: ${JSON.stringify(row).slice(0, 160)}`, { exchange: "aster" });
760
- }
761
- oldest = Math.min(oldest, t);
762
- }
763
- if (income.length < 1000 || oldest <= startTime)
764
- break;
765
- endTime = oldest - 1;
797
+ for (const row of await this._incomeRows("COMMISSION", r)) {
798
+ if (typeof row.symbol === "string" && row.symbol !== "")
799
+ apiSymbols.add(row.symbol);
766
800
  }
767
801
  return apiSymbols;
768
802
  }
@@ -851,17 +885,16 @@ export class AsterAdapter {
851
885
  }
852
886
  return allTrades.sort((a, b) => b.time - a.time).slice(0, limit);
853
887
  }
888
+ /** FUNDING_FEE income of the last 7 days (see _incomeRows), newest first, at most `limit` rows. */
854
889
  async getFundingPayments(limit = 200) {
855
- const r = this._resolveSigner();
856
- const data = await this._signedGetEip712("/fapi/v3/income", {
857
- incomeType: "FUNDING_FEE",
858
- limit: String(limit),
859
- }, r);
860
- return (data ?? []).map((f) => ({
861
- time: Number(f.time ?? 0),
862
- symbol: this._fromApi(String(f.symbol ?? "")),
863
- payment: String(f.income ?? "0"),
864
- }));
890
+ const rows = await this._incomeRows("FUNDING_FEE", this._resolveSigner());
891
+ return rows.map((f) => {
892
+ const time = Number(f.time);
893
+ if (typeof f.symbol !== "string" || f.symbol === "" || (typeof f.income !== "string" && typeof f.income !== "number") || f.income === "") {
894
+ throw new PerpError("EXCHANGE_ERROR", `Aster funding income row without symbol / income: ${JSON.stringify(f).slice(0, 160)}`, { exchange: "aster" });
895
+ }
896
+ return { time, symbol: this._fromApi(f.symbol), payment: String(f.income) };
897
+ }).sort((x, y) => y.time - x.time).slice(0, limit);
865
898
  }
866
899
  /**
867
900
  * Per-market leverage / margin mode: every row of GET /fapi/v3/positionRisk ("Position
@@ -496,9 +496,39 @@ export declare class HyperliquidAdapter implements ExchangeAdapter {
496
496
  }): Promise<unknown>;
497
497
  getRecentTrades(symbol: string, limit?: number): Promise<ExchangeTrade[]>;
498
498
  getKlines(symbol: string, interval: string, startTime: number, endTime: number): Promise<ExchangeKline[]>;
499
+ /**
500
+ * Order history: POST /info {type:"historicalOrders"} ("Retrieve a user's historical orders",
501
+ * at most 2000 most recent; live 2026-10-13: newest first, each order listed once per status —
502
+ * e.g. "open" then "filled" — with equal statusTimestamp possible). One row per oid with its
503
+ * final status (latest statusTimestamp; on a tie a non-"open" status wins). Includes canceled /
504
+ * rejected orders (the previous userFills read showed fills only). Unreadable rows → EXCHANGE_ERROR.
505
+ * The info endpoint is account-wide; rows are scoped to this adapter (see _inHistoryScope).
506
+ */
499
507
  getOrderHistory(limit?: number): Promise<ExchangeOrder[]>;
508
+ /** Fills: POST /info {type:"userFills"} (at most 2000 most recent, newest first — live 2026-10-13). */
500
509
  getTradeHistory(limit?: number): Promise<ExchangeTrade[]>;
510
+ /**
511
+ * Funding payments in this adapter's scope (see _inHistoryScope), newest first, at most `limit`.
512
+ * Reads the last 24 h first and widens to 7 days only when that holds fewer than `limit` rows
513
+ * (POST /info userFunding weighs per row returned; bots poll it).
514
+ */
501
515
  getFundingPayments(limit?: number): Promise<ExchangeFundingPayment[]>;
516
+ /**
517
+ * Every userFunding row since `startTime`. The endpoint returns rows OLDEST first, at most 500
518
+ * per response (live 2026-10-13), and every coin of a funding hour shares one millisecond — so
519
+ * pages continue from the last row's time INCLUSIVE and rows are deduped by time + coin (funding
520
+ * rows carry a zero hash). A full page that adds nothing new, or more than HL_FUNDING_MAX_PAGES
521
+ * pages → EXCHANGE_ERROR (never a list with payments silently missing).
522
+ */
523
+ private _fundingSince;
524
+ /** The asset map backs _displaySymbol / _inHistoryScope; load it before mapping history rows. */
525
+ private _historyScopeReady;
526
+ /**
527
+ * Whether an account-wide history row (historicalOrders / userFills / userFunding carry every
528
+ * dex, spot "@n" / "PAIR/USDC" and outcome "#n" coins) belongs to this adapter: a HIP-3 dex
529
+ * adapter keeps "<dex>:…" coins, the native adapter keeps native perp coins only.
530
+ */
531
+ private _inHistoryScope;
502
532
  getFundingHistory(symbol: string, limit?: number): Promise<{
503
533
  time: number;
504
534
  rate: string;
@@ -1,7 +1,7 @@
1
1
  import { LocalEvmSigner } from "../signer/index.js";
2
2
  import { DEFINITE_ORDER_FAILURE_CODES, PerpError, classifyError, extractErrorMessage, orderNotSent, orderOutcomeUnknown, sendFailure } from "../errors.js";
3
3
  import { parseFiniteVenueNumber, venueRateField } from "../utils/numeric.js";
4
- import { alignProtectionPrice, floorSize, formatComputedPrice, slippagePrice } from "./precision.js";
4
+ import { alignProtectionPrice, floorSize, formatComputedPrice, slippagePrice, subDecimal } from "./precision.js";
5
5
  import { unsupported } from "./trading-v2.js";
6
6
  import { buildHlUserSignedAction, hlUserSignedNonceField, signHlL1Action, signHlUserSignedAction } from "./hl-signing.js";
7
7
  import { HL_CLOID_RE } from "./venues/hyperliquid.js";
@@ -339,6 +339,29 @@ export function hlL1ActionRejection(type, e) {
339
339
  remediation: "The venue refused the whole request and executed nothing; fix the cause named in the venue message above before sending it again",
340
340
  });
341
341
  }
342
+ /** POST /info userFunding page size (docs: at most 500 rows per response). */
343
+ const HL_FUNDING_PAGE = 500;
344
+ /** 7 days of hourly funding = 168 rows per coin; 20 pages = 10 000 rows (~60 coins). */
345
+ const HL_FUNDING_MAX_PAGES = 20;
346
+ /** A required field of an HL info row as a string; missing / empty → EXCHANGE_ERROR. */
347
+ function hlField(row, key, endpoint) {
348
+ const v = row[key];
349
+ if ((typeof v === "string" && v !== "") || (typeof v === "number" && Number.isFinite(v)))
350
+ return String(v);
351
+ throw new PerpError("EXCHANGE_ERROR", `Hyperliquid ${endpoint} row without a readable ${key}: ${JSON.stringify(row).slice(0, 160)}`, { exchange: "hyperliquid" });
352
+ }
353
+ /** historicalOrders status precedence on equal statusTimestamp: open < triggered < any terminal status. */
354
+ function hlStatusRank(status) {
355
+ return status === "open" ? 0 : status === "triggered" ? 1 : 2;
356
+ }
357
+ /** HL side "B" (bid) → buy, "A" (ask) → sell; anything else → EXCHANGE_ERROR. */
358
+ function hlSide(v, endpoint) {
359
+ if (v === "B")
360
+ return "buy";
361
+ if (v === "A")
362
+ return "sell";
363
+ throw new PerpError("EXCHANGE_ERROR", `Hyperliquid ${endpoint} row with unknown side ${JSON.stringify(v)}`, { exchange: "hyperliquid" });
364
+ }
342
365
  export class HyperliquidAdapter {
343
366
  name = "hyperliquid";
344
367
  chain = "evm";
@@ -1774,48 +1797,150 @@ export class HyperliquidAdapter {
1774
1797
  trades: Number(c.n ?? 0),
1775
1798
  }));
1776
1799
  }
1800
+ /**
1801
+ * Order history: POST /info {type:"historicalOrders"} ("Retrieve a user's historical orders",
1802
+ * at most 2000 most recent; live 2026-10-13: newest first, each order listed once per status —
1803
+ * e.g. "open" then "filled" — with equal statusTimestamp possible). One row per oid with its
1804
+ * final status (latest statusTimestamp; on a tie a non-"open" status wins). Includes canceled /
1805
+ * rejected orders (the previous userFills read showed fills only). Unreadable rows → EXCHANGE_ERROR.
1806
+ * The info endpoint is account-wide; rows are scoped to this adapter (see _inHistoryScope).
1807
+ */
1777
1808
  async getOrderHistory(limit = 30) {
1778
1809
  this.ensureAddress();
1779
- const fills = await this._infoPost({ type: "userFills", user: this._address });
1780
- return (fills ?? []).slice(0, limit).map((f) => ({
1781
- orderId: String(f.oid ?? ""),
1782
- symbol: this._displaySymbol(String(f.coin ?? "")),
1783
- side: String(f.side) === "B" ? "buy" : String(f.side) === "A" ? "sell" : String(f.side).toLowerCase(),
1784
- price: String(f.px ?? "0"),
1785
- size: String(f.sz ?? ""),
1786
- filled: String(f.sz ?? ""),
1787
- status: "filled",
1788
- type: String(f.dir ?? ""),
1789
- triggerPrice: null, // fills carry no trigger price
1790
- }));
1810
+ const rows = await this._infoPost({ type: "historicalOrders", user: this._address });
1811
+ if (!Array.isArray(rows)) {
1812
+ throw new PerpError("EXCHANGE_ERROR", `Hyperliquid historicalOrders returned no list (${JSON.stringify(rows).slice(0, 120)})`, { exchange: "hyperliquid" });
1813
+ }
1814
+ await this._historyScopeReady();
1815
+ const latest = new Map();
1816
+ for (const row of rows) {
1817
+ const o = row.order;
1818
+ const at = Number(row.statusTimestamp);
1819
+ if (o === undefined || o === null || typeof row.status !== "string" || !Number.isFinite(at) || (typeof o.oid !== "number" && typeof o.oid !== "string")) {
1820
+ throw new PerpError("EXCHANGE_ERROR", `Hyperliquid historicalOrders row without order / oid / status / statusTimestamp: ${JSON.stringify(row).slice(0, 160)}`, { exchange: "hyperliquid" });
1821
+ }
1822
+ if (!this._inHistoryScope(hlField(o, "coin", "historicalOrders")))
1823
+ continue;
1824
+ const key = String(o.oid);
1825
+ const prev = latest.get(key);
1826
+ if (prev === undefined || at > prev.at || (at === prev.at && hlStatusRank(row.status) > hlStatusRank(prev.status))) {
1827
+ latest.set(key, { at, status: row.status, o });
1828
+ }
1829
+ }
1830
+ return [...latest.values()].sort((x, y) => y.at - x.at).slice(0, limit).map(({ status, o }) => {
1831
+ const origSz = hlField(o, "origSz", "historicalOrders");
1832
+ const remaining = hlField(o, "sz", "historicalOrders");
1833
+ const triggerPx = Number(o.triggerPx);
1834
+ return {
1835
+ orderId: String(o.oid),
1836
+ symbol: this._displaySymbol(hlField(o, "coin", "historicalOrders")),
1837
+ side: hlSide(o.side, "historicalOrders"),
1838
+ price: hlField(o, "limitPx", "historicalOrders"),
1839
+ size: origSz,
1840
+ filled: subDecimal(origSz, remaining),
1841
+ status,
1842
+ type: hlField(o, "orderType", "historicalOrders"),
1843
+ triggerPrice: o.isTrigger === true && Number.isFinite(triggerPx) && triggerPx > 0 ? String(o.triggerPx) : null,
1844
+ };
1845
+ });
1791
1846
  }
1847
+ /** Fills: POST /info {type:"userFills"} (at most 2000 most recent, newest first — live 2026-10-13). */
1792
1848
  async getTradeHistory(limit = 30) {
1793
1849
  this.ensureAddress();
1794
1850
  const fills = await this._infoPost({ type: "userFills", user: this._address });
1795
- return (fills ?? []).slice(0, limit).map((f) => ({
1796
- time: Number(f.time ?? 0),
1797
- symbol: this._displaySymbol(String(f.coin ?? "")),
1798
- side: String(f.side) === "B" ? "buy" : String(f.side) === "A" ? "sell" : String(f.side).toLowerCase(),
1799
- price: String(f.px ?? "0"),
1800
- size: String(f.sz ?? ""),
1801
- fee: String(f.fee ?? "0"),
1802
- }));
1851
+ if (!Array.isArray(fills)) {
1852
+ throw new PerpError("EXCHANGE_ERROR", `Hyperliquid userFills returned no list (${JSON.stringify(fills).slice(0, 120)})`, { exchange: "hyperliquid" });
1853
+ }
1854
+ await this._historyScopeReady();
1855
+ return fills.filter((f) => this._inHistoryScope(hlField(f, "coin", "userFills"))).slice(0, limit).map((f) => {
1856
+ const time = Number(hlField(f, "time", "userFills"));
1857
+ if (!Number.isFinite(time))
1858
+ throw new PerpError("EXCHANGE_ERROR", "Hyperliquid userFills row with a non-numeric time", { exchange: "hyperliquid" });
1859
+ return {
1860
+ time,
1861
+ symbol: this._displaySymbol(hlField(f, "coin", "userFills")),
1862
+ side: hlSide(f.side, "userFills"),
1863
+ price: hlField(f, "px", "userFills"),
1864
+ size: hlField(f, "sz", "userFills"),
1865
+ fee: hlField(f, "fee", "userFills"),
1866
+ };
1867
+ });
1803
1868
  }
1869
+ /**
1870
+ * Funding payments in this adapter's scope (see _inHistoryScope), newest first, at most `limit`.
1871
+ * Reads the last 24 h first and widens to 7 days only when that holds fewer than `limit` rows
1872
+ * (POST /info userFunding weighs per row returned; bots poll it).
1873
+ */
1804
1874
  async getFundingPayments(limit = 200) {
1805
1875
  this.ensureAddress();
1806
- // Direct API call — SDK's getUserFunding silently truncates results
1807
- const apiUrl = this._testnet ? "https://api.hyperliquid-testnet.xyz/info" : "https://api.hyperliquid.xyz/info";
1808
- const res = await fetch(apiUrl, {
1809
- method: "POST",
1810
- headers: { "Content-Type": "application/json" },
1811
- body: JSON.stringify({ type: "userFunding", user: this._address, startTime: Date.now() - 7 * 24 * 60 * 60 * 1000 }),
1812
- });
1813
- const data = await res.json();
1814
- return (data ?? []).slice(0, limit).map((h) => ({
1815
- time: h.time,
1816
- symbol: h.delta.coin + "-PERP",
1817
- payment: h.delta.usdc,
1818
- }));
1876
+ await this._historyScopeReady();
1877
+ const now = Date.now();
1878
+ let rows = await this._fundingSince(now - 24 * 60 * 60 * 1000);
1879
+ if (rows.length < limit)
1880
+ rows = await this._fundingSince(now - 7 * 24 * 60 * 60 * 1000);
1881
+ return rows.sort((a, b) => b.time - a.time).slice(0, limit);
1882
+ }
1883
+ /**
1884
+ * Every userFunding row since `startTime`. The endpoint returns rows OLDEST first, at most 500
1885
+ * per response (live 2026-10-13), and every coin of a funding hour shares one millisecond — so
1886
+ * pages continue from the last row's time INCLUSIVE and rows are deduped by time + coin (funding
1887
+ * rows carry a zero hash). A full page that adds nothing new, or more than HL_FUNDING_MAX_PAGES
1888
+ * pages → EXCHANGE_ERROR (never a list with payments silently missing).
1889
+ */
1890
+ async _fundingSince(startTime) {
1891
+ const out = [];
1892
+ const seen = new Set();
1893
+ let from = startTime;
1894
+ for (let page = 0;; page++) {
1895
+ if (page >= HL_FUNDING_MAX_PAGES) {
1896
+ throw new PerpError("EXCHANGE_ERROR", `Hyperliquid funding history needs more than ${HL_FUNDING_MAX_PAGES} pages — the result would be incomplete`, { exchange: "hyperliquid" });
1897
+ }
1898
+ const rows = await this._infoPost({ type: "userFunding", user: this._address, startTime: from });
1899
+ if (!Array.isArray(rows)) {
1900
+ throw new PerpError("EXCHANGE_ERROR", `Hyperliquid userFunding returned no list (${JSON.stringify(rows).slice(0, 120)})`, { exchange: "hyperliquid" });
1901
+ }
1902
+ let last = -Infinity;
1903
+ let fresh = 0;
1904
+ for (const h of rows) {
1905
+ const time = Number(h.time);
1906
+ const delta = h.delta;
1907
+ if (!Number.isFinite(time) || delta === undefined || delta === null || typeof delta.coin !== "string" || delta.coin === "" || typeof delta.usdc !== "string" || delta.usdc === "") {
1908
+ throw new PerpError("EXCHANGE_ERROR", `Hyperliquid userFunding row without time / delta.coin / delta.usdc: ${JSON.stringify(h).slice(0, 160)}`, { exchange: "hyperliquid" });
1909
+ }
1910
+ last = Math.max(last, time);
1911
+ const key = `${time}|${delta.coin}`;
1912
+ if (seen.has(key))
1913
+ continue;
1914
+ seen.add(key);
1915
+ fresh++;
1916
+ if (this._inHistoryScope(delta.coin))
1917
+ out.push({ time, symbol: this._displaySymbol(delta.coin), payment: delta.usdc });
1918
+ }
1919
+ if (rows.length < HL_FUNDING_PAGE)
1920
+ break;
1921
+ if (fresh === 0) {
1922
+ throw new PerpError("EXCHANGE_ERROR", `Hyperliquid returned ${HL_FUNDING_PAGE}+ funding rows at one millisecond (${last}) — paging cannot advance`, { exchange: "hyperliquid" });
1923
+ }
1924
+ from = last;
1925
+ }
1926
+ return out;
1927
+ }
1928
+ /** The asset map backs _displaySymbol / _inHistoryScope; load it before mapping history rows. */
1929
+ async _historyScopeReady() {
1930
+ if (this._assetMap.size === 0)
1931
+ await this._loadAssetMap();
1932
+ if (this._assetMapError)
1933
+ throw this._assetMapError;
1934
+ }
1935
+ /**
1936
+ * Whether an account-wide history row (historicalOrders / userFills / userFunding carry every
1937
+ * dex, spot "@n" / "PAIR/USDC" and outcome "#n" coins) belongs to this adapter: a HIP-3 dex
1938
+ * adapter keeps "<dex>:…" coins, the native adapter keeps native perp coins only.
1939
+ */
1940
+ _inHistoryScope(coin) {
1941
+ if (this._dex)
1942
+ return coin.startsWith(`${this._dex}:`);
1943
+ return !/[:@#/]/.test(coin);
1819
1944
  }
1820
1945
  async getFundingHistory(symbol, limit = 10) {
1821
1946
  const now = Date.now();
@@ -154,7 +154,7 @@ export declare function lighterTakerFilledBase(eventInfo: unknown, sentBaseAmoun
154
154
  * A required open-order row field as a string (Rule #2): missing / empty / non-finite numeric →
155
155
  * EXCHANGE_ERROR, never listed as price / size "0" or status "open".
156
156
  */
157
- export declare function lighterRowField(o: Record<string, unknown>, key: string, venue: string): string;
157
+ export declare function lighterRowField(o: Record<string, unknown>, key: string, venue: string, what?: "open order" | "order history"): string;
158
158
  /** What identifies OUR accountOrders row besides the client order index: the market sent to and the send time. */
159
159
  export interface LighterRowMatch {
160
160
  marketIndex: number;