perp-cli 0.5.0 → 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/README.md CHANGED
@@ -69,26 +69,48 @@ perp --json portfolio
69
69
  # Market data
70
70
  perp --json -e <EX> market list
71
71
  perp --json -e <EX> market book <SYM>
72
+ perp --json -e <EX> market mid <SYM> # fast mid-price lookup
72
73
  perp --json -e <EX> market funding <SYM>
74
+ perp --json -e <EX> market kline <SYM> 1h # candlestick data
75
+ perp --json -e hl market hip3 # list HIP-3 deployed dexes
73
76
 
74
77
  # Trading
75
- perp --json -e <EX> trade market <SYM> buy <SIZE>
76
- perp --json -e <EX> trade market <SYM> buy <SIZE> --smart # IOC limit at best bid/ask (less slippage)
77
- perp --json -e <EX> trade split <SYM> buy 5000 # orderbook-aware split (large orders)
78
+ perp --json -e <EX> trade buy <SYM> <SIZE> # shortcut for market buy
79
+ perp --json -e <EX> trade sell <SYM> <SIZE> # shortcut for market sell
80
+ perp --json -e <EX> trade market <SYM> buy <SIZE> --smart # IOC limit (less slippage)
81
+ perp --json -e <EX> trade split <SYM> buy 5000 # orderbook-aware split (large orders)
78
82
  perp --json -e <EX> trade close <SYM>
83
+ perp --json -e <EX> trade flatten # close ALL positions on exchange
84
+ perp --json -e <EX> trade reduce <SYM> 50 # reduce position by 50%
85
+ perp --json -e <EX> trade cancel <SYM> # cancel by symbol (or orderId)
86
+ perp --json -e <EX> trade tpsl <SYM> long # set take-profit / stop-loss
79
87
  perp --json -e <EX> trade leverage <SYM> <N>
80
88
 
89
+ # Account
90
+ perp --json -e <EX> account balance
91
+ perp --json -e <EX> account positions
92
+ perp --json -e <EX> account pnl # realized + unrealized + funding
93
+ perp --json -e <EX> account funding # personal funding payment history
94
+ perp --json -e <EX> account settings # per-market leverage & margin mode
95
+
81
96
  # Funding rate arbitrage
82
- perp --json arb scan --min 5 # perp-perp opportunities
83
- perp --json arb scan --mode spot-perp # spot+perp opportunities
84
- perp --json arb exec <SYM> <longEx> <shortEx> <$> --leverage 2 --isolated # execute both legs
85
- perp --json arb spot-exec <SYM> <spotEx> <perpEx> <$> # spot+perp entry
97
+ perp --json arb scan --min 5 # perp-perp opportunities
98
+ perp --json arb scan --mode spot-perp # spot+perp opportunities
99
+ perp --json arb scan --rates # funding rates across all exchanges
100
+ perp --json arb scan --basis # cross-exchange basis opportunities
101
+ perp --json arb scan --gaps # cross-exchange price gaps
102
+ perp --json arb scan --hip3 # HIP-3 cross-dex funding spreads
103
+ perp --json arb scan --live # continuous live monitoring
104
+ perp --json arb exec <SYM> <longEx> <shortEx> <$> # perp-perp dual-leg entry
105
+ perp --json arb exec <SYM> spot:<exch> <perpEx> <$> # spot+perp entry
106
+ perp --json arb config # show arb defaults
107
+ perp --json arb history # past arb trade performance (alias: log)
86
108
 
87
109
  # Funds (deposit, withdraw, transfer)
88
110
  perp --json funds deposit hyperliquid 100
89
111
  perp --json funds withdraw pacifica 50
90
- perp --json funds transfer 100 <ADDRESS> # HL internal transfer
91
- perp --json funds info # all routes & limits
112
+ perp --json funds transfer 100 <ADDRESS> # HL internal transfer
113
+ perp --json funds info # all routes & limits
92
114
 
93
115
  # Risk
94
116
  perp --json risk limits --max-leverage 5
@@ -103,7 +125,9 @@ perp --json bridge quote --from solana --to arbitrum --amount 100
103
125
  perp --json bridge send --from solana --to arbitrum --amount 100
