perp-cli 0.5.2 → 0.6.0

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.
@@ -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);