perp-cli 0.14.1 → 0.14.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +16 -20
- package/dist/bot/strategies/funding-arb-utils.d.ts +19 -0
- package/dist/bot/strategies/funding-arb-utils.js +44 -0
- 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 +28 -20
- 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/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 +46 -27
- 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 +19 -7
- package/dist/exchanges/aster.js +79 -46
- package/dist/exchanges/hyperliquid.d.ts +30 -0
- package/dist/exchanges/hyperliquid.js +159 -34
- 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/package.json +1 -1
- package/skills/perp-cli/SKILL.md +2 -2
- package/skills/perp-cli/references/commands.md +5 -3
|
@@ -30,7 +30,7 @@ import { networkConfig, requireCapabilities } from "../venue.js";
|
|
|
30
30
|
import { parseOrderRef, toLegacyMarketInfo, } from "../venue-types.js";
|
|
31
31
|
import { ArcusClient, arcusLeverageRejectError, arcusRejectionError } from "./client.js";
|
|
32
32
|
import { ed25519PubHexFromBase58 } from "./agent.js";
|
|
33
|
-
import { fetchArcusRawMarkets, toArcusVenueMarket } from "./public.js";
|
|
33
|
+
import { fetchArcusRawMarkets, onlineArcusMarkets, toArcusVenueMarket } from "./public.js";
|
|
34
34
|
import { QUOTE_QUANTUM, assertArcusClientId, canonicalJson, cancelPayload, modifyPayload, nowNs, placePayload, signUtf8Hex, splitEvmSignature, withdrawTypedData, } from "./signing.js";
|
|
35
35
|
/**
|
|
36
36
|
* goodTilTime horizon. Arcus rejects anything nearer than one month at
|
|
@@ -49,6 +49,18 @@ const CLIENT_ID_HISTORY_SCAN = 200;
|
|
|
49
49
|
/** GET /v1/openOrders page cap (default = max = 1000). */
|
|
50
50
|
const OPEN_ORDERS_PAGE = 1000;
|
|
51
51
|
const CANDLE_TIMEFRAMES = new Set(["1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "8h", "12h", "1d", "3d", "1w"]);
|
|
52
|
+
/** Bar length (ms) per timeframe — sizes the GET /v1/candles windows. */
|
|
53
|
+
const CANDLE_MS = {
|
|
54
|
+
"1m": 60_000, "3m": 180_000, "5m": 300_000, "15m": 900_000, "30m": 1_800_000, "1h": 3_600_000, "2h": 7_200_000,
|
|
55
|
+
"4h": 14_400_000, "8h": 28_800_000, "12h": 43_200_000, "1d": 86_400_000, "3d": 259_200_000, "1w": 604_800_000,
|
|
56
|
+
};
|
|
57
|
+
/**
|
|
58
|
+
* GET /v1/candles returns at most 1500 candles (newest first; live 2026-10-13: a 48 h 1m request
|
|
59
|
+
* answers `total: 1500` covering the newest ~25 h). Ranges are read in windows of this many bars.
|
|
60
|
+
*/
|
|
61
|
+
const CANDLE_WINDOW_BARS = 1450;
|
|
62
|
+
/** Window cap per call (≈ 72 500 bars); a wider range is refused rather than truncated. */
|
|
63
|
+
const CANDLE_MAX_WINDOWS = 50;
|
|
52
64
|
// ── Helpers ──
|
|
53
65
|
const STATUS_MAP = {
|
|
54
66
|
ACK: "accepted",
|
|
@@ -447,7 +459,7 @@ export class ArcusAdapter {
|
|
|
447
459
|
// ── TradingV2 ──
|
|
448
460
|
async getVenueMarkets() {
|
|
449
461
|
const raw = await fetchArcusRawMarkets(this.client);
|
|
450
|
-
return raw.map(toArcusVenueMarket);
|
|
462
|
+
return onlineArcusMarkets(raw).map(toArcusVenueMarket);
|
|
451
463
|
}
|
|
452
464
|
async placeOrder(input) {
|
|
453
465
|
// §13.3: GTC is the uniform default for a limit order without a TIF.
|
|
@@ -888,10 +900,25 @@ export class ArcusAdapter {
|
|
|
888
900
|
});
|
|
889
901
|
}
|
|
890
902
|
const m = await this.market(symbol);
|
|
891
|
-
const
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
903
|
+
const span = CANDLE_WINDOW_BARS * CANDLE_MS[interval];
|
|
904
|
+
const windows = Math.max(1, Math.ceil((endTime - startTime) / span));
|
|
905
|
+
if (windows > CANDLE_MAX_WINDOWS) {
|
|
906
|
+
throw new PerpError("INVALID_PARAMS", `Arcus: ${interval} candles over this range need ${windows} requests (max ${CANDLE_MAX_WINDOWS})`, {
|
|
907
|
+
remediation: "Narrow --start / --end or use a longer interval",
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
// Disjoint windows [from, to) — the venue caps one response at 1500 candles, so one wide
|
|
911
|
+
// request would silently drop the oldest bars. Merged and deduped by open time.
|
|
912
|
+
const byTime = new Map();
|
|
913
|
+
for (let from = startTime; from < endTime; from += span) {
|
|
914
|
+
const to = Math.min(from + span, endTime);
|
|
915
|
+
const res = await this.client.get("/v1/candles", "candles", { market: m.marketDisplayName, timeframe: interval, from: from * 1000, to: to * 1000 });
|
|
916
|
+
for (const c of res.candles) {
|
|
917
|
+
const time = usToMs(c.openTime);
|
|
918
|
+
byTime.set(time, { time, open: c.open, high: c.high, low: c.low, close: c.close, volume: c.volume, trades: c.tradeCount });
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
return [...byTime.values()].sort((x, y) => x.time - y.time);
|
|
895
922
|
}
|
|
896
923
|
// ── Legacy: account ──
|
|
897
924
|
async rawPositions() {
|
|
@@ -62,4 +62,10 @@ export interface ArcusRawMarket {
|
|
|
62
62
|
export declare function fetchArcusRawMarkets(client: ArcusClient): Promise<ArcusRawMarket[]>;
|
|
63
63
|
export declare function arcusAssetClass(m: ArcusRawMarket): AssetClass;
|
|
64
64
|
export declare function toArcusVenueMarket(m: ArcusRawMarket): VenueMarket;
|
|
65
|
+
/**
|
|
66
|
+
* Markets that are not ONLINE carry placeholder values (live 2026-10-13: the OFFLINE markets
|
|
67
|
+
* F, BAC, CCL, VT, SGOV, RVI report markPrice "0" and fundingRate "0") — they are left out of
|
|
68
|
+
* market / funding lists rather than listed at a $0 price and 0 % funding.
|
|
69
|
+
*/
|
|
70
|
+
export declare function onlineArcusMarkets(raw: ArcusRawMarket[]): ArcusRawMarket[];
|
|
65
71
|
export declare const arcusPublicData: VenuePublicData;
|
|
@@ -76,8 +76,8 @@ function sessionOf(m) {
|
|
|
76
76
|
export function toArcusVenueMarket(m) {
|
|
77
77
|
const assetClass = arcusAssetClass(m);
|
|
78
78
|
const mark = numOrNull(m.markPrice);
|
|
79
|
-
if (mark === null) {
|
|
80
|
-
throw new PerpError("EXCHANGE_ERROR", `Arcus market ${m.marketDisplayName}: missing markPrice`);
|
|
79
|
+
if (mark === null || !(Number(mark) > 0)) {
|
|
80
|
+
throw new PerpError("EXCHANGE_ERROR", `Arcus market ${m.marketDisplayName}: missing / non-positive markPrice (${JSON.stringify(m.markPrice)})`);
|
|
81
81
|
}
|
|
82
82
|
return {
|
|
83
83
|
venue: "arcus",
|
|
@@ -104,15 +104,23 @@ export function toArcusVenueMarket(m) {
|
|
|
104
104
|
tradable: m.status === "ONLINE",
|
|
105
105
|
};
|
|
106
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* Markets that are not ONLINE carry placeholder values (live 2026-10-13: the OFFLINE markets
|
|
109
|
+
* F, BAC, CCL, VT, SGOV, RVI report markPrice "0" and fundingRate "0") — they are left out of
|
|
110
|
+
* market / funding lists rather than listed at a $0 price and 0 % funding.
|
|
111
|
+
*/
|
|
112
|
+
export function onlineArcusMarkets(raw) {
|
|
113
|
+
return raw.filter(m => m.status === "ONLINE");
|
|
114
|
+
}
|
|
107
115
|
async function fetchMarkets(net) {
|
|
108
116
|
const raw = await fetchArcusRawMarkets(new ArcusClient(net, null));
|
|
109
|
-
return raw.map(toArcusVenueMarket);
|
|
117
|
+
return onlineArcusMarkets(raw).map(toArcusVenueMarket);
|
|
110
118
|
}
|
|
111
119
|
async function fetchFundingRates(net) {
|
|
112
120
|
const raw = await fetchArcusRawMarkets(new ArcusClient(net, null));
|
|
113
121
|
const out = [];
|
|
114
122
|
const missing = [];
|
|
115
|
-
for (const m of raw) {
|
|
123
|
+
for (const m of onlineArcusMarkets(raw)) {
|
|
116
124
|
const rate = numOrNull(m.fundingRate);
|
|
117
125
|
if (rate === null) {
|
|
118
126
|
missing.push(m.marketDisplayName);
|
|
@@ -61,8 +61,13 @@ type ResolvedSigner = {
|
|
|
61
61
|
/** EVM address of the owner/master — used as `user` field in Aster requests */
|
|
62
62
|
userAddress: string;
|
|
63
63
|
};
|
|
64
|
-
/**
|
|
65
|
-
|
|
64
|
+
/**
|
|
65
|
+
* Max GET /fapi/v3/income requests for one 7-day read (7 daily slices + splits of full slices).
|
|
66
|
+
* The endpoint weighs 30 against Aster's 2400/min request weight, so 60 requests = 1800 — inside
|
|
67
|
+
* one minute's budget with room for the per-symbol fan-out. Enough for ~2000 fills/day; beyond
|
|
68
|
+
* that the read refuses (EXCHANGE_ERROR) rather than returning a partial history.
|
|
69
|
+
*/
|
|
70
|
+
export declare const ASTER_INCOME_MAX_PAGES = 60;
|
|
66
71
|
export declare class AsterAdapter implements ExchangeAdapter {
|
|
67
72
|
readonly name = "aster";
|
|
68
73
|
readonly chain = "bnb";
|
|
@@ -209,13 +214,19 @@ export declare class AsterAdapter implements ExchangeAdapter {
|
|
|
209
214
|
*/
|
|
210
215
|
invalidateOpenOrdersCache(): void;
|
|
211
216
|
getOpenOrders(): Promise<ExchangeOrder[]>;
|
|
217
|
+
/**
|
|
218
|
+
* Every income row of `incomeType` in the last 7 days. The response order of GET /fapi/v3/income
|
|
219
|
+
* is not documented, so nothing assumes it: the window is read as disjoint [start, end] slices
|
|
220
|
+
* (startTime / endTime inclusive; one day each) and a slice that comes back full (1000 rows, the
|
|
221
|
+
* endpoint max) is split in half and re-read. More than ASTER_INCOME_MAX_PAGES requests, or a
|
|
222
|
+
* full slice of one millisecond → EXCHANGE_ERROR, never a partial list.
|
|
223
|
+
*/
|
|
224
|
+
private _incomeRows;
|
|
212
225
|
/**
|
|
213
226
|
* Symbols with account activity: open positions, open orders, and every symbol with a
|
|
214
|
-
* COMMISSION income row in the last 7 days (
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
* more than ASTER_INCOME_MAX_PAGES pages → EXCHANGE_ERROR, never a partial set. Orders on a
|
|
218
|
-
* symbol that never filled, is not open and has no position cannot be found (allOrders needs it).
|
|
227
|
+
* COMMISSION income row in the last 7 days (see _incomeRows). Aster's userTrades / allOrders
|
|
228
|
+
* both require `symbol`, so this is the per-symbol fan-out set. Orders on a symbol that never
|
|
229
|
+
* filled, is not open and has no position cannot be found (allOrders needs it).
|
|
219
230
|
*/
|
|
220
231
|
private _tradedApiSymbols;
|
|
221
232
|
/**
|
|
@@ -230,6 +241,7 @@ export declare class AsterAdapter implements ExchangeAdapter {
|
|
|
230
241
|
* A failed read throws (never an empty list); a row without a readable field → EXCHANGE_ERROR.
|
|
231
242
|
*/
|
|
232
243
|
getTradeHistory(limit?: number): Promise<ExchangeTrade[]>;
|
|
244
|
+
/** FUNDING_FEE income of the last 7 days (see _incomeRows), newest first, at most `limit` rows. */
|
|
233
245
|
getFundingPayments(limit?: number): Promise<ExchangeFundingPayment[]>;
|
|
234
246
|
/**
|
|
235
247
|
* Per-market leverage / margin mode: every row of GET /fapi/v3/positionRisk ("Position
|
package/dist/exchanges/aster.js
CHANGED
|
@@ -152,8 +152,13 @@ export function requireAsterClientOrderId(id) {
|
|
|
152
152
|
return id;
|
|
153
153
|
}
|
|
154
154
|
// ── AsterAdapter ──────────────────────────────────────────────────────────────
|
|
155
|
-
/**
|
|
156
|
-
|
|
155
|
+
/**
|
|
156
|
+
* Max GET /fapi/v3/income requests for one 7-day read (7 daily slices + splits of full slices).
|
|
157
|
+
* The endpoint weighs 30 against Aster's 2400/min request weight, so 60 requests = 1800 — inside
|
|
158
|
+
* one minute's budget with room for the per-symbol fan-out. Enough for ~2000 fills/day; beyond
|
|
159
|
+
* that the read refuses (EXCHANGE_ERROR) rather than returning a partial history.
|
|
160
|
+
*/
|
|
161
|
+
export const ASTER_INCOME_MAX_PAGES = 60;
|
|
157
162
|
export class AsterAdapter {
|
|
158
163
|
name = "aster";
|
|
159
164
|
chain = "bnb";
|
|
@@ -717,17 +722,70 @@ export class AsterAdapter {
|
|
|
717
722
|
this._ordersCache = { data: result, time: Date.now(), epoch: accountCacheEpoch() };
|
|
718
723
|
return result;
|
|
719
724
|
}
|
|
725
|
+
/**
|
|
726
|
+
* Every income row of `incomeType` in the last 7 days. The response order of GET /fapi/v3/income
|
|
727
|
+
* is not documented, so nothing assumes it: the window is read as disjoint [start, end] slices
|
|
728
|
+
* (startTime / endTime inclusive; one day each) and a slice that comes back full (1000 rows, the
|
|
729
|
+
* endpoint max) is split in half and re-read. More than ASTER_INCOME_MAX_PAGES requests, or a
|
|
730
|
+
* full slice of one millisecond → EXCHANGE_ERROR, never a partial list.
|
|
731
|
+
*/
|
|
732
|
+
async _incomeRows(incomeType, r) {
|
|
733
|
+
const end = Date.now();
|
|
734
|
+
const start = end - 7 * 86_400_000;
|
|
735
|
+
const slices = [];
|
|
736
|
+
for (let s = start; s < end; s += 86_400_000)
|
|
737
|
+
slices.push([s, Math.min(s + 86_400_000 - 1, end)]);
|
|
738
|
+
slices[slices.length - 1][1] = end; // last slice ends exactly at `end` (inclusive), no extra 1-ms request
|
|
739
|
+
const rows = [];
|
|
740
|
+
let requests = 0;
|
|
741
|
+
while (slices.length > 0) {
|
|
742
|
+
const [s, e] = slices.shift();
|
|
743
|
+
if (++requests > ASTER_INCOME_MAX_PAGES) {
|
|
744
|
+
throw new PerpError("EXCHANGE_ERROR", `Aster ${incomeType} history needs more than ${ASTER_INCOME_MAX_PAGES} requests for 7 days — the result would be incomplete`, {
|
|
745
|
+
exchange: "aster", remediation: "Check the history per symbol in the Aster web app",
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
const page = await this._signedGetEip712("/fapi/v3/income", { incomeType, startTime: String(s), endTime: String(e), limit: "1000" }, r);
|
|
749
|
+
if (!Array.isArray(page)) {
|
|
750
|
+
throw new PerpError("EXCHANGE_ERROR", `Aster income history is not a list (${JSON.stringify(page).slice(0, 120)})`, { exchange: "aster" });
|
|
751
|
+
}
|
|
752
|
+
if (page.length >= 1000) {
|
|
753
|
+
if (e <= s) {
|
|
754
|
+
throw new PerpError("EXCHANGE_ERROR", `Aster returned 1000+ ${incomeType} rows at one millisecond (${s}) — the list cannot be read completely`, {
|
|
755
|
+
exchange: "aster", remediation: "Check the history per symbol in the Aster web app",
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
const mid = Math.floor((s + e) / 2);
|
|
759
|
+
slices.unshift([s, mid], [mid + 1, e]);
|
|
760
|
+
continue;
|
|
761
|
+
}
|
|
762
|
+
for (const row of page) {
|
|
763
|
+
if (!Number.isFinite(Number(row.time))) {
|
|
764
|
+
throw new PerpError("EXCHANGE_ERROR", `Aster income row without a numeric time: ${JSON.stringify(row).slice(0, 160)}`, { exchange: "aster" });
|
|
765
|
+
}
|
|
766
|
+
rows.push(row);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
return rows;
|
|
770
|
+
}
|
|
720
771
|
/**
|
|
721
772
|
* Symbols with account activity: open positions, open orders, and every symbol with a
|
|
722
|
-
* COMMISSION income row in the last 7 days (
|
|
723
|
-
*
|
|
724
|
-
*
|
|
725
|
-
* more than ASTER_INCOME_MAX_PAGES pages → EXCHANGE_ERROR, never a partial set. Orders on a
|
|
726
|
-
* symbol that never filled, is not open and has no position cannot be found (allOrders needs it).
|
|
773
|
+
* COMMISSION income row in the last 7 days (see _incomeRows). Aster's userTrades / allOrders
|
|
774
|
+
* both require `symbol`, so this is the per-symbol fan-out set. Orders on a symbol that never
|
|
775
|
+
* filled, is not open and has no position cannot be found (allOrders needs it).
|
|
727
776
|
*/
|
|
728
777
|
async _tradedApiSymbols(r) {
|
|
729
|
-
|
|
730
|
-
|
|
778
|
+
// Raw venue symbols (no _fromApi → _toApi round trip, which would invent a USDT suffix for a
|
|
779
|
+
// non-USDT market): open positions of GET /fapi/v3/accountWithJoinMargin.
|
|
780
|
+
const account = await this._signedGetEip712("/fapi/v3/accountWithJoinMargin", {}, r);
|
|
781
|
+
if (!Array.isArray(account?.positions)) {
|
|
782
|
+
throw new PerpError("EXCHANGE_ERROR", "Aster accountWithJoinMargin has no positions list", { exchange: "aster" });
|
|
783
|
+
}
|
|
784
|
+
const apiSymbols = new Set();
|
|
785
|
+
for (const p of account.positions) {
|
|
786
|
+
if (typeof p.symbol === "string" && p.symbol !== "" && parseFiniteVenueNumber(p.positionAmt, "position.positionAmt", "aster") !== 0)
|
|
787
|
+
apiSymbols.add(p.symbol);
|
|
788
|
+
}
|
|
731
789
|
const open = await this._signedGetEip712("/fapi/v3/openOrders", {}, r);
|
|
732
790
|
if (!Array.isArray(open)) {
|
|
733
791
|
throw new PerpError("EXCHANGE_ERROR", `Aster open orders is not a list (${JSON.stringify(open).slice(0, 120)})`, { exchange: "aster" });
|
|
@@ -736,33 +794,9 @@ export class AsterAdapter {
|
|
|
736
794
|
if (typeof o.symbol === "string" && o.symbol !== "")
|
|
737
795
|
apiSymbols.add(o.symbol);
|
|
738
796
|
}
|
|
739
|
-
const
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
if (page >= ASTER_INCOME_MAX_PAGES) {
|
|
743
|
-
throw new PerpError("EXCHANGE_ERROR", `Aster commission history exceeds ${ASTER_INCOME_MAX_PAGES * 1000} rows in 7 days — the symbol list would be incomplete`, {
|
|
744
|
-
exchange: "aster", remediation: "Check fills per symbol in the Aster web app",
|
|
745
|
-
});
|
|
746
|
-
}
|
|
747
|
-
const income = await this._signedGetEip712("/fapi/v3/income", {
|
|
748
|
-
incomeType: "COMMISSION", startTime: String(startTime), endTime: String(endTime), limit: "1000",
|
|
749
|
-
}, r);
|
|
750
|
-
if (!Array.isArray(income)) {
|
|
751
|
-
throw new PerpError("EXCHANGE_ERROR", `Aster income history is not a list (${JSON.stringify(income).slice(0, 120)})`, { exchange: "aster" });
|
|
752
|
-
}
|
|
753
|
-
let oldest = Infinity;
|
|
754
|
-
for (const row of income) {
|
|
755
|
-
if (typeof row.symbol === "string" && row.symbol !== "")
|
|
756
|
-
apiSymbols.add(row.symbol);
|
|
757
|
-
const t = Number(row.time);
|
|
758
|
-
if (!Number.isFinite(t)) {
|
|
759
|
-
throw new PerpError("EXCHANGE_ERROR", `Aster income row without a numeric time: ${JSON.stringify(row).slice(0, 160)}`, { exchange: "aster" });
|
|
760
|
-
}
|
|
761
|
-
oldest = Math.min(oldest, t);
|
|
762
|
-
}
|
|
763
|
-
if (income.length < 1000 || oldest <= startTime)
|
|
764
|
-
break;
|
|
765
|
-
endTime = oldest - 1;
|
|
797
|
+
for (const row of await this._incomeRows("COMMISSION", r)) {
|
|
798
|
+
if (typeof row.symbol === "string" && row.symbol !== "")
|
|
799
|
+
apiSymbols.add(row.symbol);
|
|
766
800
|
}
|
|
767
801
|
return apiSymbols;
|
|
768
802
|
}
|
|
@@ -851,17 +885,16 @@ export class AsterAdapter {
|
|
|
851
885
|
}
|
|
852
886
|
return allTrades.sort((a, b) => b.time - a.time).slice(0, limit);
|
|
853
887
|
}
|
|
888
|
+
/** FUNDING_FEE income of the last 7 days (see _incomeRows), newest first, at most `limit` rows. */
|
|
854
889
|
async getFundingPayments(limit = 200) {
|
|
855
|
-
const
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
time:
|
|
862
|
-
|
|
863
|
-
payment: String(f.income ?? "0"),
|
|
864
|
-
}));
|
|
890
|
+
const rows = await this._incomeRows("FUNDING_FEE", this._resolveSigner());
|
|
891
|
+
return rows.map((f) => {
|
|
892
|
+
const time = Number(f.time);
|
|
893
|
+
if (typeof f.symbol !== "string" || f.symbol === "" || (typeof f.income !== "string" && typeof f.income !== "number") || f.income === "") {
|
|
894
|
+
throw new PerpError("EXCHANGE_ERROR", `Aster funding income row without symbol / income: ${JSON.stringify(f).slice(0, 160)}`, { exchange: "aster" });
|
|
895
|
+
}
|
|
896
|
+
return { time, symbol: this._fromApi(f.symbol), payment: String(f.income) };
|
|
897
|
+
}).sort((x, y) => y.time - x.time).slice(0, limit);
|
|
865
898
|
}
|
|
866
899
|
/**
|
|
867
900
|
* Per-market leverage / margin mode: every row of GET /fapi/v3/positionRisk ("Position
|
|
@@ -496,9 +496,39 @@ export declare class HyperliquidAdapter implements ExchangeAdapter {
|
|
|
496
496
|
}): Promise<unknown>;
|
|
497
497
|
getRecentTrades(symbol: string, limit?: number): Promise<ExchangeTrade[]>;
|
|
498
498
|
getKlines(symbol: string, interval: string, startTime: number, endTime: number): Promise<ExchangeKline[]>;
|
|
499
|
+
/**
|
|
500
|
+
* Order history: POST /info {type:"historicalOrders"} ("Retrieve a user's historical orders",
|
|
501
|
+
* at most 2000 most recent; live 2026-10-13: newest first, each order listed once per status —
|
|
502
|
+
* e.g. "open" then "filled" — with equal statusTimestamp possible). One row per oid with its
|
|
503
|
+
* final status (latest statusTimestamp; on a tie a non-"open" status wins). Includes canceled /
|
|
504
|
+
* rejected orders (the previous userFills read showed fills only). Unreadable rows → EXCHANGE_ERROR.
|
|
505
|
+
* The info endpoint is account-wide; rows are scoped to this adapter (see _inHistoryScope).
|
|
506
|
+
*/
|
|
499
507
|
getOrderHistory(limit?: number): Promise<ExchangeOrder[]>;
|
|
508
|
+
/** Fills: POST /info {type:"userFills"} (at most 2000 most recent, newest first — live 2026-10-13). */
|
|
500
509
|
getTradeHistory(limit?: number): Promise<ExchangeTrade[]>;
|
|
510
|
+
/**
|
|
511
|
+
* Funding payments in this adapter's scope (see _inHistoryScope), newest first, at most `limit`.
|
|
512
|
+
* Reads the last 24 h first and widens to 7 days only when that holds fewer than `limit` rows
|
|
513
|
+
* (POST /info userFunding weighs per row returned; bots poll it).
|
|
514
|
+
*/
|
|
501
515
|
getFundingPayments(limit?: number): Promise<ExchangeFundingPayment[]>;
|
|
516
|
+
/**
|
|
517
|
+
* Every userFunding row since `startTime`. The endpoint returns rows OLDEST first, at most 500
|
|
518
|
+
* per response (live 2026-10-13), and every coin of a funding hour shares one millisecond — so
|
|
519
|
+
* pages continue from the last row's time INCLUSIVE and rows are deduped by time + coin (funding
|
|
520
|
+
* rows carry a zero hash). A full page that adds nothing new, or more than HL_FUNDING_MAX_PAGES
|
|
521
|
+
* pages → EXCHANGE_ERROR (never a list with payments silently missing).
|
|
522
|
+
*/
|
|
523
|
+
private _fundingSince;
|
|
524
|
+
/** The asset map backs _displaySymbol / _inHistoryScope; load it before mapping history rows. */
|
|
525
|
+
private _historyScopeReady;
|
|
526
|
+
/**
|
|
527
|
+
* Whether an account-wide history row (historicalOrders / userFills / userFunding carry every
|
|
528
|
+
* dex, spot "@n" / "PAIR/USDC" and outcome "#n" coins) belongs to this adapter: a HIP-3 dex
|
|
529
|
+
* adapter keeps "<dex>:…" coins, the native adapter keeps native perp coins only.
|
|
530
|
+
*/
|
|
531
|
+
private _inHistoryScope;
|
|
502
532
|
getFundingHistory(symbol: string, limit?: number): Promise<{
|
|
503
533
|
time: number;
|
|
504
534
|
rate: string;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { LocalEvmSigner } from "../signer/index.js";
|
|
2
2
|
import { DEFINITE_ORDER_FAILURE_CODES, PerpError, classifyError, extractErrorMessage, orderNotSent, orderOutcomeUnknown, sendFailure } from "../errors.js";
|
|
3
3
|
import { parseFiniteVenueNumber, venueRateField } from "../utils/numeric.js";
|
|
4
|
-
import { alignProtectionPrice, floorSize, formatComputedPrice, slippagePrice } from "./precision.js";
|
|
4
|
+
import { alignProtectionPrice, floorSize, formatComputedPrice, slippagePrice, subDecimal } from "./precision.js";
|
|
5
5
|
import { unsupported } from "./trading-v2.js";
|
|
6
6
|
import { buildHlUserSignedAction, hlUserSignedNonceField, signHlL1Action, signHlUserSignedAction } from "./hl-signing.js";
|
|
7
7
|
import { HL_CLOID_RE } from "./venues/hyperliquid.js";
|
|
@@ -339,6 +339,29 @@ export function hlL1ActionRejection(type, e) {
|
|
|
339
339
|
remediation: "The venue refused the whole request and executed nothing; fix the cause named in the venue message above before sending it again",
|
|
340
340
|
});
|
|
341
341
|
}
|
|
342
|
+
/** POST /info userFunding page size (docs: at most 500 rows per response). */
|
|
343
|
+
const HL_FUNDING_PAGE = 500;
|
|
344
|
+
/** 7 days of hourly funding = 168 rows per coin; 20 pages = 10 000 rows (~60 coins). */
|
|
345
|
+
const HL_FUNDING_MAX_PAGES = 20;
|
|
346
|
+
/** A required field of an HL info row as a string; missing / empty → EXCHANGE_ERROR. */
|
|
347
|
+
function hlField(row, key, endpoint) {
|
|
348
|
+
const v = row[key];
|
|
349
|
+
if ((typeof v === "string" && v !== "") || (typeof v === "number" && Number.isFinite(v)))
|
|
350
|
+
return String(v);
|
|
351
|
+
throw new PerpError("EXCHANGE_ERROR", `Hyperliquid ${endpoint} row without a readable ${key}: ${JSON.stringify(row).slice(0, 160)}`, { exchange: "hyperliquid" });
|
|
352
|
+
}
|
|
353
|
+
/** historicalOrders status precedence on equal statusTimestamp: open < triggered < any terminal status. */
|
|
354
|
+
function hlStatusRank(status) {
|
|
355
|
+
return status === "open" ? 0 : status === "triggered" ? 1 : 2;
|
|
356
|
+
}
|
|
357
|
+
/** HL side "B" (bid) → buy, "A" (ask) → sell; anything else → EXCHANGE_ERROR. */
|
|
358
|
+
function hlSide(v, endpoint) {
|
|
359
|
+
if (v === "B")
|
|
360
|
+
return "buy";
|
|
361
|
+
if (v === "A")
|
|
362
|
+
return "sell";
|
|
363
|
+
throw new PerpError("EXCHANGE_ERROR", `Hyperliquid ${endpoint} row with unknown side ${JSON.stringify(v)}`, { exchange: "hyperliquid" });
|
|
364
|
+
}
|
|
342
365
|
export class HyperliquidAdapter {
|
|
343
366
|
name = "hyperliquid";
|
|
344
367
|
chain = "evm";
|
|
@@ -1774,48 +1797,150 @@ export class HyperliquidAdapter {
|
|
|
1774
1797
|
trades: Number(c.n ?? 0),
|
|
1775
1798
|
}));
|
|
1776
1799
|
}
|
|
1800
|
+
/**
|
|
1801
|
+
* Order history: POST /info {type:"historicalOrders"} ("Retrieve a user's historical orders",
|
|
1802
|
+
* at most 2000 most recent; live 2026-10-13: newest first, each order listed once per status —
|
|
1803
|
+
* e.g. "open" then "filled" — with equal statusTimestamp possible). One row per oid with its
|
|
1804
|
+
* final status (latest statusTimestamp; on a tie a non-"open" status wins). Includes canceled /
|
|
1805
|
+
* rejected orders (the previous userFills read showed fills only). Unreadable rows → EXCHANGE_ERROR.
|
|
1806
|
+
* The info endpoint is account-wide; rows are scoped to this adapter (see _inHistoryScope).
|
|
1807
|
+
*/
|
|
1777
1808
|
async getOrderHistory(limit = 30) {
|
|
1778
1809
|
this.ensureAddress();
|
|
1779
|
-
const
|
|
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();
|
|
@@ -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;
|