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
|
@@ -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";
|
|
@@ -191,6 +196,11 @@ export declare class AsterAdapter implements ExchangeAdapter {
|
|
|
191
196
|
rate: string;
|
|
192
197
|
price: string | null;
|
|
193
198
|
}[]>;
|
|
199
|
+
/**
|
|
200
|
+
* Candles over [startTime, endTime]. GET /fapi/v3/klines returns at most 1500 bars per request
|
|
201
|
+
* (1501 → -1130), so the range is read in pages that start after the last bar returned — a long
|
|
202
|
+
* range is never cut to the first page. More than ASTER_KLINE_MAX_PAGES pages → INVALID_PARAMS.
|
|
203
|
+
*/
|
|
194
204
|
getKlines(symbol: string, interval: string, startTime: number, endTime: number): Promise<ExchangeKline[]>;
|
|
195
205
|
getBalance(): Promise<ExchangeBalance>;
|
|
196
206
|
/**
|
|
@@ -209,13 +219,19 @@ export declare class AsterAdapter implements ExchangeAdapter {
|
|
|
209
219
|
*/
|
|
210
220
|
invalidateOpenOrdersCache(): void;
|
|
211
221
|
getOpenOrders(): Promise<ExchangeOrder[]>;
|
|
222
|
+
/**
|
|
223
|
+
* Every income row of `incomeType` in the last 7 days. The response order of GET /fapi/v3/income
|
|
224
|
+
* is not documented, so nothing assumes it: the window is read as disjoint [start, end] slices
|
|
225
|
+
* (startTime / endTime inclusive; one day each) and a slice that comes back full (1000 rows, the
|
|
226
|
+
* endpoint max) is split in half and re-read. More than ASTER_INCOME_MAX_PAGES requests, or a
|
|
227
|
+
* full slice of one millisecond → EXCHANGE_ERROR, never a partial list.
|
|
228
|
+
*/
|
|
229
|
+
private _incomeRows;
|
|
212
230
|
/**
|
|
213
231
|
* 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).
|
|
232
|
+
* COMMISSION income row in the last 7 days (see _incomeRows). Aster's userTrades / allOrders
|
|
233
|
+
* both require `symbol`, so this is the per-symbol fan-out set. Orders on a symbol that never
|
|
234
|
+
* filled, is not open and has no position cannot be found (allOrders needs it).
|
|
219
235
|
*/
|
|
220
236
|
private _tradedApiSymbols;
|
|
221
237
|
/**
|
|
@@ -230,6 +246,7 @@ export declare class AsterAdapter implements ExchangeAdapter {
|
|
|
230
246
|
* A failed read throws (never an empty list); a row without a readable field → EXCHANGE_ERROR.
|
|
231
247
|
*/
|
|
232
248
|
getTradeHistory(limit?: number): Promise<ExchangeTrade[]>;
|
|
249
|
+
/** FUNDING_FEE income of the last 7 days (see _incomeRows), newest first, at most `limit` rows. */
|
|
233
250
|
getFundingPayments(limit?: number): Promise<ExchangeFundingPayment[]>;
|
|
234
251
|
/**
|
|
235
252
|
* Per-market leverage / margin mode: every row of GET /fapi/v3/positionRisk ("Position
|
package/dist/exchanges/aster.js
CHANGED
|
@@ -152,8 +152,17 @@ 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;
|
|
162
|
+
/** GET /fapi/v3/klines maximum rows per request (docs: limit max 1500). */
|
|
163
|
+
const ASTER_KLINE_PAGE = 1500;
|
|
164
|
+
/** Page cap for one range (150 000 bars). */
|
|
165
|
+
const ASTER_KLINE_MAX_PAGES = 100;
|
|
157
166
|
export class AsterAdapter {
|
|
158
167
|
name = "aster";
|
|
159
168
|
chain = "bnb";
|
|
@@ -370,41 +379,54 @@ export class AsterAdapter {
|
|
|
370
379
|
// math depends on.
|
|
371
380
|
this._loadFundingInfo(),
|
|
372
381
|
]);
|
|
382
|
+
if (!Array.isArray(info?.symbols)) {
|
|
383
|
+
throw new PerpError("EXCHANGE_ERROR", `Aster exchangeInfo has no symbol list (${JSON.stringify(info).slice(0, 120)})`, { exchange: "aster" });
|
|
384
|
+
}
|
|
385
|
+
if (!Array.isArray(tickers)) {
|
|
386
|
+
throw new PerpError("EXCHANGE_ERROR", `Aster 24h ticker is not a list (${JSON.stringify(tickers).slice(0, 120)})`, { exchange: "aster" });
|
|
387
|
+
}
|
|
373
388
|
const tickerMap = new Map();
|
|
374
|
-
for (const t of tickers
|
|
389
|
+
for (const t of tickers) {
|
|
375
390
|
tickerMap.set(String(t.symbol), t);
|
|
376
391
|
}
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
392
|
+
// The mark price comes from premiumIndex only: a failed read throws (never a "0" / last-trade
|
|
393
|
+
// stand-in), and a symbol missing from it is left out of the list below.
|
|
394
|
+
const premiums = await this._publicGet("/fapi/v3/premiumIndex");
|
|
395
|
+
if (!Array.isArray(premiums)) {
|
|
396
|
+
throw new PerpError("EXCHANGE_ERROR", `Aster premiumIndex is not a list (${JSON.stringify(premiums).slice(0, 120)})`, { exchange: "aster" });
|
|
397
|
+
}
|
|
398
|
+
const premiumMap = new Map(premiums.map(p => [String(p.symbol), p]));
|
|
399
|
+
this._ingestPriceFilters(info.symbols);
|
|
400
|
+
// USDT-quoted perps only: the CLI symbol mapping (ETH ↔ ETHUSDT) cannot address the USD1 / U
|
|
401
|
+
// quoted markets (live 2026-10-13: 22 USD1 + 2 U), so listing them would offer untradeable rows.
|
|
402
|
+
const tradingSymbols = info.symbols
|
|
403
|
+
.filter((s) => String(s.contractType) === "PERPETUAL" && String(s.status) === "TRADING" && String(s.quoteAsset) === "USDT");
|
|
404
|
+
const result = tradingSymbols.flatMap((s) => {
|
|
387
405
|
const sym = String(s.symbol);
|
|
388
406
|
const ticker = tickerMap.get(sym);
|
|
389
407
|
const premium = premiumMap.get(sym);
|
|
390
|
-
const
|
|
408
|
+
const mark = Number(premium?.markPrice);
|
|
409
|
+
if (premium === undefined || !(mark > 0))
|
|
410
|
+
return []; // no published mark price → not listed at 0
|
|
411
|
+
// Public exchangeInfo carries no leverage (the bracket endpoint is signed) → unknown.
|
|
412
|
+
const maxLev = null;
|
|
391
413
|
const lotFilter = s.filters
|
|
392
414
|
?.find(f => f.filterType === "LOT_SIZE");
|
|
393
415
|
const fundingHours = this._fundingHoursCache.get(this._fromApi(sym));
|
|
394
|
-
return {
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
416
|
+
return [{
|
|
417
|
+
symbol: this._fromApi(sym),
|
|
418
|
+
markPrice: String(premium.markPrice),
|
|
419
|
+
indexPrice: premium?.indexPrice != null ? String(premium.indexPrice) : null,
|
|
420
|
+
fundingRate: premium?.lastFundingRate != null ? String(premium.lastFundingRate) : null,
|
|
421
|
+
volume24h: ticker?.quoteVolume != null ? String(ticker.quoteVolume) : null,
|
|
422
|
+
// /fapi/v3/exchangeInfo and ticker/24hr carry no open interest.
|
|
423
|
+
openInterest: null,
|
|
424
|
+
maxLeverage: maxLev,
|
|
425
|
+
sizeDecimals: s.quantityPrecision != null ? Number(s.quantityPrecision) : null,
|
|
426
|
+
stepSize: lotFilter?.stepSize != null ? String(lotFilter.stepSize) : null,
|
|
427
|
+
fundingHours: fundingHours ?? null,
|
|
428
|
+
marginTiers: null,
|
|
429
|
+
}];
|
|
408
430
|
});
|
|
409
431
|
this._marketsCache = result;
|
|
410
432
|
this._marketsCacheTime = Date.now();
|
|
@@ -541,29 +563,64 @@ export class AsterAdapter {
|
|
|
541
563
|
symbol: this._toApi(symbol),
|
|
542
564
|
limit: String(limit),
|
|
543
565
|
});
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
566
|
+
if (!Array.isArray(data)) {
|
|
567
|
+
throw new PerpError("EXCHANGE_ERROR", `Aster fundingRate history is not a list (${JSON.stringify(data).slice(0, 120)})`, { exchange: "aster" });
|
|
568
|
+
}
|
|
569
|
+
return data.map((d) => {
|
|
570
|
+
const time = Number(d.fundingTime);
|
|
571
|
+
if (d.fundingTime === undefined || d.fundingTime === null || !Number.isFinite(time) || time <= 0) {
|
|
572
|
+
throw new PerpError("EXCHANGE_ERROR", `Aster fundingRate row without a numeric fundingTime: ${JSON.stringify(d).slice(0, 160)}`, { exchange: "aster" });
|
|
573
|
+
}
|
|
574
|
+
return {
|
|
575
|
+
time,
|
|
576
|
+
rate: venueRateField(d.fundingRate, "fundingRate fundingRate", "aster"),
|
|
577
|
+
price: d.markPrice ? String(d.markPrice) : null,
|
|
578
|
+
};
|
|
579
|
+
});
|
|
549
580
|
}
|
|
581
|
+
/**
|
|
582
|
+
* Candles over [startTime, endTime]. GET /fapi/v3/klines returns at most 1500 bars per request
|
|
583
|
+
* (1501 → -1130), so the range is read in pages that start after the last bar returned — a long
|
|
584
|
+
* range is never cut to the first page. More than ASTER_KLINE_MAX_PAGES pages → INVALID_PARAMS.
|
|
585
|
+
*/
|
|
550
586
|
async getKlines(symbol, interval, startTime, endTime) {
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
587
|
+
if (!(startTime <= endTime)) {
|
|
588
|
+
throw new PerpError("INVALID_PARAMS", `Aster klines: start ${startTime} is after end ${endTime}`, {
|
|
589
|
+
remediation: "Pass a --start that is before --end",
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
const out = [];
|
|
593
|
+
let from = startTime;
|
|
594
|
+
for (let page = 0; from <= endTime; page++) {
|
|
595
|
+
if (page >= ASTER_KLINE_MAX_PAGES) {
|
|
596
|
+
throw new PerpError("INVALID_PARAMS", `Aster: ${interval} candles over this range need more than ${ASTER_KLINE_MAX_PAGES} requests`, {
|
|
597
|
+
remediation: "Narrow --start / --end or use a longer interval",
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
const data = await this._publicGet("/fapi/v3/klines", {
|
|
601
|
+
symbol: this._toApi(symbol), interval,
|
|
602
|
+
startTime: String(from), endTime: String(endTime), limit: String(ASTER_KLINE_PAGE),
|
|
603
|
+
});
|
|
604
|
+
if (!Array.isArray(data)) {
|
|
605
|
+
throw new PerpError("EXCHANGE_ERROR", `Aster klines is not a list (${JSON.stringify(data).slice(0, 120)})`, { exchange: "aster" });
|
|
606
|
+
}
|
|
607
|
+
for (const k of data) {
|
|
608
|
+
const time = Number(k[0]);
|
|
609
|
+
const trades = Number(k[8]);
|
|
610
|
+
if (!Number.isFinite(time) || !Number.isFinite(trades)) {
|
|
611
|
+
throw new PerpError("EXCHANGE_ERROR", `Aster kline row without open time / trade count: ${JSON.stringify(k).slice(0, 120)}`, { exchange: "aster" });
|
|
612
|
+
}
|
|
613
|
+
out.push({ time, open: String(k[1]), high: String(k[2]), low: String(k[3]), close: String(k[4]), volume: String(k[5]), trades });
|
|
614
|
+
}
|
|
615
|
+
if (data.length < ASTER_KLINE_PAGE)
|
|
616
|
+
break;
|
|
617
|
+
const next = Number(data[data.length - 1][0]) + 1;
|
|
618
|
+
if (next <= from) {
|
|
619
|
+
throw new PerpError("EXCHANGE_ERROR", `Aster klines page did not advance past ${from}`, { exchange: "aster" });
|
|
620
|
+
}
|
|
621
|
+
from = next;
|
|
622
|
+
}
|
|
623
|
+
return out;
|
|
567
624
|
}
|
|
568
625
|
// ── Account ──
|
|
569
626
|
async getBalance() {
|
|
@@ -717,17 +774,70 @@ export class AsterAdapter {
|
|
|
717
774
|
this._ordersCache = { data: result, time: Date.now(), epoch: accountCacheEpoch() };
|
|
718
775
|
return result;
|
|
719
776
|
}
|
|
777
|
+
/**
|
|
778
|
+
* Every income row of `incomeType` in the last 7 days. The response order of GET /fapi/v3/income
|
|
779
|
+
* is not documented, so nothing assumes it: the window is read as disjoint [start, end] slices
|
|
780
|
+
* (startTime / endTime inclusive; one day each) and a slice that comes back full (1000 rows, the
|
|
781
|
+
* endpoint max) is split in half and re-read. More than ASTER_INCOME_MAX_PAGES requests, or a
|
|
782
|
+
* full slice of one millisecond → EXCHANGE_ERROR, never a partial list.
|
|
783
|
+
*/
|
|
784
|
+
async _incomeRows(incomeType, r) {
|
|
785
|
+
const end = Date.now();
|
|
786
|
+
const start = end - 7 * 86_400_000;
|
|
787
|
+
const slices = [];
|
|
788
|
+
for (let s = start; s < end; s += 86_400_000)
|
|
789
|
+
slices.push([s, Math.min(s + 86_400_000 - 1, end)]);
|
|
790
|
+
slices[slices.length - 1][1] = end; // last slice ends exactly at `end` (inclusive), no extra 1-ms request
|
|
791
|
+
const rows = [];
|
|
792
|
+
let requests = 0;
|
|
793
|
+
while (slices.length > 0) {
|
|
794
|
+
const [s, e] = slices.shift();
|
|
795
|
+
if (++requests > ASTER_INCOME_MAX_PAGES) {
|
|
796
|
+
throw new PerpError("EXCHANGE_ERROR", `Aster ${incomeType} history needs more than ${ASTER_INCOME_MAX_PAGES} requests for 7 days — the result would be incomplete`, {
|
|
797
|
+
exchange: "aster", remediation: "Check the history per symbol in the Aster web app",
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
const page = await this._signedGetEip712("/fapi/v3/income", { incomeType, startTime: String(s), endTime: String(e), limit: "1000" }, r);
|
|
801
|
+
if (!Array.isArray(page)) {
|
|
802
|
+
throw new PerpError("EXCHANGE_ERROR", `Aster income history is not a list (${JSON.stringify(page).slice(0, 120)})`, { exchange: "aster" });
|
|
803
|
+
}
|
|
804
|
+
if (page.length >= 1000) {
|
|
805
|
+
if (e <= s) {
|
|
806
|
+
throw new PerpError("EXCHANGE_ERROR", `Aster returned 1000+ ${incomeType} rows at one millisecond (${s}) — the list cannot be read completely`, {
|
|
807
|
+
exchange: "aster", remediation: "Check the history per symbol in the Aster web app",
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
const mid = Math.floor((s + e) / 2);
|
|
811
|
+
slices.unshift([s, mid], [mid + 1, e]);
|
|
812
|
+
continue;
|
|
813
|
+
}
|
|
814
|
+
for (const row of page) {
|
|
815
|
+
if (!Number.isFinite(Number(row.time))) {
|
|
816
|
+
throw new PerpError("EXCHANGE_ERROR", `Aster income row without a numeric time: ${JSON.stringify(row).slice(0, 160)}`, { exchange: "aster" });
|
|
817
|
+
}
|
|
818
|
+
rows.push(row);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
return rows;
|
|
822
|
+
}
|
|
720
823
|
/**
|
|
721
824
|
* 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).
|
|
825
|
+
* COMMISSION income row in the last 7 days (see _incomeRows). Aster's userTrades / allOrders
|
|
826
|
+
* both require `symbol`, so this is the per-symbol fan-out set. Orders on a symbol that never
|
|
827
|
+
* filled, is not open and has no position cannot be found (allOrders needs it).
|
|
727
828
|
*/
|
|
728
829
|
async _tradedApiSymbols(r) {
|
|
729
|
-
|
|
730
|
-
|
|
830
|
+
// Raw venue symbols (no _fromApi → _toApi round trip, which would invent a USDT suffix for a
|
|
831
|
+
// non-USDT market): open positions of GET /fapi/v3/accountWithJoinMargin.
|
|
832
|
+
const account = await this._signedGetEip712("/fapi/v3/accountWithJoinMargin", {}, r);
|
|
833
|
+
if (!Array.isArray(account?.positions)) {
|
|
834
|
+
throw new PerpError("EXCHANGE_ERROR", "Aster accountWithJoinMargin has no positions list", { exchange: "aster" });
|
|
835
|
+
}
|
|
836
|
+
const apiSymbols = new Set();
|
|
837
|
+
for (const p of account.positions) {
|
|
838
|
+
if (typeof p.symbol === "string" && p.symbol !== "" && parseFiniteVenueNumber(p.positionAmt, "position.positionAmt", "aster") !== 0)
|
|
839
|
+
apiSymbols.add(p.symbol);
|
|
840
|
+
}
|
|
731
841
|
const open = await this._signedGetEip712("/fapi/v3/openOrders", {}, r);
|
|
732
842
|
if (!Array.isArray(open)) {
|
|
733
843
|
throw new PerpError("EXCHANGE_ERROR", `Aster open orders is not a list (${JSON.stringify(open).slice(0, 120)})`, { exchange: "aster" });
|
|
@@ -736,33 +846,9 @@ export class AsterAdapter {
|
|
|
736
846
|
if (typeof o.symbol === "string" && o.symbol !== "")
|
|
737
847
|
apiSymbols.add(o.symbol);
|
|
738
848
|
}
|
|
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;
|
|
849
|
+
for (const row of await this._incomeRows("COMMISSION", r)) {
|
|
850
|
+
if (typeof row.symbol === "string" && row.symbol !== "")
|
|
851
|
+
apiSymbols.add(row.symbol);
|
|
766
852
|
}
|
|
767
853
|
return apiSymbols;
|
|
768
854
|
}
|
|
@@ -851,17 +937,16 @@ export class AsterAdapter {
|
|
|
851
937
|
}
|
|
852
938
|
return allTrades.sort((a, b) => b.time - a.time).slice(0, limit);
|
|
853
939
|
}
|
|
940
|
+
/** FUNDING_FEE income of the last 7 days (see _incomeRows), newest first, at most `limit` rows. */
|
|
854
941
|
async getFundingPayments(limit = 200) {
|
|
855
|
-
const
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
time:
|
|
862
|
-
|
|
863
|
-
payment: String(f.income ?? "0"),
|
|
864
|
-
}));
|
|
942
|
+
const rows = await this._incomeRows("FUNDING_FEE", this._resolveSigner());
|
|
943
|
+
return rows.map((f) => {
|
|
944
|
+
const time = Number(f.time);
|
|
945
|
+
if (typeof f.symbol !== "string" || f.symbol === "" || (typeof f.income !== "string" && typeof f.income !== "number") || f.income === "") {
|
|
946
|
+
throw new PerpError("EXCHANGE_ERROR", `Aster funding income row without symbol / income: ${JSON.stringify(f).slice(0, 160)}`, { exchange: "aster" });
|
|
947
|
+
}
|
|
948
|
+
return { time, symbol: this._fromApi(f.symbol), payment: String(f.income) };
|
|
949
|
+
}).sort((x, y) => y.time - x.time).slice(0, limit);
|
|
865
950
|
}
|
|
866
951
|
/**
|
|
867
952
|
* 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;
|
|
@@ -635,10 +665,6 @@ export declare class HyperliquidAdapter implements ExchangeAdapter {
|
|
|
635
665
|
* adapter creation. Throws on a venue rejection (SSOT Rule #2).
|
|
636
666
|
*/
|
|
637
667
|
setReferrer(code: string): Promise<unknown>;
|
|
638
|
-
/**
|
|
639
|
-
* Get funding history for a symbol.
|
|
640
|
-
*/
|
|
641
|
-
getFundingHistoryRaw(symbol: string, startTime: number, endTime?: number): Promise<any>;
|
|
642
668
|
/**
|
|
643
669
|
* Get user trade fills.
|
|
644
670
|
*/
|