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.
- package/dist/arb/sizing.js +29 -7
- package/dist/bot/conditions.d.ts +5 -1
- package/dist/bot/conditions.js +16 -11
- package/dist/bot/engine.d.ts +12 -0
- package/dist/bot/engine.js +55 -44
- package/dist/bot/strategies/claude-agent.js +2 -2
- package/dist/bot/strategies/funding-arb-strategy.js +22 -26
- package/dist/bot/strategies/funding-arb-utils.d.ts +33 -1
- package/dist/bot/strategies/funding-arb-utils.js +82 -19
- package/dist/bot/strategies/funding-auto.js +61 -37
- package/dist/bot/strategies/grid-strategy.d.ts +2 -0
- package/dist/bot/strategies/grid-strategy.js +21 -2
- package/dist/bot/strategies/spot-perp-arb-strategy.js +48 -29
- package/dist/bot/tui/BotDashboard.d.ts +13 -2
- package/dist/bot/tui/BotDashboard.js +8 -3
- package/dist/bridge-engine.d.ts +2 -0
- package/dist/bridge-engine.js +68 -31
- package/dist/commands/account.js +2 -2
- package/dist/commands/arb/index.d.ts +20 -0
- package/dist/commands/arb/index.js +121 -36
- package/dist/commands/arb-auto.d.ts +36 -0
- package/dist/commands/arb-auto.js +202 -76
- package/dist/commands/arb.js +51 -35
- package/dist/commands/bot.d.ts +6 -0
- package/dist/commands/bot.js +55 -45
- package/dist/commands/funds-onchain.js +6 -5
- package/dist/commands/funds.js +18 -5
- package/dist/commands/history.js +135 -53
- package/dist/commands/market.js +2 -2
- package/dist/commands/portfolio.d.ts +5 -0
- package/dist/commands/portfolio.js +58 -20
- package/dist/commands/rebalance.js +46 -10
- package/dist/commands/settings.js +2 -1
- package/dist/commands/trade.js +8 -4
- package/dist/commands/wallet.js +1 -1
- package/dist/cross-chain-margin.d.ts +29 -2
- package/dist/cross-chain-margin.js +30 -4
- package/dist/dashboard/server.d.ts +45 -8
- package/dist/dashboard/server.js +113 -42
- package/dist/dashboard/ui.js +47 -28
- package/dist/dashboard/ws-feeds.d.ts +25 -3
- package/dist/dashboard/ws-feeds.js +219 -82
- package/dist/dex-asset-map.d.ts +23 -4
- package/dist/dex-asset-map.js +39 -13
- package/dist/exchanges/arcus/adapter.js +33 -6
- package/dist/exchanges/arcus/public.d.ts +6 -0
- package/dist/exchanges/arcus/public.js +12 -4
- package/dist/exchanges/aster.d.ts +24 -7
- package/dist/exchanges/aster.js +178 -93
- package/dist/exchanges/hyperliquid.d.ts +30 -4
- package/dist/exchanges/hyperliquid.js +159 -55
- package/dist/exchanges/interface.d.ts +3 -2
- package/dist/exchanges/interface.js +3 -2
- package/dist/exchanges/lighter.d.ts +1 -1
- package/dist/exchanges/lighter.js +11 -9
- package/dist/exchanges/ondo/funds.d.ts +0 -1
- package/dist/exchanges/pacifica.d.ts +7 -0
- package/dist/exchanges/pacifica.js +52 -26
- package/dist/rebalance.d.ts +20 -1
- package/dist/rebalance.js +31 -5
- package/dist/strategies/funding-arb.js +38 -7
- package/dist/trade-validator.d.ts +1 -1
- package/dist/trade-validator.js +7 -2
- package/package.json +1 -1
- package/skills/perp-cli/SKILL.md +2 -2
- 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
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
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
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
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
|
-
|
|
1807
|
-
const
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}
|
|
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:
|
|
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:
|
|
2105
|
-
size:
|
|
2106
|
-
filled:
|
|
2107
|
-
status:
|
|
2108
|
-
type:
|
|
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
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
return
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
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
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
return
|
|
1403
|
-
time:
|
|
1404
|
-
symbol:
|
|
1405
|
-
payment:
|
|
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
|
}
|
package/dist/rebalance.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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.
|