perp-cli 0.14.1 → 0.14.3

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 (66) 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 +22 -26
  8. package/dist/bot/strategies/funding-arb-utils.d.ts +33 -1
  9. package/dist/bot/strategies/funding-arb-utils.js +82 -19
  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 +48 -29
  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/market.js +2 -2
  30. package/dist/commands/portfolio.d.ts +5 -0
  31. package/dist/commands/portfolio.js +58 -20
  32. package/dist/commands/rebalance.js +46 -10
  33. package/dist/commands/settings.js +2 -1
  34. package/dist/commands/trade.js +8 -4
  35. package/dist/commands/wallet.js +1 -1
  36. package/dist/cross-chain-margin.d.ts +29 -2
  37. package/dist/cross-chain-margin.js +30 -4
  38. package/dist/dashboard/server.d.ts +45 -8
  39. package/dist/dashboard/server.js +113 -42
  40. package/dist/dashboard/ui.js +47 -28
  41. package/dist/dashboard/ws-feeds.d.ts +25 -3
  42. package/dist/dashboard/ws-feeds.js +219 -82
  43. package/dist/dex-asset-map.d.ts +23 -4
  44. package/dist/dex-asset-map.js +39 -13
  45. package/dist/exchanges/arcus/adapter.js +33 -6
  46. package/dist/exchanges/arcus/public.d.ts +6 -0
  47. package/dist/exchanges/arcus/public.js +12 -4
  48. package/dist/exchanges/aster.d.ts +24 -7
  49. package/dist/exchanges/aster.js +178 -93
  50. package/dist/exchanges/hyperliquid.d.ts +30 -4
  51. package/dist/exchanges/hyperliquid.js +159 -55
  52. package/dist/exchanges/interface.d.ts +3 -2
  53. package/dist/exchanges/interface.js +3 -2
  54. package/dist/exchanges/lighter.d.ts +1 -1
  55. package/dist/exchanges/lighter.js +11 -9
  56. package/dist/exchanges/ondo/funds.d.ts +0 -1
  57. package/dist/exchanges/pacifica.d.ts +7 -0
  58. package/dist/exchanges/pacifica.js +52 -26
  59. package/dist/rebalance.d.ts +20 -1
  60. package/dist/rebalance.js +31 -5
  61. package/dist/strategies/funding-arb.js +38 -7
  62. package/dist/trade-validator.d.ts +1 -1
  63. package/dist/trade-validator.js +7 -2
  64. package/package.json +1 -1
  65. package/skills/perp-cli/SKILL.md +2 -2
  66. package/skills/perp-cli/references/commands.md +5 -3
@@ -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();
@@ -2260,27 +2385,6 @@ export class HyperliquidAdapter {
2260
2385
  }
2261
2386
  return res;
2262
2387
  }
2263
- /**
2264
- * Get funding history for a symbol.
2265
- */
2266
- async getFundingHistoryRaw(symbol, startTime, endTime) {
2267
- const baseUrl = this._testnet
2268
- ? "https://api.hyperliquid-testnet.xyz"
2269
- : "https://api.hyperliquid.xyz";
2270
- const body = {
2271
- type: "fundingHistory",
2272
- coin: symbol.toUpperCase(),
2273
- startTime,
2274
- };
2275
- if (endTime)
2276
- body.endTime = endTime;
2277
- const res = await fetch(`${baseUrl}/info`, {
2278
- method: "POST",
2279
- headers: { "Content-Type": "application/json" },
2280
- body: JSON.stringify(body),
2281
- });
2282
- return res.json();
2283
- }
2284
2388
  /**
2285
2389
  * Get user trade fills.
2286
2390
  */
