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.
@@ -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
- const upper = token.toUpperCase();
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 => b.token !== "USDC" && Number(b.total) > 0));
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
- // Round down to each exchange's lot size (inferred from orderbook)
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 matchedUsd = matchedBase * avgPrice;
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 spot USDC balance is sufficient after transfer
1701
+ // 2b. Verify balance is sufficient after transfer
1693
1702
  try {
1694
- const spotBalances = await spotAdapter.getSpotBalances();
1695
- const spotUsdc = spotBalances.find(b => b.token === "USDC_SPOT" || b.token === "USDC");
1696
- const spotUsdcAvail = Number(spotUsdc?.available ?? 0);
1697
- // Also check perp balance for the perp leg
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
- if (spotUsdcAvail < sizeUsd * 0.9) {
1701
- const msg = `Insufficient spot USDC: have $${spotUsdcAvail.toFixed(2)}, need ~$${sizeUsd}. Transfer may have failed.`;
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: sizeUsd }));
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 matchedSize = (Math.floor(matchedBase * 10 ** decimals) / 10 ** decimals).toFixed(decimals);
1780
- const avgPrice = (spotCheck.avgFillPrice + perpCheck.avgFillPrice) / 2;
1781
- const matchedUsd = matchedBase * avgPrice;
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)})`));
@@ -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", "Ann. Spread", "Direction", "Avg 8h", "Avg 24h", "Avg 7d"], rows));
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(snapshot.symbols));
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 baseToken = tokenNames.get(u.tokens[0]) ?? "";
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: u.szDecimals ?? 2,
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.szDecimals ?? 2,
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
- // HL spot L2 book uses the universe name, which is like "ETH/USDC" internally
117
- // But the API takes coin=<name>, where name is from spotMeta universe
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: `@${spotIndex}`,
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
- const state = await this._hl.client.info.spot.getSpotClearinghouseState(this._hl.address);
137
- const balances = state?.balances ?? [];
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.sdk.info.spot.getSpotClearinghouseState(this._address);
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
- // Fetch all orderbooks in parallel (instead of sequential per-market)
88
- const obResults = await Promise.allSettled(entries.map(([symbol]) => this.getSpotOrderbook(symbol).then(ob => {
89
- if (ob.bids.length > 0 && ob.asks.length > 0) {
90
- return String((Number(ob.bids[0][0]) + Number(ob.asks[0][0])) / 2);
91
- }
92
- return "0";
93
- })));
94
- return entries.map(([symbol], i) => {
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
- const res = await this._restGet("/account", {
136
- by: "l1_address",
137
- value: address,
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
- if (acct.assets && acct.assets.length > 0) {
146
- for (const asset of acct.assets) {
147
- const bal = parseFloat(asset.balance || "0");
148
- const locked = parseFloat(asset.locked_balance || "0");
149
- if (bal > 0) {
150
- balances.push({
151
- token: asset.symbol === "USDC" ? "USDC_SPOT" : asset.symbol,
152
- total: asset.balance,
153
- available: String(bal - locked),
154
- held: asset.locked_balance,
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
- // Access signer through the adapter
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: parseInt(orderId),
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: 0,
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
- // Retry account lookup on 429 (rate limit) — critical for _accountIndex
77
- for (let attempt = 0; attempt < 3; attempt++) {
78
- try {
79
- const res = await fetch(`${this._baseUrl}/api/v1/account?by=l1_address&value=${this._address}`);
80
- if (res.status === 429 && attempt < 2) {
81
- await new Promise(r => setTimeout(r, 1000 * (attempt + 1)));
82
- continue;
83
- }
84
- const json = await res.json();
85
- if (json.accounts && json.accounts.length > 0) {
86
- this._accountIndex = this._accountIndexInit >= 0
87
- ? this._accountIndexInit
88
- : json.accounts[0].account_index;
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
- break;
91
- }
92
- catch {
93
- if (attempt < 2) {
94
- await new Promise(r => setTimeout(r, 1000));
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: 0,
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: 0,
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: parseInt(orderId), nonce,
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: 0, time: Math.floor(Date.now() / 1000) + 86400, nonce,
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: parseInt(orderId),
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: 0,
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
- // Use API-provided unrealized PnL from positions
88
- const totalPnl = positions.reduce((sum, p) => sum + Number(p.unrealized_pnl ?? 0), 0);
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: String(p.unrealized_pnl ?? "0"),
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
- // Deprecated: perp status → use 'perp portfolio' or 'perp account'
326
- const statusCmd = program.command("status").description("Use 'perp portfolio'")
327
- .option("--health", "Check connectivity").action(async (opts) => {
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
- console.log("Use 'perp portfolio' or 'perp account' instead.");
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "perp-cli",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "description": "Multi-DEX Perpetual Futures CLI - Pacifica, Hyperliquid, Lighter",
5
5
  "bin": {
6
6
  "perp": "dist/index.js",