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
package/dist/ws/node.js CHANGED
@@ -29,6 +29,14 @@ var WsError = class extends Error {
29
29
  ...opts?.details !== void 0 ? { details: opts.details } : {}
30
30
  };
31
31
  }
32
+ /** `structured.code` — the same place as `code` on error events (`e.code`). */
33
+ get code() {
34
+ return this.structured.code;
35
+ }
36
+ /** `structured.remediation` — the same place as `remediation` on error events; null when none. */
37
+ get remediation() {
38
+ return this.structured.remediation === void 0 ? null : this.structured.remediation;
39
+ }
32
40
  };
33
41
  function isPerpError(e) {
34
42
  if (e === null || typeof e !== "object") return false;
@@ -37,11 +45,89 @@ function isPerpError(e) {
37
45
  return typeof s.code === "string" && typeof s.message === "string";
38
46
  }
39
47
 
48
+ // src/ws/unclassified.ts
49
+ var UNCLASSIFIED_TICKERS = {
50
+ hyperliquid: /* @__PURE__ */ new Set(["SPX"]),
51
+ aster: /* @__PURE__ */ new Set(["SPX"]),
52
+ pacifica: /* @__PURE__ */ new Set(["SPX", "BP", "CHIP"]),
53
+ lighter: /* @__PURE__ */ new Set(["BB", "BOT", "WEN", "QNT", "SPX"])
54
+ };
55
+ var LIGHTER_RH_CLASS = {
56
+ // crypto
57
+ BTC: "crypto",
58
+ ETH: "crypto",
59
+ SOL: "crypto",
60
+ HYPE: "crypto",
61
+ ZEC: "crypto",
62
+ LIT: "crypto",
63
+ VVV: "crypto",
64
+ SUI: "crypto",
65
+ NEAR: "crypto",
66
+ XRP: "crypto",
67
+ CASHCAT: "crypto",
68
+ PONS: "crypto",
69
+ // commodity (canonical via EXPLICIT_CANONICAL: XAU → GOLD, XAG → SILVER)
70
+ XAU: "commodity",
71
+ XAG: "commodity",
72
+ // etf
73
+ SPY: "etf",
74
+ QQQ: "etf",
75
+ SOXL: "etf",
76
+ SLV: "etf",
77
+ USO: "etf",
78
+ SGOV: "etf",
79
+ // equity
80
+ AAPL: "equity",
81
+ AMZN: "equity",
82
+ GOOGL: "equity",
83
+ META: "equity",
84
+ MSFT: "equity",
85
+ NVDA: "equity",
86
+ TSLA: "equity",
87
+ AMD: "equity",
88
+ INTC: "equity",
89
+ MU: "equity",
90
+ TSM: "equity",
91
+ ORCL: "equity",
92
+ PLTR: "equity",
93
+ COIN: "equity",
94
+ CRCL: "equity",
95
+ CRWV: "equity",
96
+ BABA: "equity",
97
+ SNDK: "equity",
98
+ SMCI: "equity",
99
+ SOFI: "equity",
100
+ AMC: "equity",
101
+ RGTI: "equity",
102
+ QBTS: "equity",
103
+ CLSK: "equity",
104
+ IREN: "equity",
105
+ WULF: "equity",
106
+ LUNR: "equity",
107
+ ASTS: "equity",
108
+ BE: "equity",
109
+ USAR: "equity",
110
+ SPCX: "equity",
111
+ SKHY: "equity",
112
+ // pre-IPO / private (explicit-only canonical → null unless mapped)
113
+ OPENAI: "preIpo",
114
+ ANTHROPIC: "preIpo",
115
+ SHEIN: "preIpo",
116
+ ANSEM: "preIpo",
117
+ AI: "preIpo"
118
+ };
119
+ function isUnclassifiedTicker(venue, base) {
120
+ const up = base.toUpperCase();
121
+ if (venue === "lighter-rh") return LIGHTER_RH_CLASS[up] === void 0;
122
+ return UNCLASSIFIED_TICKERS[venue]?.has(up) === true;
123
+ }
124
+
40
125
  // src/ws/canonical.ts
41
126
  var USD_LIKE_QUOTES = /* @__PURE__ */ new Set(["USDT", "USD1", "USDC", "USD", "USDG"]);
42
- var defaultCanonicalize = (_venue, m) => {
127
+ var defaultCanonicalize = (venue, m) => {
43
128
  const base = m.base.toUpperCase();
44
129
  if (m.dex !== null && m.dex !== "") return `${m.dex}:${base}`;
130
+ if (isUnclassifiedTicker(venue, base)) return null;
45
131
  if (m.quote === null) return null;
46
132
  return USD_LIKE_QUOTES.has(m.quote.toUpperCase()) ? base : null;
47
133
  };
@@ -114,6 +200,7 @@ var Connection = class {
114
200
  this.sendQueue = [];
115
201
  this.sendTimer = null;
116
202
  this.reconnectTimer = null;
203
+ this.closing = /* @__PURE__ */ new Set();
117
204
  this.staleTimer = null;
118
205
  this.pingTimer = null;
119
206
  this.awaitingPongSince = null;
@@ -438,15 +525,26 @@ var Connection = class {
438
525
  ws.close(code, reason);
439
526
  } catch {
440
527
  }
528
+ if (ws.whenClosed !== void 0) {
529
+ const p = ws.whenClosed;
530
+ this.closing.add(p);
531
+ void p.then(() => {
532
+ this.closing.delete(p);
533
+ });
534
+ }
441
535
  }
442
536
  }
537
+ /** Settles when every socket this connection closed is fully gone (transports that report it). */
538
+ drained() {
539
+ return Promise.all(Array.from(this.closing)).then(() => void 0);
540
+ }
443
541
  };