@@ -13,7 +13,8 @@ export interface ExchangeMarketInfo {
13
13
  volume24h: string | null;
14
14
  /** null = venue does not publish open interest (never "0" as a stand-in) */
15
15
  openInterest: string | null;
16
- maxLeverage: number;
16
+ /** null = the venue does not publish it (Aster: the public exchangeInfo carries no leverage) */
17
+ maxLeverage: number | null;
17
18
  /** quantity precision (e.g. 0 = integers only, 2 = 0.01); null = unknown */
18
19
  sizeDecimals: number | null;
19
20
  /** lot step size (e.g. "1", "0.1", "0.001"); null = unknown */
@@ -38,7 +39,7 @@ export interface ExchangeMarketInfo {
38
39
  * A venue that publishes no tiers (`marginTiers === null`) has one flat cap,
39
40
  * `maxLeverage`, at every size — that is the venue's definition, not a fallback.
40
41
  */
41
- export declare function maxLeverageForNotional(market: ExchangeMarketInfo, notional: number): number;
42
+ export declare function maxLeverageForNotional(market: ExchangeMarketInfo, notional: number): number | null;
42
43
  export interface ExchangePosition {
43
44
  symbol: string;
44
45
  side: "long" | "short";
@@ -10,8 +10,9 @@ export function maxLeverageForNotional(market, notional) {
10
10
  }
11
11
  const tiers = market.marginTiers;
12
12
  if (tiers === null || tiers.length === 0)
13
- return market.maxLeverage;
14
- let effective = market.maxLeverage;
13
+ return market.maxLeverage; // null = not published
14
+ // No headline but published tiers: the first (lowest-notional) tier is the cap below every threshold.
15
+ let effective = market.maxLeverage ?? tiers[0].maxLeverage;
15
16
  for (const t of tiers) {
16
17
  if (notional >= t.lowerBound)
17
18
  effective = t.maxLeverage;
@@ -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;
@@ -250,12 +250,12 @@ export function lighterTakerFilledBase(eventInfo, sentBaseAmount) {
250
250
  * A required open-order row field as a string (Rule #2): missing / empty / non-finite numeric →
251
251
  * EXCHANGE_ERROR, never listed as price / size "0" or status "open".
252
252
  */
253
- export function lighterRowField(o, key, venue) {
253
+ export function lighterRowField(o, key, venue, what = "open order") {
254
254
  const v = o[key];
255
255
  if ((typeof v === "string" && v.trim() !== "") || (typeof v === "number" && Number.isFinite(v)))
256
256
  return String(v);
257
- throw new PerpError("EXCHANGE_ERROR", `${venue} open order ${lighterOrderIdOf(o) || "(no id)"}: row has no ${key} (got ${JSON.stringify(v ?? null)})`, {
258
- exchange: venue, remediation: `Retry: perp -e ${venue} account orders`,
257
+ throw new PerpError("EXCHANGE_ERROR", `${venue} ${what} ${lighterOrderIdOf(o) || "(no id)"}: row has no ${key} (got ${JSON.stringify(v ?? null)})`, {
258
+ exchange: venue, remediation: `Retry: perp -e ${venue} ${what === "open order" ? "account orders" : "account history"}`,
259
259
  });
260
260
  }
261
261
  /** `iocOutcomeOf` parser shared by LighterAdapter and LighterSpotAdapter. */
@@ -1438,7 +1438,7 @@ export class LighterAdapter {
1438
1438
  size: lighterRowField(o, "initial_base_amount", this.name),
1439
1439
  filled: lighterRowField(o, "filled_base_amount", this.name),
1440
1440
  status: lighterRowField(o, "status", this.name),
1441
- type: String(o.type ?? "limit"),
1441
+ type: lighterRowField(o, "type", this.name),
1442
1442
  triggerPrice: Number(o.trigger_price) > 0 ? String(o.trigger_price) : null,
1443
1443
  };
1444
1444
  });
@@ -2097,15 +2097,17 @@ export class LighterAdapter {
2097
2097
  if (!this._address)
2098
2098
  throw new Error("No private key configured — account data unavailable. Run: perp setup");
2099
2099
  const orders = await this.historyRows("/accountInactiveOrders", "orders", { market_type: "perp" }, limit, true);
2100
+ // Every field is required: a missing price / size / status / type is an unreadable row, never "0" / "done" / "limit".
2101
+ const req = (o, k) => lighterRowField(o, k, this.name, "order history");
2100
2102
  return orders.map((o) => ({
2101
2103
  orderId: lighterOrderIdOf(o),
2102
2104
  symbol: this.historySymbol(o.market_index, "order history", o),
2103
2105
  side: this.sideOfIsAsk(o.is_ask, "order history", o),
2104
- price: String(o.price ?? "0"),
2105
- size: String(o.initial_base_amount ?? o.base_amount ?? ""),
2106
- filled: String(o.filled_base_amount ?? "0"),
2107
- status: String(o.status ?? "done"),
2108
- type: String(o.type ?? "limit"),
2106
+ price: req(o, "price"),
2107
+ size: req(o, "initial_base_amount"),
2108
+ filled: req(o, "filled_base_amount"),
2109
+ status: req(o, "status"),
2110
+ type: req(o, "type"),
2109
2111
  triggerPrice: Number(o.trigger_price) > 0 ? String(o.trigger_price) : null,
2110
2112
  }));
2111
2113
  }
@@ -21,7 +21,6 @@ import { type OndoDepositChain } from "../venues/ondo.js";
21
21
  export interface OndoFundsClient {
22
22
  get<T>(path: string, query: null): Promise<T>;
23
23
  post<T>(path: string, body: unknown): Promise<T>;
24
- postNoResult(path: string, body: unknown): Promise<void>;
25
24
  }
26
25
  export declare const ONDO_DEPOSIT_CREDIT_TIMEOUT_MS = 600000;
27
26
  export declare const ONDO_DEPOSIT_CREDIT_POLL_MS = 10000;
@@ -381,6 +381,13 @@ export declare class PacificaAdapter implements ExchangeAdapter {
381
381
  * readable field → EXCHANGE_ERROR, never an empty list or a placeholder value.
382
382
  */
383
383
  getOrderHistory(limit?: number): Promise<ExchangeOrder[]>;
384
+ /**
385
+ * Recent fills, newest first (GET /trades/history; the client unwraps `{success, data}`).
386
+ * `side` is the position effect — open_long / close_short buy, open_short / close_long sell
387
+ * (live 2026-10-13: `"side":"close_short"` on a buy-to-close fill); bid / ask are accepted for
388
+ * rows that carry an order side. Anything else, a non-list or an unreadable field → EXCHANGE_ERROR.
389
+ */
384
390
  getTradeHistory(limit?: number): Promise<ExchangeTrade[]>;
391
+ /** Funding paid / received per position (GET /funding/history, `payout` signed). Strict like getTradeHistory. */
385
392
  getFundingPayments(limit?: number): Promise<ExchangeFundingPayment[]>;
386
393
  }
@@ -332,6 +332,23 @@ function spotInfoSymbols(rows) {
332
332
  export function isPacificaStopType(type) {
333
333
  return /^(stop|take_profit|stop_loss)_/.test(type);
334
334
  }
335
+ /** Fill sides of GET /trades/history (position effect) plus the order sides bid / ask. */
336
+ const PACIFICA_FILL_BUY_SIDES = new Set(["open_long", "close_short", "bid"]);
337
+ const PACIFICA_FILL_SELL_SIDES = new Set(["open_short", "close_long", "ask"]);
338
+ /** `created_at` (ms) of a Pacifica history row; non-numeric → EXCHANGE_ERROR (never NaN). */
339
+ function pacificaTime(row, endpoint) {
340
+ const t = Number(pacificaField(row, "created_at", endpoint));
341
+ if (!Number.isFinite(t))
342
+ throw new PerpError("EXCHANGE_ERROR", `Pacifica ${endpoint} row with a non-numeric created_at`, { exchange: "pacifica" });
343
+ return t;
344
+ }
345
+ /** A required field of a Pacifica history row as a string; missing / empty → EXCHANGE_ERROR. */
346
+ function pacificaField(row, key, endpoint) {
347
+ const v = row[key];
348
+ if ((typeof v === "string" && v !== "") || (typeof v === "number" && Number.isFinite(v)))
349
+ return String(v);
350
+ throw new PerpError("EXCHANGE_ERROR", `Pacifica ${endpoint} row without a readable ${key}: ${JSON.stringify(row).slice(0, 160)}`, { exchange: "pacifica" });
351
+ }
335
352
  export class PacificaAdapter {
336
353
  name = "pacifica";
337
354
  chain = "solana";
@@ -1352,12 +1369,7 @@ export class PacificaAdapter {
1352
1369
  if (!Array.isArray(rows)) {
1353
1370
  throw new PerpError("EXCHANGE_ERROR", `Pacifica /orders/history returned no order list (${JSON.stringify(rows).slice(0, 120)})`, { exchange: "pacifica" });
1354
1371
  }
1355
- const field = (o, k) => {
1356
- const v = o[k];
1357
- if ((typeof v === "string" && v !== "") || typeof v === "number")
1358
- return String(v);
1359
- throw new PerpError("EXCHANGE_ERROR", `Pacifica order-history row without a readable ${k}: ${JSON.stringify(o).slice(0, 160)}`, { exchange: "pacifica" });
1360
- };
1372
+ const field = (o, k) => pacificaField(o, k, "/orders/history");
1361
1373
  return rows.slice(0, limit).map((o) => {
1362
1374
  const side = field(o, "side");
1363
1375
  if (side !== "bid" && side !== "ask") {
@@ -1376,33 +1388,47 @@ export class PacificaAdapter {
1376
1388
  };
1377
1389
  });
1378
1390
  }
1391
+ /**
1392
+ * Recent fills, newest first (GET /trades/history; the client unwraps `{success, data}`).
1393
+ * `side` is the position effect — open_long / close_short buy, open_short / close_long sell
1394
+ * (live 2026-10-13: `"side":"close_short"` on a buy-to-close fill); bid / ask are accepted for
1395
+ * rows that carry an order side. Anything else, a non-list or an unreadable field → EXCHANGE_ERROR.
1396
+ */
1379
1397
  async getTradeHistory(limit = 30) {
1380
1398
  if (!this._hasRealKey)
1381
1399
  throw new Error("No private key configured — account data unavailable. Run: perp setup");
1382
- const raw = await this.client.getTradeHistory(this.account);
1383
- const trades = (raw.data ?? raw);
1384
- if (!Array.isArray(trades))
1385
- return [];
1386
- return trades.slice(0, limit).map((t) => ({
1387
- time: Number(t.created_at ?? 0),
1388
- symbol: String(t.symbol ?? ""),
1389
- side: String(t.side) === "bid" ? "buy" : "sell",
1390
- price: String(t.price ?? "0"),
1391
- size: String(t.amount ?? ""),
1392
- fee: String(t.fee ?? "0"),
1393
- }));
1400
+ const rows = await this.client.getTradeHistory(this.account, { limit });
1401
+ if (!Array.isArray(rows)) {
1402
+ throw new PerpError("EXCHANGE_ERROR", `Pacifica /trades/history returned no list (${JSON.stringify(rows).slice(0, 120)})`, { exchange: "pacifica" });
1403
+ }
1404
+ return rows.slice(0, limit).map((t) => {
1405
+ const side = pacificaField(t, "side", "/trades/history");
1406
+ const buy = PACIFICA_FILL_BUY_SIDES.has(side);
1407
+ if (!buy && !PACIFICA_FILL_SELL_SIDES.has(side)) {
1408
+ throw new PerpError("EXCHANGE_ERROR", `Pacifica /trades/history row with unknown side "${side}"`, { exchange: "pacifica" });
1409
+ }
1410
+ return {
1411
+ time: pacificaTime(t, "/trades/history"),
1412
+ symbol: pacificaField(t, "symbol", "/trades/history"),
1413
+ side: buy ? "buy" : "sell",
1414
+ price: pacificaField(t, "price", "/trades/history"),
1415
+ size: pacificaField(t, "amount", "/trades/history"),
1416
+ fee: pacificaField(t, "fee", "/trades/history"),
1417
+ };
1418
+ });
1394
1419
  }
1420
+ /** Funding paid / received per position (GET /funding/history, `payout` signed). Strict like getTradeHistory. */
1395
1421
  async getFundingPayments(limit = 200) {
1396
1422
  if (!this._hasRealKey)
1397
1423
  throw new Error("No private key configured — account data unavailable. Run: perp setup");
1398
- const raw = await this.client.getFundingAccountHistory(this.account);
1399
- const history = (raw.data ?? raw);
1400
- if (!Array.isArray(history))
1401
- return [];
1402
- return history.slice(0, limit).map((h) => ({
1403
- time: Number(h.created_at ?? 0),
1404
- symbol: String(h.symbol ?? ""),
1405
- payment: String(h.payout ?? "0"),
1424
+ const rows = await this.client.getFundingAccountHistory(this.account, { limit });
1425
+ if (!Array.isArray(rows)) {
1426
+ throw new PerpError("EXCHANGE_ERROR", `Pacifica /funding/history returned no list (${JSON.stringify(rows).slice(0, 120)})`, { exchange: "pacifica" });
1427
+ }
1428
+ return rows.slice(0, limit).map((h) => ({
1429
+ time: pacificaTime(h, "/funding/history"),
1430
+ symbol: pacificaField(h, "symbol", "/funding/history"),
1431
+ payment: pacificaField(h, "payout", "/funding/history"),
1406
1432
  }));
1407
1433
  }
1408
1434
  }
@@ -28,10 +28,29 @@ export interface RebalanceMove {
28
28
  amount: number;
29
29
  reason: string;
30
30
  }
31
+ /** A venue whose balance could not be read (left out of the snapshots). */
32
+ export interface BalanceReadFailure {
33
+ exchange: string;
34
+ error: string;
35
+ }
31
36
  /**
32
- * Fetch balances from all exchanges in parallel.
37
+ * Fetch balances from all exchanges in parallel. Venues whose read failed are warned on
38
+ * stderr and left out; callers that must know which (plan / execute) use fetchBalancesWithFailures.
33
39
  */
34
40
  export declare function fetchAllBalances(adapters: Map<string, ExchangeAdapter>): Promise<ExchangeBalanceSnapshot[]>;
41
+ /**
42
+ * Fetch balances from all exchanges in parallel; a venue whose read failed (error, empty or
43
+ * non-finite field) is returned in `failed` — never dropped silently, never counted as $0.
44
+ * A read-only adapter (no credentials: `isReadOnly`) cannot hold funds the CLI trades, so it
45
+ * is not read and is returned in `notConfigured` — not a failure.
46
+ */
47
+ export declare function fetchBalancesWithFailures(adapters: Map<string, ExchangeAdapter>): Promise<{
48
+ snapshots: ExchangeBalanceSnapshot[];
49
+ failed: BalanceReadFailure[];
50
+ notConfigured: string[];
51
+ }>;
52
+ /** An adapter built without credentials (read-only market data) — `isReadOnly` on the venue adapters. */
53
+ export declare function isReadOnlyAdapter(a: ExchangeAdapter): boolean;
35
54
  /**
36
55
  * Compute a rebalancing plan to equalize available balance across exchanges.
37
56
  *
package/dist/rebalance.js CHANGED
@@ -20,10 +20,26 @@ import { EXCHANGE_TO_CHAIN } from "./bridge-engine.js";
20
20
  // Re-export for backward compat
21
21
  const EXCHANGE_CHAINS = Object.fromEntries(Object.entries(EXCHANGE_TO_CHAIN).map(([ex, chain]) => [ex, { chain }]));
22
22
  /**
23
- * Fetch balances from all exchanges in parallel.
23
+ * Fetch balances from all exchanges in parallel. Venues whose read failed are warned on
24
+ * stderr and left out; callers that must know which (plan / execute) use fetchBalancesWithFailures.
24
25
  */
25
26
  export async function fetchAllBalances(adapters) {
26
- const entries = [...adapters.entries()];
27
+ const { snapshots, failed, notConfigured } = await fetchBalancesWithFailures(adapters);
28
+ for (const f of failed)
29
+ process.stderr.write(`[warn] ${f.exchange} balance unavailable (left out): ${f.error}\n`);
30
+ for (const n of notConfigured)
31
+ process.stderr.write(`[info] ${n} not configured (no credentials) — left out\n`);
32
+ return snapshots;
33
+ }
34
+ /**
35
+ * Fetch balances from all exchanges in parallel; a venue whose read failed (error, empty or
36
+ * non-finite field) is returned in `failed` — never dropped silently, never counted as $0.
37
+ * A read-only adapter (no credentials: `isReadOnly`) cannot hold funds the CLI trades, so it
38
+ * is not read and is returned in `notConfigured` — not a failure.
39
+ */
40
+ export async function fetchBalancesWithFailures(adapters) {
41
+ const notConfigured = [...adapters.entries()].filter(([, a]) => isReadOnlyAdapter(a)).map(([n]) => n);
42
+ const entries = [...adapters.entries()].filter(([, a]) => !isReadOnlyAdapter(a));
27
43
  const results = await Promise.allSettled(entries.map(async ([name, adapter]) => {
28
44
  const bal = await adapter.getBalance();
29
45
  // Strict policy (qa/2026-05-16): empty string is corruption, not
@@ -57,9 +73,19 @@ export async function fetchAllBalances(adapters) {
57
73
  unrealizedPnl,
58
74
  };
59
75
  }));
60
- return results
61
- .filter((r) => r.status === "fulfilled")
62
- .map((r) => r.value);
76
+ const snapshots = [];
77
+ const failed = [];
78
+ results.forEach((r, i) => {
79
+ if (r.status === "fulfilled")
80
+ snapshots.push(r.value);
81
+ else
82
+ failed.push({ exchange: entries[i][0], error: r.reason instanceof Error ? r.reason.message : String(r.reason) });
83
+ });
84
+ return { snapshots, failed, notConfigured };
85
+ }
86
+ /** An adapter built without credentials (read-only market data) — `isReadOnly` on the venue adapters. */
87
+ export function isReadOnlyAdapter(a) {
88
+ return a.isReadOnly === true;
63
89
  }
64
90
  /**
65
91
  * Compute a rebalancing plan to equalize available balance across exchanges.