perp-cli 0.7.10 → 0.7.12
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.
|
@@ -141,8 +141,30 @@ export class FundingArbStrategy {
|
|
|
141
141
|
if (longAdapter && shortAdapter) {
|
|
142
142
|
const price = ratesByExchange.get(best.longExchange)?.get(best.symbol)?.price ?? 0;
|
|
143
143
|
if (price > 0) {
|
|
144
|
+
// Balance check — reduce size to fit available balance
|
|
145
|
+
let targetSizeUsd = params.size_usd;
|
|
146
|
+
try {
|
|
147
|
+
const [longBal, shortBal] = await Promise.all([
|
|
148
|
+
longAdapter.getBalance(),
|
|
149
|
+
shortAdapter.getBalance(),
|
|
150
|
+
]);
|
|
151
|
+
const longAvail = parseFloat(longBal.available);
|
|
152
|
+
const shortAvail = parseFloat(shortBal.available);
|
|
153
|
+
const minAvail = Math.min(longAvail, shortAvail);
|
|
154
|
+
// Use at most 80% of available balance per leg
|
|
155
|
+
const maxAffordable = minAvail * 0.8;
|
|
156
|
+
if (maxAffordable < 10) {
|
|
157
|
+
ctx.log(` [ARB] Skip ${best.symbol}: insufficient balance ($${minAvail.toFixed(2)} available, need $10+ per leg)`);
|
|
158
|
+
return [];
|
|
159
|
+
}
|
|
160
|
+
if (targetSizeUsd > maxAffordable) {
|
|
161
|
+
ctx.log(` [ARB] Size reduced $${targetSizeUsd} → $${maxAffordable.toFixed(0)} (limited by balance $${minAvail.toFixed(2)})`);
|
|
162
|
+
targetSizeUsd = Math.floor(maxAffordable);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
catch { /* non-critical, proceed with original size */ }
|
|
144
166
|
// Liquidity check
|
|
145
|
-
const liq = await checkArbLiquidity(longAdapter, shortAdapter, best.symbol,
|
|
167
|
+
const liq = await checkArbLiquidity(longAdapter, shortAdapter, best.symbol, targetSizeUsd, 0.5, (msg) => ctx.log(` ${msg}`));
|
|
146
168
|
if (!liq.viable)
|
|
147
169
|
return [];
|
|
148
170
|
// Compute matched size
|
|
@@ -69,7 +69,8 @@ export function BotDashboard({ initialState, onQuit, onPause, subscribe }) {
|
|
|
69
69
|
const bidOrders = state.openOrders.filter((o) => o.side.toLowerCase() === "buy");
|
|
70
70
|
const askOrders = state.openOrders.filter((o) => o.side.toLowerCase() === "sell");
|
|
71
71
|
const hasMultiExchange = (state.exchangeBalances?.length ?? 0) > 1;
|
|
72
|
-
|
|
72
|
+
const isMultiSymbol = state.symbol.toUpperCase() === "ALL";
|
|
73
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", marginBottom: 1, children: [_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { bold: true, color: "cyan", children: [state.strategy, " | ", state.symbol, " | ", state.exchange] }), _jsxs(Text, { color: "gray", children: ["Runtime: ", formatDuration(state.runtime), " | Tick: ", state.tick] })] }), _jsx(Box, { children: _jsx(Text, { bold: true, color: phaseColor(state.phase), children: phaseIndicator(state.phase) }) })] }), isMultiSymbol ? (_jsxs(Box, { borderStyle: "single", borderColor: "gray", paddingX: 1, flexDirection: "row", justifyContent: "space-between", children: [_jsxs(Text, { children: ["Exchanges: ", _jsx(Text, { bold: true, color: "white", children: state.exchangeBalances?.length ?? 1 })] }), _jsxs(Text, { color: "gray", children: ["Positions: ", _jsx(Text, { bold: true, color: state.positions.length > 0 ? "yellow" : "white", children: state.positions.length })] }), _jsxs(Text, { color: "gray", children: ["Total Equity: ", _jsxs(Text, { bold: true, color: "white", children: ["$", (state.exchangeBalances?.reduce((s, e) => s + parseFloat(e.equity), 0) ?? state.equity).toFixed(2)] })] }), _jsxs(Text, { color: "gray", children: ["PnL: ", _jsxs(Text, { bold: true, color: state.totalPnl >= 0 ? "green" : "red", children: ["$", state.totalPnl.toFixed(2)] })] })] })) : (_jsxs(Box, { borderStyle: "single", borderColor: "gray", paddingX: 1, flexDirection: "row", justifyContent: "space-between", children: [_jsxs(Text, { children: ["Price: ", _jsxs(Text, { bold: true, color: "white", children: ["$", state.price.toFixed(2)] })] }), _jsxs(Text, { color: "gray", children: ["Vol24h: ", _jsxs(Text, { color: "white", children: [state.volatility24h.toFixed(1), "%"] })] }), _jsxs(Text, { color: "gray", children: ["FR: ", _jsxs(Text, { color: state.fundingRate >= 0 ? "green" : "red", children: [(state.fundingRate * 100).toFixed(4), "%"] })] }), _jsxs(Text, { color: "gray", children: ["OI: ", _jsx(Text, { color: "white", children: state.openInterest })] })] })), _jsxs(Box, { marginTop: 1, flexDirection: "row", justifyContent: "space-between", children: [_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: "cyan", bold: true, children: "Account" }), _jsxs(Text, { children: ["Equity: ", _jsxs(Text, { bold: true, color: "white", children: ["$", state.equity.toFixed(2)] }), _jsxs(Text, { color: "gray", children: [" (peak: $", state.peakEquity.toFixed(2), ")"] })] }), _jsxs(Text, { children: ["Daily P&L: ", _jsxs(Text, { bold: true, color: pnlColor(state.dailyPnl), children: ["$", state.dailyPnl.toFixed(2)] })] })] }), _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: " " }), _jsxs(Text, { children: ["Drawdown: ", _jsxs(Text, { bold: true, color: drawdown > 0 ? "red" : "green", children: ["$", drawdown.toFixed(2), " (", drawdownPct.toFixed(1), "%)"] })] }), _jsxs(Text, { children: ["Fills: ", _jsx(Text, { bold: true, color: "white", children: state.fills }), _jsx(Text, { color: "gray", children: " | Total P&L: " }), _jsxs(Text, { bold: true, color: pnlColor(state.totalPnl), children: ["$", state.totalPnl.toFixed(2)] })] })] })] }), state.exchangeBalances && state.exchangeBalances.length > 1 && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "cyan", bold: true, children: "Exchange Balances" }), _jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: "gray", children: pad("Exchange", 16) }), _jsx(Text, { color: "gray", children: pad("Equity", 14) }), _jsx(Text, { color: "gray", children: "Available" })] }), state.exchangeBalances.map((eb, i) => (_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: "white", children: pad(eb.exchange, 16) }), _jsx(Text, { color: "white", children: pad(`$${parseFloat(eb.equity).toFixed(2)}`, 14) }), _jsxs(Text, { color: "gray", children: ["$", parseFloat(eb.available).toFixed(2)] })] }, i)))] })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "cyan", bold: true, children: "Positions" }), state.positions.length === 0 ? (_jsx(Text, { color: "gray", children: " No open positions" })) : (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", children: [hasMultiExchange && _jsx(Text, { color: "gray", children: pad("Exchange", 14) }), _jsx(Text, { color: "gray", children: pad("Symbol", 10) }), _jsx(Text, { color: "gray", children: pad("Side", 6) }), _jsx(Text, { color: "gray", children: pad("Size", 12) }), _jsx(Text, { color: "gray", children: pad("Entry", 12) }), _jsx(Text, { color: "gray", children: pad("Mark", 12) }), _jsx(Text, { color: "gray", children: "uPnL" })] }), state.positions.map((p, i) => {
|
|
73
74
|
const upnl = parseFloat(p.unrealizedPnl);
|
|
74
75
|
return (_jsxs(Box, { flexDirection: "row", children: [hasMultiExchange && _jsx(Text, { color: "cyan", children: pad(p.exchange ?? "—", 14) }), _jsx(Text, { color: "white", children: pad(p.symbol, 10) }), _jsx(Text, { color: p.side.toLowerCase() === "long" ? "green" : "red", children: pad(p.side, 6) }), _jsx(Text, { color: "white", children: pad(p.size, 12) }), _jsx(Text, { color: "white", children: pad(p.entryPrice, 12) }), _jsx(Text, { color: "white", children: pad(p.markPrice, 12) }), _jsxs(Text, { color: pnlColor(upnl), children: ["$", upnl.toFixed(2)] })] }, i));
|
|
75
76
|
})] }))] }), _jsxs(Box, { marginTop: 1, flexDirection: "row", justifyContent: "space-between", children: [_jsxs(Box, { flexDirection: "column", width: "50%", children: [_jsxs(Text, { color: "green", bold: true, children: ["Bids (", bidOrders.length, ")"] }), bidOrders.length === 0 ? (_jsx(Text, { color: "gray", children: " --" })) : (bidOrders.slice(0, 8).map((o, i) => (_jsxs(Text, { color: "green", children: [pad(o.price, 12), " ", pad(o.size, 10), " ", o.type] }, i))))] }), _jsxs(Box, { flexDirection: "column", width: "50%", children: [_jsxs(Text, { color: "red", bold: true, children: ["Asks (", askOrders.length, ")"] }), askOrders.length === 0 ? (_jsx(Text, { color: "gray", children: " --" })) : (askOrders.slice(0, 8).map((o, i) => (_jsxs(Text, { color: "red", children: [pad(o.price, 12), " ", pad(o.size, 10), " ", o.type] }, i))))] })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "cyan", bold: true, children: "Strategy State" }), _jsxs(Box, { flexDirection: "row", flexWrap: "wrap", children: [Object.entries(state.strategyState).slice(0, 12).map(([k, v]) => (_jsxs(Box, { marginRight: 2, children: [_jsxs(Text, { color: "gray", children: [k, ": "] }), _jsx(Text, { color: "white", children: String(v) })] }, k))), Object.keys(state.strategyState).length === 0 && (_jsx(Text, { color: "gray", children: " --" }))] })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, height: MAX_LOG_LINES + 2, children: [_jsx(Text, { color: "cyan", bold: true, children: "Log" }), logs.length === 0 ? (_jsx(Text, { color: "gray", children: " Waiting for events..." })) : (logs.map((entry, i) => (_jsxs(Text, { children: [_jsxs(Text, { color: "gray", children: [entry.time, " "] }), _jsx(Text, { children: colorizeLog(entry.message) })] }, i))))] }), _jsx(Box, { marginTop: 1, flexDirection: "row", justifyContent: "center", children: _jsxs(Text, { color: "gray", children: ["[", _jsx(Text, { color: "white", bold: true, children: "Q" }), "]uit [", _jsx(Text, { color: "white", bold: true, children: "P" }), "]", paused ? "resume" : "ause"] }) })] }));
|
|
@@ -12,6 +12,9 @@ export declare class AsterAdapter implements ExchangeAdapter {
|
|
|
12
12
|
private _apiSecret;
|
|
13
13
|
private _baseUrl;
|
|
14
14
|
private _testnet;
|
|
15
|
+
private _marketsCache;
|
|
16
|
+
private _marketsCacheTime;
|
|
17
|
+
private static readonly CACHE_TTL;
|
|
15
18
|
constructor(apiKey?: string, apiSecret?: string, testnet?: boolean);
|
|
16
19
|
get isReadOnly(): boolean;
|
|
17
20
|
/** CLI symbol → Aster API symbol (ETH → ETHUSDT) */
|
|
@@ -55,6 +58,8 @@ export declare class AsterAdapter implements ExchangeAdapter {
|
|
|
55
58
|
routeType?: number;
|
|
56
59
|
}): Promise<unknown>;
|
|
57
60
|
private _sign;
|
|
61
|
+
/** Handle 429 rate limit with retry */
|
|
62
|
+
private _handleResponse;
|
|
58
63
|
private _publicGet;
|
|
59
64
|
private _signedGet;
|
|
60
65
|
private _signedPost;
|
package/dist/exchanges/aster.js
CHANGED
|
@@ -12,6 +12,9 @@ export class AsterAdapter {
|
|
|
12
12
|
_apiSecret;
|
|
13
13
|
_baseUrl;
|
|
14
14
|
_testnet;
|
|
15
|
+
_marketsCache = null;
|
|
16
|
+
_marketsCacheTime = 0;
|
|
17
|
+
static CACHE_TTL = 10_000; // 10 seconds
|
|
15
18
|
constructor(apiKey, apiSecret, testnet = false) {
|
|
16
19
|
this._apiKey = apiKey || process.env.ASTER_API_KEY || "";
|
|
17
20
|
this._apiSecret = apiSecret || process.env.ASTER_API_SECRET || "";
|
|
@@ -40,6 +43,10 @@ export class AsterAdapter {
|
|
|
40
43
|
}
|
|
41
44
|
// ── Market Data ──
|
|
42
45
|
async getMarkets() {
|
|
46
|
+
// Return cached result if fresh
|
|
47
|
+
if (this._marketsCache && Date.now() - this._marketsCacheTime < AsterAdapter.CACHE_TTL) {
|
|
48
|
+
return this._marketsCache;
|
|
49
|
+
}
|
|
43
50
|
const [info, tickers] = await Promise.all([
|
|
44
51
|
this._publicGet("/fapi/v1/exchangeInfo"),
|
|
45
52
|
this._publicGet("/fapi/v1/ticker/24hr"),
|
|
@@ -57,28 +64,12 @@ export class AsterAdapter {
|
|
|
57
64
|
catch { /* non-critical */ }
|
|
58
65
|
const tradingSymbols = (info?.symbols ?? [])
|
|
59
66
|
.filter((s) => String(s.contractType) === "PERPETUAL" && String(s.status) === "TRADING");
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
const oiMap = new Map();
|
|
63
|
-
const OI_BATCH = 20;
|
|
64
|
-
for (let i = 0; i < tradingSymbols.length; i += OI_BATCH) {
|
|
65
|
-
const batch = tradingSymbols.slice(i, i + OI_BATCH);
|
|
66
|
-
const results = await Promise.allSettled(batch.map(async (s) => {
|
|
67
|
-
const sym = String(s.symbol);
|
|
68
|
-
const res = await this._publicGet("/fapi/v1/openInterest", { symbol: sym });
|
|
69
|
-
return { sym, oi: String(res?.openInterest ?? "0") };
|
|
70
|
-
}));
|
|
71
|
-
for (const r of results) {
|
|
72
|
-
if (r.status === "fulfilled")
|
|
73
|
-
oiMap.set(r.value.sym, r.value.oi);
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
return tradingSymbols
|
|
67
|
+
// OI is fetched lazily per symbol (see getMarketInfo) to avoid 300+ API calls
|
|
68
|
+
const result = tradingSymbols
|
|
77
69
|
.map((s) => {
|
|
78
70
|
const sym = String(s.symbol);
|
|
79
71
|
const ticker = tickerMap.get(sym);
|
|
80
72
|
const premium = premiumMap.get(sym);
|
|
81
|
-
// Extract max leverage from leverage brackets if available
|
|
82
73
|
const maxLev = Number(s.maxLeverage ?? 50);
|
|
83
74
|
return {
|
|
84
75
|
symbol: this._fromApi(sym),
|
|
@@ -86,10 +77,13 @@ export class AsterAdapter {
|
|
|
86
77
|
indexPrice: String(premium?.indexPrice ?? "0"),
|
|
87
78
|
fundingRate: String(premium?.lastFundingRate ?? "0"),
|
|
88
79
|
volume24h: String(ticker?.quoteVolume ?? ticker?.volume ?? "0"),
|
|
89
|
-
openInterest:
|
|
80
|
+
openInterest: "0",
|
|
90
81
|
maxLeverage: maxLev,
|
|
91
82
|
};
|
|
92
83
|
});
|
|
84
|
+
this._marketsCache = result;
|
|
85
|
+
this._marketsCacheTime = Date.now();
|
|
86
|
+
return result;
|
|
93
87
|
}
|
|
94
88
|
async getOrderbook(symbol) {
|
|
95
89
|
const res = await this._publicGet("/fapi/v1/depth", { symbol: this._toApi(symbol), limit: "50" });
|
|
@@ -359,62 +353,82 @@ export class AsterAdapter {
|
|
|
359
353
|
.digest("hex");
|
|
360
354
|
return `${queryString}&signature=${signature}`;
|
|
361
355
|
}
|
|
356
|
+
/** Handle 429 rate limit with retry */
|
|
357
|
+
async _handleResponse(res, method, path, attempt = 0) {
|
|
358
|
+
if (res.status === 429) {
|
|
359
|
+
if (attempt >= 2)
|
|
360
|
+
throw new Error(`${method} ${path} rate limited after ${attempt + 1} attempts`);
|
|
361
|
+
const retryAfter = parseInt(res.headers.get("Retry-After") || "5", 10);
|
|
362
|
+
const waitMs = Math.min(retryAfter * 1000, 30000);
|
|
363
|
+
await new Promise(r => setTimeout(r, waitMs));
|
|
364
|
+
return null; // signal retry
|
|
365
|
+
}
|
|
366
|
+
if (!res.ok) {
|
|
367
|
+
const text = await res.text();
|
|
368
|
+
throw new Error(`${method} ${path} failed (${res.status}): ${text.slice(0, 200)}`);
|
|
369
|
+
}
|
|
370
|
+
return res.json();
|
|
371
|
+
}
|
|
362
372
|
async _publicGet(path, params = {}) {
|
|
363
373
|
const qs = Object.entries(params)
|
|
364
374
|
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
|
|
365
375
|
.join("&");
|
|
366
376
|
const url = `${this._baseUrl}${path}${qs ? `?${qs}` : ""}`;
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
const
|
|
370
|
-
|
|
377
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
378
|
+
const res = await fetch(url);
|
|
379
|
+
const result = await this._handleResponse(res, "GET", path, attempt);
|
|
380
|
+
if (result !== null)
|
|
381
|
+
return result;
|
|
371
382
|
}
|
|
372
|
-
|
|
383
|
+
throw new Error(`GET ${path} failed: max retries exceeded`);
|
|
373
384
|
}
|
|
374
385
|
async _signedGet(path, params = {}) {
|
|
375
386
|
if (this.isReadOnly)
|
|
376
387
|
throw new Error("No API key configured for Aster. Set ASTER_API_KEY and ASTER_API_SECRET in ~/.perp/.env");
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
const
|
|
384
|
-
|
|
388
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
389
|
+
const signed = this._sign({ ...params });
|
|
390
|
+
const url = `${this._baseUrl}${path}?${signed}`;
|
|
391
|
+
const res = await fetch(url, {
|
|
392
|
+
headers: { "X-MBX-APIKEY": this._apiKey },
|
|
393
|
+
});
|
|
394
|
+
const result = await this._handleResponse(res, "GET", path, attempt);
|
|
395
|
+
if (result !== null)
|
|
396
|
+
return result;
|
|
385
397
|
}
|
|
386
|
-
|
|
398
|
+
throw new Error(`GET ${path} failed: max retries exceeded`);
|
|
387
399
|
}
|
|
388
400
|
async _signedPost(path, params = {}) {
|
|
389
401
|
if (this.isReadOnly)
|
|
390
402
|
throw new Error("No API key configured for Aster. Set ASTER_API_KEY and ASTER_API_SECRET in ~/.perp/.env");
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
const
|
|
402
|
-
|
|
403
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
404
|
+
const signed = this._sign({ ...params });
|
|
405
|
+
const res = await fetch(`${this._baseUrl}${path}`, {
|
|
406
|
+
method: "POST",
|
|
407
|
+
headers: {
|
|
408
|
+
"X-MBX-APIKEY": this._apiKey,
|
|
409
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
410
|
+
},
|
|
411
|
+
body: signed,
|
|
412
|
+
});
|
|
413
|
+
const result = await this._handleResponse(res, "POST", path, attempt);
|
|
414
|
+
if (result !== null)
|
|
415
|
+
return result;
|
|
403
416
|
}
|
|
404
|
-
|
|
417
|
+
throw new Error(`POST ${path} failed: max retries exceeded`);
|
|
405
418
|
}
|
|
406
419
|
async _signedDelete(path, params = {}) {
|
|
407
420
|
if (this.isReadOnly)
|
|
408
421
|
throw new Error("No API key configured for Aster. Set ASTER_API_KEY and ASTER_API_SECRET in ~/.perp/.env");
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
const
|
|
416
|
-
|
|
422
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
423
|
+
const signed = this._sign({ ...params });
|
|
424
|
+
const res = await fetch(`${this._baseUrl}${path}?${signed}`, {
|
|
425
|
+
method: "DELETE",
|
|
426
|
+
headers: { "X-MBX-APIKEY": this._apiKey },
|
|
427
|
+
});
|
|
428
|
+
const result = await this._handleResponse(res, "DELETE", path, attempt);
|
|
429
|
+
if (result !== null)
|
|
430
|
+
return result;
|
|
417
431
|
}
|
|
418
|
-
|
|
432
|
+
throw new Error(`DELETE ${path} failed: max retries exceeded`);
|
|
419
433
|
}
|
|
420
434
|
}
|