444
542
  function stateDetails(s) {
445
543
  const { status: _status, ...rest } = s;
446
544
  return rest;
447
545
  }
448
546
  function closedError(venue) {
449
- return new WsError("INVALID_PARAMS", `${venue} stream is closed`, { exchange: venue, remediation: "Create a new stream" });
547
+ return new WsError("INVALID_PARAMS", `${venue} stream is closed`, { exchange: venue, remediation: "Create a new stream", details: { closeReason: "CLIENT_CLOSE" } });
450
548
  }
451
549
 
452
550
  // src/ws/ids.ts
@@ -976,6 +1074,7 @@ function checkCodec(codec) {
976
1074
  throw new WsError("INVALID_PARAMS", `createVenueStream: first argument must be a venue codec (${problem})`, { remediation: CODEC_REMEDIATION });
977
1075
  }
978
1076
  }
1077
+ var CLOSE_DRAIN_MS = 2e3;
979
1078
  function suggestCanonicals(missing, entries) {
980
1079
  const m = missing.toUpperCase();
981
1080
  const out = entries.filter((e) => e.canonical !== null && (e.canonical.toUpperCase() === m || e.base.toUpperCase() === m || e.canonical.toUpperCase().endsWith(`:${m}`))).map((e) => e.canonical);
@@ -983,7 +1082,7 @@ function suggestCanonicals(missing, entries) {
983
1082
  }
984
1083
  function suggestSymbols(missing, entries) {
985
1084
  const m = missing.toUpperCase();
986
- const out = entries.filter((e) => e.symbol.toUpperCase() === m || e.canonical !== null && e.canonical.toUpperCase() === m || e.base.toUpperCase() === m).map((e) => e.symbol);
1085
+ const out = entries.filter((e) => e.symbol.toUpperCase() === m || e.canonical !== null && (e.canonical.toUpperCase() === m || e.base.toUpperCase() === m)).map((e) => e.symbol);
987
1086
  return Array.from(new Set(out)).slice(0, 5);
988
1087
  }
989
1088
  var EVENT_CHANNEL = { account: "account", positions: "positions", orders: "orders", fill: "fills", fills: "fills" };
@@ -1742,7 +1841,7 @@ function createVenueStream(codec, opts = {}) {
1742
1841
  }
1743
1842
  if (target.kind === "market") {
1744
1843
  const channels = target.channels;
1745
- const valid = `Use channels ${MARKET_CHANNELS.map((c) => `"${c}"`).join(" / ")} (book and trades arrive in 0.15.1)`;
1844
+ const valid = `Use channels ${MARKET_CHANNELS.map((c) => `"${c}"`).join(" / ")} (book and trades: planned, not yet available)`;
1746
1845
  if (!Array.isArray(channels) || channels.length === 0) {
1747
1846
  throw wsError("INVALID_PARAMS", 'kind "market" needs at least one channel', valid);
1748
1847
  }
@@ -1753,7 +1852,7 @@ function createVenueStream(codec, opts = {}) {
1753
1852
  throw wsError(
1754
1853
  "CAPABILITY_UNSUPPORTED",
1755
1854
  `${venue} channel(s) ${later.join(", ")} are not available in this release`,
1756
- 'Use channels "market" / "bbo" (book and trades arrive in 0.15.1)'
1855
+ 'Use channels "market" / "bbo" (book and trades: planned, not yet available)'
1757
1856
  );
1758
1857
  }
1759
1858
  if (target.channels.includes("bbo") && !codec.capabilities.channels.bbo) {
@@ -2067,6 +2166,14 @@ function createVenueStream(codec, opts = {}) {
2067
2166
  sub.end();
2068
2167
  }
2069
2168
  stateListeners.clear();
2169
+ let bound = null;
2170
+ await Promise.race([
2171
+ Promise.all(shards.map((sh) => sh.conn.drained())),
2172
+ new Promise((resolve) => {
2173
+ bound = setTimeout(resolve, CLOSE_DRAIN_MS);
2174
+ })
2175
+ ]);
2176
+ if (bound !== null) clearTimeout(bound);
2070
2177
  }
2071
2178
  };
2072
2179
  return stream;
@@ -2428,6 +2535,23 @@ function mulDecimalInt(s, n, shift) {
2428
2535
  const abs2 = (neg ? -product : product).toString();
2429
2536
  return shiftDecimal(`${neg ? "-" : ""}${abs2}`, shift - frac.length);
2430
2537
  }
2538
+ function sumDecimals(values) {
2539
+ let scale = 0;
2540
+ const parts = [];
2541
+ for (const v of values) {
2542
+ const m = DECIMAL_RE.exec(v);
2543
+ if (m === null || m[4] !== void 0 || m[2] === "" && (m[3] === void 0 || m[3] === "")) return void 0;
2544
+ const frac = m[3] === void 0 ? "" : m[3];
2545
+ const digits = `${m[2]}${frac}`.replace(/^0+(?=\d)/, "");
2546
+ parts.push({ int: BigInt(digits === "" ? "0" : digits) * (m[1] === "-" ? -1n : 1n), scale: frac.length });
2547
+ if (frac.length > scale) scale = frac.length;
2548
+ }
2549
+ let total = 0n;
2550
+ for (const p of parts) total += p.int * 10n ** BigInt(scale - p.scale);
2551
+ const neg = total < 0n;
2552
+ const abs2 = (neg ? -total : total).toString();
2553
+ return shiftDecimal(`${neg ? "-" : ""}${abs2}`, -scale);
2554
+ }
2431
2555
 
2432
2556
  // src/ws/codecs/common.ts
2433
2557
  function isObj(v) {
@@ -2508,9 +2632,9 @@ function capabilities(p) {
2508
2632
  channels: { allMarkets: true, market: true, bbo: true, book: false, trades: false, user: p.user },
2509
2633
  fields: p.fields,
2510
2634
  notes: {
2511
- book: "Not in this release (0.15.1)",
2512
- trades: "Not in this release (0.15.1)",
2513
- ...p.user ? {} : { user: "Not in this release (0.15.1)" },
2635
+ book: "Planned, not yet available",
2636
+ trades: "Planned, not yet available",
2637
+ ...p.user ? {} : { user: "Planned, not yet available" },
2514
2638
  ...p.notes
2515
2639
  }
2516
2640
  };
@@ -2750,6 +2874,16 @@ var SIDE = { B: "buy", A: "sell" };
2750
2874
  function abs(d) {
2751
2875
  return d === null ? null : d.replace(/^-/, "");
2752
2876
  }
2877
+ function accountUnrealized(rows, bad) {
2878
+ if (bad.includes("assetPositions") || bad.includes("assetPositions[]")) return null;
2879
+ const values = [];
2880
+ for (const r of rows) {
2881
+ if (r.unrealizedPnl === null) return null;
2882
+ values.push(r.unrealizedPnl);
2883
+ }
2884
+ const sum = sumDecimals(values);
2885
+ return sum === void 0 ? null : sum;
2886
+ }
2753
2887
  function decodeClearinghouse(d) {
2754
2888
  if (!isObj(d) || typeof d.dex !== "string" || !isObj(d.clearinghouseState)) return [protocolError("hyperliquid clearinghouseState frame without dex/clearinghouseState")];
2755
2889
  const wire = `clearinghouseState:${d.dex}`;
@@ -2792,8 +2926,10 @@ function decodeClearinghouse(d) {
2792
2926
  equity: ms === null ? null : dec(ms, "accountValue", bad),
2793
2927
  available: dec(cs, "withdrawable", bad),
2794
2928
  marginUsed: ms === null ? null : dec(ms, "totalMarginUsed", bad),
2795
- unrealizedPnl: null
2796
- // not in clearinghouseState (per-position only)
2929
+ // clearinghouseState has no account-level unrealizedPnl, but the frame carries every
2930
+ // position of this dex with its own `unrealizedPnl`: the account value is their exact sum
2931
+ // ("0" with no position). null when any position's value is missing (unknown, never guessed).
2932
+ unrealizedPnl: accountUnrealized(rows, bad)
2797
2933
  },
2798
2934
  { kind: "positions", snapshot: true, scope: d.dex, rows, remove: [] }
2799
2935
  ];
@@ -3288,6 +3424,11 @@ function decodeBbo2(d) {
3288
3424
  return out;
3289
3425
  }
3290
3426
 
3427
+ // src/ws/lighter-market.ts
3428
+ function isLighterMarketLive(row) {
3429
+ return row.status === "active" && row.is_frozen !== true;
3430
+ }
3431
+
3291
3432
  // src/ws/codecs/lighter.ts
3292
3433
  var ALL2 = "market_stats/all";
3293
3434
  var ORDERS_TOKEN_TTL_SEC = 3600;
@@ -3296,6 +3437,10 @@ var ZERO = /^-?0*(\.0*)?$/;
3296
3437
  function bookSide(o, key, bad) {
3297
3438
  return o[key] === "" ? null : readDec(o, key, bad);
3298
3439
  }
3440
+ function lastTrade(o, bad) {
3441
+ const v = readDec(o, "last_trade_price", bad);
3442
+ return v !== void 0 && v !== null && ZERO.test(v) ? null : v;
3443
+ }
3299
3444
  function percentToFraction(o, key, bad) {
3300
3445
  const v = readDec(o, key, bad);
3301
3446
  if (v === void 0 || v === null) return v;
@@ -3328,7 +3473,7 @@ function makeLighter(venue, quote) {
3328
3473
  notes: {
3329
3474
  rate: "WS funding is PERCENT per 1 h on the wire; emitted as a fraction per 1 h",
3330
3475
  nextFundingTime: "Hourly settlement rule (venue-definition)",
3331
- openInterest: "USD notional (Phase 0), sides not stated by the venue",
3476
+ openInterest: "USD notional, one side \u2014 inferred, not documented by the venue (= REST orderBookDetails open_interest, base, \xD7 mark; the venue's exchangeMetrics kind=open_interest, a daily snapshot, read \u2248 2\xD7 when compared with the current value)",
3332
3477
  user: "By accountIndex. user_stats / account_all_positions / account_all_trades need no auth; account_all_orders needs CredentialProvider.authToken (re-called at 80 % TTL). account_all_trades does not replay \u2192 gap refetch-required for fills after a reconnect",
3333
3478
  clientId: "orders: client_order_id; fills: ask_/bid_client_id_str of our side; 0 = no client id \u2192 null",
3334
3479
  liquidationPrice: 'liquidation_price; "0" (no liquidation price, e.g. well-covered cross margin) \u2192 null, as in the CLI REST path',
@@ -3345,7 +3490,7 @@ function makeLighter(venue, quote) {
3345
3490
  limits: S5.limits,
3346
3491
  closesOnBadSubscribe: false,
3347
3492
  staleAfterMs: S5.staleAfterMs,
3348
- openInterest: { unit: "usd", sides: null },
3493
+ openInterest: { unit: "usd", sides: "one" },
3349
3494
  refresh: null,
3350
3495
  async loadDirectory(ctx) {
3351
3496
  const raw = await fetchJson(ctx.fetch, venue, `${ctx.restUrl}/api/v1/orderBooks`);
@@ -3363,6 +3508,10 @@ function makeLighter(venue, quote) {
3363
3508
  continue;
3364
3509
  }
3365
3510
  idToSymbol.set(id, m.symbol);
3511
+ if (!isLighterMarketLive(m)) {
3512
+ nonPerp.add(id);
3513
+ continue;
3514
+ }
3366
3515
  entries.push({
3367
3516
  venue,
3368
3517
  dex: null,
@@ -3498,7 +3647,7 @@ function decodeStats(venue, f, d, recvTs) {
3498
3647
  index: readDec(r, "index_price", bad),
3499
3648
  bestBid: bookSide(r, "best_bid_price", bad),
3500
3649
  bestAsk: bookSide(r, "best_ask_price", bad),
3501
- last: readDec(r, "last_trade_price", bad),
3650
+ last: lastTrade(r, bad),
3502
3651
  rate: percentToFraction(r, "funding_rate", bad),
3503
3652
  predictedRate: percentToFraction(r, "current_funding_rate", bad),
3504
3653
  openInterest: readDec(r, "open_interest", bad),
@@ -3714,6 +3863,14 @@ function decodeTicker(venue, wire, f, d) {
3714
3863
  return out;
3715
3864
  }
3716
3865
 
3866
+ // src/ws/aster-market.ts
3867
+ function isAsterListedPerpetual(row) {
3868
+ return row.contractType === "PERPETUAL" && row.status === "TRADING";
3869
+ }
3870
+ function asterMatchable(row) {
3871
+ return row.quoteAsset === "USDT";
3872
+ }
3873
+
3717
3874
  // src/ws/codecs/aster.ts
3718
3875
  var S3 = VENUE_STATIC.aster;
3719
3876
  var MARK = "!markPrice@arr@1s";
@@ -3761,7 +3918,7 @@ var aster = {
3761
3918
  openInterest: "Not on WS (REST only)",
3762
3919
  fundingInterval: `Labelled REST source ${FUNDING_INFO}, refreshed hourly; missing \u2192 null`,
3763
3920
  volume24hNotional: "!ticker@arr q (quote-asset volume)",
3764
- user: 'KNOWN GAP in 0.15.0: no Aster user channels (signed USER_STREAM listenKey) \u2014 kind:"user" rejects CAPABILITY_UNSUPPORTED; arrives in 0.15.1',
3921
+ user: 'KNOWN GAP: no Aster user channels (signed USER_STREAM listenKey) \u2014 kind:"user" rejects CAPABILITY_UNSUPPORTED; planned, not yet available',
3765
3922
  bbo: "<symbol>@bookTicker; `u` orders frames (non-contiguous)"
3766
3923
  }
3767
3924
  }),
@@ -3801,6 +3958,7 @@ var aster = {
3801
3958
  if (!isObj(s) || typeof s.symbol !== "string" || typeof s.baseAsset !== "string") {
3802
3959
  throw badShape("aster", "exchangeInfo symbol without symbol/baseAsset");
3803
3960
  }
3961
+ if (!isAsterListedPerpetual(s)) continue;
3804
3962
  const quote = typeof s.quoteAsset === "string" && s.quoteAsset !== "" ? s.quoteAsset : null;
3805
3963
  listed.add(s.symbol);
3806
3964
  const hours = intervals.get(s.symbol);
@@ -3811,7 +3969,8 @@ var aster = {
3811
3969
  venueSymbol: s.symbol,
3812
3970
  base: s.baseAsset,
3813
3971
  quote,
3814
- canonical: ctx.canonicalize("aster", { venueSymbol: s.symbol, dex: null, base: s.baseAsset, quote }),
3972
+ // Only the USDT listing is matchable (asterMatchable — the CLI rule): BTCUSD1 is never a second BTC leg.
3973
+ canonical: asterMatchable(s) ? ctx.canonicalize("aster", { venueSymbol: s.symbol, dex: null, base: s.baseAsset, quote }) : null,
3815
3974
  rateIntervalHours: hours === void 0 ? null : hours,
3816
3975
  settlementIntervalHours: hours === void 0 ? null : hours,
3817
3976
  source: "rest:/fapi/v3/exchangeInfo"
@@ -3934,6 +4093,11 @@ function decodeArr(wire, data2, d, fn) {
3934
4093
  return out;
3935
4094
  }
3936
4095
 
4096
+ // src/ws/arcus-market.ts
4097
+ function isArcusOnlineMarket(row) {
4098
+ return row.status === "ONLINE";
4099
+ }
4100
+
3937
4101
  // src/ws/codecs/arcus.ts
3938
4102
  var S4 = VENUE_STATIC.arcus;
3939
4103
  var MARKETS = "markets";
@@ -3984,6 +4148,7 @@ var arcus = {
3984
4148
  if (!isObj(m) || typeof m.marketDisplayName !== "string" || typeof m.marketId !== "number" || typeof m.baseAsset !== "string") {
3985
4149
  throw badShape("arcus", "market without marketDisplayName/marketId/baseAsset");
3986
4150
  }
4151
+ if (!isArcusOnlineMarket(m)) continue;
3987
4152
  const quote = typeof m.quoteAsset === "string" ? m.quoteAsset : null;
3988
4153
  idToSymbol.set(String(m.marketId), m.marketDisplayName);
3989
4154
  entries.push({
@@ -4322,6 +4487,7 @@ function loadWs() {
4322
4487
  return wsModule;
4323
4488
  }
4324
4489
  var DEFAULT_MAX_PAYLOAD = 16 * 1024 * 1024;
4490
+ var CLOSE_FLUSH_MS = 250;
4325
4491
  function createNodeTransport(opts = {}) {
4326
4492
  const maxPayload = opts.maxPayload ?? DEFAULT_MAX_PAYLOAD;
4327
4493
  if (!Number.isSafeInteger(maxPayload) || maxPayload <= 0) {
@@ -4331,12 +4497,34 @@ function createNodeTransport(opts = {}) {
4331
4497
  return (url) => {
4332
4498
  const ws = new WebSocket(url, { maxPayload });
4333
4499
  ws.on("error", () => void 0);
4500
+ let terminateTimer = null;
4501
+ const whenClosed = new Promise((resolve) => {
4502
+ ws.once("close", () => {
4503
+ if (terminateTimer !== null) {
4504
+ clearTimeout(terminateTimer);
4505
+ terminateTimer = null;
4506
+ }
4507
+ resolve();
4508
+ });
4509
+ });
4334
4510
  const like = {
4511
+ whenClosed,
4335
4512
  get readyState() {
4336
4513
  return ws.readyState;
4337
4514
  },
4338
4515
  send: (data2) => ws.send(data2),
4339
- close: (code, reason) => ws.close(code, reason),
4516
+ // The close frame is sent, then the socket is destroyed at once: `ws` would otherwise keep a
4517
+ // 30 s close-handshake timer (and the TCP socket) alive until the server answers, so the
4518
+ // process was not idle after stream.close() (conformance ws:all:close-timers).
4519
+ close: (code, reason) => {
4520
+ ws.close(code, reason);
4521
+ if (terminateTimer === null && ws.readyState !== ws.CLOSED) {
4522
+ terminateTimer = setTimeout(() => {
4523
+ terminateTimer = null;
4524
+ ws.terminate();
4525
+ }, CLOSE_FLUSH_MS);
4526
+ }
4527
+ },
4340
4528
  onopen: null,
4341
4529
  onmessage: null,
4342
4530
  onclose: null,
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Tickers that name DIFFERENT instruments on different venues — listed everywhere, never matched
3
+ * across venues (canonical null): the one table for the CLI (assetClass "unclassified"), the
4
+ * public directory / MCP / arb and the WS directory (defaultCanonicalize). src/ws keeps it so the
5
+ * WS bundle needs no CLI module.
6
+ * - SPX: the SPX6900 token on Hyperliquid / Aster / Lighter / Pacifica; its ticker equals the
7
+ * canonical key of the S&P 500 index.
8
+ * - Pacifica BP / CHIP: tickers that collide with a listed equity / ETF.
9
+ * - Lighter BB / BOT / WEN / QNT: grouped with US equities there, each also a live crypto ticker.
10
+ * - Lighter RH: every listed perp is classified (LIGHTER_RH_CLASS); an unlisted one is not.
11
+ */
12
+ export declare const UNCLASSIFIED_TICKERS: Readonly<Record<string, ReadonlySet<string>>>;
13
+ /** Lighter RH listing classification (live listings 2026-09-30). */
14
+ export declare const LIGHTER_RH_CLASS: Readonly<Record<string, "crypto" | "commodity" | "etf" | "equity" | "preIpo">>;
15
+ /** true = `base` on `venue` is never matched across venues (native markets only — not HIP-3 dexes). */
16
+ export declare function isUnclassifiedTicker(venue: string, base: string): boolean;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Tickers that name DIFFERENT instruments on different venues — listed everywhere, never matched
3
+ * across venues (canonical null): the one table for the CLI (assetClass "unclassified"), the
4
+ * public directory / MCP / arb and the WS directory (defaultCanonicalize). src/ws keeps it so the
5
+ * WS bundle needs no CLI module.
6
+ * - SPX: the SPX6900 token on Hyperliquid / Aster / Lighter / Pacifica; its ticker equals the
7
+ * canonical key of the S&P 500 index.
8
+ * - Pacifica BP / CHIP: tickers that collide with a listed equity / ETF.
9
+ * - Lighter BB / BOT / WEN / QNT: grouped with US equities there, each also a live crypto ticker.
10
+ * - Lighter RH: every listed perp is classified (LIGHTER_RH_CLASS); an unlisted one is not.
11
+ */
12
+ export const UNCLASSIFIED_TICKERS = {
13
+ hyperliquid: new Set(["SPX"]),
14
+ aster: new Set(["SPX"]),
15
+ pacifica: new Set(["SPX", "BP", "CHIP"]),
16
+ lighter: new Set(["BB", "BOT", "WEN", "QNT", "SPX"]),
17
+ };
18
+ /** Lighter RH listing classification (live listings 2026-09-30). */
19
+ export const LIGHTER_RH_CLASS = {
20
+ // crypto
21
+ BTC: "crypto", ETH: "crypto", SOL: "crypto", HYPE: "crypto", ZEC: "crypto", LIT: "crypto",
22
+ VVV: "crypto", SUI: "crypto", NEAR: "crypto", XRP: "crypto", CASHCAT: "crypto", PONS: "crypto",
23
+ // commodity (canonical via EXPLICIT_CANONICAL: XAU → GOLD, XAG → SILVER)
24
+ XAU: "commodity", XAG: "commodity",
25
+ // etf
26
+ SPY: "etf", QQQ: "etf", SOXL: "etf", SLV: "etf", USO: "etf", SGOV: "etf",
27
+ // equity
28
+ AAPL: "equity", AMZN: "equity", GOOGL: "equity", META: "equity", MSFT: "equity", NVDA: "equity",
29
+ TSLA: "equity", AMD: "equity", INTC: "equity", MU: "equity", TSM: "equity", ORCL: "equity",
30
+ PLTR: "equity", COIN: "equity", CRCL: "equity", CRWV: "equity", BABA: "equity", SNDK: "equity",
31
+ SMCI: "equity", SOFI: "equity", AMC: "equity", RGTI: "equity", QBTS: "equity", CLSK: "equity",
32
+ IREN: "equity", WULF: "equity", LUNR: "equity", ASTS: "equity", BE: "equity", USAR: "equity",
33
+ SPCX: "equity", SKHY: "equity",
34
+ // pre-IPO / private (explicit-only canonical → null unless mapped)
35
+ OPENAI: "preIpo", ANTHROPIC: "preIpo", SHEIN: "preIpo", ANSEM: "preIpo", AI: "preIpo",
36
+ };
37
+ /** true = `base` on `venue` is never matched across venues (native markets only — not HIP-3 dexes). */
38
+ export function isUnclassifiedTicker(venue, base) {
39
+ const up = base.toUpperCase();
40
+ if (venue === "lighter-rh")
41
+ return LIGHTER_RH_CLASS[up] === undefined;
42
+ return UNCLASSIFIED_TICKERS[venue]?.has(up) === true;
43
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "perp-cli",
3
- "version": "0.15.0",
4
- "description": "Multi-DEX Perpetual Futures CLI - Pacifica, Hyperliquid, Lighter, Aster, Lighter RH, Ondo Perps, Arcus, QFEX, RISEx (read-only), TxFlow (market data, unofficial API)",
3
+ "version": "0.16.0",
4
+ "description": "Multi-DEX perpetual futures CLI + MCP server for traders and AI agents — trade on Hyperliquid (incl. HIP-3), Pacifica, Lighter, Aster, Ondo, Arcus, QFEX and more; funding-rate arb, portfolio, bots, bridge. 31 MCP tools, agent skill, JSON output.",
5
5
  "bin": {
6
6
  "perp": "dist/index.js",
7
7
  "perp-mcp": "dist/mcp-server.js",
@@ -47,6 +47,10 @@
47
47
  "test": "vitest run --exclude '**/integration/**'",
48
48
  "test:integration": "vitest run -c vitest.integration.config.ts",
49
49
  "test:all": "vitest run",
50
+ "test:conformance": "vitest run src/__tests__/conformance",
51
+ "test:conformance:live": "PERP_LIVE=1 vitest run -c vitest.conformance.live.config.ts",
52
+ "conformance:record": "tsx scripts/conformance/record.ts",
53
+ "release-qa": "node scripts/release-qa/run.mjs",
50
54
  "e2e": "tsx scripts/e2e/run.ts",
51
55
  "e2e:typecheck": "tsc -p scripts/e2e/tsconfig.json",
52
56
  "typecheck:test": "tsc -p tsconfig.test.json",
@@ -85,6 +89,15 @@
85
89
  "ethereum"
86
90
  ],
87
91
  "license": "MIT",
92
+ "author": "HypurrQuant",
93
+ "repository": {
94
+ "type": "git",
95
+ "url": "git+https://github.com/hypurrquant/perp-cli.git"
96
+ },
97
+ "homepage": "https://github.com/hypurrquant/perp-cli#readme",
98
+ "bugs": {
99
+ "url": "https://github.com/hypurrquant/perp-cli/issues"
100
+ },
88
101
  "engines": {
89
102
  "node": "^20.0.0 || ^22.0.0 || ^24.0.0"
90
103
  },