perp-cli 0.15.0 → 0.16.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.
Files changed (154) hide show
  1. package/README.ja.md +243 -0
  2. package/README.ko.md +243 -0
  3. package/README.md +129 -535
  4. package/README.zh.md +243 -0
  5. package/dist/api/public/lighter.d.ts +5 -0
  6. package/dist/api/public/lighter.js +5 -0
  7. package/dist/api/public/pacifica.js +2 -2
  8. package/dist/arb/sizing.js +4 -2
  9. package/dist/arb/state.d.ts +6 -0
  10. package/dist/balance-by-address.d.ts +39 -0
  11. package/dist/balance-by-address.js +44 -0
  12. package/dist/bot/quote-rules.js +1 -1
  13. package/dist/bot/strategies/funding-arb-strategy.js +10 -3
  14. package/dist/bot/strategies/funding-arb-utils.d.ts +14 -2
  15. package/dist/bot/strategies/funding-arb-utils.js +48 -15
  16. package/dist/bot/strategies/spot-perp-arb-strategy.d.ts +1 -1
  17. package/dist/bot/strategies/spot-perp-arb-strategy.js +4 -2
  18. package/dist/bot/strategies/spot-perp-executor.js +2 -2
  19. package/dist/cli-program.js +1 -1
  20. package/dist/commands/account.js +168 -24
  21. package/dist/commands/arb/index.js +19 -77
  22. package/dist/commands/arb-auto.d.ts +24 -0
  23. package/dist/commands/arb-auto.js +155 -81
  24. package/dist/commands/arb.d.ts +8 -0
  25. package/dist/commands/arb.js +49 -23
  26. package/dist/commands/backtest.js +23 -21
  27. package/dist/commands/market.d.ts +48 -0
  28. package/dist/commands/market.js +241 -78
  29. package/dist/commands/options.d.ts +5 -0
  30. package/dist/commands/options.js +20 -3
  31. package/dist/commands/pacifica-earn.js +8 -21
  32. package/dist/commands/portfolio.d.ts +16 -0
  33. package/dist/commands/portfolio.js +51 -8
  34. package/dist/commands/trade.js +4 -4
  35. package/dist/cross-chain-margin.d.ts +3 -1
  36. package/dist/cross-chain-margin.js +4 -2
  37. package/dist/dashboard/server.js +2 -2
  38. package/dist/dashboard/ui.js +9 -6
  39. package/dist/errors.d.ts +5 -0
  40. package/dist/errors.js +10 -0
  41. package/dist/exchanges/address.d.ts +8 -0
  42. package/dist/exchanges/address.js +47 -0
  43. package/dist/exchanges/arcus/adapter.d.ts +10 -6
  44. package/dist/exchanges/arcus/adapter.js +33 -6
  45. package/dist/exchanges/arcus/public.js +3 -2
  46. package/dist/exchanges/aster.d.ts +27 -7
  47. package/dist/exchanges/aster.js +124 -42
  48. package/dist/exchanges/canonical-symbols.d.ts +7 -0
  49. package/dist/exchanges/canonical-symbols.js +20 -0
  50. package/dist/exchanges/capabilities.d.ts +2 -0
  51. package/dist/exchanges/capabilities.js +2 -1
  52. package/dist/exchanges/derive/options.d.ts +5 -0
  53. package/dist/exchanges/derive/options.js +20 -1
  54. package/dist/exchanges/extended/adapter.d.ts +16 -2
  55. package/dist/exchanges/extended/adapter.js +96 -34
  56. package/dist/exchanges/extended/public.d.ts +3 -0
  57. package/dist/exchanges/extended/public.js +9 -2
  58. package/dist/exchanges/grvt/adapter.d.ts +16 -2
  59. package/dist/exchanges/grvt/adapter.js +98 -21
  60. package/dist/exchanges/hyperliquid-outcome.d.ts +2 -0
  61. package/dist/exchanges/hyperliquid-outcome.js +29 -1
  62. package/dist/exchanges/hyperliquid-spot.d.ts +8 -0
  63. package/dist/exchanges/hyperliquid-spot.js +20 -12
  64. package/dist/exchanges/hyperliquid.d.ts +180 -13
  65. package/dist/exchanges/hyperliquid.js +487 -185
  66. package/dist/exchanges/interface.d.ts +64 -8
  67. package/dist/exchanges/interface.js +31 -0
  68. package/dist/exchanges/lighter-public.d.ts +66 -11
  69. package/dist/exchanges/lighter-public.js +249 -81
  70. package/dist/exchanges/lighter-spot.d.ts +7 -1
  71. package/dist/exchanges/lighter-spot.js +99 -90
  72. package/dist/exchanges/lighter.d.ts +70 -11
  73. package/dist/exchanges/lighter.js +285 -92
  74. package/dist/exchanges/ondo/adapter.d.ts +34 -8
  75. package/dist/exchanges/ondo/adapter.js +73 -16
  76. package/dist/exchanges/ondo/public.d.ts +27 -2
  77. package/dist/exchanges/ondo/public.js +63 -31
  78. package/dist/exchanges/outcome-interface.d.ts +4 -0
  79. package/dist/exchanges/pacifica-spot.d.ts +6 -0
  80. package/dist/exchanges/pacifica-spot.js +23 -15
  81. package/dist/exchanges/pacifica-vaults.d.ts +9 -0
  82. package/dist/exchanges/pacifica-vaults.js +14 -1
  83. package/dist/exchanges/pacifica.d.ts +77 -6
  84. package/dist/exchanges/pacifica.js +321 -130
  85. package/dist/exchanges/public-balances.d.ts +25 -0
  86. package/dist/exchanges/public-balances.js +158 -0
  87. package/dist/exchanges/public-positions.d.ts +43 -0
  88. package/dist/exchanges/public-positions.js +154 -0
  89. package/dist/exchanges/qfex/adapter.d.ts +52 -9
  90. package/dist/exchanges/qfex/adapter.js +74 -9
  91. package/dist/exchanges/qfex/public.d.ts +62 -9
  92. package/dist/exchanges/qfex/public.js +128 -22
  93. package/dist/exchanges/qfex/ws-transport.d.ts +1 -1
  94. package/dist/exchanges/risex/adapter.d.ts +19 -7
  95. package/dist/exchanges/risex/adapter.js +58 -12
  96. package/dist/exchanges/spot-factory.js +1 -1
  97. package/dist/exchanges/txflow/adapter.d.ts +14 -7
  98. package/dist/exchanges/txflow/adapter.js +51 -19
  99. package/dist/exchanges/txflow/client.js +3 -1
  100. package/dist/exchanges/txflow/public.d.ts +12 -1
  101. package/dist/exchanges/txflow/public.js +82 -11
  102. package/dist/exchanges/unknown-symbol.d.ts +6 -1
  103. package/dist/exchanges/unknown-symbol.js +7 -2
  104. package/dist/exchanges/variational/adapter.d.ts +2 -6
  105. package/dist/exchanges/venue-types.d.ts +46 -3
  106. package/dist/exchanges/venue-types.js +80 -9
  107. package/dist/exchanges/venue.d.ts +99 -3
  108. package/dist/exchanges/venue.js +72 -0
  109. package/dist/exchanges/venues/hyperliquid.js +9 -0
  110. package/dist/exchanges/venues/lighter-rh.js +13 -3
  111. package/dist/exchanges/venues/lighter.js +13 -3
  112. package/dist/exchanges/venues/pacifica.js +9 -0
  113. package/dist/exchanges/venues/risex.js +15 -0
  114. package/dist/funding/legacy-public.d.ts +5 -3
  115. package/dist/funding/legacy-public.js +33 -37
  116. package/dist/funding/rates.d.ts +54 -5
  117. package/dist/funding/rates.js +49 -13
  118. package/dist/liquidity.d.ts +3 -1
  119. package/dist/liquidity.js +8 -4
  120. package/dist/mcp-pacifica-earn.js +11 -5
  121. package/dist/mcp-server.js +104 -15
  122. package/dist/pacifica/client.d.ts +2 -1
  123. package/dist/pacifica/client.js +11 -0
  124. package/dist/positions-by-address.d.ts +34 -0
  125. package/dist/positions-by-address.js +46 -0
  126. package/dist/strategies/funding-arb.js +35 -62
  127. package/dist/utils.d.ts +11 -1
  128. package/dist/venue-health.js +1 -1
  129. package/dist/ws/arcus-market.d.ts +9 -0
  130. package/dist/ws/arcus-market.js +9 -0
  131. package/dist/ws/aster-market.d.ts +33 -0
  132. package/dist/ws/aster-market.js +30 -0
  133. package/dist/ws/index.d.ts +30 -9
  134. package/dist/ws/index.js +180 -15
  135. package/dist/ws/lighter-market.d.ts +14 -0
  136. package/dist/ws/lighter-market.js +13 -0
  137. package/dist/ws/node.d.ts +30 -9
  138. package/dist/ws/node.js +204 -16
  139. package/dist/ws/unclassified.d.ts +16 -0
  140. package/dist/ws/unclassified.js +43 -0
  141. package/package.json +15 -2
  142. package/skills/perp-cli/SKILL.md +97 -33
  143. package/skills/perp-cli/package.json +15 -2
  144. package/skills/perp-cli/references/agent-operations.md +15 -12
  145. package/skills/perp-cli/references/commands.md +67 -12
  146. package/skills/perp-cli/references/strategies.md +38 -3
  147. package/skills/perp-cli/references/ws-library.md +55 -24
  148. package/skills/perp-cli/scripts/arb-monitor.sh +13 -10
  149. package/skills/perp-cli/scripts/funding-analysis.sh +57 -29
  150. package/skills/perp-cli/scripts/preflight.sh +27 -19
  151. package/skills/perp-cli/scripts/spot-perp-scan.sh +58 -38
  152. package/skills/perp-cli/scripts/validate-arb.sh +56 -39
  153. package/dist/cli-spec.d.ts +0 -46
  154. package/dist/cli-spec.js +0 -81