104
126
  ```
105
127
 
106
- Exchange flags: `-e hyperliquid`, `-e pacifica`, `-e lighter` (aliases: `hl`, `pac`, `lt`).
128
+ Exchange flag: `-e hyperliquid` / `-e pacifica` / `-e lighter` (aliases: `hl`, `pac`, `lt`).
129
+
130
+ Global flags: `--json`, `--fields <f>`, `--ndjson`, `--dry-run`, `--dex <name>` (HIP-3), `-w, --wallet <name>`.
107
131
 
108
132
  ## AI Agent Skill
109
133
 
package/dist/cli-spec.js CHANGED
@@ -75,8 +75,8 @@ export function getCliSpec(program) {
75
75
  "Check error.retryable before retrying failed commands",
76
76
  "Use 'perp --json trade check <symbol> <side> <size>' to validate before trading",
77
77
  "Use 'perp --json market mid <symbol>' for fast price lookups",
78
- "Use 'perp --json health' to check exchange connectivity before operations",
79
- "If this CLI is useful, run 'perp settings set referrals true' to support development",
78
+ "Use 'perp agent ping' to check exchange connectivity before operations",
79
+ "If this CLI is useful, run 'perp settings referrals on' to support development",
80
80
  ],
81
81
  };
82
82
  }
@@ -97,7 +97,7 @@ function buildSummary(snapshots) {
97
97
  }
98
98
  function pac(adapter) {
99
99
  if (!(adapter instanceof PacificaAdapter))
100
- throw new Error("This command requires --exchange pacifica");
100
+ throw new Error("Market settings are only available on Pacifica.");
101
101
  return adapter;
102
102
  }
103
103
  export function registerAccountCommands(program, getAdapter, isJson, getAdapterForExchange) {
@@ -622,14 +622,38 @@ export function registerAccountCommands(program, getAdapter, isJson, getAdapterF
622
622
  .description("Show per-market account settings (leverage, margin mode)")
623
623
  .action(async () => {
624
624
  const adapter = await getAdapter();
625
- const p = pac(adapter);
626
- const settings = await p.sdk.getAccountSettings(p.publicKey);
627
- if (isJson())
628
- return printJson(jsonOk(settings));
629
- if (!Array.isArray(settings) || settings.length === 0) {
630
- console.log(chalk.gray("\n No market settings configured.\n"));
625
+ // Pacifica: use dedicated SDK endpoint
626
+ if (adapter instanceof PacificaAdapter) {
627
+ const settings = await adapter.sdk.getAccountSettings(adapter.publicKey);
628
+ if (isJson())
629
+ return printJson(jsonOk(settings));
630
+ if (!Array.isArray(settings) || settings.length === 0) {
631
+ console.log(chalk.gray("\n No market settings configured.\n"));
632
+ return;
633
+ }
634
+ const rows = settings.map((s) => [
635
+ chalk.white.bold(s.symbol),
636
+ s.margin_mode,
637
+ `${s.leverage}x`,
638
+ ]);
639
+ console.log(makeTable(["Symbol", "Margin Mode", "Leverage"], rows));
640
+ return;
641
+ }
642
+ // HL / Lighter: derive settings from open positions
643
+ const positions = await adapter.getPositions();
644
+ if (positions.length === 0) {
645
+ if (isJson())
646
+ return printJson(jsonOk([]));
647
+ console.log(chalk.gray("\n No open positions — settings shown per active position.\n"));
631
648
  return;
632
649
  }
650
+ const settings = positions.map(p => ({
651
+ symbol: p.symbol,
652
+ leverage: p.leverage,
653
+ margin_mode: "cross", // HL/Lighter default to cross
654
+ }));
655
+ if (isJson())
656
+ return printJson(jsonOk(settings));
633
657
  const rows = settings.map((s) => [
634
658
  chalk.white.bold(s.symbol),
635
659
  s.margin_mode,
@@ -661,6 +685,7 @@ export function registerAccountCommands(program, getAdapter, isJson, getAdapterF
661
685
  });
662
686
  account
663
687
  .command("funding-history")
688
+ .alias("funding")
664
689
  .description("Personal funding payment history")
665
690
  .action(async () => {
666
691
  const adapter = await getAdapter();
@@ -899,7 +924,7 @@ export function registerAccountCommands(program, getAdapter, isJson, getAdapterF
899
924
  if (getAdapterForExchange) {
900
925
  program
901
926
  .command("portfolio")
902
- .description("Cross-exchange portfolio overview (all exchanges at once)")
927
+ .description("Cross-exchange portfolio overview with risk metrics")
903
928
  .option("--exchange <exchanges>", "Comma-separated exchanges to include (default: all)")
904
929
  .action(async (opts) => {
905
930
  await withJsonErrors(isJson(), async () => {
@@ -962,6 +987,66 @@ export function registerAccountCommands(program, getAdapter, isJson, getAdapterF
962
987
  else {
963
988
  console.log(chalk.gray("\n No open positions.\n"));
964
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
+ }
965
1050
  // ── Risk Metrics ──
966
1051
  console.log(chalk.white.bold("\n Risk Metrics"));
967
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",
@@ -914,6 +922,7 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
914
922
  // ── arb history ──
915
923
  arb
916
924
  .command("history")
925
+ .alias("log")
917
926
  .description("Past arb trade performance and statistics")
918
927
  .option("--period <days>", "Number of days to look back", "30")
919
928
  .action(async (opts) => {
@@ -1177,6 +1186,31 @@ export function registerArbManageCommands(program, getAdapterForExchange, isJson
1177
1186
  console.log();
1178
1187
  });
1179
1188
  // ── arb rebalance ──
1189
+ arb
1190
+ .command("config")
1191
+ .description("Show arb configuration defaults (use 'arb auto --help' for all options)")
1192
+ .action(async () => {
1193
+ if (isJson()) {
1194
+ return printJson(jsonOk({
1195
+ minSpread: 30, closeSpread: 5, size: 100, holdDays: 7,
1196
+ bridgeCost: 0, interval: 60, cooldown: 30, minMargin: 30,
1197
+ settleStrategy: "block", maxLegs: 5,
1198
+ }));
1199
+ }
1200
+ console.log(chalk.cyan.bold("\n Arb Auto Defaults\n"));
1201
+ console.log(` Min Spread: 30% annual`);
1202
+ console.log(` Close Spread: 5% annual`);
1203
+ console.log(` Size: $100 per leg`);
1204
+ console.log(` Hold Days: 7d (for cost amortization)`);
1205
+ console.log(` Bridge Cost: $0`);
1206
+ console.log(` Check Interval: 60s`);
1207
+ console.log(` Cooldown: 30min (after closing a symbol)`);
1208
+ console.log(` Min Margin: 30%`);
1209
+ console.log(` Settle Strategy: block`);
1210
+ console.log(` Max Legs: 5`);
1211
+ console.log(chalk.gray(`\n Override with 'perp arb auto --min-spread 20 --size 50 ...'\n`));
1212
+ console.log(chalk.gray(` Run 'perp arb auto --help' for all options.\n`));
1213
+ });
1180
1214
  arb
1181
1215
  .command("rebalance")
1182
1216
  .description("Cross-exchange balance rebalancing for arb")
@@ -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);
@@ -262,9 +301,12 @@ export async function handleBasisScan(isJson, opts) {
262
301
  console.log(chalk.cyan(" Fetching prices for basis calculation...\n"));
263
302
  const [pacRates, hlRates, ltRates] = await Promise.all([fetchPacificaRates(), fetchHyperliquidRates(), fetchLighterRates()]);
264
303
  const exchangePrices = new Map();
304
+ const filterSymbol = opts.symbol?.toUpperCase();
265
305
  for (const r of [...pacRates, ...hlRates, ...ltRates]) {
266
306
  if (r.markPrice <= 0)
267
307
  continue;
308
+ if (filterSymbol && r.symbol.toUpperCase() !== filterSymbol)
309
+ continue;
268
310
  if (!exchangePrices.has(r.symbol))
269
311
  exchangePrices.set(r.symbol, new Map());
270
312
  exchangePrices.get(r.symbol).set(r.exchange, r.markPrice);
@@ -312,19 +312,33 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
312
312
  });
313
313
  trade
314
314
  .command("cancel <symbolOrOrderId> [orderId]")
315
- .description("Cancel an order by orderId (auto-detects symbol), or by symbol + orderId")
315
+ .description("Cancel order(s) — by orderId, by symbol + orderId, or by symbol name (cancels all orders for that symbol)")
316
316
  .action(async (symbolOrOrderId, orderId) => {
317
317
  const adapter = await getAdapter();
318
- let symbol;
319
- let oid;
320
318
  if (orderId) {
321
319
  // cancel <symbol> <orderId>
322
- symbol = symbolOrOrderId.toUpperCase();
323
- oid = orderId;
320
+ const symbol = symbolOrOrderId.toUpperCase();
321
+ const oid = orderId;
322
+ if (dryRunGuard("cancel", { exchange: adapter.name, symbol, orderId: oid }))
323
+ return;
324
+ try {
325
+ const result = await adapter.cancelOrder(symbol, oid);
326
+ logExecution({ type: "cancel", exchange: adapter.name, symbol, side: "cancel", size: "0", status: "success", dryRun: false, meta: { orderId: oid } });
327
+ if (isJson())
328
+ return printJson(jsonOk(result));
329
+ console.log(chalk.green(`\n Order ${oid} cancelled on ${adapter.name}.\n`));
330
+ }
331
+ catch (err) {
332
+ logExecution({ type: "cancel", exchange: adapter.name, symbol, side: "cancel", size: "0", status: "failed", dryRun: false, error: err instanceof Error ? err.message : String(err), meta: { orderId: oid } });
333
+ throw err;
334
+ }
335
+ return;
324
336
  }
325
- else {
337
+ // Single argument — is it an orderId (numeric) or a symbol name?
338
+ const isNumeric = /^\d+$/.test(symbolOrOrderId);
339
+ if (isNumeric) {
326
340
  // cancel <orderId> — look up symbol from open orders
327
- oid = symbolOrOrderId;
341
+ const oid = symbolOrOrderId;
328
342
  const orders = await adapter.getOpenOrders();
329
343
  const match = orders.find((o) => String(o.orderId) === oid);
330
344
  if (!match) {
@@ -334,20 +348,46 @@ export function registerTradeCommands(program, getAdapter, isJson, isDryRun = ()
334
348
  console.log(chalk.yellow(`\n ${err}\n`));
335
349
  return;
336
350
  }
337
- symbol = match.symbol;
338
- }
339
- if (dryRunGuard("cancel", { exchange: adapter.name, symbol, orderId: oid }))
340
- return;
341
- try {
342
- const result = await adapter.cancelOrder(symbol, oid);
343
- logExecution({ type: "cancel", exchange: adapter.name, symbol, side: "cancel", size: "0", status: "success", dryRun: false, meta: { orderId: oid } });
344
- if (isJson())
345
- return printJson(jsonOk(result));
346
- console.log(chalk.green(`\n Order ${oid} cancelled on ${adapter.name}.\n`));
351
+ const symbol = match.symbol;
352
+ if (dryRunGuard("cancel", { exchange: adapter.name, symbol, orderId: oid }))
353
+ return;
354
+ try {
355
+ const result = await adapter.cancelOrder(symbol, oid);
356
+ logExecution({ type: "cancel", exchange: adapter.name, symbol, side: "cancel", size: "0", status: "success", dryRun: false, meta: { orderId: oid } });
357
+ if (isJson())
358
+ return printJson(jsonOk(result));
359
+ console.log(chalk.green(`\n Order ${oid} cancelled on ${adapter.name}.\n`));
360
+ }
361
+ catch (err) {
362
+ logExecution({ type: "cancel", exchange: adapter.name, symbol, side: "cancel", size: "0", status: "failed", dryRun: false, error: err instanceof Error ? err.message : String(err), meta: { orderId: oid } });
363
+ throw err;
364
+ }
347
365
  }
348
- catch (err) {
349
- logExecution({ type: "cancel", exchange: adapter.name, symbol, side: "cancel", size: "0", status: "failed", dryRun: false, error: err instanceof Error ? err.message : String(err), meta: { orderId: oid } });
350
- throw err;
366
+ else {
367
+ // cancel <symbol> — cancel all open orders for this symbol
368
+ const symbol = symbolOrOrderId.toUpperCase();
369
+ const orders = await adapter.getOpenOrders();
370
+ const matching = orders.filter((o) => symbolMatch(o.symbol, symbol));
371
+ if (matching.length === 0) {
372
+ const err = `No open orders found for ${symbol}`;
373
+ if (isJson())
374
+ return printJson(jsonOk({ cancelled: false, symbol, reason: err }));
375
+ console.log(chalk.yellow(`\n ${err}\n`));
376
+ return;
377
+ }
378
+ if (dryRunGuard("cancel_by_symbol", { exchange: adapter.name, symbol, count: matching.length }))
379
+ return;
380
+ try {
381
+ const result = await adapter.cancelAllOrders(symbol);
382
+ logExecution({ type: "cancel", exchange: adapter.name, symbol, side: "cancel", size: "0", status: "success", dryRun: false, meta: { bySymbol: true, count: matching.length } });
383
+ if (isJson())
384
+ return printJson(jsonOk({ cancelled: true, symbol, count: matching.length, result }));
385
+ console.log(chalk.green(`\n ${matching.length} order(s) for ${symbol} cancelled on ${adapter.name}.\n`));
386
+ }
387
+ catch (err) {
388
+ logExecution({ type: "cancel", exchange: adapter.name, symbol, side: "cancel", size: "0", status: "failed", dryRun: false, error: err instanceof Error ? err.message : String(err), meta: { bySymbol: true } });
389
+ throw err;
390
+ }
351
391
  }
352
392
  });
353
393
  trade
@@ -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);