perp-cli 0.14.2 → 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.
@@ -7,7 +7,7 @@ import { logExecution, pruneExecutionLog } from "../../execution-log.js";
7
7
  import { annualizeHourlyRate } from "../../funding/normalize.js";
8
8
  import { getBotSpotAdapter, nonQuoteSpotBalances, readSpotQuoteAvailableFresh, spotNetworkOf, spotTokenBase, sweepSpotQuoteToPerp } from "../../exchanges/spot-factory.js";
9
9
  import { getVenue } from "../../exchanges/venue.js";
10
- import { isPositionGone, transferUsdcToSpot, transferUsdcToPerp, getPerpSymbol, findRate, getPriceEstimate, fetchRates, botSpotPerpMatches, assertFundingArbLegs, cancelBotSymbols, collectFundingSince } from "./funding-arb-utils.js";
10
+ import { isPositionGone, transferUsdcToSpot, transferUsdcToPerp, getPerpSymbol, findRate, cappedLeverage, getPriceEstimate, fetchRates, botSpotPerpMatches, assertFundingArbLegs, cancelBotSymbols, collectFundingSince } from "./funding-arb-utils.js";
11
11
  import { fillSizeString, sizeDecimalsOf, spotLotStepOf, spotMarketFill } from "../../exchanges/spot-result.js";
12
12
  import { floorToStep, lotStepOf } from "../../exchanges/precision.js";
13
13
  import { isQuarantined, quarantineKey, quarantineSymbol } from "../quarantine.js";