@@ -5,12 +5,12 @@ import { tradingV2Of } from "../exchanges/legacy-trading-v2.js";
5
5
  import { invalidateCache } from "../cache.js";
6
6
  import { isDryRun, networkOf, supportsDryRun } from "../cli-flags.js";
7
7
  import chalk from "chalk";
8
- import { formatUsd, printJson, jsonOk, jsonError, makeTable, logSettledRejections, symbolMatch } from "../utils.js";
8
+ import { formatUsd, printJson, jsonOk, jsonError, makeTable, symbolMatch } from "../utils.js";
9
9
  import { computeExecutableSize } from "../liquidity.js";
10
10
  import { computeMatchedSize, computeSpotPerpMatchedSize } from "../arb/index.js";
11
- import { toHourlyRate } from "../funding.js";
11
+ import { getFundingHours, toHourlyRate } from "../funding.js";
12
+ import { fetchVenuesRates } from "../funding/rates.js";
12
13
  import { toHourlyRateByBasis } from "../funding/normalize.js";
13
- import { fetchPacificaPricesRaw, parsePacificaRaw, fetchHyperliquidMetaRaw, parseHyperliquidMetaRaw, fetchLighterOrderBookDetailsRaw, fetchLighterFundingRatesRaw, parseLighterRaw, } from "../shared-api.js";
14
14
  import { fetchAllDexAssetsDetailed, findDexArbPairs, scanDexArb } from "../dex-asset-map.js";
15
15
  import { logExecution, readExecutionLog } from "../execution-log.js";
16
16
  import { smartOrder } from "../smart-order.js";
