perp-cli 0.14.0 → 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 (75) 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/cli-program.js +19 -2
  19. package/dist/commands/account.js +2 -2
  20. package/dist/commands/arb/index.d.ts +20 -0
  21. package/dist/commands/arb/index.js +121 -36
  22. package/dist/commands/arb-auto.d.ts +36 -0
  23. package/dist/commands/arb-auto.js +202 -76
  24. package/dist/commands/arb.js +51 -35
  25. package/dist/commands/bot.d.ts +6 -0
  26. package/dist/commands/bot.js +55 -45
  27. package/dist/commands/funds-onchain.d.ts +1 -1
  28. package/dist/commands/funds-onchain.js +12 -49
  29. package/dist/commands/funds.js +51 -52
  30. package/dist/commands/history.js +165 -62
  31. package/dist/commands/portfolio.d.ts +5 -0
  32. package/dist/commands/portfolio.js +58 -20
  33. package/dist/commands/rebalance.js +46 -10
  34. package/dist/commands/settings.js +2 -1
  35. package/dist/commands/trade.js +8 -4
  36. package/dist/commands/wallet.js +74 -155
  37. package/dist/cross-chain-margin.d.ts +29 -2
  38. package/dist/cross-chain-margin.js +30 -4
  39. package/dist/dashboard/server.d.ts +45 -8
  40. package/dist/dashboard/server.js +113 -42
  41. package/dist/dashboard/ui.js +46 -27
  42. package/dist/dashboard/ws-feeds.d.ts +25 -3
  43. package/dist/dashboard/ws-feeds.js +219 -82
  44. package/dist/dex-asset-map.d.ts +23 -4
  45. package/dist/dex-asset-map.js +39 -13
  46. package/dist/exchanges/arcus/adapter.js +33 -6
  47. package/dist/exchanges/arcus/public.d.ts +6 -0
  48. package/dist/exchanges/arcus/public.js +12 -4
  49. package/dist/exchanges/aster.d.ts +33 -0
  50. package/dist/exchanges/aster.js +162 -55
  51. package/dist/exchanges/hyperliquid.d.ts +30 -0
  52. package/dist/exchanges/hyperliquid.js +159 -34
  53. package/dist/exchanges/lighter.d.ts +1 -1
  54. package/dist/exchanges/lighter.js +11 -9
  55. package/dist/exchanges/ondo/adapter.d.ts +4 -18
  56. package/dist/exchanges/ondo/adapter.js +5 -29
  57. package/dist/exchanges/ondo/funds.d.ts +1 -29
  58. package/dist/exchanges/ondo/funds.js +2 -94
  59. package/dist/exchanges/pacifica.d.ts +12 -0
  60. package/dist/exchanges/pacifica.js +78 -35
  61. package/dist/exchanges/qfex/adapter.js +4 -1
  62. package/dist/exchanges/venues/ondo.d.ts +0 -5
  63. package/dist/exchanges/venues/ondo.js +2 -6
  64. package/dist/exchanges/venues/qfex.d.ts +11 -0
  65. package/dist/exchanges/venues/qfex.js +10 -2
  66. package/dist/mcp-server.js +2 -2
  67. package/dist/onchain-balances.d.ts +40 -0
  68. package/dist/onchain-balances.js +173 -0
  69. package/dist/rebalance.d.ts +20 -1
  70. package/dist/rebalance.js +31 -5
  71. package/dist/strategies/funding-arb.js +38 -7
  72. package/package.json +1 -1
  73. package/skills/perp-cli/SKILL.md +20 -3
  74. package/skills/perp-cli/references/agent-operations.md +2 -2
  75. package/skills/perp-cli/references/commands.md +12 -9
@@ -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,6 +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
+ /**
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;
64
71
  export declare class AsterAdapter implements ExchangeAdapter {
65
72
  readonly name = "aster";
66
73
  readonly chain = "bnb";
@@ -207,8 +214,34 @@ export declare class AsterAdapter implements ExchangeAdapter {
207
214
  */
208
215
  invalidateOpenOrdersCache(): void;
209
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;
225
+ /**
226
+ * Symbols with account activity: open positions, open orders, and every symbol with a
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).
230
+ */
231
+ private _tradedApiSymbols;
232
+ /**
233
+ * Recent orders across the symbols with account activity (see _tradedApiSymbols), newest first.
234
+ * A failed read throws (never an empty list); a row without a readable field → EXCHANGE_ERROR.
235
+ */
210
236
  getOrderHistory(limit?: number): Promise<ExchangeOrder[]>;
237
+ /**
238
+ * Recent fills. `GET /fapi/v3/userTrades` requires a symbol (asterdex/api-docs V3), so the
239
+ * symbols come from the open positions plus every symbol with a COMMISSION income row in the
240
+ * venue's default window (`GET /fapi/v3/income`: "the recent 7-day data" when no time is sent).
241
+ * A failed read throws (never an empty list); a row without a readable field → EXCHANGE_ERROR.
242
+ */
211
243
  getTradeHistory(limit?: number): Promise<ExchangeTrade[]>;
