perp-cli 0.5.2 → 0.5.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/commands/account.js +60 -0
- package/dist/commands/arb/index.js +12 -4
- package/dist/commands/arb-auto.js +50 -19
- package/dist/commands/arb.js +43 -4
- package/dist/exchanges/hyperliquid-spot.d.ts +1 -0
- package/dist/exchanges/hyperliquid-spot.js +25 -10
- package/dist/exchanges/hyperliquid.d.ts +2 -0
- package/dist/exchanges/hyperliquid.js +10 -2
- package/dist/exchanges/lighter-spot.d.ts +6 -0
- package/dist/exchanges/lighter-spot.js +80 -31
- package/dist/exchanges/lighter.d.ts +3 -0
- package/dist/exchanges/lighter.js +46 -25
- package/dist/exchanges/pacifica.js +26 -4
- package/dist/index.js +273 -5
- package/dist/utils.d.ts +2 -0
- package/dist/utils.js +32 -0
- package/package.json +1 -1
package/dist/commands/account.js
CHANGED
|
@@ -987,6 +987,66 @@ export function registerAccountCommands(program, getAdapter, isJson, getAdapterF
|
|
|
987
987
|
else {
|
|
988
988
|
console.log(chalk.gray("\n No open positions.\n"));
|
|
989
989
|
}
|
|
990
|
+
// ── Spot Holdings ──
|
|
991
|
+
const spotRows = [];
|
|
992
|
+
for (const exName of exchanges) {
|
|
993
|
+
try {
|
|
994
|
+
const adapter = await getAdapterForExchange(exName);
|
|
995
|
+
if (exName === "hyperliquid") {
|
|
996
|
+
const { HyperliquidSpotAdapter } = await import("../exchanges/hyperliquid-spot.js");
|
|
997
|
+
const hlSpot = new HyperliquidSpotAdapter(adapter);
|
|
998
|
+
await hlSpot.init();
|
|
999
|
+
const bals = await hlSpot.getSpotBalances();
|
|
1000
|
+
const markets = await hlSpot.getSpotMarkets();
|
|
1001
|
+
const priceMap = new Map(markets.map(m => [m.baseToken.toUpperCase(), Number(m.markPrice)]));
|
|
1002
|
+
for (const b of bals) {
|
|
1003
|
+
const base = b.token.replace(/-SPOT$/i, "").toUpperCase();
|
|
1004
|
+
if (base === "USDC" || Number(b.total) <= 0)
|
|
1005
|
+
continue;
|
|
1006
|
+
const price = priceMap.get(base) ?? 0;
|
|
1007
|
+
const value = price * Number(b.total);
|
|
1008
|
+
if (value < 1)
|
|
1009
|
+
continue; // skip dust
|
|
1010
|
+
spotRows.push([
|
|
1011
|
+
chalk.white.bold(base),
|
|
1012
|
+
chalk.gray("hyperliquid"),
|
|
1013
|
+
b.total,
|
|
1014
|
+
`$${formatUsd(price)}`,
|
|
1015
|
+
`$${formatUsd(value)}`,
|
|
1016
|
+
]);
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
else if (exName === "lighter") {
|
|
1020
|
+
const { LighterAdapter } = await import("../exchanges/lighter.js");
|
|
1021
|
+
const { LighterSpotAdapter } = await import("../exchanges/lighter-spot.js");
|
|
1022
|
+
const ltSpot = new LighterSpotAdapter(adapter);
|
|
1023
|
+
await ltSpot.init();
|
|
1024
|
+
const bals = await ltSpot.getSpotBalances();
|
|
1025
|
+
const markets = await ltSpot.getSpotMarkets();
|
|
1026
|
+
const priceMap = new Map(markets.map(m => [m.baseToken.toUpperCase(), Number(m.markPrice)]));
|
|
1027
|
+
for (const b of bals) {
|
|
1028
|
+
if (b.token === "USDC" || b.token === "USDC_SPOT" || Number(b.total) <= 0)
|
|
1029
|
+
continue;
|
|
1030
|
+
const price = priceMap.get(b.token.toUpperCase()) ?? 0;
|
|
1031
|
+
const value = price * Number(b.total);
|
|
1032
|
+
if (value < 1)
|
|
1033
|
+
continue;
|
|
1034
|
+
spotRows.push([
|
|
1035
|
+
chalk.white.bold(b.token),
|
|
1036
|
+
chalk.gray("lighter"),
|
|
1037
|
+
b.total,
|
|
1038
|
+
`$${formatUsd(price)}`,
|
|
1039
|
+
`$${formatUsd(value)}`,
|
|
1040
|
+
]);
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
catch { /* spot not available */ }
|
|
1045
|
+
}
|
|
1046
|
+
if (spotRows.length > 0) {
|
|
1047
|
+
console.log(chalk.white.bold("\n Spot Holdings"));
|
|
1048
|
+
console.log(makeTable(["Token", "Exchange", "Amount", "Price", "Value"], spotRows));
|
|
1049
|
+
}
|
|
990
1050
|
// ── Risk Metrics ──
|
|
991
1051
|
console.log(chalk.white.bold("\n Risk Metrics"));
|
|
992
1052
|
const levelColor = {
|
|
@@ -224,9 +224,10 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
|
|
|
224
224
|
// ── Spot-Perp position detection ──
|
|
225
225
|
// Fetch spot balances from HL and LT, match with perp shorts
|
|
226
226
|
const spotPerpPositions = [];
|
|
227
|
-
// Build reverse map: spot token → perp symbol (UETH→ETH, UBTC→BTC, etc.)
|
|
227
|
+
// Build reverse map: spot token → perp symbol (UETH→ETH, UBTC→BTC, PURR-SPOT→PURR, etc.)
|
|
228
228
|
const spotToPerpSymbol = (token) => {
|
|
229
|
-
|
|
229
|
+
// Strip "-SPOT" suffix that HL SDK appends to spot token names
|
|
230
|
+
const upper = token.replace(/-SPOT$/i, "").toUpperCase();
|
|
230
231
|
return SPOT_PERP_TOKEN_MAP[upper] ?? upper;
|
|
231
232
|
};
|
|
232
233
|
// Collect spot balances per exchange
|
|
@@ -240,7 +241,10 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
|
|
|
240
241
|
const hlSpot = new HyperliquidSpotAdapter(adapter);
|
|
241
242
|
await hlSpot.init();
|
|
242
243
|
const balances = await hlSpot.getSpotBalances();
|
|
243
|
-
spotBalancesByExchange.set(exName, balances.filter(b =>
|
|
244
|
+
spotBalancesByExchange.set(exName, balances.filter(b => {
|
|
245
|
+
const base = b.token.replace(/-SPOT$/i, "").toUpperCase();
|
|
246
|
+
return base !== "USDC" && Number(b.total) > 0;
|
|
247
|
+
}));
|
|
244
248
|
}
|
|
245
249
|
else if (exName === "lighter") {
|
|
246
250
|
const { LighterSpotAdapter } = await import("../../exchanges/lighter-spot.js");
|
|
@@ -266,6 +270,10 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
|
|
|
266
270
|
continue;
|
|
267
271
|
// Find matching perp short position (on any exchange)
|
|
268
272
|
const matchingShorts = allPositions.filter(p => p.symbol === perpSymbol && p.side === "short");
|
|
273
|
+
// Skip dust balances (< $1 at perp mark price)
|
|
274
|
+
const refPrice = matchingShorts[0]?.markPrice ?? 0;
|
|
275
|
+
if (refPrice > 0 && spotAmount * refPrice < 1)
|
|
276
|
+
continue;
|
|
269
277
|
for (const shortPos of matchingShorts) {
|
|
270
278
|
// Check if this short is already used by a perp-perp pair
|
|
271
279
|
const usedByPerpPerp = arbPairs.some(ap => ap.symbol === perpSymbol && ap.shortExchange === shortPos.exchange);
|
|
@@ -311,7 +319,7 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
|
|
|
311
319
|
mode: "spot-perp",
|
|
312
320
|
spotExchange: spotEx,
|
|
313
321
|
perpExchange: shortPos.exchange,
|
|
314
|
-
spotToken: bal.token,
|
|
322
|
+
spotToken: bal.token.replace(/-SPOT$/i, ""),
|
|
315
323
|
spotAmount,
|
|
316
324
|
spotValueUsd,
|
|
317
325
|
perpSide: "short",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
2
|
import { formatUsd, printJson, jsonOk, makeTable } from "../utils.js";
|
|
3
3
|
import { computeExecutableSize } from "../liquidity.js";
|
|
4
|
+
import { computeMatchedSize, computeSpotPerpMatchedSize } from "../arb/sizing.js";
|
|
4
5
|
import { computeAnnualSpread, toHourlyRate } from "../funding.js";
|
|
5
6
|
import { fetchPacificaPricesRaw, parsePacificaRaw, fetchHyperliquidMetaRaw, parseHyperliquidMetaRaw, fetchLighterOrderBookDetailsRaw, fetchLighterFundingRatesRaw, parseLighterRaw, } from "../shared-api.js";
|
|
6
7
|
import { scanDexArb } from "../dex-asset-map.js";
|
|
@@ -1390,7 +1391,7 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
1390
1391
|
return;
|
|
1391
1392
|
}
|
|
1392
1393
|
// 4. Compute matched size in base asset (use the smaller side)
|
|
1393
|
-
//
|
|
1394
|
+
// Uses computeMatchedSize which enforces exchange min order values
|
|
1394
1395
|
const inferDecimals = (levels) => {
|
|
1395
1396
|
for (const [, s] of levels) {
|
|
1396
1397
|
const dot = s.indexOf(".");
|
|
@@ -1401,11 +1402,19 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
1401
1402
|
};
|
|
1402
1403
|
const longDecimals = inferDecimals(longBook.asks);
|
|
1403
1404
|
const shortDecimals = inferDecimals(shortBook.bids);
|
|
1404
|
-
const decimals = Math.min(longDecimals, shortDecimals);
|
|
1405
1405
|
const matchedBase = Math.min(longCheck.recommendedSize, shortCheck.recommendedSize);
|
|
1406
|
-
const matchedSize = (Math.floor(matchedBase * 10 ** decimals) / 10 ** decimals).toFixed(decimals);
|
|
1407
1406
|
const avgPrice = (longCheck.avgFillPrice + shortCheck.avgFillPrice) / 2;
|
|
1408
|
-
const
|
|
1407
|
+
const matchedUsdRaw = matchedBase * avgPrice;
|
|
1408
|
+
const sizing = computeMatchedSize(matchedUsdRaw, avgPrice, longExch, shortExch, { longSizeDecimals: longDecimals, shortSizeDecimals: shortDecimals });
|
|
1409
|
+
if (!sizing) {
|
|
1410
|
+
const msg = `Order too small: $${matchedUsdRaw.toFixed(2)} below exchange minimum. Request a larger size.`;
|
|
1411
|
+
if (isJson())
|
|
1412
|
+
return printJson(jsonOk({ error: msg, computedUsd: matchedUsdRaw }));
|
|
1413
|
+
console.log(chalk.red(`\n ${msg}`));
|
|
1414
|
+
return;
|
|
1415
|
+
}
|
|
1416
|
+
const matchedSize = sizing.size;
|
|
1417
|
+
const matchedUsd = sizing.notional;
|
|
1409
1418
|
if (!isJson()) {
|
|
1410
1419
|
console.log(chalk.cyan(`\n Arb Exec: ${sym}${opts.smart ? " (smart order)" : ""}${dryRun ? " [DRY RUN]" : ""}`));
|
|
1411
1420
|
console.log(chalk.gray(` LONG ${longExch}: buy ${matchedSize} (~$${(matchedBase * longCheck.avgFillPrice).toFixed(2)}, slippage ${longCheck.slippagePct.toFixed(3)}%)`));
|
|
@@ -1689,22 +1698,35 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
1689
1698
|
// Continue — spot order may still work if spot balance already exists
|
|
1690
1699
|
}
|
|
1691
1700
|
}
|
|
1692
|
-
// 2b. Verify
|
|
1701
|
+
// 2b. Verify balance is sufficient after transfer
|
|
1693
1702
|
try {
|
|
1694
|
-
|
|
1695
|
-
const
|
|
1696
|
-
|
|
1697
|
-
|
|
1703
|
+
// HL unified account: perp equity IS spot balance (shared)
|
|
1704
|
+
const isUnified = spotExch === "hyperliquid";
|
|
1705
|
+
let spotUsdcAvail;
|
|
1706
|
+
if (isUnified) {
|
|
1707
|
+
// Unified account: perp adapter's available balance = spot purchasing power
|
|
1708
|
+
const hlBalance = await perpAdapter.getBalance();
|
|
1709
|
+
spotUsdcAvail = Number(hlBalance?.available ?? 0);
|
|
1710
|
+
}
|
|
1711
|
+
else {
|
|
1712
|
+
const spotBalances = await spotAdapter.getSpotBalances();
|
|
1713
|
+
const spotUsdc = spotBalances.find(b => b.token === "USDC_SPOT" || b.token === "USDC");
|
|
1714
|
+
spotUsdcAvail = Number(spotUsdc?.available ?? 0);
|
|
1715
|
+
}
|
|
1716
|
+
// Check perp balance (for perp leg margin)
|
|
1698
1717
|
const perpBalance = await perpAdapter.getBalance();
|
|
1699
1718
|
const perpAvail = Number(perpBalance?.available ?? 0);
|
|
1700
|
-
|
|
1701
|
-
|
|
1719
|
+
// For unified accounts (same exchange), total needed = spotSize + perpMargin from same pool
|
|
1720
|
+
const totalNeeded = spotExch === perpExch ? sizeUsd * 1.5 : sizeUsd;
|
|
1721
|
+
const checkBalance = spotExch === perpExch ? spotUsdcAvail : spotUsdcAvail;
|
|
1722
|
+
if (checkBalance < totalNeeded * 0.9) {
|
|
1723
|
+
const msg = `Insufficient balance: have $${spotUsdcAvail.toFixed(2)}, need ~$${totalNeeded.toFixed(0)} (spot + perp margin)`;
|
|
1702
1724
|
if (isJson())
|
|
1703
|
-
return printJson(jsonOk({ error: msg, spotUsdcAvail, required:
|
|
1725
|
+
return printJson(jsonOk({ error: msg, spotUsdcAvail, required: totalNeeded }));
|
|
1704
1726
|
console.log(chalk.red(` ${msg}`));
|
|
1705
1727
|
return;
|
|
1706
1728
|
}
|
|
1707
|
-
if (perpAvail < sizeUsd * 0.5) {
|
|
1729
|
+
if (spotExch !== perpExch && perpAvail < sizeUsd * 0.5) {
|
|
1708
1730
|
const msg = `Insufficient perp balance: have $${perpAvail.toFixed(2)} available on ${perpExch}, need margin for ~$${sizeUsd}`;
|
|
1709
1731
|
if (isJson())
|
|
1710
1732
|
return printJson(jsonOk({ error: msg, perpAvail, required: sizeUsd }));
|
|
@@ -1763,7 +1785,8 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
1763
1785
|
console.log(chalk.gray(` ${perpExch} perp bids: $${perpCheck.depthUsd.toFixed(0)}`));
|
|
1764
1786
|
return;
|
|
1765
1787
|
}
|
|
1766
|
-
// 4. Compute matched size
|
|
1788
|
+
// 4. Compute matched size (with min order value enforcement)
|
|
1789
|
+
const avgPrice = (spotCheck.avgFillPrice + perpCheck.avgFillPrice) / 2;
|
|
1767
1790
|
const inferDecimals = (levels) => {
|
|
1768
1791
|
for (const [, s] of levels) {
|
|
1769
1792
|
const dot = s.indexOf(".");
|
|
@@ -1773,12 +1796,20 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
|
|
|
1773
1796
|
return 0;
|
|
1774
1797
|
};
|
|
1775
1798
|
const spotDecimals = inferDecimals(spotBook.asks);
|
|
1776
|
-
const perpDecimals = inferDecimals(perpBook.bids);
|
|
1777
|
-
const decimals = Math.min(spotDecimals, perpDecimals);
|
|
1778
1799
|
const matchedBase = Math.min(spotCheck.recommendedSize, perpCheck.recommendedSize);
|
|
1779
|
-
const
|
|
1780
|
-
|
|
1781
|
-
|
|
1800
|
+
const matchedUsdRaw = matchedBase * avgPrice;
|
|
1801
|
+
// Use computeSpotPerpMatchedSize which enforces exchange min order values
|
|
1802
|
+
// (e.g. HL $10 min) — rounds UP if floor would drop below minimum
|
|
1803
|
+
const sizing = computeSpotPerpMatchedSize(matchedUsdRaw, avgPrice, spotExch, perpExch, spotDecimals);
|
|
1804
|
+
if (!sizing) {
|
|
1805
|
+
const msg = `Order too small: $${matchedUsdRaw.toFixed(2)} below exchange minimum. Request a larger size.`;
|
|
1806
|
+
if (isJson())
|
|
1807
|
+
return printJson(jsonOk({ error: msg, computedUsd: matchedUsdRaw }));
|
|
1808
|
+
console.log(chalk.red(`\n ${msg}`));
|
|
1809
|
+
return;
|
|
1810
|
+
}
|
|
1811
|
+
const matchedSize = sizing.size;
|
|
1812
|
+
const matchedUsd = sizing.notional;
|
|
1782
1813
|
if (!isJson()) {
|
|
1783
1814
|
console.log(chalk.cyan(`\n Spot+Perp Arb: ${sym}`));
|
|
1784
1815
|
console.log(chalk.gray(` SPOT ${spotExch}: buy ${matchedSize} (~$${(matchedBase * spotCheck.avgFillPrice).toFixed(2)})`));
|
package/dist/commands/arb.js
CHANGED
|
@@ -190,21 +190,50 @@ function printSnapshotTable(snapshot) {
|
|
|
190
190
|
console.log(chalk.gray(" No funding rate data available.\n"));
|
|
191
191
|
return;
|
|
192
192
|
}
|
|
193
|
+
// Build 24h trend: arrow + change in annualized spread
|
|
194
|
+
const now = new Date();
|
|
195
|
+
const h24ago = new Date(now.getTime() - 24 * 3600_000);
|
|
196
|
+
const trendMap = new Map(); // symbol → "▲+12.3%" or "▼-5.1%"
|
|
197
|
+
for (const s of snapshot.symbols) {
|
|
198
|
+
const bestEx = s.rates.find(r => r.exchange === "hyperliquid")?.exchange
|
|
199
|
+
?? s.rates.find(r => r.exchange === "pacifica")?.exchange
|
|
200
|
+
?? s.rates[0]?.exchange ?? "hyperliquid";
|
|
201
|
+
const history = getHistoricalRates(s.symbol, bestEx, h24ago, now);
|
|
202
|
+
if (history.length >= 2) {
|
|
203
|
+
const oldRate = history[0].hourlyRate;
|
|
204
|
+
const newRate = history[history.length - 1].hourlyRate;
|
|
205
|
+
// Change in annualized rate (hourly × 8760)
|
|
206
|
+
const oldAnn = Math.abs(oldRate) * 8760 * 100;
|
|
207
|
+
const newAnn = Math.abs(newRate) * 8760 * 100;
|
|
208
|
+
const delta = newAnn - oldAnn;
|
|
209
|
+
const absDelta = Math.abs(delta);
|
|
210
|
+
if (absDelta < 1) {
|
|
211
|
+
trendMap.set(s.symbol, chalk.gray(`\u2500 ${absDelta.toFixed(1)}%`)); // ─ stable
|
|
212
|
+
}
|
|
213
|
+
else if (delta > 0) {
|
|
214
|
+
trendMap.set(s.symbol, chalk.red(`\u25B2+${absDelta.toFixed(1)}%`)); // ▲ rising
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
trendMap.set(s.symbol, chalk.green(`\u25BC-${absDelta.toFixed(1)}%`)); // ▼ falling
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
193
221
|
const rows = snapshot.symbols.map(s => {
|
|
194
222
|
const pacRate = s.rates.find(r => r.exchange === "pacifica");
|
|
195
223
|
const hlRate = s.rates.find(r => r.exchange === "hyperliquid");
|
|
196
224
|
const ltRate = s.rates.find(r => r.exchange === "lighter");
|
|
197
225
|
const spreadColor = s.maxSpreadAnnual >= 30 ? chalk.green : s.maxSpreadAnnual >= 10 ? chalk.yellow : chalk.white;
|
|
198
226
|
const bestRate = hlRate ?? pacRate ?? ltRate;
|
|
227
|
+
const trend = trendMap.get(s.symbol) ?? chalk.gray("-");
|
|
199
228
|
return [
|
|
200
229
|
chalk.white.bold(s.symbol), pacRate ? formatPercent(pacRate.fundingRate) : chalk.gray("-"),
|
|
201
230
|
hlRate ? formatPercent(hlRate.fundingRate) : chalk.gray("-"), ltRate ? formatPercent(ltRate.fundingRate) : chalk.gray("-"),
|
|
202
|
-
spreadColor(`${s.maxSpreadAnnual.toFixed(1)}%`),
|
|
231
|
+
spreadColor(`${s.maxSpreadAnnual.toFixed(1)}%`), trend,
|
|
203
232
|
s.maxSpreadAnnual >= 5 ? `${exAbbr(s.shortExchange)}>${exAbbr(s.longExchange)}` : chalk.gray("-"),
|
|
204
233
|
formatAvgRate(bestRate?.historicalAvg?.avg8h), formatAvgRate(bestRate?.historicalAvg?.avg24h), formatAvgRate(bestRate?.historicalAvg?.avg7d),
|
|
205
234
|
];
|
|
206
235
|
});
|
|
207
|
-
console.log(makeTable(["Symbol", "Pacifica", "Hyperliquid", "Lighter", "
|
|
236
|
+
console.log(makeTable(["Symbol", "Pacifica", "Hyperliquid", "Lighter", "Spread", "24h", "Direction", "Avg 8h", "Avg 24h", "Avg 7d"], rows));
|
|
208
237
|
console.log(chalk.gray(`\n ${snapshot.symbols.length} symbols compared across exchanges.\n`));
|
|
209
238
|
}
|
|
210
239
|
function printFundingExchangeStatus(snapshot) {
|
|
@@ -250,9 +279,19 @@ export async function handleRates(isJson, opts) {
|
|
|
250
279
|
}
|
|
251
280
|
catch { /* non-critical */ }
|
|
252
281
|
if (isJson()) {
|
|
282
|
+
// Enrich with 24h rate history for each symbol
|
|
283
|
+
const now = new Date();
|
|
284
|
+
const h24ago = new Date(now.getTime() - 24 * 3600_000);
|
|
285
|
+
const enriched = snapshot.symbols.map(s => {
|
|
286
|
+
const bestEx = s.rates.find(r => r.exchange === "hyperliquid")?.exchange
|
|
287
|
+
?? s.rates.find(r => r.exchange === "pacifica")?.exchange
|
|
288
|
+
?? s.rates[0]?.exchange ?? "hyperliquid";
|
|
289
|
+
const history = getHistoricalRates(s.symbol, bestEx, h24ago, now);
|
|
290
|
+
return { ...s, rateHistory: history.map(h => ({ ts: h.ts, hourlyRate: h.hourlyRate })) };
|
|
291
|
+
});
|
|
253
292
|
if (process.argv.includes("--ndjson") || process.argv.includes("--fields"))
|
|
254
|
-
return printJson(jsonOk(
|
|
255
|
-
return printJson(jsonOk(snapshot));
|
|
293
|
+
return printJson(jsonOk(enriched));
|
|
294
|
+
return printJson(jsonOk({ ...snapshot, symbols: enriched }));
|
|
256
295
|
}
|
|
257
296
|
printSnapshotTable(snapshot);
|
|
258
297
|
printFundingExchangeStatus(snapshot);
|
|
@@ -14,6 +14,7 @@ export declare class HyperliquidSpotAdapter implements SpotAdapter {
|
|
|
14
14
|
private _spotAssetMap;
|
|
15
15
|
private _spotAssetReverse;
|
|
16
16
|
private _spotDecimals;
|
|
17
|
+
private _spotCanonical;
|
|
17
18
|
private _initialized;
|
|
18
19
|
private _cachedMetaCtx;
|
|
19
20
|
constructor(hlAdapter: HyperliquidAdapter);
|
|
@@ -26,6 +26,7 @@ export class HyperliquidSpotAdapter {
|
|
|
26
26
|
_spotAssetMap = new Map(); // "ETH" → spotIndex (not +10000)
|
|
27
27
|
_spotAssetReverse = new Map(); // spotIndex → "ETH"
|
|
28
28
|
_spotDecimals = new Map();
|
|
29
|
+
_spotCanonical = new Map(); // "PURR" → true, "UBTC" → false
|
|
29
30
|
_initialized = false;
|
|
30
31
|
_cachedMetaCtx = null;
|
|
31
32
|
constructor(hlAdapter) {
|
|
@@ -46,27 +47,36 @@ export class HyperliquidSpotAdapter {
|
|
|
46
47
|
return;
|
|
47
48
|
this._cachedMetaCtx = metaCtx;
|
|
48
49
|
const meta = metaCtx[0];
|
|
49
|
-
// Token index → name map
|
|
50
|
+
// Token index → name + szDecimals map (szDecimals lives on tokens, not universe)
|
|
50
51
|
const tokenNames = new Map();
|
|
52
|
+
const tokenSzDecimals = new Map();
|
|
51
53
|
for (const t of meta.tokens ?? []) {
|
|
52
54
|
tokenNames.set(t.index, t.name);
|
|
55
|
+
if (t.szDecimals !== undefined)
|
|
56
|
+
tokenSzDecimals.set(t.index, t.szDecimals);
|
|
53
57
|
}
|
|
54
58
|
// Find USDC token index for filtering
|
|
55
59
|
const usdcTokenIndex = meta.tokens?.find(t => t.name === "USDC")?.index ?? 0;
|
|
60
|
+
// HIP-1 meme tokens with low liquidity / unreliable orderbooks — skip for spot trading
|
|
61
|
+
const SPOT_EXCLUDE = new Set(["TRUMP", "PEPE", "HFUN"]);
|
|
56
62
|
for (const u of meta.universe) {
|
|
57
|
-
const
|
|
63
|
+
const baseTokenIndex = u.tokens[0];
|
|
64
|
+
const baseToken = tokenNames.get(baseTokenIndex) ?? "";
|
|
58
65
|
if (!baseToken)
|
|
59
66
|
continue;
|
|
60
67
|
const key = baseToken.toUpperCase();
|
|
68
|
+
if (SPOT_EXCLUDE.has(key))
|
|
69
|
+
continue;
|
|
61
70
|
const isUsdcPair = u.tokens[1] === usdcTokenIndex;
|
|
62
71
|
if (this._spotAssetMap.has(key) && !isUsdcPair)
|
|
63
72
|
continue;
|
|
64
73
|
this._spotAssetMap.set(key, u.index);
|
|
65
74
|
this._spotAssetReverse.set(u.index, key);
|
|
66
75
|
this._spotDecimals.set(key, {
|
|
67
|
-
size:
|
|
76
|
+
size: tokenSzDecimals.get(baseTokenIndex) ?? 2,
|
|
68
77
|
price: 6,
|
|
69
78
|
});
|
|
79
|
+
this._spotCanonical.set(key, u.isCanonical === true);
|
|
70
80
|
}
|
|
71
81
|
}
|
|
72
82
|
catch (e) {
|
|
@@ -82,8 +92,11 @@ export class HyperliquidSpotAdapter {
|
|
|
82
92
|
if (!metaCtx?.[0]?.universe)
|
|
83
93
|
return [];
|
|
84
94
|
const tokenNames = new Map();
|
|
95
|
+
const tokenSzDec = new Map();
|
|
85
96
|
for (const t of metaCtx[0].tokens ?? []) {
|
|
86
97
|
tokenNames.set(t.index, t.name);
|
|
98
|
+
if (t.szDecimals !== undefined)
|
|
99
|
+
tokenSzDec.set(t.index, t.szDecimals);
|
|
87
100
|
}
|
|
88
101
|
const ctxs = metaCtx[1] ?? [];
|
|
89
102
|
const markets = [];
|
|
@@ -100,7 +113,7 @@ export class HyperliquidSpotAdapter {
|
|
|
100
113
|
quoteToken,
|
|
101
114
|
markPrice: String(ctx.markPx ?? ctx.midPx ?? "0"),
|
|
102
115
|
volume24h: String(ctx.dayNtlVlm ?? "0"),
|
|
103
|
-
sizeDecimals: u.
|
|
116
|
+
sizeDecimals: tokenSzDec.get(u.tokens[0]) ?? 2,
|
|
104
117
|
priceDecimals: 6,
|
|
105
118
|
});
|
|
106
119
|
}
|
|
@@ -112,12 +125,13 @@ export class HyperliquidSpotAdapter {
|
|
|
112
125
|
}
|
|
113
126
|
async getSpotOrderbook(symbol) {
|
|
114
127
|
await this.init();
|
|
115
|
-
const { spotIndex } = this._resolveBase(symbol);
|
|
116
|
-
//
|
|
117
|
-
|
|
128
|
+
const { base, spotIndex } = this._resolveBase(symbol);
|
|
129
|
+
// Canonical tokens (e.g. PURR) use "BASE/USDC", non-canonical (e.g. UBTC) use "@index"
|
|
130
|
+
const isCanonical = this._spotCanonical.get(base) ?? false;
|
|
131
|
+
const coin = isCanonical ? `${base}/USDC` : `@${spotIndex}`;
|
|
118
132
|
const book = await this._infoPost({
|
|
119
133
|
type: "l2Book",
|
|
120
|
-
coin
|
|
134
|
+
coin,
|
|
121
135
|
});
|
|
122
136
|
const levels = book?.levels ?? [[], []];
|
|
123
137
|
return {
|
|
@@ -133,8 +147,9 @@ export class HyperliquidSpotAdapter {
|
|
|
133
147
|
}
|
|
134
148
|
async getSpotBalances() {
|
|
135
149
|
try {
|
|
136
|
-
|
|
137
|
-
const
|
|
150
|
+
// Reuse cached spot clearinghouse state (shared with getBalance() — saves 1 API call)
|
|
151
|
+
const state = await this._hl._getSpotClearinghouseState();
|
|
152
|
+
const balances = (state?.balances ?? []);
|
|
138
153
|
return balances.map((b) => ({
|
|
139
154
|
token: String(b.coin ?? ""),
|
|
140
155
|
total: String(b.total ?? "0"),
|
|
@@ -43,6 +43,8 @@ export declare class HyperliquidAdapter implements ExchangeAdapter {
|
|
|
43
43
|
private ensureAddress;
|
|
44
44
|
/** Always fetches live clearinghouseState, writes result to shared cache for dashboard */
|
|
45
45
|
private _getClearinghouseState;
|
|
46
|
+
/** Cached spot clearinghouse state — shared between getBalance() and HyperliquidSpotAdapter */
|
|
47
|
+
_getSpotClearinghouseState(): Promise<Record<string, unknown>>;
|
|
46
48
|
getBalance(): Promise<ExchangeBalance>;
|
|
47
49
|
getPositions(): Promise<ExchangePosition[]>;
|
|
48
50
|
getOpenOrders(): Promise<ExchangeOrder[]>;
|
|
@@ -258,6 +258,14 @@ export class HyperliquidAdapter {
|
|
|
258
258
|
return state;
|
|
259
259
|
});
|
|
260
260
|
}
|
|
261
|
+
/** Cached spot clearinghouse state — shared between getBalance() and HyperliquidSpotAdapter */
|
|
262
|
+
async _getSpotClearinghouseState() {
|
|
263
|
+
this.ensureAddress();
|
|
264
|
+
const { withCache, TTL_ACCOUNT } = await import("../cache.js");
|
|
265
|
+
return withCache(`acct:hl:spot_chs:${this._address}`, TTL_ACCOUNT, async () => {
|
|
266
|
+
return await this.sdk.info.spot.getSpotClearinghouseState(this._address);
|
|
267
|
+
});
|
|
268
|
+
}
|
|
261
269
|
async getBalance() {
|
|
262
270
|
const state = await this._getClearinghouseState();
|
|
263
271
|
const s = state;
|
|
@@ -277,8 +285,8 @@ export class HyperliquidAdapter {
|
|
|
277
285
|
// Unified account: spot USDC total IS the true equity (includes perp margin as "hold").
|
|
278
286
|
// perp accountValue is a subset — adding both double-counts.
|
|
279
287
|
try {
|
|
280
|
-
const spotState = await this.
|
|
281
|
-
const balances = spotState?.balances ?? [];
|
|
288
|
+
const spotState = await this._getSpotClearinghouseState();
|
|
289
|
+
const balances = (spotState?.balances ?? []);
|
|
282
290
|
const usdc = balances.find((b) => String(b.coin).startsWith("USDC"));
|
|
283
291
|
const spotTotal = usdc?.total !== undefined ? Number(usdc.total) : NaN;
|
|
284
292
|
const spotHold = Number(usdc?.hold ?? 0);
|
|
@@ -15,7 +15,9 @@ export declare class LighterSpotAdapter implements SpotAdapter {
|
|
|
15
15
|
private _spotBaseMap;
|
|
16
16
|
private _spotDecimals;
|
|
17
17
|
private _spotMinSize;
|
|
18
|
+
private _spotPrices;
|
|
18
19
|
private _initialized;
|
|
20
|
+
private _clientOrderCounter;
|
|
19
21
|
constructor(ltAdapter: LighterAdapter);
|
|
20
22
|
init(): Promise<void>;
|
|
21
23
|
private _loadSpotMarkets;
|
|
@@ -32,6 +34,8 @@ export declare class LighterSpotAdapter implements SpotAdapter {
|
|
|
32
34
|
tif?: string;
|
|
33
35
|
}): Promise<unknown>;
|
|
34
36
|
spotCancelOrder(symbol: string, orderId: string): Promise<unknown>;
|
|
37
|
+
/** Cancel all orders across all markets (spot + perp) */
|
|
38
|
+
spotCancelAllOrders(_symbol?: string): Promise<unknown>;
|
|
35
39
|
/** Transfer USDC from perp account to spot account (required before spot buys) */
|
|
36
40
|
transferUsdcToSpot(amount: number): Promise<unknown>;
|
|
37
41
|
/** Transfer USDC from spot account back to perp account */
|
|
@@ -48,6 +52,8 @@ export declare class LighterSpotAdapter implements SpotAdapter {
|
|
|
48
52
|
/** Check if a base token has a spot market */
|
|
49
53
|
hasSpotMarket(base: string): boolean;
|
|
50
54
|
private _placeOrder;
|
|
55
|
+
/** Generate a unique client order index (uint48). Uses timestamp + counter. */
|
|
56
|
+
private _nextClientOrderIndex;
|
|
51
57
|
private _getNextNonce;
|
|
52
58
|
private _sendTx;
|
|
53
59
|
private _restGet;
|
|
@@ -13,7 +13,9 @@ export class LighterSpotAdapter {
|
|
|
13
13
|
_spotBaseMap = new Map(); // "ETH" → "ETH/USDC"
|
|
14
14
|
_spotDecimals = new Map();
|
|
15
15
|
_spotMinSize = new Map(); // min order sizes
|
|
16
|
+
_spotPrices = new Map(); // "ETH/USDC" → markPrice (from init)
|
|
16
17
|
_initialized = false;
|
|
18
|
+
_clientOrderCounter = 0;
|
|
17
19
|
constructor(ltAdapter) {
|
|
18
20
|
this._lt = ltAdapter;
|
|
19
21
|
}
|
|
@@ -40,6 +42,11 @@ export class LighterSpotAdapter {
|
|
|
40
42
|
base: parseFloat(ob.min_base_amount || "0"),
|
|
41
43
|
quote: parseFloat(ob.min_quote_amount || "0"),
|
|
42
44
|
});
|
|
45
|
+
// Cache price from orderBooks response (avoids N separate orderbook calls in getSpotMarkets)
|
|
46
|
+
const price = ob.mark_price ?? (ob.best_ask_price && ob.best_bid_price
|
|
47
|
+
? String((parseFloat(ob.best_ask_price) + parseFloat(ob.best_bid_price)) / 2) : "0");
|
|
48
|
+
if (price !== "0")
|
|
49
|
+
this._spotPrices.set(symbol, price);
|
|
43
50
|
const base = symbol.split("/")[0];
|
|
44
51
|
if (base)
|
|
45
52
|
this._spotBaseMap.set(base, symbol);
|
|
@@ -84,22 +91,25 @@ export class LighterSpotAdapter {
|
|
|
84
91
|
async getSpotMarkets() {
|
|
85
92
|
await this.init();
|
|
86
93
|
const entries = Array.from(this._spotMarketMap.entries());
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
94
|
+
// Use prices cached from init's /orderBooks response (0 extra API calls).
|
|
95
|
+
// Only fetch individual orderbooks if no cached price available.
|
|
96
|
+
const missingPrices = entries.filter(([sym]) => !this._spotPrices.has(sym));
|
|
97
|
+
if (missingPrices.length > 0) {
|
|
98
|
+
const results = await Promise.allSettled(missingPrices.map(([symbol]) => this.getSpotOrderbook(symbol).then(ob => {
|
|
99
|
+
if (ob.bids.length > 0 && ob.asks.length > 0) {
|
|
100
|
+
this._spotPrices.set(symbol, String((Number(ob.bids[0][0]) + Number(ob.asks[0][0])) / 2));
|
|
101
|
+
}
|
|
102
|
+
})));
|
|
103
|
+
void results; // consume
|
|
104
|
+
}
|
|
105
|
+
return entries.map(([symbol]) => {
|
|
95
106
|
const parts = symbol.split("/");
|
|
96
107
|
const base = parts[0] ?? "";
|
|
97
108
|
const quote = parts[1] ?? "USDC";
|
|
98
109
|
const dec = this._spotDecimals.get(symbol) ?? { size: 4, price: 2 };
|
|
99
|
-
const markPrice = obResults[i].status === "fulfilled" ? obResults[i].value : "0";
|
|
100
110
|
return {
|
|
101
111
|
symbol, baseToken: base, quoteToken: quote,
|
|
102
|
-
markPrice, volume24h: "0",
|
|
112
|
+
markPrice: this._spotPrices.get(symbol) ?? "0", volume24h: "0",
|
|
103
113
|
sizeDecimals: dec.size, priceDecimals: dec.price,
|
|
104
114
|
};
|
|
105
115
|
});
|
|
@@ -128,32 +138,36 @@ export class LighterSpotAdapter {
|
|
|
128
138
|
async getSpotBalances() {
|
|
129
139
|
try {
|
|
130
140
|
await this.init();
|
|
131
|
-
const balances = [];
|
|
132
141
|
const address = this._lt.address;
|
|
133
142
|
if (!address)
|
|
134
143
|
return [];
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
144
|
+
// Reuse cached account data (shared with LighterAdapter.fetchAccount() — saves 1 API call)
|
|
145
|
+
const { withCache, TTL_ACCOUNT } = await import("../cache.js");
|
|
146
|
+
const acct = await withCache(`acct:lt:account:${address}`, TTL_ACCOUNT, async () => {
|
|
147
|
+
const res = await this._restGet("/account", {
|
|
148
|
+
by: "l1_address",
|
|
149
|
+
value: address,
|
|
150
|
+
});
|
|
151
|
+
const found = res.accounts?.find(a => a.account_index === this._lt.accountIndex)
|
|
152
|
+
?? res.accounts?.[0];
|
|
153
|
+
return found ?? null;
|
|
138
154
|
});
|
|
139
|
-
const acct = res.accounts?.find(a => a.account_index === this._lt.accountIndex)
|
|
140
|
-
?? res.accounts?.[0];
|
|
141
155
|
if (!acct)
|
|
142
156
|
return [];
|
|
157
|
+
const balances = [];
|
|
143
158
|
// Spot token balances from the assets array only.
|
|
144
159
|
// Perp USDC (collateral) is already reported by getBalance() — don't duplicate here.
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
}
|
|
160
|
+
const assets = (acct.assets ?? []);
|
|
161
|
+
for (const asset of assets) {
|
|
162
|
+
const bal = parseFloat(asset.balance || "0");
|
|
163
|
+
const locked = parseFloat(asset.locked_balance || "0");
|
|
164
|
+
if (bal > 0) {
|
|
165
|
+
balances.push({
|
|
166
|
+
token: asset.symbol === "USDC" ? "USDC_SPOT" : asset.symbol,
|
|
167
|
+
total: asset.balance,
|
|
168
|
+
available: String(bal - locked),
|
|
169
|
+
held: asset.locked_balance,
|
|
170
|
+
});
|
|
157
171
|
}
|
|
158
172
|
}
|
|
159
173
|
return balances;
|
|
@@ -244,18 +258,42 @@ export class LighterSpotAdapter {
|
|
|
244
258
|
if (marketId === undefined) {
|
|
245
259
|
throw new Error(`Unknown Lighter spot market: ${symbol}`);
|
|
246
260
|
}
|
|
247
|
-
//
|
|
261
|
+
// Lighter order IDs can exceed Number.MAX_SAFE_INTEGER (2^53-1).
|
|
262
|
+
// The WASM signer only accepts JS number, so large IDs lose precision
|
|
263
|
+
// and the cancel silently targets the wrong order.
|
|
264
|
+
// Fall back to cancelAll for the market when the ID is unsafe.
|
|
265
|
+
const idNum = Number(orderId);
|
|
266
|
+
if (!Number.isSafeInteger(idNum)) {
|
|
267
|
+
return this.spotCancelAllOrders(symbol);
|
|
268
|
+
}
|
|
248
269
|
const signer = this._lt.signer;
|
|
249
270
|
const nonce = await this._getNextNonce();
|
|
250
271
|
const signed = await signer.signCancelOrder({
|
|
251
272
|
marketIndex: marketId,
|
|
252
|
-
orderIndex:
|
|
273
|
+
orderIndex: idNum,
|
|
253
274
|
nonce,
|
|
254
275
|
apiKeyIndex: this._lt._apiKeyIndex,
|
|
255
276
|
accountIndex: this._lt.accountIndex,
|
|
256
277
|
});
|
|
257
278
|
return this._sendTx(signed);
|
|
258
279
|
}
|
|
280
|
+
/** Cancel all orders across all markets (spot + perp) */
|
|
281
|
+
async spotCancelAllOrders(_symbol) {
|
|
282
|
+
if (this._lt.isReadOnly)
|
|
283
|
+
throw new Error("Cancel requires API key");
|
|
284
|
+
const signer = this._lt.signer;
|
|
285
|
+
const nonce = await this._getNextNonce();
|
|
286
|
+
const apiKeyIndex = this._lt._apiKeyIndex;
|
|
287
|
+
// time must be in milliseconds, in the future
|
|
288
|
+
const signed = await signer.signCancelAllOrders({
|
|
289
|
+
timeInForce: 1,
|
|
290
|
+
time: Date.now() + 3600_000,
|
|
291
|
+
nonce,
|
|
292
|
+
apiKeyIndex,
|
|
293
|
+
accountIndex: this._lt.accountIndex,
|
|
294
|
+
});
|
|
295
|
+
return this._sendTx(signed);
|
|
296
|
+
}
|
|
259
297
|
/** Transfer USDC from perp account to spot account (required before spot buys) */
|
|
260
298
|
async transferUsdcToSpot(amount) {
|
|
261
299
|
return this._selfTransfer(amount, 0, 1); // route: Perp(0) → Spot(1)
|
|
@@ -333,9 +371,13 @@ export class LighterSpotAdapter {
|
|
|
333
371
|
const nonce = await this._getNextNonce();
|
|
334
372
|
const signer = this._lt.signer;
|
|
335
373
|
const apiKeyIndex = this._lt._apiKeyIndex;
|
|
374
|
+
// Use a unique clientOrderIndex (uint48, max 2^48-1 ≈ 281 trillion).
|
|
375
|
+
// This value is used for cancel operations instead of the exchange-assigned
|
|
376
|
+
// order_id which can exceed Number.MAX_SAFE_INTEGER.
|
|
377
|
+
const clientOrderIndex = this._nextClientOrderIndex();
|
|
336
378
|
const signed = await signer.signCreateOrder({
|
|
337
379
|
marketIndex: opts.marketIndex,
|
|
338
|
-
clientOrderIndex
|
|
380
|
+
clientOrderIndex,
|
|
339
381
|
baseAmount: opts.baseAmount,
|
|
340
382
|
price: opts.price,
|
|
341
383
|
isAsk: opts.isAsk,
|
|
@@ -353,6 +395,13 @@ export class LighterSpotAdapter {
|
|
|
353
395
|
}
|
|
354
396
|
return this._sendTx(signed);
|
|
355
397
|
}
|
|
398
|
+
/** Generate a unique client order index (uint48). Uses timestamp + counter. */
|
|
399
|
+
_nextClientOrderIndex() {
|
|
400
|
+
// Timestamp in seconds gives ~10 digits. Add counter for uniqueness within same second.
|
|
401
|
+
// uint48 max = 281_474_976_710_655. Date.now()/1000 ≈ 1.7 trillion (fits).
|
|
402
|
+
const base = Math.floor(Date.now() / 1000) % 281_474_976_710_000;
|
|
403
|
+
return base + (this._clientOrderCounter++ % 655);
|
|
404
|
+
}
|
|
356
405
|
async _getNextNonce() {
|
|
357
406
|
const apiKeyIndex = this._lt._apiKeyIndex;
|
|
358
407
|
const res = await this._restGet("/nextNonce", {
|
|
@@ -9,6 +9,7 @@ export declare class LighterAdapter implements ExchangeAdapter {
|
|
|
9
9
|
private _address;
|
|
10
10
|
private _marketMap;
|
|
11
11
|
private _marketDecimals;
|
|
12
|
+
private _clientOrderCounter;
|
|
12
13
|
private _evmKey;
|
|
13
14
|
private _apiKey;
|
|
14
15
|
private _accountIndexInit;
|
|
@@ -149,6 +150,8 @@ export declare class LighterAdapter implements ExchangeAdapter {
|
|
|
149
150
|
private signOrder;
|
|
150
151
|
private sendTx;
|
|
151
152
|
private restGet;
|
|
153
|
+
/** Generate a unique client order index (uint48). */
|
|
154
|
+
private nextClientOrderIndex;
|
|
152
155
|
private ensureSigner;
|
|
153
156
|
private static _wasmClient;
|
|
154
157
|
private static getWasmClient;
|
|
@@ -7,6 +7,7 @@ export class LighterAdapter {
|
|
|
7
7
|
_address;
|
|
8
8
|
_marketMap = new Map(); // symbol → marketIndex
|
|
9
9
|
_marketDecimals = new Map(); // symbol → decimals
|
|
10
|
+
_clientOrderCounter = 0;
|
|
10
11
|
_evmKey;
|
|
11
12
|
_apiKey;
|
|
12
13
|
_accountIndexInit;
|
|
@@ -73,26 +74,30 @@ export class LighterAdapter {
|
|
|
73
74
|
await this._refreshMarketMap();
|
|
74
75
|
return;
|
|
75
76
|
}
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
77
|
+
// If accountIndex is already known (from env/opts), skip API lookup entirely
|
|
78
|
+
if (this._accountIndexInit >= 0) {
|
|
79
|
+
this._accountIndex = this._accountIndexInit;
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
// Retry account lookup on 429 (rate limit) — critical for _accountIndex
|
|
83
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
84
|
+
try {
|
|
85
|
+
const res = await fetch(`${this._baseUrl}/api/v1/account?by=l1_address&value=${this._address}`);
|
|
86
|
+
if (res.status === 429 && attempt < 2) {
|
|
87
|
+
await new Promise(r => setTimeout(r, 2000 * (attempt + 1)));
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const json = await res.json();
|
|
91
|
+
if (json.accounts && json.accounts.length > 0) {
|
|
92
|
+
this._accountIndex = json.accounts[0].account_index;
|
|
93
|
+
}
|
|
94
|
+
break;
|
|
89
95
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
continue;
|
|
96
|
+
catch {
|
|
97
|
+
if (attempt < 2) {
|
|
98
|
+
await new Promise(r => setTimeout(r, 2000));
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
96
101
|
}
|
|
97
102
|
}
|
|
98
103
|
}
|
|
@@ -434,7 +439,7 @@ export class LighterAdapter {
|
|
|
434
439
|
const { priceTicks: slippageTicks } = this.toTicks(symbol, 0, slippagePrice);
|
|
435
440
|
const signed = await this.signOrder({
|
|
436
441
|
marketIndex,
|
|
437
|
-
clientOrderIndex:
|
|
442
|
+
clientOrderIndex: this.nextClientOrderIndex(),
|
|
438
443
|
baseAmount,
|
|
439
444
|
price: Math.max(slippageTicks, 1),
|
|
440
445
|
isAsk: side === "sell" ? 1 : 0,
|
|
@@ -457,7 +462,7 @@ export class LighterAdapter {
|
|
|
457
462
|
const isIoc = opts?.tif?.toUpperCase() === "IOC";
|
|
458
463
|
const signed = await this.signOrder({
|
|
459
464
|
marketIndex,
|
|
460
|
-
clientOrderIndex:
|
|
465
|
+
clientOrderIndex: this.nextClientOrderIndex(),
|
|
461
466
|
baseAmount,
|
|
462
467
|
price: priceTicks,
|
|
463
468
|
isAsk: side === "sell" ? 1 : 0,
|
|
@@ -473,10 +478,17 @@ export class LighterAdapter {
|
|
|
473
478
|
async cancelOrder(symbol, orderId) {
|
|
474
479
|
this.ensureSigner();
|
|
475
480
|
await this.ensureMarketMap();
|
|
481
|
+
// Lighter order IDs can exceed Number.MAX_SAFE_INTEGER (2^53-1).
|
|
482
|
+
// The WASM signer only accepts JS number, so large IDs lose precision.
|
|
483
|
+
// Fall back to cancelAllOrders when the ID is unsafe.
|
|
484
|
+
const idNum = Number(orderId);
|
|
485
|
+
if (!Number.isSafeInteger(idNum)) {
|
|
486
|
+
return this.cancelAllOrders(symbol);
|
|
487
|
+
}
|
|
476
488
|
const nonce = await this.getNextNonce();
|
|
477
489
|
const marketIndex = this.getMarketIndex(symbol);
|
|
478
490
|
const signed = await this._signer.signCancelOrder({
|
|
479
|
-
marketIndex, orderIndex:
|
|
491
|
+
marketIndex, orderIndex: idNum, nonce,
|
|
480
492
|
apiKeyIndex: this._apiKeyIndex, accountIndex: this._accountIndex,
|
|
481
493
|
});
|
|
482
494
|
return this.sendTx(signed);
|
|
@@ -495,7 +507,7 @@ export class LighterAdapter {
|
|
|
495
507
|
}
|
|
496
508
|
const nonce = await this.getNextNonce();
|
|
497
509
|
const signed = await this._signer.signCancelAllOrders({
|
|
498
|
-
timeInForce:
|
|
510
|
+
timeInForce: 1, time: Date.now() + 3600_000, nonce,
|
|
499
511
|
apiKeyIndex: this._apiKeyIndex, accountIndex: this._accountIndex,
|
|
500
512
|
});
|
|
501
513
|
return this.sendTx(signed);
|
|
@@ -506,9 +518,13 @@ export class LighterAdapter {
|
|
|
506
518
|
const nonce = await this.getNextNonce();
|
|
507
519
|
const marketIndex = this.getMarketIndex(symbol);
|
|
508
520
|
const { baseAmount, priceTicks } = this.toTicks(symbol, parseFloat(size), parseFloat(price));
|
|
521
|
+
const idNum = Number(orderId);
|
|
522
|
+
if (!Number.isSafeInteger(idNum)) {
|
|
523
|
+
throw new Error(`Order ID ${orderId} exceeds MAX_SAFE_INTEGER — cancel and re-place instead`);
|
|
524
|
+
}
|
|
509
525
|
const signed = await this._signer.signModifyOrder({
|
|
510
526
|
marketIndex,
|
|
511
|
-
index:
|
|
527
|
+
index: idNum,
|
|
512
528
|
baseAmount,
|
|
513
529
|
price: priceTicks,
|
|
514
530
|
triggerPrice: 0,
|
|
@@ -541,7 +557,7 @@ export class LighterAdapter {
|
|
|
541
557
|
}
|
|
542
558
|
const signed = await this.signOrder({
|
|
543
559
|
marketIndex,
|
|
544
|
-
clientOrderIndex:
|
|
560
|
+
clientOrderIndex: this.nextClientOrderIndex(),
|
|
545
561
|
baseAmount,
|
|
546
562
|
price: Math.max(priceTicks, 1),
|
|
547
563
|
isAsk: side === "sell" ? 1 : 0,
|
|
@@ -946,6 +962,11 @@ export class LighterAdapter {
|
|
|
946
962
|
throw new Error(`GET ${path} failed (${res.status}): ${await res.text()}`);
|
|
947
963
|
return res.json();
|
|
948
964
|
}
|
|
965
|
+
/** Generate a unique client order index (uint48). */
|
|
966
|
+
nextClientOrderIndex() {
|
|
967
|
+
const base = Math.floor(Date.now() / 1000) % 281_474_976_710_000;
|
|
968
|
+
return base + (this._clientOrderCounter++ % 655);
|
|
969
|
+
}
|
|
949
970
|
ensureSigner() {
|
|
950
971
|
if (this._readOnly || this._accountIndex < 0 || !this._signer) {
|
|
951
972
|
throw new Error("This command requires a Lighter API key. Run `perp -e lighter manage setup-api-key` first, " +
|
|
@@ -79,13 +79,26 @@ export class PacificaAdapter {
|
|
|
79
79
|
async getBalance() {
|
|
80
80
|
if (!this._hasRealKey)
|
|
81
81
|
throw new Error("No private key configured — account data unavailable. Run: perp init");
|
|
82
|
-
const [info, positions] = await Promise.all([
|
|
82
|
+
const [info, positions, prices] = await Promise.all([
|
|
83
83
|
this.client.getAccount(this.account),
|
|
84
84
|
this._getPositions(),
|
|
85
|
+
this._getPrices(),
|
|
85
86
|
]);
|
|
86
87
|
const raw = info;
|
|
87
|
-
|
|
88
|
-
|
|
88
|
+
const priceMap = new Map(prices.map((p) => [p.symbol, p]));
|
|
89
|
+
// API often returns unrealized_pnl=0 — compute from price delta when needed
|
|
90
|
+
let totalPnl = 0;
|
|
91
|
+
for (const p of positions) {
|
|
92
|
+
let upnl = Number(p.unrealized_pnl ?? 0);
|
|
93
|
+
if (upnl === 0 && Number(p.amount) > 0) {
|
|
94
|
+
const mark = Number(priceMap.get(p.symbol)?.mark ?? p.mark_price ?? 0);
|
|
95
|
+
const entry = Number(p.entry_price);
|
|
96
|
+
const dir = p.side === "bid" ? 1 : -1;
|
|
97
|
+
if (mark > 0 && entry > 0)
|
|
98
|
+
upnl = (mark - entry) * Number(p.amount) * dir;
|
|
99
|
+
}
|
|
100
|
+
totalPnl += upnl;
|
|
101
|
+
}
|
|
89
102
|
return {
|
|
90
103
|
equity: info.account_equity,
|
|
91
104
|
available: info.available_to_spend,
|
|
@@ -114,6 +127,15 @@ export class PacificaAdapter {
|
|
|
114
127
|
return positions.map((p) => {
|
|
115
128
|
const mark = priceMap.get(p.symbol)?.mark ?? p.mark_price ?? "0";
|
|
116
129
|
const side = p.side === "bid" ? "long" : "short";
|
|
130
|
+
const size = Number(p.amount);
|
|
131
|
+
const entry = Number(p.entry_price);
|
|
132
|
+
const markNum = Number(mark);
|
|
133
|
+
// API often returns unrealized_pnl=0 — compute from price delta when needed
|
|
134
|
+
let upnl = Number(p.unrealized_pnl ?? 0);
|
|
135
|
+
if (upnl === 0 && size > 0 && entry > 0 && markNum > 0) {
|
|
136
|
+
const dir = side === "long" ? 1 : -1;
|
|
137
|
+
upnl = (markNum - entry) * size * dir;
|
|
138
|
+
}
|
|
117
139
|
return {
|
|
118
140
|
symbol: p.symbol,
|
|
119
141
|
side: side,
|
|
@@ -121,7 +143,7 @@ export class PacificaAdapter {
|
|
|
121
143
|
entryPrice: String(p.entry_price),
|
|
122
144
|
markPrice: mark,
|
|
123
145
|
liquidationPrice: String(p.liquidation_price ?? "N/A"),
|
|
124
|
-
unrealizedPnl:
|
|
146
|
+
unrealizedPnl: upnl.toFixed(4),
|
|
125
147
|
leverage: p.leverage ?? levMap.get(String(p.symbol)) ?? 1,
|
|
126
148
|
};
|
|
127
149
|
});
|
package/dist/index.js
CHANGED
|
@@ -322,16 +322,284 @@ const apiSpecCmd = program
|
|
|
322
322
|
printJson(jsonOk(getCliSpec(program)));
|
|
323
323
|
});
|
|
324
324
|
apiSpecCmd._hidden = true;
|
|
325
|
-
//
|
|
326
|
-
|
|
327
|
-
.
|
|
325
|
+
// Unified dashboard: balances + positions + top arb opportunities
|
|
326
|
+
program.command("status")
|
|
327
|
+
.description("Unified dashboard: balances, positions, and top arb opportunities")
|
|
328
|
+
.option("--health", "Check connectivity only")
|
|
329
|
+
.action(async (opts) => {
|
|
328
330
|
if (opts.health) {
|
|
329
331
|
const { runHealthCheck } = await import("./commands/risk.js");
|
|
330
332
|
return runHealthCheck(isJson);
|
|
331
333
|
}
|
|
332
|
-
|
|
334
|
+
const json = isJson();
|
|
335
|
+
const { formatUsd, formatPnl, makeTable, printJson, jsonOk, withJsonErrors } = await import("./utils.js");
|
|
336
|
+
const { fetchAllFundingRates, TOP_SYMBOLS } = await import("./funding-rates.js");
|
|
337
|
+
const { saveFundingSnapshot, getHistoricalRates } = await import("./funding-history.js");
|
|
338
|
+
await withJsonErrors(json, async () => {
|
|
339
|
+
const EX_LIST = ["pacifica", "hyperliquid", "lighter"];
|
|
340
|
+
const exAbbr = (e) => e === "pacifica" ? "PAC" : e === "hyperliquid" ? "HL" : "LT";
|
|
341
|
+
const snapshotPromises = EX_LIST.map(async (ex) => {
|
|
342
|
+
try {
|
|
343
|
+
const adapter = await getAdapterForExchange(ex);
|
|
344
|
+
const [balance, positions, orders] = await Promise.all([
|
|
345
|
+
adapter.getBalance(), adapter.getPositions(), adapter.getOpenOrders(),
|
|
346
|
+
]);
|
|
347
|
+
// Fetch spot balances for HL and LT (Pacifica is perp-only)
|
|
348
|
+
let spotHoldings = [];
|
|
349
|
+
let isUnified = false;
|
|
350
|
+
try {
|
|
351
|
+
if (ex === "hyperliquid") {
|
|
352
|
+
const { HyperliquidSpotAdapter } = await import("./exchanges/hyperliquid-spot.js");
|
|
353
|
+
const hlSpot = new HyperliquidSpotAdapter(adapter);
|
|
354
|
+
await hlSpot.init();
|
|
355
|
+
const [raw, markets] = await Promise.all([hlSpot.getSpotBalances(), hlSpot.getSpotMarkets()]);
|
|
356
|
+
const priceMap = new Map(markets.map(m => [m.baseToken.toUpperCase(), Number(m.markPrice)]));
|
|
357
|
+
const strip = (t) => t.replace(/-SPOT$/i, "").toUpperCase();
|
|
358
|
+
spotHoldings = raw.filter(b => Number(b.total) > 0).map(b => {
|
|
359
|
+
const base = strip(b.token);
|
|
360
|
+
return { ...b, valueUsd: base === "USDC" ? Number(b.total) : (priceMap.get(base) ?? 0) * Number(b.total) };
|
|
361
|
+
});
|
|
362
|
+
isUnified = !adapter.dex;
|
|
363
|
+
}
|
|
364
|
+
else if (ex === "lighter") {
|
|
365
|
+
const { LighterAdapter } = await import("./exchanges/lighter.js");
|
|
366
|
+
const { LighterSpotAdapter } = await import("./exchanges/lighter-spot.js");
|
|
367
|
+
const ltSpot = new LighterSpotAdapter(adapter);
|
|
368
|
+
await ltSpot.init();
|
|
369
|
+
const [raw, markets] = await Promise.all([ltSpot.getSpotBalances(), ltSpot.getSpotMarkets()]);
|
|
370
|
+
const priceMap = new Map(markets.map(m => [m.baseToken.toUpperCase(), Number(m.markPrice)]));
|
|
371
|
+
spotHoldings = raw.filter(b => Number(b.total) > 0).map(b => ({
|
|
372
|
+
...b,
|
|
373
|
+
valueUsd: b.token === "USDC" || b.token === "USDC_SPOT" ? Number(b.total) : (priceMap.get(b.token.toUpperCase()) ?? 0) * Number(b.total),
|
|
374
|
+
}));
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
catch { /* spot not available */ }
|
|
378
|
+
return { exchange: ex, connected: true, balance, positions, openOrders: orders.length, spotHoldings, isUnified, error: undefined };
|
|
379
|
+
}
|
|
380
|
+
catch (err) {
|
|
381
|
+
return { exchange: ex, connected: false, balance: null, positions: [], openOrders: 0, spotHoldings: [], isUnified: false, error: err instanceof Error ? err.message : String(err) };
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
const arbPromise = fetchAllFundingRates({ symbols: TOP_SYMBOLS, minSpread: 0 }).catch(() => null);
|
|
385
|
+
const [snapshots, arbSnapshot] = await Promise.all([Promise.all(snapshotPromises), arbPromise]);
|
|
386
|
+
// Save funding snapshot for sparkline history
|
|
387
|
+
if (arbSnapshot) {
|
|
388
|
+
try {
|
|
389
|
+
const allRates = arbSnapshot.symbols.flatMap(s => s.rates);
|
|
390
|
+
if (allRates.length > 0)
|
|
391
|
+
saveFundingSnapshot(allRates);
|
|
392
|
+
}
|
|
393
|
+
catch { /* */ }
|
|
394
|
+
}
|
|
395
|
+
// Build totals (include non-USDC spot value for unified accounts)
|
|
396
|
+
let totalEquity = 0;
|
|
397
|
+
let totalSpotValue = 0;
|
|
398
|
+
const allPositions = [];
|
|
399
|
+
const allSpotHoldings = [];
|
|
400
|
+
for (const s of snapshots) {
|
|
401
|
+
if (s.balance)
|
|
402
|
+
totalEquity += Number(s.balance.equity);
|
|
403
|
+
for (const p of s.positions)
|
|
404
|
+
allPositions.push({ ...p, exchange: s.exchange });
|
|
405
|
+
// For unified accounts (HL), USDC is already in perp equity — only add non-USDC spot
|
|
406
|
+
const displaySpot = s.isUnified
|
|
407
|
+
? s.spotHoldings.filter(b => b.token.replace(/-SPOT$/i, "").toUpperCase() !== "USDC")
|
|
408
|
+
: s.spotHoldings;
|
|
409
|
+
const spotVal = displaySpot.reduce((sum, b) => sum + b.valueUsd, 0);
|
|
410
|
+
totalSpotValue += spotVal;
|
|
411
|
+
totalEquity += spotVal;
|
|
412
|
+
for (const b of displaySpot)
|
|
413
|
+
allSpotHoldings.push({ ...b, exchange: s.exchange });
|
|
414
|
+
}
|
|
415
|
+
// Top arb
|
|
416
|
+
const topArb = arbSnapshot
|
|
417
|
+
? arbSnapshot.symbols.filter(s => s.maxSpreadAnnual >= 5).sort((a, b) => b.maxSpreadAnnual - a.maxSpreadAnnual).slice(0, 5)
|
|
418
|
+
: [];
|
|
419
|
+
// Risk
|
|
420
|
+
const marginUsed = snapshots.reduce((s, x) => s + (x.balance ? Number(x.balance.marginUsed) : 0), 0);
|
|
421
|
+
const marginPct = totalEquity > 0 ? (marginUsed / totalEquity) * 100 : 0;
|
|
422
|
+
const riskLevel = marginPct < 30 ? "LOW" : marginPct < 60 ? "MEDIUM" : "HIGH";
|
|
423
|
+
if (json) {
|
|
424
|
+
const now = new Date();
|
|
425
|
+
const h24 = new Date(now.getTime() - 24 * 3600_000);
|
|
426
|
+
return printJson(jsonOk({
|
|
427
|
+
version: _pkg.version,
|
|
428
|
+
totalEquity,
|
|
429
|
+
riskLevel,
|
|
430
|
+
exchanges: snapshots.map(s => ({
|
|
431
|
+
name: s.exchange, connected: s.connected,
|
|
432
|
+
equity: s.balance ? Number(s.balance.equity) : 0,
|
|
433
|
+
available: s.balance ? Number(s.balance.available) : 0,
|
|
434
|
+
spotHoldings: s.spotHoldings.filter(b => Number(b.total) > 0).map(b => ({
|
|
435
|
+
token: b.token, total: b.total, available: b.available, valueUsd: b.valueUsd,
|
|
436
|
+
})),
|
|
437
|
+
})),
|
|
438
|
+
totalSpotValue,
|
|
439
|
+
positions: allPositions,
|
|
440
|
+
topArbOpportunities: await (async () => {
|
|
441
|
+
const { getHistoricalAverages: getAvgs } = await import("./funding-history.js");
|
|
442
|
+
const syms = topArb.map(a => a.symbol);
|
|
443
|
+
const avgs = syms.length > 0 ? getAvgs(syms, ["hyperliquid", "pacifica", "lighter"]) : new Map();
|
|
444
|
+
return topArb.map(a => {
|
|
445
|
+
const bestEx = a.rates.find(r => r.exchange === "hyperliquid")?.exchange ?? a.rates[0]?.exchange ?? "hyperliquid";
|
|
446
|
+
const avg = avgs.get(`${a.symbol}:${bestEx}`);
|
|
447
|
+
return {
|
|
448
|
+
symbol: a.symbol, spreadAnnual: a.maxSpreadAnnual,
|
|
449
|
+
direction: `${exAbbr(a.shortExchange)}>${exAbbr(a.longExchange)}`,
|
|
450
|
+
avg24h: avg?.avg24h != null ? Math.abs(avg.avg24h) * 8760 * 100 : null,
|
|
451
|
+
avg7d: avg?.avg7d != null ? Math.abs(avg.avg7d) * 8760 * 100 : null,
|
|
452
|
+
rateHistory: getHistoricalRates(a.symbol, bestEx, h24, now).map(h => ({ ts: h.ts, hourlyRate: h.hourlyRate })),
|
|
453
|
+
};
|
|
454
|
+
});
|
|
455
|
+
})(),
|
|
456
|
+
}));
|
|
457
|
+
}
|
|
458
|
+
// ── Terminal UI ──
|
|
459
|
+
console.log(chalk.cyan.bold(`\n perp-cli v${_pkg.version}`) + chalk.gray(` — ${snapshots.filter(s => s.connected).length} exchanges connected\n`));
|
|
460
|
+
// Balance bars + Top Arb side by side
|
|
461
|
+
const now = new Date();
|
|
462
|
+
const h24 = new Date(now.getTime() - 24 * 3600_000);
|
|
463
|
+
// Left column: Balances (perp + spot per exchange)
|
|
464
|
+
// Compute per-exchange totals: perp equity + non-USDC spot (unified) or all spot
|
|
465
|
+
const exTotals = snapshots.map(s => {
|
|
466
|
+
if (!s.connected || !s.balance)
|
|
467
|
+
return { total: 0, margin: 0, equity: 0 };
|
|
468
|
+
const equity = Number(s.balance.equity);
|
|
469
|
+
const margin = Number(s.balance.marginUsed);
|
|
470
|
+
const displaySpot = s.isUnified
|
|
471
|
+
? s.spotHoldings.filter(b => b.token.replace(/-SPOT$/i, "").toUpperCase() !== "USDC")
|
|
472
|
+
: s.spotHoldings;
|
|
473
|
+
const spotVal = displaySpot.reduce((sum, b) => sum + b.valueUsd, 0);
|
|
474
|
+
return { total: equity + spotVal, margin, equity };
|
|
475
|
+
});
|
|
476
|
+
const balLines = [];
|
|
477
|
+
balLines.push(chalk.white.bold(" Balances"));
|
|
478
|
+
for (let i = 0; i < snapshots.length; i++) {
|
|
479
|
+
const s = snapshots[i];
|
|
480
|
+
if (!s.connected) {
|
|
481
|
+
balLines.push(` ${chalk.gray(exAbbr(s.exchange).padEnd(4))} ${chalk.red("disconnected")}`);
|
|
482
|
+
continue;
|
|
483
|
+
}
|
|
484
|
+
const { total, margin, equity } = exTotals[i];
|
|
485
|
+
const usagePct = equity > 0 ? (margin / equity) * 100 : 0;
|
|
486
|
+
const barFull = Math.min(20, Math.round(usagePct / 5));
|
|
487
|
+
const usageColor = usagePct < 30 ? chalk.green : usagePct < 60 ? chalk.yellow : chalk.red;
|
|
488
|
+
const bar = usageColor("\u2588".repeat(barFull)) + chalk.gray("\u2591".repeat(20 - barFull));
|
|
489
|
+
balLines.push(` ${chalk.white.bold(exAbbr(s.exchange).padEnd(4))} $${formatUsd(total).padEnd(9)} ${bar} ${usagePct.toFixed(0)}% used`);
|
|
490
|
+
}
|
|
491
|
+
// Spot holdings (non-USDC tokens with value)
|
|
492
|
+
const displaySpotHoldings = allSpotHoldings.filter(b => {
|
|
493
|
+
const tk = b.token.replace(/[-_]SPOT$/i, "").toUpperCase();
|
|
494
|
+
return tk !== "USDC";
|
|
495
|
+
});
|
|
496
|
+
if (displaySpotHoldings.length > 0) {
|
|
497
|
+
balLines.push("");
|
|
498
|
+
balLines.push(chalk.white.bold(" Spot Holdings"));
|
|
499
|
+
for (const b of displaySpotHoldings) {
|
|
500
|
+
const token = b.token.replace(/-SPOT$/i, "");
|
|
501
|
+
const ex = exAbbr(b.exchange);
|
|
502
|
+
balLines.push(` ${chalk.gray(ex.padEnd(4))} ${chalk.white.bold(token.padEnd(6))} ${b.total.padEnd(12)} ${chalk.gray(`$${formatUsd(b.valueUsd)}`)}`);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
const riskColor = riskLevel === "LOW" ? chalk.green : riskLevel === "MEDIUM" ? chalk.yellow : chalk.red;
|
|
506
|
+
balLines.push(` ${"─".repeat(44)}`);
|
|
507
|
+
balLines.push(` ${chalk.cyan.bold("Total")} ${chalk.cyan.bold(`$${formatUsd(totalEquity)}`.padEnd(9))} Risk: ${riskColor(riskLevel)}`);
|
|
508
|
+
// Right column: Top Arb Opportunities (with 24h/7d averages from local history — 0 API calls)
|
|
509
|
+
const { getHistoricalAverages } = await import("./funding-history.js");
|
|
510
|
+
const arbSymbols = topArb.map(a => a.symbol);
|
|
511
|
+
const arbExchanges = ["hyperliquid", "pacifica", "lighter"];
|
|
512
|
+
const histAvgs = arbSymbols.length > 0 ? getHistoricalAverages(arbSymbols, arbExchanges) : new Map();
|
|
513
|
+
const arbLines = [];
|
|
514
|
+
arbLines.push(chalk.white.bold(" Top Arb Opportunities"));
|
|
515
|
+
if (topArb.length > 0) {
|
|
516
|
+
// SYMBOL SPREAD 24h TREND 7d DIR
|
|
517
|
+
arbLines.push(chalk.gray(" " + "".padEnd(8) + "now".padEnd(9) + "24h".padEnd(7) + "".padEnd(9) + "7d".padEnd(7)));
|
|
518
|
+
for (const a of topArb) {
|
|
519
|
+
const spreadColor = a.maxSpreadAnnual >= 100 ? chalk.green.bold : a.maxSpreadAnnual >= 30 ? chalk.green : chalk.yellow;
|
|
520
|
+
const dir = `${exAbbr(a.shortExchange)}>${exAbbr(a.longExchange)}`;
|
|
521
|
+
const bestEx = a.rates.find(r => r.exchange === "hyperliquid")?.exchange ?? a.rates[0]?.exchange ?? "hyperliquid";
|
|
522
|
+
// 24h trend arrow
|
|
523
|
+
const history = getHistoricalRates(a.symbol, bestEx, h24, now);
|
|
524
|
+
let trend = "";
|
|
525
|
+
if (history.length >= 2) {
|
|
526
|
+
const oldAnn = Math.abs(history[0].hourlyRate) * 8760 * 100;
|
|
527
|
+
const newAnn = Math.abs(history[history.length - 1].hourlyRate) * 8760 * 100;
|
|
528
|
+
const delta = newAnn - oldAnn;
|
|
529
|
+
const abs = Math.abs(delta);
|
|
530
|
+
trend = abs < 1 ? chalk.gray(`\u2500${abs.toFixed(0)}%`)
|
|
531
|
+
: delta > 0 ? chalk.red(`\u25B2+${abs.toFixed(0)}%`)
|
|
532
|
+
: chalk.green(`\u25BC-${abs.toFixed(0)}%`);
|
|
533
|
+
}
|
|
534
|
+
// 24h and 7d averages (annualized from hourly)
|
|
535
|
+
const avg = histAvgs.get(`${a.symbol}:${bestEx}`);
|
|
536
|
+
const avg24h = avg?.avg24h != null ? `${(Math.abs(avg.avg24h) * 8760 * 100).toFixed(0)}%` : "-";
|
|
537
|
+
const avg7d = avg?.avg7d != null ? `${(Math.abs(avg.avg7d) * 8760 * 100).toFixed(0)}%` : "-";
|
|
538
|
+
arbLines.push(` ${chalk.white.bold(a.symbol.padEnd(8))} ${spreadColor(`${a.maxSpreadAnnual.toFixed(1)}%`.padEnd(9))}${chalk.gray(avg24h.padEnd(7))}${trend.padEnd(9)}${chalk.gray(avg7d.padEnd(7))}${chalk.gray(dir)}`);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
else {
|
|
542
|
+
arbLines.push(chalk.gray(" No opportunities above 5%"));
|
|
543
|
+
}
|
|
544
|
+
// Print layout — side-by-side if terminal is wide enough, else stacked
|
|
545
|
+
const stripAnsi = (s) => s.replace(/\u001b\[[0-9;]*m/g, "");
|
|
546
|
+
const termW = process.stdout.columns || 80;
|
|
547
|
+
const SIDE_BY_SIDE_MIN = 100; // 2 indent + 1 + 46 + 1 + 48 + 1
|
|
548
|
+
if (termW >= SIDE_BY_SIDE_MIN) {
|
|
549
|
+
const LEFT_W = 46;
|
|
550
|
+
const RIGHT_W = 48;
|
|
551
|
+
const maxLines = Math.max(balLines.length, arbLines.length);
|
|
552
|
+
console.log(` ${"┌"}${"─".repeat(LEFT_W)}${"┬"}${"─".repeat(RIGHT_W)}${"┐"}`);
|
|
553
|
+
for (let i = 0; i < maxLines; i++) {
|
|
554
|
+
const left = balLines[i] ?? "";
|
|
555
|
+
const right = arbLines[i] ?? "";
|
|
556
|
+
const leftPad = LEFT_W - stripAnsi(left).length;
|
|
557
|
+
const rightPad = RIGHT_W - stripAnsi(right).length;
|
|
558
|
+
console.log(` \u2502${left}${" ".repeat(Math.max(0, leftPad))}\u2502${right}${" ".repeat(Math.max(0, rightPad))}\u2502`);
|
|
559
|
+
}
|
|
560
|
+
console.log(` ${"└"}${"─".repeat(LEFT_W)}${"┴"}${"─".repeat(RIGHT_W)}${"┘"}`);
|
|
561
|
+
}
|
|
562
|
+
else {
|
|
563
|
+
// Stacked layout for narrow terminals
|
|
564
|
+
const boxW = Math.min(termW - 4, 74); // 2 indent + 2 borders
|
|
565
|
+
const printBox = (lines) => {
|
|
566
|
+
console.log(` ${"┌"}${"─".repeat(boxW)}${"┐"}`);
|
|
567
|
+
for (const line of lines) {
|
|
568
|
+
const pad = boxW - stripAnsi(line).length;
|
|
569
|
+
console.log(` \u2502${line}${" ".repeat(Math.max(0, pad))}\u2502`);
|
|
570
|
+
}
|
|
571
|
+
console.log(` ${"└"}${"─".repeat(boxW)}${"┘"}`);
|
|
572
|
+
};
|
|
573
|
+
printBox(balLines);
|
|
574
|
+
console.log();
|
|
575
|
+
printBox(arbLines);
|
|
576
|
+
}
|
|
577
|
+
// Positions
|
|
578
|
+
if (allPositions.length > 0) {
|
|
579
|
+
console.log(chalk.white.bold("\n Positions"));
|
|
580
|
+
const posRows = allPositions.map(p => {
|
|
581
|
+
const sideColor = p.side === "long" ? chalk.green : chalk.red;
|
|
582
|
+
const notional = Math.abs(Number(p.size) * Number(p.markPrice));
|
|
583
|
+
const levStr = p.leverage > 0 ? `${p.leverage}x` : "-";
|
|
584
|
+
const warn = p.leverage >= 5 ? chalk.red(" \u26A0") : "";
|
|
585
|
+
return [
|
|
586
|
+
chalk.white.bold(p.symbol.replace("-PERP", "")),
|
|
587
|
+
chalk.gray(exAbbr(p.exchange)),
|
|
588
|
+
sideColor(p.side.toUpperCase()),
|
|
589
|
+
p.size,
|
|
590
|
+
`$${formatUsd(p.entryPrice)}\u2192$${formatUsd(p.markPrice)}`,
|
|
591
|
+
formatPnl(p.unrealizedPnl),
|
|
592
|
+
`$${formatUsd(notional)}`,
|
|
593
|
+
levStr + warn,
|
|
594
|
+
];
|
|
595
|
+
});
|
|
596
|
+
console.log(makeTable(["Symbol", "Ex", "Side", "Size", "Entry\u2192Mark", "uPnL", "Notional", "Lev"], posRows));
|
|
597
|
+
}
|
|
598
|
+
else {
|
|
599
|
+
console.log(chalk.gray("\n No open positions.\n"));
|
|
600
|
+
}
|
|
601
|
+
});
|
|
333
602
|
});
|
|
334
|
-
statusCmd._hidden = true;
|
|
335
603
|
// Switch shared API URLs if --network testnet is used
|
|
336
604
|
program.hook("preAction", () => {
|
|
337
605
|
const network = program.opts().network;
|
package/dist/utils.d.ts
CHANGED
|
@@ -3,6 +3,8 @@ export declare function formatUsd(value: string | number): string;
|
|
|
3
3
|
export declare function formatPnl(value: string | number): string;
|
|
4
4
|
export declare function formatPercent(value: string | number): string;
|
|
5
5
|
export declare function makeTable(head: string[], rows: string[][]): string;
|
|
6
|
+
/** Render a sparkline from numeric values. Returns unicode bar chart string. */
|
|
7
|
+
export declare function sparkline(values: number[], width?: number): string;
|
|
6
8
|
export declare function printJson(data: unknown): void;
|
|
7
9
|
export declare function errorAndExit(msg: string): never;
|
|
8
10
|
/** Standard JSON response envelope */
|
package/dist/utils.js
CHANGED
|
@@ -96,6 +96,38 @@ function sanitizeValue(val) {
|
|
|
96
96
|
}
|
|
97
97
|
return val;
|
|
98
98
|
}
|
|
99
|
+
// ── Sparkline ──
|
|
100
|
+
// Density-based shading — renders consistently across all terminal fonts
|
|
101
|
+
// (height-based ▁▂▃▄▅▆▇█ looks identical in many monospace fonts)
|
|
102
|
+
const SPARK_CHARS = [" ", "░", "▒", "▓", "█"];
|
|
103
|
+
/** Render a sparkline from numeric values. Returns unicode bar chart string. */
|
|
104
|
+
export function sparkline(values, width) {
|
|
105
|
+
if (values.length === 0)
|
|
106
|
+
return "";
|
|
107
|
+
const data = width && values.length > width ? downsample(values, width) : values;
|
|
108
|
+
const min = Math.min(...data);
|
|
109
|
+
const max = Math.max(...data);
|
|
110
|
+
const range = max - min;
|
|
111
|
+
if (range === 0)
|
|
112
|
+
return SPARK_CHARS[2].repeat(data.length);
|
|
113
|
+
return data.map(v => {
|
|
114
|
+
const idx = Math.round(((v - min) / range) * (SPARK_CHARS.length - 1));
|
|
115
|
+
return SPARK_CHARS[idx];
|
|
116
|
+
}).join("");
|
|
117
|
+
}
|
|
118
|
+
function downsample(values, targetLen) {
|
|
119
|
+
const bucketSize = values.length / targetLen;
|
|
120
|
+
const result = [];
|
|
121
|
+
for (let i = 0; i < targetLen; i++) {
|
|
122
|
+
const start = Math.floor(i * bucketSize);
|
|
123
|
+
const end = Math.floor((i + 1) * bucketSize);
|
|
124
|
+
let sum = 0;
|
|
125
|
+
for (let j = start; j < end; j++)
|
|
126
|
+
sum += values[j];
|
|
127
|
+
result.push(sum / (end - start));
|
|
128
|
+
}
|
|
129
|
+
return result;
|
|
130
|
+
}
|
|
99
131
|
// ── JSON Output ──
|
|
100
132
|
const _isNdjson = () => process.argv.includes("--ndjson");
|
|
101
133
|
export function printJson(data) {
|