@@ -18,9 +18,23 @@ import { aggressiveSettleBoost, estimateFundingUntilSettlement, computeBasisRisk
18
18
  import { checkChainMargins, marginEntryBlocks, computeAutoSize, } from "../cross-chain-margin.js";
19
19
  import { loadArbState, addPosition as persistAddPosition, removePosition as persistRemovePosition, updatePosition as persistUpdatePosition, createInitialState, createDaemonStateStore, } from "../arb-state.js";
20
20
  import { handleRates, handleBasisScan, handleDexScan, handleCompare, handleFundingHistory, handleFundingPositions, fetchAllPrices, printGapTable, printTrackSummary, formatGapPrice, } from "./arb.js";
21
+ /** The venue symbol a snapshot / position leg trades on `exchange` (the canonical key when the venue uses it). */
22
+ export function legSymbol(row, exchange) {
23
+ return row.venueSymbols?.[exchange] ?? row.symbol;
24
+ }
25
+ /** longSymbol / shortSymbol of a new position — only when a leg's venue symbol differs from the key. */
26
+ function legSymbolsOf(snap, longExchange, shortExchange) {
27
+ const l = legSymbol(snap, longExchange), sh = legSymbol(snap, shortExchange);
28
+ return l === snap.symbol && sh === snap.symbol ? {} : { longSymbol: l, shortSymbol: sh };
29
+ }
30
+ /** --symbols matches a row by its canonical key or any venue's own symbol (kPEPE or 1000PEPE). */
31
+ export function matchesSymbolFilter(row, filter) {
32
+ const want = new Set(filter.map(s => s.toUpperCase()));
33
+ return want.has(row.symbol.toUpperCase()) || Object.values(row.venueSymbols ?? {}).some(v => want.has(v.toUpperCase()));
34
+ }
21
35
  // ── Fee-Adjusted Net Spread Calculation ──
22
36
  import { getTakerFee } from "../constants.js";
23
- import { assertFundingArbVenue, getVenue, listVenuesWithRole, networkConfig, resolveVenueId, roleUnsupported, tryResolveVenueId } from "../exchanges/venue.js";
37
+ import { assertFundingArbVenue, getVenue, listVenuesWithRole, resolveVenueId, roleUnsupported, tryResolveVenueId } from "../exchanges/venue.js";
24
38
  import { sharedApiNetwork } from "../api/public/urls.js";
25
39
  import { findSpotQuoteBalance, readSpotQuoteAvailableFresh, requireBotSpotVenue, requireSpotAdapter, spotNetworkOf, spotPairSymbol, spotSharesPerpMargin, sweepSpotQuoteToPerp, transferQuoteToPerp, transferQuoteToSpot } from "../exchanges/spot-factory.js";
26
40
  import { fillSizeString, readSpotBalancesFresh, sizeDecimalsOf, spotLotStepOf, spotMarketFill } from "../exchanges/spot-result.js";
@@ -380,67 +394,60 @@ export function dexCloseBlockedBy(pos, failedDexes) {
380
394
  return null;
381
395
  }
382
396
  const _warnedArbPriceMismatch = new Set();
383
- async function fetchFundingSpreads() {
384
- // Selected network (review L7): the Pacifica / HL / Lighter raw fetchers follow -n via
385
- // setSharedApiNetwork; Aster's REST base comes from the venue definition for that network.
386
- const asterRest = networkConfig(getVenue("aster"), sharedApiNetwork()).restUrl;
387
- // SSOT rule #2: each DEX fetch is independent. allSettled lets us keep the
388
- // multi-DEX comparison alive when one DEX errors out, while the rejection
389
- // reasons get logged to stderr (explicit failure, never silent).
390
- const settled = await Promise.allSettled([
391
- fetchPacificaPricesRaw(),
392
- fetchHyperliquidMetaRaw(),
393
- fetchLighterOrderBookDetailsRaw(),
394
- fetchLighterFundingRatesRaw(),
395
- fetch(`${asterRest}/fapi/v1/premiumIndex`).then(r => r.json()),
396
- fetch(`${asterRest}/fapi/v3/fundingInfo`).then(r => r.json()),
397
- ]);
398
- logSettledRejections(settled, ["pacifica", "hyperliquid", "lighter:orderbook", "lighter:funding", "aster:premiumIndex", "aster:fundingInfo"], "arb-auto");
399
- const pacRes = settled[0].status === "fulfilled" ? settled[0].value : null;
400
- const hlRes = settled[1].status === "fulfilled" ? settled[1].value : null;
401
- const ltDetailsRes = settled[2].status === "fulfilled" ? settled[2].value : null;
402
- const ltFundingRes = settled[3].status === "fulfilled" ? settled[3].value : null;
403
- // Both Aster calls are needed: a rate without its per-symbol interval cannot be normalized.
404
- const astPremiums = settled[4].status === "fulfilled" && settled[5].status === "fulfilled"
405
- ? settled[4].value
406
- : [];
407
- const astFundingInfo = settled[5].status === "fulfilled" ? settled[5].value : [];
397
+ /** " (lastSettled)"-style tag of a snapshot leg's rate kind; "" when the venue is not a leg. */
398
+ const kindTag = (s, ex) => { const k = s.rateKinds?.[ex]; return k === undefined ? "" : `(${k ?? "?"})`; };
399
+ export async function fetchFundingSpreads() {
400
+ // The SAME rate rows as `arb scan --rates` (fetchVenuesRates over each venue's public directory):
401
+ // one rate per venue and canonical symbol (unclassified / ambiguous tickers and non-matchable
402
+ // listings have no canonical and never pair), each with its basis and rateKind. A venue that
403
+ // fails is reported, never silently treated as "no spread".
404
+ const venues = ["pacifica", "hyperliquid", "lighter", "aster"].map(id => getVenue(id));
405
+ const { rates: fetched, exchangeErrors } = await fetchVenuesRates(venues, sharedApiNetwork(), false);
406
+ // Never a leg on an unverified rate (row label; the venue filter above already drops them).
407
+ const rates = fetched.filter(r => r.fundingVerified);
408
+ for (const [ex, msg] of Object.entries(exchangeErrors))
409
+ process.stderr.write(`[arb-auto] ${ex}: funding rates unavailable — ${msg}\n`);
410
+ const pacRates = new Map(), pacPrices = new Map();
411
+ const hlRates = new Map(), hlPrices = new Map();
412
+ const ltRates = new Map(), ltPrices = new Map();
413
+ const astRates = new Map(), astPrices = new Map();
408
414
  const astHoursBySym = new Map();
409
- for (const row of Array.isArray(astFundingInfo) ? astFundingInfo : []) {
410
- const rawSym = String(row.symbol ?? "");
411
- const hours = Number(row.fundingIntervalHours);
412
- if (rawSym.endsWith("USDT") && Number.isFinite(hours) && hours > 0)
413
- astHoursBySym.set(rawSym.replace(/USDT$/, ""), hours);
414
- }
415
- const { rates: pacRates, prices: pacPrices } = parsePacificaRaw(pacRes);
416
- const { rates: hlRates, prices: hlPrices } = parseHyperliquidMetaRaw(hlRes);
417
- const { rates: ltRates, prices: ltPrices } = parseLighterRaw(ltDetailsRes, ltFundingRes);
418
- // Aster: parse premiumIndex.
419
- // SSOT rule #2: never silently coerce missing markPrice/lastFundingRate to 0
420
- // — a 0 markPrice corrupts every downstream sizing calculation, and a 0
421
- // funding rate flips the symbol off the arb radar without explanation.
422
- // Skip the symbol entirely if either field is missing or non-finite.
423
- const astRates = new Map();
424
- const astPrices = new Map();
415
+ const kindsBySym = new Map();
416
+ const venueSymbolsBySym = new Map();
425
417
  const astNoInterval = [];
426
- for (const p of astPremiums ?? []) {
427
- const rawSym = String(p.symbol ?? "");
428
- if (!rawSym.endsWith("USDT"))
429
- continue;
430
- const sym = rawSym.replace(/USDT$/, "");
431
- const fundingRate = Number(p.lastFundingRate);
432
- const markPrice = Number(p.markPrice);
433
- if (!Number.isFinite(fundingRate) || !Number.isFinite(markPrice) || markPrice <= 0) {
434
- // Aster premiumIndex omitted or returned non-numeric values for this
435
- // symbol; skip rather than poison downstream math with a 0.
418
+ for (const r of rates) {
419
+ const sym = r.canonical;
420
+ const kinds = kindsBySym.get(sym) ?? {};
421
+ kinds[r.exchange] = r.rateKind;
422
+ kindsBySym.set(sym, kinds);
423
+ const vs = venueSymbolsBySym.get(sym) ?? {};
424
+ vs[r.exchange] = r.symbol; // the venue's own symbol (orders use it)
425
+ venueSymbolsBySym.set(sym, vs);
426
+ if (r.exchange === "aster") {
427
+ if (r.basis.kind !== "perHours") {
428
+ astNoInterval.push(r.symbol);
429
+ continue;
430
+ }
431
+ astRates.set(sym, r.fundingRate);
432
+ astPrices.set(sym, r.markPrice);
433
+ astHoursBySym.set(sym, r.basis.hours);
436
434
  continue;
437
435
  }
438
- if (!astHoursBySym.has(sym)) {
439
- astNoInterval.push(sym);
440
- continue;
436
+ // The snapshot keeps each venue's rate in the venue's own basis (snapshotHourly /
437
+ // computeAnnualSpread normalize with it): convert from the row's hourly rate.
438
+ const inVenueBasis = r.hourlyRate * getFundingHours(r.exchange);
439
+ if (r.exchange === "pacifica") {
440
+ pacRates.set(sym, inVenueBasis);
441
+ pacPrices.set(sym, r.markPrice);
442
+ }
443
+ else if (r.exchange === "hyperliquid") {
444
+ hlRates.set(sym, inVenueBasis);
445
+ hlPrices.set(sym, r.markPrice);
446
+ }
447
+ else if (r.exchange === "lighter") {
448
+ ltRates.set(sym, inVenueBasis);
449
+ ltPrices.set(sym, r.markPrice);
441
450
  }
442
- astRates.set(sym, fundingRate);
443
- astPrices.set(sym, markPrice);
444
451
  }
445
452
  if (astNoInterval.length > 0) {
446
453
  process.stderr.write(`[arb-auto] aster: ${astNoInterval.length} symbol(s) excluded — no fundingIntervalHours in /fapi/v3/fundingInfo: ${astNoInterval.slice(0, 10).join(", ")}${astNoInterval.length > 10 ? ", …" : ""}\n`);
@@ -528,12 +535,54 @@ async function fetchFundingSpreads() {
528
535
  ltMarkPrice: ltPrices.get(sym) ?? 0,
529
536
  astMarkPrice: astPrices.get(sym) ?? 0,
530
537
  astHours: ast !== undefined ? astHours : null,
538
+ rateKinds: Object.fromEntries(available.map(r => [r.exchange, kindsBySym.get(sym)?.[r.exchange] ?? null])),
539
+ venueSymbols: venueSymbolsBySym.get(sym) ?? {},
531
540
  });
532
541
  }
533
542
  return snapshots.sort((a, b) => Math.abs(b.spread) - Math.abs(a.spread));
534
543
  }
535
544
  // Module-level holder for spot-close handler -- assigned inside registerArbAutoCommands.
536
545
  export let handleSpotClose;
546
+ /**
547
+ * `arb auto` flag validation: every numeric flag must be a finite number in its range, `--size` a
548
+ * number or "auto", `--settle-strategy` one of block | aggressive | off. INVALID_PARAMS otherwise.
549
+ */
550
+ export function assertArbAutoFlags(opts) {
551
+ const num = (flag, key, min, opt = {}) => {
552
+ const raw = opts[key];
553
+ if (raw === undefined)
554
+ return;
555
+ const text = String(raw).trim();
556
+ const v = Number(text);
557
+ const ok = text !== "" && Number.isFinite(v) && (opt.exclusiveMin ? v > min : v >= min) && (!opt.integer || Number.isInteger(v));
558
+ if (!ok) {
559
+ throw new PerpError("INVALID_PARAMS", `${flag} must be ${opt.integer ? "a whole number" : "a number"} ${opt.exclusiveMin ? ">" : ">="} ${min}, got "${text}"`, {
560
+ remediation: `Pass e.g. ${flag} ${opt.exclusiveMin ? min + 1 : Math.max(min, 1)}`,
561
+ });
562
+ }
563
+ };
564
+ num("--min-spread", "minSpread", 0);
565
+ num("--close-spread", "closeSpread", 0);
566
+ if (opts.size !== undefined && String(opts.size).toLowerCase() !== "auto")
567
+ num("--size", "size", 0, { exclusiveMin: true });
568
+ num("--min-size", "minSize", 0);
569
+ num("--max-positions", "maxPositions", 1, { integer: true });
570
+ num("--interval", "interval", 0, { integer: true });
571
+ num("--hold-days", "holdDays", 0, { exclusiveMin: true });
572
+ num("--bridge-cost", "bridgeCost", 0);
573
+ num("--min-margin", "minMargin", 0);
574
+ num("--max-basis", "maxBasis", 0, { exclusiveMin: true });
575
+ num("--cooldown", "cooldown", 0);
576
+ num("--min-oi", "minOi", 0);
577
+ const strat = opts.settleStrategy;
578
+ if (strat !== undefined && !["block", "aggressive", "off"].includes(String(strat))) {
579
+ throw new PerpError("INVALID_PARAMS", `--settle-strategy must be block, aggressive or off, got "${String(strat)}"`, { remediation: "Pass --settle-strategy block (default)" });
580
+ }
581
+ const grade = opts.minGrade;
582
+ if (grade !== undefined && !["A", "B", "C", "D"].includes(String(grade).toUpperCase())) {
583
+ throw new PerpError("INVALID_PARAMS", `--min-grade must be A, B, C or D, got "${String(grade)}"`, { remediation: "Pass --min-grade C (default)" });
584
+ }
585
+ }
537
586
  export function registerArbAutoCommands(program, getAdapterForExchange, isJson, getHLAdapterForDex) {
538
587
  const arb = program.commands.find(c => c.name() === "arb");
539
588
  if (!arb)
@@ -567,6 +616,9 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
567
616
  .option("--min-oi <usd>", "Min OI (USD) to enter (hip3 mode)", "50000")
568
617
  .option("--min-grade <grade>", "Min viability grade: A, B, C, D (hip3 mode)", "C")
569
618
  .action(async (opts) => {
619
+ // Numeric flags are checked up front (also before --background starts a job): a value that is
620
+ // not a number ("--cooldown abc" → NaN) would otherwise silently disable the check it feeds.
621
+ assertArbAutoFlags(opts);
570
622
  // Route to HIP-3 dex-auto logic if --hip3 flag is set
571
623
  if (opts.hip3) {
572
624
  return handleDexAuto({
@@ -647,6 +699,8 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
647
699
  const marginUnknownNotified = new Set();
648
700
  // Track close times per symbol to prevent close→re-entry loops
649
701
  const closeCooldowns = new Map(); // symbol → close timestamp ms
702
+ // --symbols entries already reported as resolving to another canonical symbol (logged once).
703
+ const aliasNoted = new Set();
650
704
  // Consecutive definite close failures per symbol (quarantined at MAX_PAIR_CLOSE_FAILURES)
651
705
  const closeFailures = new Map();
652
706
  // -- State Persistence: Initialize or recover --
@@ -687,7 +741,7 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
687
741
  (await getAdapterForExchange(p.longExchange)).getPositions(),
688
742
  (await getAdapterForExchange(p.shortExchange)).getPositions(),
689
743
  ]);
690
- const has = (list, side) => list.some(x => symbolMatch(x.symbol, p.symbol) && x.side === side && Math.abs(Number(x.size)) > 0);
744
+ const has = (list, side) => list.some(x => symbolMatch(x.symbol, (side === "long" ? p.longSymbol : p.shortSymbol) ?? p.symbol) && x.side === side && Math.abs(Number(x.size)) > 0);
691
745
  if (has(longs, "long") && has(shorts, "short")) {
692
746
  staleSymbols.delete(p.symbol);
693
747
  return true;
@@ -722,6 +776,7 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
722
776
  entryMarkPrice: persisted.entryLongPrice,
723
777
  accumulatedFundingUsd: persisted.accumulatedFunding,
724
778
  lastCheckTime: new Date(persisted.lastCheckTime).getTime(),
779
+ longSymbol: persisted.longSymbol, shortSymbol: persisted.shortSymbol,
725
780
  });
726
781
  }
727
782
  daemonState.lastStartTime = new Date().toISOString();
@@ -793,6 +848,7 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
793
848
  entryMarkPrice: persisted.entryLongPrice,
794
849
  accumulatedFundingUsd: persisted.accumulatedFunding,
795
850
  lastCheckTime: new Date(persisted.lastCheckTime).getTime(),
851
+ longSymbol: persisted.longSymbol, shortSymbol: persisted.shortSymbol,
796
852
  });
797
853
  }
798
854
  }
@@ -813,7 +869,7 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
813
869
  try {
814
870
  const spreads = await fetchFundingSpreads();
815
871
  const filtered = filterSymbols
816
- ? spreads.filter(s => filterSymbols.includes(s.symbol))
872
+ ? spreads.filter(s => matchesSymbolFilter(s, filterSymbols))
817
873
  : spreads;
818
874
  const now = new Date().toLocaleTimeString();
819
875
  // Check for close conditions on open positions
@@ -827,7 +883,9 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
827
883
  // Per-position isolation: one position's failing exit check (e.g. an
828
884
  // unknown funding basis in snapshotHourly) must not skip the others.
829
885
  try {
830
- const current = filtered.find(s => s.symbol === pos.symbol);
886
+ // By canonical key; a position opened before 0.15.1 is keyed by its (shared) venue symbol.
887
+ const current = filtered.find(s => s.symbol === pos.symbol
888
+ || (legSymbol(s, pos.longExchange) === pos.symbol && legSymbol(s, pos.shortExchange) === pos.symbol));
831
889
  if (!current)
832
890
  continue;
833
891
  const currentSpread = Math.abs(current.spread);
@@ -884,8 +942,8 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
884
942
  const sizeStr = String(pos.size);
885
943
  const closeRes = await closePerpPairVerified({
886
944
  source: "arb-auto", sym: pos.symbol,
887
- longAdapter, longExch: pos.longExchange, longSym: pos.symbol, longSize: sizeStr,
888
- shortAdapter, shortExch: pos.shortExchange, shortSym: pos.symbol, shortSize: sizeStr, smart: false,
945
+ longAdapter, longExch: pos.longExchange, longSym: pos.longSymbol ?? pos.symbol, longSize: sizeStr,
946
+ shortAdapter, shortExch: pos.shortExchange, shortSym: pos.shortSymbol ?? pos.symbol, shortSize: sizeStr, smart: false,
889
947
  log: (m) => console.log(chalk.red(m)),
890
948
  });
891
949
  if (closeRes.status === "closed") {
@@ -1098,8 +1156,15 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
1098
1156
  }
1099
1157
  };
1100
1158
  for (const sym of filterSymbols ?? []) {
1101
- if (!filtered.some(x => x.symbol === sym))
1159
+ // A --symbols entry may be a venue form (kPEPE): it monitors the canonical row it resolves to.
1160
+ const hit = filtered.find(x => matchesSymbolFilter(x, [sym]));
1161
+ if (hit === undefined)
1102
1162
  console.log(chalk.gray(` ${now} SKIP ${sym}: not in this cycle's funding scan (no rate on two compared venues)`));
1163
+ else if (hit.symbol !== sym && !aliasNoted.has(sym)) {
1164
+ aliasNoted.add(sym);
1165
+ const forms = Object.entries(hit.venueSymbols ?? {}).map(([v, vs]) => `${v} ${vs}`).join(" / ");
1166
+ console.log(chalk.gray(` ${now} MONITOR ${sym} → ${hit.symbol} (canonical${forms !== "" ? `; ${forms}` : ""})`));
1167
+ }
1103
1168
  }
1104
1169
  if (openPositions.length >= maxPositions) {
1105
1170
  console.log(chalk.gray(` ${now} NO ENTRY: max positions reached (${openPositions.length}/${maxPositions}: ${openPositions.map(p => `${p.symbol} ${p.longExchange}/${p.shortExchange}`).join(", ")})`));
@@ -1195,7 +1260,7 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
1195
1260
  try {
1196
1261
  const longAdapter = await getAdapterForExchange(longExchange);
1197
1262
  const shortAdapter = await getAdapterForExchange(shortExchange);
1198
- actualSizeUsd = await computeAutoSize(longAdapter, shortAdapter, snap.symbol, 0.3);
1263
+ actualSizeUsd = await computeAutoSize(longAdapter, shortAdapter, legSymbol(snap, longExchange), 0.3, legSymbol(snap, shortExchange));
1199
1264
  if (actualSizeUsd <= 0) {
1200
1265
  console.log(chalk.gray(` ${now} SKIP ${snap.symbol}: auto-size returned $0 (insufficient depth/margin)`));
1201
1266
  continue;
@@ -1239,7 +1304,7 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
1239
1304
  legsStarted = true;
1240
1305
  const res = await openPerpPairVerified({
1241
1306
  source: "arb-auto", sym: snap.symbol,
1242
- longAdapter, longExch: longExchange, longSym: snap.symbol, shortAdapter, shortExch: shortExchange, shortSym: snap.symbol,
1307
+ longAdapter, longExch: longExchange, longSym: legSymbol(snap, longExchange), shortAdapter, shortExch: shortExchange, shortSym: legSymbol(snap, shortExchange),
1243
1308
  size: sizeInAsset, smart: false, log: (m) => console.log(chalk.red(m)),
1244
1309
  });
1245
1310
  legsStarted = false; // outcome known (opened / not opened / quarantined)
@@ -1268,6 +1333,7 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
1268
1333
  entryShortPrice: snap.markPrice,
1269
1334
  accumulatedFunding: 0,
1270
1335
  lastCheckTime: entryTime,
1336
+ ...legSymbolsOf(snap, longExchange, shortExchange),
1271
1337
  });
1272
1338
  }
1273
1339
  catch (e) {
@@ -1291,6 +1357,9 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
1291
1357
  });
1292
1358
  console.error(chalk.red(` ${now} ENTRY FAILED ${snap.symbol}: ${res.reason}${res.rolledBack !== null ? ` — rolled back long ${res.rolledBack}; no open exposure` : " — nothing opened"}`));
1293
1359
  donePending();
1360
+ // Cool down like a close: a leg the venue rejects (unknown symbol, lot, margin)
1361
+ // is not re-sent — with its fees and rollback — every cycle.
1362
+ closeCooldowns.set(snap.symbol, Date.now());
1294
1363
  continue;
1295
1364
  }
1296
1365
  else {
@@ -1341,6 +1410,7 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
1341
1410
  entryMarkPrice: snap.markPrice,
1342
1411
  accumulatedFundingUsd: 0,
1343
1412
  lastCheckTime: Date.now(),
1413
+ ...legSymbolsOf(snap, longExchange, shortExchange),
1344
1414
  });
