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.
- package/dist/bot/strategies/funding-arb-strategy.js +7 -7
- package/dist/bot/strategies/funding-arb-utils.d.ts +14 -1
- package/dist/bot/strategies/funding-arb-utils.js +38 -19
- package/dist/bot/strategies/spot-perp-arb-strategy.js +21 -10
- package/dist/commands/market.js +2 -2
- package/dist/dashboard/ui.js +1 -1
- package/dist/exchanges/aster.d.ts +5 -0
- package/dist/exchanges/aster.js +99 -47
- package/dist/exchanges/hyperliquid.d.ts +0 -4
- package/dist/exchanges/hyperliquid.js +0 -21
- package/dist/exchanges/interface.d.ts +3 -2
- package/dist/exchanges/interface.js +3 -2
- 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
|
@@ -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
|
|
641
|
-
|
|
642
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
514
|
-
|
|
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
|
|
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);
|
package/dist/commands/market.js
CHANGED
|
@@ -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
|
package/dist/dashboard/ui.js
CHANGED
|
@@ -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}
|
|
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
|
/**
|
package/dist/exchanges/aster.js
CHANGED
|
@@ -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
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
|
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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
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
|
-
|
|
16
|
+
/** null = the venue does not publish it (Aster: the public exchangeInfo carries no leverage) */
|
|
17
|
+
maxLeverage: number | null;
|
|
17
18
|
/** quantity precision (e.g. 0 = integers only, 2 = 0.01); null = unknown */
|
|
18
19
|
sizeDecimals: number | null;
|
|
19
20
|
/** lot step size (e.g. "1", "0.1", "0.001"); null = unknown */
|
|
@@ -38,7 +39,7 @@ export interface ExchangeMarketInfo {
|
|
|
38
39
|
* A venue that publishes no tiers (`marginTiers === null`) has one flat cap,
|
|
39
40
|
* `maxLeverage`, at every size — that is the venue's definition, not a fallback.
|
|
40
41
|
*/
|
|
41
|
-
export declare function maxLeverageForNotional(market: ExchangeMarketInfo, notional: number): number;
|
|
42
|
+
export declare function maxLeverageForNotional(market: ExchangeMarketInfo, notional: number): number | null;
|
|
42
43
|
export interface ExchangePosition {
|
|
43
44
|
symbol: string;
|
|
44
45
|
side: "long" | "short";
|
|
@@ -10,8 +10,9 @@ export function maxLeverageForNotional(market, notional) {
|
|
|
10
10
|
}
|
|
11
11
|
const tiers = market.marginTiers;
|
|
12
12
|
if (tiers === null || tiers.length === 0)
|
|
13
|
-
return market.maxLeverage;
|
|
14
|
-
|
|
13
|
+
return market.maxLeverage; // null = not published
|
|
14
|
+
// No headline but published tiers: the first (lowest-notional) tier is the cap below every threshold.
|
|
15
|
+
let effective = market.maxLeverage ?? tiers[0].maxLeverage;
|
|
15
16
|
for (const t of tiers) {
|
|
16
17
|
if (notional >= t.lowerBound)
|
|
17
18
|
effective = t.maxLeverage;
|
package/dist/trade-validator.js
CHANGED
|
@@ -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
|
-
|
|
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
package/skills/perp-cli/SKILL.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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`.
|