244
+ /** FUNDING_FEE income of the last 7 days (see _incomeRows), newest first, at most `limit` rows. */
212
245
  getFundingPayments(limit?: number): Promise<ExchangeFundingPayment[]>;
213
246
  /**
214
247
  * Per-market leverage / margin mode: every row of GET /fapi/v3/positionRisk ("Position
@@ -152,6 +152,13 @@ export function requireAsterClientOrderId(id) {
152
152
  return id;
153
153
  }
154
154
  // ── AsterAdapter ──────────────────────────────────────────────────────────────
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;
155
162
  export class AsterAdapter {
156
163
  name = "aster";
157
164
  chain = "bnb";
@@ -715,79 +722,179 @@ export class AsterAdapter {
715
722
  this._ordersCache = { data: result, time: Date.now(), epoch: accountCacheEpoch() };
716
723
  return result;
717
724
  }
718
- async getOrderHistory(limit = 30) {
719
- const positions = await this.getPositions();
720
- const apiSymbols = new Set(positions.map(p => this._toApi(p.symbol)));
721
- if (apiSymbols.size === 0) {
722
- for (const s of ["BTCUSDT", "ETHUSDT", "SOLUSDT", "BNBUSDT", "XRPUSDT"]) {
723
- apiSymbols.add(s);
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);
724
767
  }
725
768
  }
769
+ return rows;
770
+ }
771
+ /**
772
+ * Symbols with account activity: open positions, open orders, and every symbol with a
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).
776
+ */
777
+ async _tradedApiSymbols(r) {
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
+ }
789
+ const open = await this._signedGetEip712("/fapi/v3/openOrders", {}, r);
790
+ if (!Array.isArray(open)) {
791
+ throw new PerpError("EXCHANGE_ERROR", `Aster open orders is not a list (${JSON.stringify(open).slice(0, 120)})`, { exchange: "aster" });
792
+ }
793
+ for (const o of open) {
794
+ if (typeof o.symbol === "string" && o.symbol !== "")
795
+ apiSymbols.add(o.symbol);
796
+ }
797
+ for (const row of await this._incomeRows("COMMISSION", r)) {
798
+ if (typeof row.symbol === "string" && row.symbol !== "")
799
+ apiSymbols.add(row.symbol);
800
+ }
801
+ return apiSymbols;
802
+ }
803
+ /**
804
+ * Recent orders across the symbols with account activity (see _tradedApiSymbols), newest first.
805
+ * A failed read throws (never an empty list); a row without a readable field → EXCHANGE_ERROR.
806
+ */
807
+ async getOrderHistory(limit = 30) {
726
808
  const r = this._resolveSigner();
727
- const allOrders = [];
809
+ const apiSymbols = await this._tradedApiSymbols(r);
810
+ const field = (o, k) => {
811
+ const v = o[k];
812
+ if ((typeof v === "string" && v !== "") || typeof v === "number")
813
+ return String(v);
814
+ throw new PerpError("EXCHANGE_ERROR", `Aster allOrders row without a readable ${k}: ${JSON.stringify(o).slice(0, 160)}`, { exchange: "aster" });
815
+ };
816
+ const rows = [];
728
817
  for (const sym of apiSymbols) {
729
- try {
730
- const orders = await this._signedGetEip712("/fapi/v3/allOrders", {
731
- symbol: sym,
732
- limit: String(limit),
733
- }, r);
734
- for (const o of orders ?? []) {
735
- allOrders.push({
736
- orderId: String(o.orderId ?? ""),
737
- symbol: this._fromApi(String(o.symbol ?? "")),
738
- side: String(o.side).toLowerCase(),
739
- price: String(o.price ?? "0"),
740
- size: String(o.origQty ?? "0"),
741
- filled: String(o.executedQty ?? "0"),
742
- status: String(o.status ?? ""),
743
- type: String(o.type ?? ""),
744
- triggerPrice: Number(o.stopPrice) > 0 ? String(o.stopPrice) : null,
745
- });
818
+ const orders = await this._signedGetEip712("/fapi/v3/allOrders", { symbol: sym, limit: String(limit) }, r);
819
+ if (!Array.isArray(orders)) {
820
+ throw new PerpError("EXCHANGE_ERROR", `Aster allOrders for ${sym} is not a list (${JSON.stringify(orders).slice(0, 120)})`, { exchange: "aster" });
821
+ }
822
+ for (const o of orders) {
823
+ const side = field(o, "side").toLowerCase();
824
+ if (side !== "buy" && side !== "sell") {
825
+ throw new PerpError("EXCHANGE_ERROR", `Aster allOrders row with unknown side "${side}"`, { exchange: "aster" });
746
826
  }
827
+ const time = Number(field(o, "time"));
828
+ if (!Number.isFinite(time))
829
+ throw new PerpError("EXCHANGE_ERROR", `Aster allOrders row with a non-numeric time`, { exchange: "aster" });
830
+ rows.push({
831
+ time,
832
+ order: {
833
+ orderId: field(o, "orderId"),
834
+ symbol: this._fromApi(field(o, "symbol")),
835
+ side,
836
+ price: field(o, "price"),
837
+ size: field(o, "origQty"),
838
+ filled: field(o, "executedQty"),
839
+ status: field(o, "status"),
840
+ type: field(o, "type"),
841
+ triggerPrice: Number(o.stopPrice) > 0 ? String(o.stopPrice) : null,
842
+ },
843
+ });
747
844
  }
748
- catch { /* skip */ }
749
845
  }
750
- return allOrders.slice(0, limit);
846
+ return rows.sort((x, y) => y.time - x.time).slice(0, limit).map(x => x.order);
751
847
  }
848
+ /**
849
+ * Recent fills. `GET /fapi/v3/userTrades` requires a symbol (asterdex/api-docs V3), so the
850
+ * symbols come from the open positions plus every symbol with a COMMISSION income row in the
851
+ * venue's default window (`GET /fapi/v3/income`: "the recent 7-day data" when no time is sent).
852
+ * A failed read throws (never an empty list); a row without a readable field → EXCHANGE_ERROR.
853
+ */
752
854
  async getTradeHistory(limit = 30) {
753
- const positions = await this.getPositions();
754
- const apiSymbols = new Set(positions.map(p => this._toApi(p.symbol)));
755
- if (apiSymbols.size === 0)
756
- apiSymbols.add("BTCUSDT");
757
855
  const r = this._resolveSigner();
856
+ const apiSymbols = await this._tradedApiSymbols(r);
857
+ const field = (t, k) => {
858
+ const v = t[k];
859
+ if ((typeof v === "string" && v !== "") || typeof v === "number")
860
+ return String(v);
861
+ throw new PerpError("EXCHANGE_ERROR", `Aster userTrades row without a readable ${k}: ${JSON.stringify(t).slice(0, 160)}`, { exchange: "aster" });
862
+ };
758
863
  const allTrades = [];
759
864
  for (const sym of apiSymbols) {
760
- try {
761
- const trades = await this._signedGetEip712("/fapi/v3/userTrades", {
762
- symbol: sym,
763
- limit: String(limit),
764
- }, r);
765
- for (const t of trades ?? []) {
766
- allTrades.push({
767
- time: Number(t.time ?? 0),
768
- symbol: this._fromApi(String(t.symbol ?? "")),
769
- side: t.buyer ? "buy" : "sell",
770
- price: String(t.price ?? "0"),
771
- size: String(t.qty ?? "0"),
772
- fee: String(t.commission ?? "0"),
773
- });
865
+ const trades = await this._signedGetEip712("/fapi/v3/userTrades", { symbol: sym, limit: String(limit) }, r);
866
+ if (!Array.isArray(trades)) {
867
+ throw new PerpError("EXCHANGE_ERROR", `Aster userTrades for ${sym} is not a list (${JSON.stringify(trades).slice(0, 120)})`, { exchange: "aster" });
868
+ }
869
+ for (const t of trades) {
870
+ if (typeof t.buyer !== "boolean") {
871
+ throw new PerpError("EXCHANGE_ERROR", `Aster userTrades row without a boolean buyer: ${JSON.stringify(t).slice(0, 160)}`, { exchange: "aster" });
774
872
  }
873
+ const time = Number(field(t, "time"));
874
+ if (!Number.isFinite(time))
875
+ throw new PerpError("EXCHANGE_ERROR", `Aster userTrades row with a non-numeric time`, { exchange: "aster" });
876
+ allTrades.push({
877
+ time,
878
+ symbol: this._fromApi(field(t, "symbol")),
879
+ side: t.buyer ? "buy" : "sell",
880
+ price: field(t, "price"),
881
+ size: field(t, "qty"),
882
+ fee: field(t, "commission"),
883
+ });
775
884
  }
776
- catch { /* skip */ }
777
885
  }
778
886
  return allTrades.sort((a, b) => b.time - a.time).slice(0, limit);
779
887
  }
888
+ /** FUNDING_FEE income of the last 7 days (see _incomeRows), newest first, at most `limit` rows. */
780
889
  async getFundingPayments(limit = 200) {
781
- const r = this._resolveSigner();
782
- const data = await this._signedGetEip712("/fapi/v3/income", {
783
- incomeType: "FUNDING_FEE",
784
- limit: String(limit),
785
- }, r);
786
- return (data ?? []).map((f) => ({
787
- time: Number(f.time ?? 0),
788
- symbol: this._fromApi(String(f.symbol ?? "")),
789
- payment: String(f.income ?? "0"),
790
- }));
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);
791
898
  }
792
899
  /**
793
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;