@@ -637,9 +637,10 @@ export class FundingArbStrategy {
637
637
  continue;
638
638
  // Determine leverage for this symbol
639
639
  const configLeverage = params.leverage ?? 3;
640
- const longMaxLev = findRate(ratesByExchange, best.longExchange, best.symbol)?.maxLeverage ?? 1;
641
- const shortMaxLev = findRate(ratesByExchange, best.shortExchange, best.symbol)?.maxLeverage ?? 1;
642
- const leverage = Math.min(configLeverage, longMaxLev, shortMaxLev);
640
+ const leverage = cappedLeverage(configLeverage, [
641
+ { exchange: best.longExchange, maxLeverage: findRate(ratesByExchange, best.longExchange, best.symbol)?.maxLeverage },
642
+ { exchange: best.shortExchange, maxLeverage: findRate(ratesByExchange, best.shortExchange, best.symbol)?.maxLeverage },
643
+ ], ctx.log);
643
644
  // Set leverage on both exchanges before ordering
644
645
  try {
645
646
  await longAdapter.setLeverage(best.symbol, leverage, "cross");
@@ -1020,9 +1021,8 @@ export class FundingArbStrategy {
1020
1021
  const configLeverage = params.leverage ?? 3;
1021
1022
  const perpSymbolUpper = getPerpSymbol(opp.symbol, opp.shortExchange).toUpperCase();
1022
1023
  const perpMaxLev = findRate(ratesByExchange, opp.shortExchange, perpSymbolUpper)?.maxLeverage
1023
- ?? findRate(ratesByExchange, opp.shortExchange, opp.symbol.toUpperCase())?.maxLeverage
1024
- ?? 1;
1025
- const leverage = Math.min(configLeverage, perpMaxLev);
1024
+ ?? findRate(ratesByExchange, opp.shortExchange, opp.symbol.toUpperCase())?.maxLeverage;
1025
+ const leverage = cappedLeverage(configLeverage, [{ exchange: opp.shortExchange, maxLeverage: perpMaxLev }], ctx.log);
1026
1026
  // Set leverage on perp before ordering
1027
1027
  try {
1028
1028
  await perpAdapter.setLeverage(getPerpSymbol(opp.symbol, opp.shortExchange), leverage, "cross");
@@ -47,6 +47,15 @@ export declare function botSpotPerpMatches(spotVenue: string, spotMarkets: reado
47
47
  export declare function getPerpSymbol(baseSymbol: string, _exchangeName: string): string;
48
48
  /** Look up rate from ratesByExchange, trying symbol, symbol-PERP, and symbol without -PERP. */
49
49
  export declare function findRate(ratesByExchange: RateMap, exchange: string, symbol: string): RateEntry | undefined;
50
+ /**
51
+ * Leverage for an entry: the configured value, capped by every leg's published max.
52
+ * A leg whose max is unpublished (undefined) is not a cap — the venue enforces its own
53
+ * limit on setLeverage / the order — and `logUnknown` names it, so it is never read as 1x.
54
+ */
55
+ export declare function cappedLeverage(configured: number, legs: {
56
+ exchange: string;
57
+ maxLeverage: number | undefined;
58
+ }[], logUnknown: (msg: string) => void): number;
50
59
  /** Match a symbol against a target, accounting for -PERP suffix variations. */
51
60
  export declare function matchSymbol(s: string, target: string): boolean;
52
61
  /** Get a price estimate for a symbol from the perp adapter. */
@@ -68,7 +77,11 @@ export interface FundingScore {
68
77
  * ORDER_PENDING outcome is never "gone").
69
78
  */
70
79
  export declare function isPositionGone(e: unknown): boolean;
71
- /** Score a symbol's funding rate using history. Returns null if insufficient data. */
80
+ /**
81
+ * Score a symbol's funding rate using history. null = not enough data (fewer than 2 history
82
+ * points, or the per-symbol funding basis is unknown). A failed read THROWS — callers must not
83
+ * mistake a 429 / timeout for "history too thin".
84
+ */
72
85
  export declare function scoreFunding(adapter: ExchangeAdapter, symbol: string, exchangeName: string, periods: number, cache?: Map<string, {
73
86
  score: FundingScore;
74
87
  ts: number;
@@ -110,6 +110,23 @@ export function findRate(ratesByExchange, exchange, symbol) {
110
110
  const upper = symbol.toUpperCase();
111
111
  return map.get(upper) ?? map.get(upper + "-PERP") ?? map.get(upper.replace(/-PERP$/, ""));
112
112
  }
113
+ /**
114
+ * Leverage for an entry: the configured value, capped by every leg's published max.
115
+ * A leg whose max is unpublished (undefined) is not a cap — the venue enforces its own
116
+ * limit on setLeverage / the order — and `logUnknown` names it, so it is never read as 1x.
117
+ */
118
+ export function cappedLeverage(configured, legs, logUnknown) {
119
+ let leverage = configured;
120
+ for (const leg of legs) {
121
+ if (leg.maxLeverage === undefined) {
122
+ logUnknown(` [LEV] ${leg.exchange} does not publish a max leverage — using ${configured}x; the venue enforces its cap`);
123
+ }
124
+ else {
125
+ leverage = Math.min(leverage, leg.maxLeverage);
126
+ }
127
+ }
128
+ return leverage;
129
+ }
113
130
  /** Match a symbol against a target, accounting for -PERP suffix variations. */
114
131
  export function matchSymbol(s, target) {
115
132
  const u = s.toUpperCase();
@@ -165,32 +182,33 @@ async function marketFundingHours(adapter, exchangeName, symbol) {
165
182
  }
166
183
  return getFundingHours(exchangeName);
167
184
  }
168
- /** Score a symbol's funding rate using history. Returns null if insufficient data. */
185
+ /**
186
+ * Score a symbol's funding rate using history. null = not enough data (fewer than 2 history
187
+ * points, or the per-symbol funding basis is unknown). A failed read THROWS — callers must not
188
+ * mistake a 429 / timeout for "history too thin".
189
+ */
169
190
  export async function scoreFunding(adapter, symbol, exchangeName, periods, cache) {
170
191
  const key = `${exchangeName}:${symbol}`;
171
192
  const cached = cache?.get(key);
172
193
  if (cached && Date.now() - cached.ts < 10 * 60 * 1000)
173
194
  return cached.score;
174
- // Outside the try: an unknown venue is a configuration error, not "no score".
175
195
  const venueHours = hasPerSymbolFundingHours(adapter) ? null : getFundingHours(exchangeName);
176
- try {
177
- const fH = venueHours !== null ? venueHours : await marketFundingHours(adapter, exchangeName, symbol);
178
- if (fH === null)
179
- return null; // per-symbol basis unknown — the hourly rate would be a guess
180
- const history = await adapter.getFundingHistory(symbol, periods);
181
- if (history.length < 2)
182
- return null;
183
- const rates = history.map(h => parseFloat(h.rate));
184
- const avgRate = rates.reduce((s, r) => s + r, 0) / rates.length;
185
- const consistency = rates.filter(r => r > 0).length / rates.length;
186
- const annualized = annualizeHourlyRate(avgRate / fH);
187
- const score = { symbol, exchange: exchangeName, avgRate, consistency, annualized, payments: rates.length };
188
- cache?.set(key, { score, ts: Date.now() });
189
- return score;
190
- }
191
- catch {
196
+ const fH = venueHours !== null ? venueHours : await marketFundingHours(adapter, exchangeName, symbol);
197
+ if (fH === null)
198
+ return null; // per-symbol basis unknown — the hourly rate would be a guess
199
+ const history = await adapter.getFundingHistory(symbol, periods);
200
+ if (history.length < 2)
192
201
  return null;
202
+ const rates = history.map(h => parseFloat(h.rate));
203
+ if (rates.some(r => !Number.isFinite(r))) {
204
+ throw new PerpError("EXCHANGE_ERROR", `${exchangeName} ${symbol}: funding history has a non-numeric rate`, { exchange: exchangeName });
193
205
  }
206
+ const avgRate = rates.reduce((s, r) => s + r, 0) / rates.length;
207
+ const consistency = rates.filter(r => r > 0).length / rates.length;
208
+ const annualized = annualizeHourlyRate(avgRate / fH);
209
+ const score = { symbol, exchange: exchangeName, avgRate, consistency, annualized, payments: rates.length };
210
+ cache?.set(key, { score, ts: Date.now() });
211
+ return score;
194
212
  }
195
213
  /**
196
214
  * Fetch funding rates from an exchange adapter. Failures propagate — callers
@@ -207,6 +225,7 @@ export async function fetchRates(adapter, exchangeName) {
207
225
  continue;
208
226
  m.fundingHours = await marketFundingHours(adapter, exchangeName, m.symbol);
209
227
  }
228
+ // maxLeverage undefined = not published by the venue (callers cap by published maxes only — see cappedLeverage).
210
229
  const result = [];
211
230
  for (const m of withRates) {
212
231
  // Skip aster symbols with unknown funding period — the hourly rate would be a guess.
@@ -221,7 +240,7 @@ export async function fetchRates(adapter, exchangeName) {
221
240
  rate: parseFloat(m.fundingRate),
222
241
  price: parseFloat(m.markPrice),
223
242
  sizeDecimals: m.sizeDecimals,
224
- maxLeverage: m.maxLeverage,
243
+ maxLeverage: m.maxLeverage ?? undefined,
225
244
  fundingHours: m.fundingHours,
226
245
  });
227
246
  }
@@ -12,7 +12,7 @@ import { registerStrategy } from "../strategy-registry.js";
12
12
  import { getFundingHours } from "../../funding.js";
13
13
  import { annualizeHourlyRate } from "../../funding/normalize.js";
14
14
  import { pruneExecutionLog } from "../../execution-log.js";
15
- import { buildAdapterMap, getPerpSymbol, findRate, matchSymbol, fetchRates, scoreFunding, botSpotPerpMatches, assertFundingArbLegs, cancelBotSymbols, collectFundingSince } from "./funding-arb-utils.js";
15
+ import { buildAdapterMap, getPerpSymbol, findRate, cappedLeverage, matchSymbol, fetchRates, scoreFunding, botSpotPerpMatches, assertFundingArbLegs, cancelBotSymbols, collectFundingSince } from "./funding-arb-utils.js";
16
16
  import { SpotPerpExecutor } from "./spot-perp-executor.js";
17
17
  import { findSpotQuoteBalance, getBotSpotAdapter, nonQuoteSpotBalances, spotNetworkOf, spotSharesPerpMargin, spotTokenBase } from "../../exchanges/spot-factory.js";
18
18
  import { getVenue } from "../../exchanges/venue.js";
@@ -313,7 +313,7 @@ export class SpotPerpArbStrategy {
313
313
  const allowed = allowedByBase.get(pos.symbol.toUpperCase());
314
314
  if (allowed === undefined)
315
315
  continue;
316
- const candidates = await this.findPerpCandidates(pos.symbol, adapters, ratesByExchange, allowed);
316
+ const candidates = await this.findPerpCandidates(pos.symbol, adapters, ratesByExchange, allowed, ctx.log);
317
317
  const best = candidates[0];
318
318
  if (!best || best.exchange === pos.perpExchange)
319
319
  continue;
@@ -331,12 +331,12 @@ export class SpotPerpArbStrategy {
331
331
  continue;
332
332
  const perpSym = getPerpSymbol(pos.symbol, pos.perpExchange);
333
333
  const newMaxLev = findRate(ratesByExchange, best.exchange, getPerpSymbol(pos.symbol, best.exchange))?.maxLeverage
334
- ?? findRate(ratesByExchange, best.exchange, pos.symbol)?.maxLeverage ?? 1;
334
+ ?? findRate(ratesByExchange, best.exchange, pos.symbol)?.maxLeverage;
335
335
  const ok = await this.executor.rotateShort({
336
336
  symbol: pos.symbol, perpSymbol: perpSym,
337
337
  currentExchange: pos.perpExchange, newExchange: best.exchange,
338
338
  currentAdapter: oldAdapter, newAdapter,
339
- size: pos.size, leverage: Math.min(p.leverage, newMaxLev), log: ctx.log,
339
+ size: pos.size, leverage: cappedLeverage(p.leverage, [{ exchange: best.exchange, maxLeverage: newMaxLev }], ctx.log), log: ctx.log,
340
340
  });
341
341
  if (ok.ok) {
342
342
  // The spot leg is unchanged: the state entry follows the short to its new venue.
@@ -387,7 +387,7 @@ export class SpotPerpArbStrategy {
387
387
  continue;
388
388
  if (this.quarantined(base, ctx))
389
389
  continue;
390
- const candidates = await this.findPerpCandidates(base, adapters, ratesByExchange, allowed);
390
+ const candidates = await this.findPerpCandidates(base, adapters, ratesByExchange, allowed, ctx.log);
391
391
  for (const cand of candidates) {
392
392
  if (cand.score.annualized < p.min_rate || cand.score.consistency < p.min_consistency)
393
393
  continue;
@@ -501,7 +501,7 @@ export class SpotPerpArbStrategy {
501
501
  return byBase;
502
502
  }
503
503
  /** Find all perp exchanges for a symbol, sorted by annualized rate (highest first). */
504
- async findPerpCandidates(symbol, adapters, ratesByExchange, allowedPerpVenues) {
504
+ async findPerpCandidates(symbol, adapters, ratesByExchange, allowedPerpVenues, log) {
505
505
  const candidates = [];
506
506
  for (const [exName, adapter] of adapters) {
507
507
  if (!allowedPerpVenues.has(exName))
@@ -510,8 +510,20 @@ export class SpotPerpArbStrategy {
510
510
  const ri = findRate(ratesByExchange, exName, perpSym) ?? findRate(ratesByExchange, exName, symbol);
511
511
  if (!ri || ri.rate <= 0)
512
512
  continue;
513
- const score = await this.scoreFundingLocal(adapter, perpSym, exName)
514
- ?? await this.scoreFundingLocal(adapter, symbol, exName);
513
+ // A failed history read is not "too little history": say so, then try the next symbol form;
514
+ // a venue with no readable form is left out this cycle.
515
+ let score = null;
516
+ for (const sym of perpSym === symbol ? [perpSym] : [perpSym, symbol]) {
517
+ try {
518
+ score = await this.scoreFundingLocal(adapter, sym, exName);
519
+ }
520
+ catch (e) {
521
+ log(` [SPA] ${sym} on ${exName}: funding history unreadable — not a candidate this cycle: ${e instanceof Error ? e.message : String(e)}`);
522
+ continue;
523
+ }
524
+ if (score)
525
+ break;
526
+ }
515
527
  if (!score)
516
528
  continue;
517
529
  candidates.push({ exchange: exName, rate: ri.rate, score });
@@ -569,8 +581,7 @@ export class SpotPerpArbStrategy {
569
581
  const perpSymbol = getPerpSymbol(symbol, perpExchange);
570
582
  const rateInfo = findRate(ratesByExchange, perpExchange, perpSymbol)
571
583
  ?? findRate(ratesByExchange, perpExchange, symbol);
572
- const perpMaxLev = rateInfo?.maxLeverage ?? 1;
573
- const leverage = Math.min(p.leverage, perpMaxLev);
584
+ const leverage = cappedLeverage(p.leverage, [{ exchange: perpExchange, maxLeverage: rateInfo?.maxLeverage }], ctx.log);
574
585
  // Pre-entry: verify fundingHours (aster lazy bootstrap may not have run yet)
575
586
  if (perpExchange === "aster" && rateInfo && "getFundingHours" in perpAdapter) {
576
587
  const actualFh = await perpAdapter.getFundingHours(symbol);
@@ -27,7 +27,7 @@ getAccountAdapter) {
27
27
  m.fundingRate != null ? formatPercent(m.fundingRate) : chalk.gray("N/A"),
28
28
  usdOrDash(m.volume24h),
29
29
  usdOrDash(m.openInterest),
30
- String(m.maxLeverage) + "x",
30
+ m.maxLeverage === null ? chalk.gray("-") : String(m.maxLeverage) + "x",
31
31
  ];
32
32
  // Multi-exchange mode
33
33
  if (!explicitExchange && getAdapterForExchange) {
@@ -182,7 +182,7 @@ getAccountAdapter) {
182
182
  console.log(` Funding Rate: ${m.fundingRate != null ? formatPercent(m.fundingRate) : chalk.gray("N/A")}`);
183
183
  console.log(` 24h Volume: ${usdOrDash(m.volume24h)}`);
184
184
  console.log(` Open Interest: ${usdOrDash(m.openInterest)}`);
185
- console.log(` Max Leverage: ${m.maxLeverage}x`);
185
+ console.log(` Max Leverage: ${m.maxLeverage === null ? chalk.gray("-") : `${m.maxLeverage}x`}`);
186
186
  console.log();
187
187
  });
188
188
  market
@@ -334,7 +334,7 @@ function renderPanels(exchanges) {
334
334
  return \`<tr>\${isHL?\`<td style="color:var(--purple);font-size:11px">\${dex}</td>\`:''}<td>\${sym}</td><td class="side-\${o.side}">\${o.side.toUpperCase()}</td><td>\${o.type}</td><td>$\${fmt(o.price)}</td><td>\${o.size}</td><td>\${o.filled}</td><td>\${o.status}</td></tr>\`;
335
335
  }).join('')}</tbody></table>\`:(ordKnown?'<div class="empty-msg">No open orders</div>':'<div class="empty-msg">Open orders '+NA+'</div>')}
336
336
  <div class="section-title">Markets (Top 10)</div>
337
- \${ex.topMarkets.length?\`<table><thead><tr><th>Symbol</th><th>Mark</th><th>Index</th><th>Funding</th><th>24h Vol</th><th>OI</th><th>Max Lev</th></tr></thead><tbody>\${ex.topMarkets.map(m=>{const fr=Number(m.fundingRate);return\`<tr><td>\${m.symbol}</td><td>$\${fmt(m.markPrice)}</td><td>\${usdOrDash(m.indexPrice)}</td><td class="\${rateClass(fr)}">\${(fr*100).toFixed(4)}%</td><td>\${usdOrDash(m.volume24h,0)}</td><td>\${usdOrDash(m.openInterest,0)}</td><td>\${m.maxLeverage}x</td></tr>\`;}).join('')}</tbody></table>\`:'<div class="empty-msg">No market data</div>'}
337
+ \${ex.topMarkets.length?\`<table><thead><tr><th>Symbol</th><th>Mark</th><th>Index</th><th>Funding</th><th>24h Vol</th><th>OI</th><th>Max Lev</th></tr></thead><tbody>\${ex.topMarkets.map(m=>{const fr=Number(m.fundingRate);return\`<tr><td>\${m.symbol}</td><td>$\${fmt(m.markPrice)}</td><td>\${usdOrDash(m.indexPrice)}</td><td class="\${rateClass(fr)}">\${(fr*100).toFixed(4)}%</td><td>\${usdOrDash(m.volume24h,0)}</td><td>\${usdOrDash(m.openInterest,0)}</td><td>\${m.maxLeverage==null?'-':m.maxLeverage+'x'}</td></tr>\`;}).join('')}</tbody></table>\`:'<div class="empty-msg">No market data</div>'}
338
338
  </div>\`;
339
339
  }).join('');
340
340
 
@@ -196,6 +196,11 @@ export declare class AsterAdapter implements ExchangeAdapter {
196
196
  rate: string;
197
197
  price: string | null;
198
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
+ */
199
204
  getKlines(symbol: string, interval: string, startTime: number, endTime: number): Promise<ExchangeKline[]>;
200
205
  getBalance(): Promise<ExchangeBalance>;
201
206
  /**
@@ -159,6 +159,10 @@ export function requireAsterClientOrderId(id) {
159
159
  * that the read refuses (EXCHANGE_ERROR) rather than returning a partial history.
160
160
  */
161
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;
162
166
  export class AsterAdapter {
163
167
  name = "aster";
164
168
  chain = "bnb";
@@ -375,41 +379,54 @@ export class AsterAdapter {
375
379
  // math depends on.
376
380
  this._loadFundingInfo(),
377
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
+ }
378
388
  const tickerMap = new Map();
379
- for (const t of tickers ?? []) {
389
+ for (const t of tickers) {
380
390
  tickerMap.set(String(t.symbol), t);
381
391
  }
382
- let premiumMap = new Map();
383
- try {
384
- const premiums = await this._publicGet("/fapi/v3/premiumIndex");
385
- premiumMap = new Map(premiums.map(p => [String(p.symbol), p]));
386
- }
387
- catch { /* non-critical */ }
388
- this._ingestPriceFilters(info?.symbols ?? []);
389
- const tradingSymbols = (info?.symbols ?? [])
390
- .filter((s) => String(s.contractType) === "PERPETUAL" && String(s.status) === "TRADING");
391
- const result = tradingSymbols.map((s) => {
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) => {
392
405
  const sym = String(s.symbol);
393
406
  const ticker = tickerMap.get(sym);
394
407
  const premium = premiumMap.get(sym);
395
- const maxLev = Number(s.maxLeverage ?? 50);
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;
396
413
  const lotFilter = s.filters
397
414
  ?.find(f => f.filterType === "LOT_SIZE");
398
415
  const fundingHours = this._fundingHoursCache.get(this._fromApi(sym));
399
- return {
400
- symbol: this._fromApi(sym),
401
- markPrice: String(premium?.markPrice ?? ticker?.lastPrice ?? "0"),
402
- indexPrice: premium?.indexPrice != null ? String(premium.indexPrice) : null,
403
- fundingRate: premium?.lastFundingRate != null ? String(premium.lastFundingRate) : null,
404
- volume24h: ticker?.quoteVolume != null ? String(ticker.quoteVolume) : null,
405
- // /fapi/v3/exchangeInfo and ticker/24hr carry no open interest.
406
- openInterest: null,
407
- maxLeverage: maxLev,
408
- sizeDecimals: s.quantityPrecision != null ? Number(s.quantityPrecision) : null,
409
- stepSize: lotFilter?.stepSize != null ? String(lotFilter.stepSize) : null,
410
- fundingHours: fundingHours ?? null,
411
- marginTiers: null,
412
- };
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
+ }];
413
430
  });
414
431
  this._marketsCache = result;
415
432
  this._marketsCacheTime = Date.now();
@@ -546,29 +563,64 @@ export class AsterAdapter {
546
563
  symbol: this._toApi(symbol),
547
564
  limit: String(limit),
548
565
  });
549
- return (data ?? []).map((d) => ({
550
- time: Number(d.fundingTime ?? 0),
551
- rate: venueRateField(d.fundingRate, "fundingRate fundingRate", "aster"),
552
- price: d.markPrice ? String(d.markPrice) : null,
553
- }));
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
+ });
554
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
+ */
555
586
  async getKlines(symbol, interval, startTime, endTime) {
556
- const data = await this._publicGet("/fapi/v3/klines", {
557
- symbol: this._toApi(symbol),
558
- interval,
559
- startTime: String(startTime),
560
- endTime: String(endTime),
561
- limit: "500",
562
- });
563
- return (data ?? []).map((k) => ({
564
- time: Number(k[0]),
565
- open: String(k[1]),
566
- high: String(k[2]),
567
- low: String(k[3]),
568
- close: String(k[4]),
569
- volume: String(k[5]),
570
- trades: Number(k[8] ?? 0),
571
- }));
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;
572
624
  }
573
625
  // ── Account ──
574
626
  async getBalance() {
@@ -665,10 +665,6 @@ export declare class HyperliquidAdapter implements ExchangeAdapter {
665
665
  * adapter creation. Throws on a venue rejection (SSOT Rule #2).
666
666
  */
667
667
  setReferrer(code: string): Promise<unknown>;
668
- /**
669
- * Get funding history for a symbol.
670
- */
671
- getFundingHistoryRaw(symbol: string, startTime: number, endTime?: number): Promise<any>;
672
668
  /**
673
669
  * Get user trade fills.
674
670
  */
@@ -2385,27 +2385,6 @@ export class HyperliquidAdapter {
2385
2385
  }
2386
2386
  return res;
2387
2387
  }
2388
- /**
2389
- * Get funding history for a symbol.
2390
- */
2391
- async getFundingHistoryRaw(symbol, startTime, endTime) {
2392
- const baseUrl = this._testnet
2393
- ? "https://api.hyperliquid-testnet.xyz"
2394
- : "https://api.hyperliquid.xyz";
2395
- const body = {
2396
- type: "fundingHistory",
2397
- coin: symbol.toUpperCase(),
2398
- startTime,
2399
- };
2400
- if (endTime)
2401
- body.endTime = endTime;
2402
- const res = await fetch(`${baseUrl}/info`, {
2403
- method: "POST",
2404
- headers: { "Content-Type": "application/json" },
2405
- body: JSON.stringify(body),
2406
- });
2407
- return res.json();
2408
- }
2409
2388
  /**
2410
2389
  * Get user trade fills.
2411
2390
  */
@@ -13,7 +13,8 @@ export interface ExchangeMarketInfo {
13
13
  volume24h: string | null;
14
14
  /** null = venue does not publish open interest (never "0" as a stand-in) */
15
15
  openInterest: string | null;
16
- maxLeverage: number;
16
+ /** null = the venue does not publish it (Aster: the public exchangeInfo carries no leverage) */
17
+ maxLeverage: number | null;
17
18
  /** quantity precision (e.g. 0 = integers only, 2 = 0.01); null = unknown */
18
19
  sizeDecimals: number | null;
19
20
  /** lot step size (e.g. "1", "0.1", "0.001"); null = unknown */
@@ -38,7 +39,7 @@ export interface ExchangeMarketInfo {
38
39
  * A venue that publishes no tiers (`marginTiers === null`) has one flat cap,
39
40
  * `maxLeverage`, at every size — that is the venue's definition, not a fallback.
40
41
  */
41
- export declare function maxLeverageForNotional(market: ExchangeMarketInfo, notional: number): number;
42
+ export declare function maxLeverageForNotional(market: ExchangeMarketInfo, notional: number): number | null;
42
43
  export interface ExchangePosition {
43
44
  symbol: string;
44
45
  side: "long" | "short";
@@ -10,8 +10,9 @@ export function maxLeverageForNotional(market, notional) {
10
10
  }
11
11
  const tiers = market.marginTiers;
12
12
  if (tiers === null || tiers.length === 0)
13
- return market.maxLeverage;
14
- let effective = market.maxLeverage;
13
+ return market.maxLeverage; // null = not published
14
+ // No headline but published tiers: the first (lowest-notional) tier is the cap below every threshold.
15
+ let effective = market.maxLeverage ?? tiers[0].maxLeverage;
15
16
  for (const t of tiers) {
16
17
  if (notional >= t.lowerBound)
17
18
  effective = t.maxLeverage;
@@ -19,7 +19,7 @@ export interface TradeValidation {
19
19
  symbol: string;
20
20
  markPrice: number;
21
21
  fundingRate: number;
22
- maxLeverage: number;
22
+ maxLeverage: number | null;
23
23
  };
24
24
  timestamp: string;
25
25
  }
@@ -69,7 +69,12 @@ export async function validateTrade(adapter, params) {
69
69
  // reject an order sized past a threshold even though the flat value allows it,
70
70
  // so resolve the cap AT THIS ORDER'S NOTIONAL.
71
71
  const effectiveMax = maxLeverageForNotional(market, notional);
72
- const leverage = params.leverage ?? effectiveMax;
72
+ // A venue that publishes no max leverage (Aster's public exchangeInfo): the margin estimate
73
+ // uses 1x — the most conservative requirement — and says so; the venue enforces its own cap.
74
+ if (effectiveMax === null && params.leverage === undefined) {
75
+ warnings.push(`Max leverage for ${sym} is not published by ${adapter.name} — margin estimated at 1x; pass --leverage for a tighter estimate`);
76
+ }
77
+ const leverage = params.leverage ?? effectiveMax ?? 1;
73
78
  if (!Number.isFinite(leverage) || leverage <= 0) {
74
79
  throw new Error(`No usable leverage for ${sym} (resolved=${leverage}); supply params.leverage explicitly.`);
75
80
  }
@@ -197,7 +202,7 @@ export async function validateTrade(adapter, params) {
197
202
  const fee = notional * DEFAULT_TAKER_FEE;
198
203
  const estimatedSlippage = params.type === "limit" ? 0 : notional * 0.001;
199
204
  const valid = checks.every(c => c.passed);
200
- if (leverage > effectiveMax) {
205
+ if (effectiveMax !== null && market.maxLeverage !== null && leverage > effectiveMax) {
201
206
  // Compare against the tier that applies at this notional, not the headline
202
207
  // cap — otherwise the check is tautological whenever leverage defaulted FROM
203
208
  // maxLeverage, and it never fires for the tiered case it exists to catch.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "perp-cli",
3
- "version": "0.14.2",
3
+ "version": "0.14.3",
4
4
  "description": "Multi-DEX Perpetual Futures CLI - Pacifica, Hyperliquid, Lighter, Aster, Lighter RH, Ondo Perps, Arcus",
5
5
  "bin": {
6
6
  "perp": "dist/index.js",
@@ -6,7 +6,7 @@ allowed-tools: "Bash(perp:*), Bash(npx -y perp-cli:*), Bash(bash scripts/preflig
6
6
  compatibility: "Requires Node.js 20 or newer and the perp-cli npm package (global install or npx). Network access required for exchange APIs and bridge providers. Mutating commands need a wallet key imported into the OWS vault via 'perp wallet set EXCHANGE' (key on stdin or hidden prompt — never argv). Skill is portable across Claude Code, Claude.ai, Cursor, Codex, and Gemini CLI — no platform-specific features."
7
7
  metadata:
8
8
  author: hypurrquant
9
- version: "0.14.2"
9
+ version: "0.14.3"
10
10
  ---
11
11
 
12
12
  # perp-cli
@@ -46,7 +46,7 @@ Also bundled under `scripts/`: `preflight.sh`, `arb-monitor.sh`, `funding-analys
46
46
  ### Step 1: Verify install + version
47
47
 
48
48
  ```bash
49
- perp --version 2>/dev/null || npx -y perp-cli@latest --version # must be >= 0.14.2
49
+ perp --version 2>/dev/null || npx -y perp-cli@latest --version # must be >= 0.14.3
50
50
  ```
51
51
 
52
52
  If it is missing or older, ask the user before installing/upgrading (`npm install -g perp-cli@latest`) — never install on your own. If `perp` is not on PATH, prefix every command with `npx -y perp-cli@latest`.