1345
1415
  // Dry-run: record the simulated entry in the in-memory state only (never the real file).
1346
1416
  if (dryRun) {
@@ -1594,7 +1664,7 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
1594
1664
  const topCheck = filtered.slice(0, 5);
1595
1665
  await Promise.allSettled(topCheck.map(async (s) => {
1596
1666
  try {
1597
- const [hlOB, ltOB] = await Promise.all([fetchHLOrderbook(s.symbol), fetchLighterOrderbook(s.symbol)]);
1667
+ const [hlOB, ltOB] = await Promise.all([fetchHLOrderbook(legSymbol(s, "hyperliquid")), fetchLighterOrderbook(legSymbol(s, "lighter"))]);
1598
1668
  const hlDepth = hlOB.reduce((sum, l) => sum + l[0] * l[1], 0);
1599
1669
  const ltDepth = ltOB.reduce((sum, l) => sum + l[0] * l[1], 0);
1600
1670
  const hlBest = hlOB[0]?.[0] ?? 0;
@@ -1613,12 +1683,13 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
1613
1683
  const grossColor = grossSpread >= 50 ? chalk.green.bold : grossSpread >= 30 ? chalk.green : chalk.yellow;
1614
1684
  const netColor = netSpread >= 20 ? chalk.green : netSpread >= 0 ? chalk.yellow : chalk.red;
1615
1685
  const rates = [];
1686
+ // Each leg names which rate it is (lastSettled / accruing / predicted; ? = unverified).
1616
1687
  if (s.pacRate)
1617
- rates.push(`PAC:${(s.pacRate * 100).toFixed(4)}%`);
1688
+ rates.push(`PAC:${(s.pacRate * 100).toFixed(4)}%${kindTag(s, "pacifica")}`);
1618
1689
  if (s.hlRate)
1619
- rates.push(`HL:${(s.hlRate * 100).toFixed(4)}%`);
1690
+ rates.push(`HL:${(s.hlRate * 100).toFixed(4)}%${kindTag(s, "hyperliquid")}`);
1620
1691
  if (s.ltRate)
1621
- rates.push(`LT:${(s.ltRate * 100).toFixed(4)}%`);
1692
+ rates.push(`LT:${(s.ltRate * 100).toFixed(4)}%${kindTag(s, "lighter")}`);
1622
1693
  let liqInfo = "";
1623
1694
  const ld = liqData.get(s.symbol);
1624
1695
  if (ld)
@@ -1682,8 +1753,10 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
1682
1753
  const netSpread = computeNetSpread(s.annualSpreadPct, holdDays, rtCost, effectiveBridgeCost, sizeUsd);
1683
1754
  return {
1684
1755
  mode: "spot-perp", symbol: s.symbol, perpExchange: s.perpExchange,
1685
- spotExchanges: s.spotExchanges, grossSpreadPct: s.annualSpreadPct,
1686
- netSpreadPct: netSpread, estFeesPct: rtCost,
1756
+ // grossSpread / netSpread: the names every other arb scan row uses (same % values);
1757
+ // the *Pct names stay for existing consumers.
1758
+ spotExchanges: s.spotExchanges, grossSpreadPct: s.annualSpreadPct, grossSpread: s.annualSpreadPct,
1759
+ netSpreadPct: netSpread, netSpread, estFeesPct: rtCost,
1687
1760
  perpFundingRate: s.perpFundingRate, direction: s.direction,
1688
1761
  markPrice: s.bestMarkPrice, estHourlyIncomeUsd: s.estHourlyIncomeUsd,
1689
1762
  breakEvenDays: rtCost > 0 ? Math.ceil(rtCost / (s.annualSpreadPct / 365)) : 0,
@@ -1747,7 +1820,7 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
1747
1820
  const shortPrice = pFor(s.shortExch);
1748
1821
  const mid = (longPrice + shortPrice) / 2;
1749
1822
  const priceGapPct = mid > 0 ? Math.abs(longPrice - shortPrice) / mid * 100 : 0;
1750
- return { mode: "perp-perp", symbol: s.symbol, longExch: s.longExch, shortExch: s.shortExch, markPrice: s.markPrice, grossSpread, netSpread: net, estFeesPct: rtCost, priceGapPct: Math.round(priceGapPct * 10000) / 10000 };
1823
+ return { mode: "perp-perp", symbol: s.symbol, longExch: s.longExch, shortExch: s.shortExch, markPrice: s.markPrice, grossSpread, grossSpreadPct: grossSpread, netSpread: net, netSpreadPct: net, estFeesPct: rtCost, priceGapPct: Math.round(priceGapPct * 10000) / 10000 };
1751
1824
  });
1752
1825
  // Merge spot-perp results if "all" mode
1753
1826
  const spotPerp = (opts.mode === "all" && spotSpreads) ? spotSpreads.map(s => {
@@ -1757,8 +1830,8 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
1757
1830
  const net = computeNetSpread(s.annualSpreadPct, holdDays, rtCost, effectiveBridgeCost, sizeUsd);
1758
1831
  return {
1759
1832
  mode: "spot-perp", symbol: s.symbol, perpExchange: s.perpExchange,
1760
- spotExchanges: s.spotExchanges, grossSpread: s.annualSpreadPct,
1761
- netSpread: net, estFeesPct: rtCost,
1833
+ spotExchanges: s.spotExchanges, grossSpread: s.annualSpreadPct, grossSpreadPct: s.annualSpreadPct,
1834
+ netSpread: net, netSpreadPct: net, estFeesPct: rtCost,
1762
1835
  perpFundingRate: s.perpFundingRate, direction: s.direction,
1763
1836
  markPrice: s.bestMarkPrice, breakEvenDays: rtCost > 0 ? Math.ceil(rtCost / (s.annualSpreadPct / 365)) : 0,
1764
1837
  };
@@ -1787,14 +1860,15 @@ export function registerArbAutoCommands(program, getAdapterForExchange, isJson,
1787
1860
  const priceGapPct = mid > 0 ? Math.abs(longPrice - shortPrice) / mid * 100 : 0;
1788
1861
  const gapColor = priceGapPct >= 0.5 ? chalk.red : priceGapPct >= 0.1 ? chalk.yellow : chalk.gray;
1789
1862
  const rates = [];
1863
+ // Each leg names which rate it is (lastSettled / accruing / predicted; ? = unverified).
1790
1864
  if (s.pacRate)
1791
- rates.push(`PAC:${(s.pacRate * 100).toFixed(4)}%`);
1865
+ rates.push(`PAC:${(s.pacRate * 100).toFixed(4)}%${kindTag(s, "pacifica")}`);
1792
1866
  if (s.hlRate)
1793
- rates.push(`HL:${(s.hlRate * 100).toFixed(4)}%`);
1867
+ rates.push(`HL:${(s.hlRate * 100).toFixed(4)}%${kindTag(s, "hyperliquid")}`);
1794
1868
  if (s.ltRate)
1795
- rates.push(`LT:${(s.ltRate * 100).toFixed(4)}%`);
1869
+ rates.push(`LT:${(s.ltRate * 100).toFixed(4)}%${kindTag(s, "lighter")}`);
1796
1870
  if (s.astRate)
1797
- rates.push(`AST:${(s.astRate * 100).toFixed(4)}%`);
1871
+ rates.push(`AST:${(s.astRate * 100).toFixed(4)}%${kindTag(s, "aster")}`);
1798
1872
  return [
1799
1873
  chalk.white.bold(s.symbol),
1800
1874
  grossColor(`${grossSpread.toFixed(1)}%`),
@@ -13,6 +13,14 @@ export interface PriceSnapshot {
13
13
  expensive: string;
14
14
  }
15
15
  export declare function fetchAllPrices(): Promise<PriceSnapshot[]>;
16
+ /**
17
+ * Cross-venue price gaps by venue symbol. The same ticker on two venues is not proof of the same
18
+ * instrument (Pacifica SAMSUNG 165.8 USD vs Lighter SAMSUNG 220,597 — a KRW-quoted listing): each
19
+ * symbol's prices go through the funding scan's median price-outlier guard (dropPriceOutliers,
20
+ * MAX_PRICE_DEVIATION_PCT, one warning per symbol / venue) before a gap is computed, so a gap is
21
+ * only reported between prices that agree within the guard.
22
+ */
23
+ export declare function buildGapSnapshots(pacPrices: Map<string, number>, hlPrices: Map<string, number>, ltPrices: Map<string, number>): PriceSnapshot[];
16
24
  export declare function formatGapPrice(p: number): string;
17
25
  export declare function printGapTable(snapshots: PriceSnapshot[], minGap: number): void;
18
26
  export declare function printTrackSummary(symbol: string, samples: {
@@ -1,7 +1,7 @@
1
1
  import { makeTable, formatUsd, formatPercent, formatPnl, printJson, jsonOk, withJsonErrors, logSettledRejections } from "../utils.js";
2
2
  import chalk from "chalk";
3
3
  import { toHourlyRate, annualizeHourlyRate, estimateHourlyFundingFromHourly } from "../funding.js";
4
- import { fetchAllFundingRates, fetchCanonicalPriceGroups, fetchSymbolFundingRates, TOP_SYMBOLS, } from "../funding-rates.js";
4
+ import { fetchAllFundingRates, fetchCanonicalPriceGroups, dropPriceOutliers, fetchSymbolFundingRates, isRankedComparison, TOP_SYMBOLS, } from "../funding-rates.js";
5
5
  import { saveFundingSnapshot, getHistoricalRates, getCompoundedAnnualReturn, getExchangeCompoundingHours, } from "../funding-history.js";
6
6
  import { getVenue, listVenuesWithRole, tryResolveVenueId } from "../exchanges/venue.js";
7
7
  import "../exchanges/venues/index.js";
@@ -46,23 +46,34 @@ export async function fetchAllPrices() {
46
46
  ltPrices.set(sym, price);
47
47
  }
48
48
  }
49
+ return buildGapSnapshots(pacPrices, hlPrices, ltPrices);
50
+ }
51
+ /**
52
+ * Cross-venue price gaps by venue symbol. The same ticker on two venues is not proof of the same
53
+ * instrument (Pacifica SAMSUNG 165.8 USD vs Lighter SAMSUNG 220,597 — a KRW-quoted listing): each
54
+ * symbol's prices go through the funding scan's median price-outlier guard (dropPriceOutliers,
55
+ * MAX_PRICE_DEVIATION_PCT, one warning per symbol / venue) before a gap is computed, so a gap is
56
+ * only reported between prices that agree within the guard.
57
+ */
58
+ export function buildGapSnapshots(pacPrices, hlPrices, ltPrices) {
49
59
  const allSymbols = new Set([...pacPrices.keys(), ...hlPrices.keys(), ...ltPrices.keys()]);
50
60
  const snapshots = [];
61
+ const LABEL = { pacifica: "PAC", hyperliquid: "HL", lighter: "LT" };
51
62
  for (const sym of allSymbols) {
52
- const pac = pacPrices.get(sym) ?? null;
53
- const hl = hlPrices.get(sym) ?? null;
54
- const lt = ltPrices.get(sym) ?? null;
55
- // Need at least 2 exchanges
56
- const available = [];
57
- if (pac !== null)
58
- available.push({ ex: "PAC", price: pac });
59
- if (hl !== null)
60
- available.push({ ex: "HL", price: hl });
61
- if (lt !== null)
62
- available.push({ ex: "LT", price: lt });
63
- if (available.length < 2)
63
+ const listed = [];
64
+ for (const [exchange, map] of [["pacifica", pacPrices], ["hyperliquid", hlPrices], ["lighter", ltPrices]]) {
65
+ const p = map.get(sym);
66
+ if (p !== undefined)
67
+ listed.push({ exchange, markPrice: p });
68
+ }
69
+ if (listed.length < 2)
64
70
  continue;
65
- available.sort((a, b) => a.price - b.price);
71
+ const kept = dropPriceOutliers(sym, listed, "gaps");
72
+ // Need at least 2 exchanges whose prices agree
73
+ if (kept.length < 2)
74
+ continue;
75
+ const priceOf = (ex) => kept.find(k => k.exchange === ex)?.markPrice ?? null;
76
+ const available = kept.map(k => ({ ex: LABEL[k.exchange], price: k.markPrice })).sort((a, b) => a.price - b.price);
66
77
  const cheapest = available[0];
67
78
  const expensive = available[available.length - 1];
68
79
  const maxGap = expensive.price - cheapest.price;
@@ -70,9 +81,9 @@ export async function fetchAllPrices() {
70
81
  const maxGapPct = mid > 0 ? (maxGap / mid) * 100 : 0;
71
82
  snapshots.push({
72
83
  symbol: sym,
73
- pacPrice: pac,
74
- hlPrice: hl,
75
- ltPrice: lt,
84
+ pacPrice: priceOf("pacifica"),
85
+ hlPrice: priceOf("hyperliquid"),
86
+ ltPrice: priceOf("lighter"),
76
87
  maxGap,
77
88
  maxGapPct,
78
89
  cheapest: cheapest.ex,
@@ -194,7 +205,8 @@ function printSnapshotTable(snapshot) {
194
205
  }
195
206
  const rows = snapshot.symbols.map(s => {
196
207
  const rateOf = (id) => s.rates.find(r => r.exchange === id);
197
- const spreadColor = s.maxSpreadAnnual >= 30 ? chalk.green : s.maxSpreadAnnual >= 10 ? chalk.yellow : chalk.white;
208
+ const spread = s.maxSpreadAnnual;
209
+ const spreadColor = spread === null ? chalk.gray : spread >= 30 ? chalk.green : spread >= 10 ? chalk.yellow : chalk.white;
198
210
  // Historical-average source preference: HL, PAC, LT, AST, then whichever venue quoted.
199
211
  const bestRate = rateOf("hyperliquid") ?? rateOf("pacifica") ?? rateOf("lighter") ?? rateOf("aster") ?? s.rates[0];
200
212
  const trend = trendMap.get(s.symbol) ?? chalk.gray("-");
@@ -204,8 +216,10 @@ function printSnapshotTable(snapshot) {
204
216
  const r = rateOf(v.id);
205
217
  return r ? formatPercent(r.fundingRate) : chalk.gray("-");
206
218
  }),
207
- spreadColor(`${s.maxSpreadAnnual.toFixed(1)}%`), trend,
208
- s.maxSpreadAnnual >= 5 ? `${exAbbr(s.shortExchange)}>${exAbbr(s.longExchange)}${s.executable ? "" : chalk.gray(" (read-only)")}` : chalk.gray("-"),
219
+ // No ranked pair (< 2 verified rows): unverified rates are shown, never ranked.
220
+ spread === null ? chalk.gray("n/a") : spreadColor(`${spread.toFixed(1)}%`), trend,
221
+ spread === null || s.longExchange === null || s.shortExchange === null ? chalk.gray("unranked (unverified)")
222
+ : spread >= 5 ? `${exAbbr(s.shortExchange)}>${exAbbr(s.longExchange)}${s.executable ? "" : chalk.gray(s.readOnlyLegs.length > 0 ? " (read-only)" : " (unverified)")}` : chalk.gray("-"),
209
223
  formatAvgRate(bestRate?.historicalAvg?.avg8h), formatAvgRate(bestRate?.historicalAvg?.avg24h), formatAvgRate(bestRate?.historicalAvg?.avg7d),
210
224
  ];
211
225
  });
@@ -232,9 +246,16 @@ function printDetailedComparison(comparison) {
232
246
  const color = r.fundingRate > 0 ? chalk.red : r.fundingRate < 0 ? chalk.green : chalk.white;
233
247
  console.log(` ${chalk.white.bold(exAbbr(r.exchange).padEnd(4))} Raw: ${color(formatPercent(r.fundingRate).padEnd(14))} Hourly: ${(r.hourlyRate * 100).toFixed(6)}% Annual: ${r.annualizedPct.toFixed(2)}% APY: ${(compoundedReturn * 100).toFixed(2)}%`);
234
248
  }
249
+ if (!isRankedComparison(comparison)) {
250
+ console.log(chalk.yellow(`\n No ranked pair: fewer than two venues with verified funding (${comparison.unverifiedLegs.join(", ")} unverified — display only, never a long / short leg).\n`));
251
+ return;
252
+ }
235
253
  const spreadColor = comparison.maxSpreadAnnual >= 30 ? chalk.green.bold : comparison.maxSpreadAnnual >= 10 ? chalk.yellow : chalk.white;
236
254
  console.log(`\n Max Spread: ${spreadColor(`${comparison.maxSpreadAnnual.toFixed(1)}%`)} annual`);
237
- console.log(` Direction: Long ${exAbbr(comparison.longExchange)} / Short ${exAbbr(comparison.shortExchange)}${comparison.executable ? "" : chalk.yellow(` — not executable: ${comparison.readOnlyLegs.join(", ")} is read-only in perp-cli`)}`);
255
+ console.log(` Direction: Long ${exAbbr(comparison.longExchange)} / Short ${exAbbr(comparison.shortExchange)}${comparison.executable ? "" : chalk.yellow(` — not executable: ${[
256
+ ...comparison.readOnlyLegs.map(v => `${v} is read-only in perp-cli`),
257
+ ...comparison.unverifiedLegs.map(v => `${v} funding is unverified (display only)`),
258
+ ].join("; ")}`)}`);
238
259
  console.log(` Est. Income: $${comparison.estHourlyIncomeUsd.toFixed(4)}/hr per $1K notional\n`);
239
260
  }
240
261
  // registerFundingSubcommands removed — use 'arb scan --rates/--compare/--history/--positions' instead
@@ -287,12 +308,17 @@ export async function handleRates(isJson, opts) {
287
308
  const history = getHistoricalRates(s.symbol, bestEx, h24ago, now);
288
309
  return { ...s, rateHistory: history.map(h => ({ ts: h.ts, hourlyRate: h.hourlyRate })) };
289
310
  });
311
+ // --min never hides rows silently: the threshold and the hidden count are in meta (and data).
312
+ const minMeta = { minSpread: snapshot.minSpread, belowMinSpread: snapshot.belowMinSpread };
290
313
  if (process.argv.includes("--ndjson") || process.argv.includes("--fields"))
291
- return printJson(jsonOk(enriched));
292
- return printJson(jsonOk({ ...snapshot, symbols: enriched }));
314
+ return printJson(jsonOk(enriched, minMeta));
315
+ return printJson(jsonOk({ ...snapshot, symbols: enriched }, minMeta));
293
316
  }
294
317
  printSnapshotTable(snapshot);
295
318
  printFundingExchangeStatus(snapshot);
319
+ if (snapshot.belowMinSpread > 0) {
320
+ console.log(chalk.gray(` ${snapshot.belowMinSpread} pair${snapshot.belowMinSpread === 1 ? "" : "s"} below --min ${snapshot.minSpread}% hidden (show them: --min 0)\n`));
321
+ }
296
322
  }
297
323
  /**
298
324
  * Cross-venue price basis — a PRICE comparison, independent of funding.