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
@@ -59,7 +59,10 @@ During transition, you are **unhedged**. Price can move against you. Factor this
59
59
  ```bash
60
60
  perp --json arb scan --min 5 # find spreads > 5% annual (shows longExch/shortExch/netSpread)
61
61
  perp --json arb scan --rates # raw funding rates across all exchanges
62
- perp --json arb scan --history <SYMBOL> # historical funding for a symbol
62
+ perp --json -e <EX> market funding <SYMBOL> -l 168 # settled funding history per venue (newest first; average it yourself)
63
+ # `arb scan --history <SYMBOL>` = only snapshots THIS machine recorded during earlier `arb scan --rates` runs
64
+ # (often `rates: {}`; `historicalAvg` in --rates / --compare rows is null for the same reason) — not venue history.
65
+ # Testnet rates are meaningless (thousands of %/yr): analyse on mainnet only.
63
66
  # NOTE: legacy 'arb rates'/'arb prices'/'arb dex' are removed — all routes go through 'arb scan'
64
67
  ```
65
68
 
@@ -75,6 +78,8 @@ netSpread: 12.87 → annualized % after round-trip trading costs amorti
75
78
  (and --bridge-cost if given)
76
79
  ```
77
80
 
81
+ (perp-perp rows. `--rates` / `--compare` rows name the legs `longExchange` / `shortExchange` and are actionable only when `executable === true`; spot-perp rows have `spotExchanges` / `perpExchange` / `direction` — see "Spot-Perp Carry".)
82
+
78
83
  **Rules:**
79
84
  1. **ALWAYS follow longExch/shortExch directions exactly.** DO NOT reverse them.
80
85
  2. **NEVER enter if netSpread ≤ 0.** Negative netSpread = loss after fees over the assumed hold. A shorter real hold makes costs weigh more — re-scan with your `--hold-days`.
@@ -181,7 +186,7 @@ Desired size: 1.0
181
186
  ```bash
182
187
  perp --json -e <EX> market info <SYM> # check maxLeverage, volume, openInterest
183
188
  ```
184
- Round `ORDER_SIZE` to the coarser step size of the two exchanges. If the matched size falls below either exchange's minimum, the arb is NOT executable at this size — reduce target or skip.
189
+ Round `ORDER_SIZE` to the coarser step size of the two exchanges. If the matched size is below $15 notional on either leg (the skill's default smallest order — the $10 venue minimums plus a buffer for price moves, step rounding and fees; see SKILL.md Step 4), the arb is NOT executable at this size — raise the target or skip.
185
190
 
186
191
  #### Step 2: Execute Legs Sequentially with Same Size
187
192
 
@@ -267,6 +272,34 @@ If positions already have mismatched sizes, close to the smaller size first, the
267
272
  - Better opportunity found (after accounting for switch cost!)
268
273
  - Risk limits breached
269
274
 
275
+ ## Spot-Perp Carry (long spot + short perp)
276
+
277
+ **What:** buy the asset spot and short the same asset's perp while perp funding is POSITIVE (longs pay shorts) — the short leg collects funding, the spot leg pays none, price exposure nets out.
278
+
279
+ **Find:**
280
+ ```bash
281
+ perp --json arb scan --mode spot-perp --min 0 --hold-days 14 --size 500 # mainnet; keep only direction == "long-spot-short-perp"
282
+ perp --json -e <perpExchange> market funding <SYM> -l 168 # is the rate persistent? (settled, newest first)
283
+ ```
284
+ Row fields: `spotExchanges[]` (venues that can hold the spot leg; `arb exec` spot venues: hl, lt, lrh, aster, ondo), `perpExchange`, `direction`, `grossSpread` / `netSpread` (annual %), `estFeesPct` (round trip on BOTH legs: 2 × (taker A + taker B) + 0.1 % slippage — taker fees from `perp settings fees show`), `breakEvenDays` (days of funding needed to pay `estFeesPct`), `estHourlyIncomeUsd` (per $1000). `--hold-days` amortizes the fees into `netSpread`; `--size` / `--bridge-cost` add a bridge cost when the legs sit on different venues. A `sell-spot-long-perp` row is NOT a carry trade for perp-cli: `arb exec spot:` always buys spot and shorts the perp (it does not check the funding sign), so on such a row it pays funding.
285
+
286
+ **Economics:**
287
+ - Net ≈ funding received on the short − `estFeesPct` (entry + exit, both legs) − bridge cost if the legs are on different venues.
288
+ - Capital = the FULL spot notional in the spot venue's quote token (`trade spot-balance`; separate-margin venues transfer the shortfall perp→spot) + perp margin (notional / leverage, with a buffer) on the perp venue. Hyperliquid unified accounts fund both legs from one balance.
289
+ - Practical ceiling: on many venues funding sits at the base interest rate, 0.00125 %/h ≈ 10.95 % APR (observed 2026-10-26 on HYPE: Pacifica / Ondo / Arcus 10.95 %, Hyperliquid 24 h average ≈ 7.6 %). Treat ~11 % gross as the normal ceiling for liquid assets; much higher prints are usually short-lived spikes — check the settled history before entering.
290
+ - There is no spot price / book read in perp-cli, so the spot-perp basis cannot be pre-computed here; `arb exec` itself compares the spot and perp books (refuses an empty book or a > 5 % mid gap) and checks depth and venue minimums before anything moves.
291
+
292
+ **Risks:** funding flips negative (the short then PAYS — exit when the settled rate stays negative); short-leg liquidation on a rally (spot gains on another venue do not margin the perp) → 1–2x leverage, isolated margin, watch `risk liquidation-distance`; basis moves at entry / exit; venue / transfer risk on the spot leg.
293
+
294
+ **Execute / exit (dry-run first, user confirmation for each live step):**
295
+ ```bash
296
+ perp --json --dry-run arb exec <SYM> spot:hl hl <USD> --leverage 2 --isolated # only for a long-spot-short-perp row
297
+ perp --json arb exec <SYM> spot:hl hl <USD> --leverage 2 --isolated
298
+ perp --json arb status # spotPerpPairs; fails as a whole if one venue read fails
299
+ perp --json --dry-run arb close <SYM> --spot-exch hl --perp-exch hl
300
+ perp --json arb close <SYM> --spot-exch hl --perp-exch hl
301
+ ```
302
+
270
303
  ## Risk Management — THE MOST IMPORTANT SECTION
271
304
 
272
305
  **Risk management is more important than profit.** A single liquidation can wipe out months of funding rate income. Every decision you make should be filtered through risk first, profit second.
@@ -399,7 +432,9 @@ Compare each position's unrealized P&L. In a perfect arb, they should roughly of
399
432
 
400
433
  Think in terms of total capital across all exchanges:
401
434
  ```bash
402
- perp --json portfolio # totalEquity, marginUtilization, concentration
435
+ perp --json portfolio # totals.{equity,marginUsed,marginPct,funding24h,fundingIncomplete}, risk.{level,marginPct}, exchanges[].perp.available
436
+ # MCP `portfolio`: totalEquity / totalAvailable / totalMarginUsed / riskMetrics.marginUtilization are null when no balance
437
+ # was read — check accountState ("ok" | "unread" | "noAccount") and totalsCover (venues included) first
403
438
  ```
404
439
 
405
440
  Rules of thumb:
@@ -4,10 +4,10 @@
4
4
  custom live dashboard, funding comparison or price/account feed for **hyperliquid** (native + HIP-3),
5
5
  **pacifica**, **lighter**, **lighter-rh**, **aster** and **arcus**, in the browser or in Node.
6
6
 
7
- > **Stability:** ships in **0.15.0** and is **@experimental** through 0.15.x — breaking changes only in a
7
+ > **Stability:** shipped since **0.15.0** and **@experimental** through 0.15.x — breaking changes only in a
8
8
  > minor bump (CHANGELOG callouts); every event carries `schema: 1`. **@stable from 0.16.0** after consumer
9
- > sign-off. Planned for **0.15.1**: Aster user channels, Ondo / QFEX, `book` / `trades`, `perp stream`.
10
- > The `VenueId` type already includes `"ondo"` and `"qfex"`, but there are no codecs for them until 0.15.1.
9
+ > sign-off. Planned, not yet available: Aster user channels, Ondo / QFEX, `book` / `trades`, `perp stream`.
10
+ > The `VenueId` type already includes `"ondo"` and `"qfex"`, but there are no codecs for them yet.
11
11
  > **GRVT** (CLI venue, market data only) has no codec: its public WS has per-instrument selectors only (no
12
12
  > all-markets stream) and a 150-stream cap below its 184 perpetuals, so `allMarkets` cannot be served.
13
13
  > **Extended** (CLI venue `extended`, market data only) has no codec: its public streams are one socket
@@ -26,7 +26,7 @@ Two packages, **the same code and API** (built from one source):
26
26
 
27
27
  | Package | Install | When |
28
28
  |---|---|---|
29
- | **`@hypurrquant/perp-ws`** (recommended for integrators) | `npm install @hypurrquant/perp-ws` (+ `ws` for the Node entry) | apps, dashboards, bots — **no dependencies** (~100 KB ESM) |
29
+ | **`@hypurrquant/perp-ws`** (recommended for integrators) | `npm install @hypurrquant/perp-ws` (+ `ws` for the Node entry) | apps, dashboards, bots — **no dependencies** (~170 KB ESM, ~41 KB gzip; 0.16.0 build) |
30
30
  | `perp-cli/ws` | `npm install perp-cli` | perp-cli is already installed (the CLI pulls ~140 MB of CLI deps) |
31
31
 
32
32
  | Runtime | `@hypurrquant/perp-ws` | `perp-cli` | Transport |
@@ -76,7 +76,7 @@ page's job, like a bundler's resolver):
76
76
 
77
77
  Serve the page and `node_modules` from the same static server (any origin works: the venues do not check
78
78
  Origin and their REST CORS is `*`). Do the same with a CDN that serves npm files (e.g.
79
- `https://cdn.jsdelivr.net/npm/@hypurrquant/perp-ws@0.15.0/dist/index.js`). The `/node` entry is
79
+ `https://cdn.jsdelivr.net/npm/@hypurrquant/perp-ws@<version>/dist/index.js`). The `/node` entry is
80
80
  Node-only (it loads `ws`); never map it in a browser.
81
81
 
82
82
  ## 2. Minimal examples
@@ -123,7 +123,8 @@ declare function show(venue: string, text: string): void; // your UI: one status
123
123
 
124
124
  const streams: VenueStream[] = [hyperliquid, pacifica, lighter, aster, arcus].map((c) => createVenueStream(c));
125
125
  for (const stream of streams) {
126
- // canonicals: "BTC" → hyperliquid/pacifica/lighter BTC, aster BTCUSDT + BTCUSD1, arcus BTC-USD.
126
+ // canonicals: "BTC" → hyperliquid/pacifica/lighter BTC, aster BTCUSDT, arcus BTC-USD (Aster's
127
+ // BTCUSD1 / BTCU listings are canonical null — only the USDT listing is matchable, as in the CLI).
127
128
  // (symbols would need each venue's directory symbols; ["BTC"] rejects on aster and arcus.)
128
129
  // A canonical this venue does not list (e.g. SOL somewhere) is skipped with a non-fatal warning;
129
130
  // ready rejects only when the venue lists none of them.
@@ -172,10 +173,12 @@ await Promise.all(streams.map((s) => s.close()));
172
173
  - `subscribe(target)`:
173
174
  - `{ kind: "allMarkets", canonicals? | symbols? }` → `MarketUpdate` for every market, or a subset:
174
175
  **`canonicals`** (recommended across venues) selects every directory market whose `canonical` matches
175
- — `["BTC"]` is `BTC` on hyperliquid / pacifica / lighter / lighter-rh (USDG), `BTCUSDT` + `BTCUSD1` on
176
+ — `["BTC"]` is `BTC` on hyperliquid / pacifica / lighter / lighter-rh (USDG), `BTCUSDT` on
176
177
  aster and `BTC-USD` on arcus; HIP-3 markets are `"<dex>:<BASE>"` (`"xyz:TSLA"`, with `hip3Dexes`) and
177
178
  `"BTC"` never pulls them in. Canonicals come from `opts.canonicalize` (default: USD-like quotes
178
- collapse) — see `MarketDirectoryEntry.canonical` in `stream.markets()`. Canonicals the venue does
179
+ collapse; an ambiguous ticker — SPX6900 on Hyperliquid / Aster / Lighter / Pacifica, Pacifica BP /
180
+ CHIP, Lighter BB / BOT / WEN / QNT, an unclassified Lighter RH market — and a non-USDT Aster listing
181
+ are null, never grouped: the CLI's rule) — see `MarketDirectoryEntry.canonical` in `stream.markets()`. Canonicals the venue does
179
182
  not list are **skipped and reported once** as a non-fatal control
180
183
  `{ type: "error", code: "SYMBOL_NOT_FOUND", fatal: false, details: { missing: string[], suggestions:
181
184
  Record<string, string[]> } }` (suggestions e.g. `"xyz:TSLA"` for `"TSLA"`; the others are subscribed and
@@ -183,7 +186,7 @@ await Promise.all(streams.map((s) => s.close()));
183
186
  **`symbols`** are this venue's exact directory symbols. Pass **one or the other** — both together
184
187
  reject `INVALID_PARAMS`. Same wire subscription and budget either way (filtering is client-side);
185
188
  - `{ kind: "market", symbol, channels: ["market" | "bbo"] }`. `"book"` and `"trades"` are already in
186
- the types but **reject with `CAPABILITY_UNSUPPORTED` until 0.15.1** — don't ship UI that needs them.
189
+ the types but **reject with `CAPABILITY_UNSUPPORTED`** (planned, not yet available) — don't ship UI that needs them.
187
190
  Any other channel name (e.g. `"ticker"`) or an empty `channels` rejects with `INVALID_PARAMS`;
188
191
  - `{ kind: "user", channels: ["account" | "positions" | "orders" | "fills"] }` (needs `credentials`).
189
192
  - **`symbols` / `symbol` mean the venue's directory symbols, case-sensitive** — not canonical names. `BTC` is `"BTC"` on
@@ -211,7 +214,9 @@ await Promise.all(streams.map((s) => s.close()));
211
214
  `connecting` / `reconnecting`: it stays `live` with
212
215
  `sockets` = open sockets and `pending` = sockets still opening — the channels on those are not ready
213
216
  yet (their `ready` resolves when they open). `pending` is present only on multi-socket streams.
214
- - `closed` is terminal: a later `subscribe` rejects with the close reason. A handshake failure before
217
+ - `closed` is terminal: a later `subscribe` rejects with the close reason (e.g. the code that exhausted
218
+ the reconnects). After your own `close()` the reason is `CLIENT_CLOSE`, which is not an error code: the
219
+ rejection is `INVALID_PARAMS` "stream is closed" with `details.closeReason: "CLIENT_CLOSE"`. A handshake failure before
215
220
  open = `EXCHANGE_UNREACHABLE` ("network, venue outage, or Origin/CORS block").
216
221
  - Ack timeout: no acknowledgement and no data within the channel's stale threshold → a **pending**
217
222
  subscription fails with `WS_PROTOCOL_ERROR`.
@@ -232,9 +237,17 @@ funding{ rate, rateKind, rateSource, rateIntervalHours, settlementIntervalHours,
232
237
  nextFundingTime, predictedRate, marketClosed }, openInterest{ value, unit: "base"|"usd"|null, sides },
233
238
  volume24hNotional, venueTs, recvTs, source, changed[], asOf{}`.
234
239
 
240
+ - **Aster `volume24hNotional` can stay `null` for minutes after subscribing:** the venue's `!ticker@arr`
241
+ pushes only the tickers that changed, so a quiet market's first ticker (and its 24 h notional) arrives
242
+ late. `null` there means "not received yet", not "no volume" — a volume filter must not treat it as 0
243
+ (see the example in §5, which keeps such legs out explicitly and says so).
244
+
235
245
  Funding:
236
246
  - `rate` is **raw per `rateIntervalHours`** — never pre-annualized. Use `toHourly(rate, h)` to compare and
237
247
  `toApr(rate, h)` only for display (both return `null` for `null` input).
248
+ - **Units:** `toHourly` and `toApr` return **fractions** (`toApr` 0.1095 = 10.95 % a year, simple ×24×365).
249
+ The CLI's `annualizedPct` (`perp --json arb scan --rates`, MCP `get_funding_rates`) is the same figure in
250
+ **percent** (10.95). Multiply `toApr` by 100 before comparing it with `annualizedPct` or printing a "%".
238
251
  - `rateKind`: `"lastSettled"` (the last payment), `"accruing"` (the current period so far, the "if it
239
252
  settled now" rate), `"predicted"` (the venue's estimate of the next payment). `predictedRate` is always
240
253
  kind `"predicted"`.
@@ -243,7 +256,7 @@ Funding:
243
256
  |---|---|---|---|---|---|
244
257
  | hyperliquid | accruing | null | 1 h | base, one side | bid/ask/last null → `bbo` channel |
245
258
  | pacifica | lastSettled | next_funding | 1 h | base, `sides` null | bid/ask/last/nextFundingTime null |
246
- | lighter / lighter-rh | lastSettled | current_funding_rate | 1 h | **usd** | wire values are percent/h, emitted as fractions/h |
259
+ | lighter / lighter-rh | lastSettled | current_funding_rate | 1 h | **usd**, one side (inferred, not documented by the venue) | wire values are percent/h, emitted as fractions/h; REST `market info` OI is the same side in base units — convert before comparing; inactive / frozen markets are not streamed (same set as `market list`) |
247
260
  | aster | accruing | null | per market (`rest:/fapi/v3/fundingInfo`, null if missing) | null | all quotes (USDT, USD1, …) with `quote` set |
248
261
  | arcus | lastSettled | nextFundingRate | 1 h | base, one side | bid/ask null → `bbo` channel |
249
262
 
@@ -286,12 +299,12 @@ Options and defaults:
286
299
  are forward-looking estimates of the coming payment, so the row is usable for ranking; care when you
287
300
  need exact like-for-like numbers (e.g. P&L attribution, or an accruing rate early in its period that
288
301
  can still move a lot) — then use `kind: "accruing"` or `"predicted"` and accept fewer legs.
289
- - **Several legs per venue:** one venue can contribute more than one leg to a row. Aster lists BTC as
290
- `BTCUSDT` (quote USDT) and `BTCUSD1` (quote USD1); both canonicalize to `"BTC"`. Pick one per venue
291
- before ranking — by `quote`, or by the most liquid market (below) — or pass a `canonicalize` that
292
- returns `null` for the quotes you don't want.
302
+ - **One leg per venue by default:** Aster lists BTC as `BTCUSDT` (quote USDT) and `BTCUSD1` (quote
303
+ USD1); the default canonicalization keeps only the USDT listing matchable and gives every other
304
+ quote `canonical: null`, as the CLI does. A custom `canonicalize` that
305
+ returns a key for both brings back several legs per venue — pick one before ranking.
293
306
  - **`fundingVerified`** (per leg): the CLI's verdict that the venue's funding unit and which published
294
- rate actually settles were confirmed against its funding payments. All six venues in 0.15.0 are
307
+ rate actually settles were confirmed against its funding payments. All six WS venues are
295
308
  `true`; a `false` leg (a future venue) is indicative only — the CLI keeps such venues out of funding
296
309
  comparison and refuses them as funding-arb legs, so do the same.
297
310
  - **Filter illiquid outliers before ranking spreads.** Tiny or newly listed markets often show extreme
@@ -316,13 +329,19 @@ for (const s of streams) {
316
329
  }
317
330
 
318
331
  const MIN_NOTIONAL = 1_000_000;
319
- /** One leg per venue: the most liquid market that passes the filter (null volume = unknown → skipped). */
332
+ /**
333
+ * One leg per venue: the most liquid market that passes the filter. A leg whose 24 h notional is not
334
+ * known yet (Aster sends a ticker only when it changes — minutes on quiet markets) is skipped, never
335
+ * read as 0; `unknownVolume` counts them so a dashboard can show "N legs waiting for volume".
336
+ */
337
+ let unknownVolume = 0;
320
338
  function pickLegs(row: FundingRow): FundingLeg[] {
321
339
  const best = new Map<string, { leg: FundingLeg; vol: number }>();
322
340
  for (const leg of row.legs) {
323
341
  if (leg.rate === null || leg.stale || !leg.fundingVerified) continue;
324
342
  const vol = notional.get(`${leg.venue}|${leg.symbol}`);
325
- if (vol === undefined || vol < MIN_NOTIONAL) continue;
343
+ if (vol === undefined) { unknownVolume++; continue; } // not received yet — unknown, not 0
344
+ if (vol < MIN_NOTIONAL) continue;
326
345
  const cur = best.get(leg.venue);
327
346
  if (cur === undefined || vol > cur.vol) best.set(leg.venue, { leg, vol });
328
347
  }
@@ -397,18 +416,28 @@ sub.on((e) => {
397
416
  - **The library never holds keys.** `CredentialProvider` is async callbacks: `user()` (the account
398
417
  identity), `authToken({ purpose, maxTtlSec })` (Lighter `account_all_orders`; re-called at 80 % of the
399
418
  TTL with backoff retries; a read-only `ro:` token is recommended in browsers), `hmacSha256` /
400
- `signTypedData` (reserved for 0.15.1 venues). Tokens handed in are redacted from every error, log and
419
+ `signTypedData` (reserved for planned venues). Tokens handed in are redacted from every error, log and
401
420
  raw excerpt.
402
421
  - `allowInPageSecrets: true` is required for venues whose auth needs a signing secret in the page
403
- (Aster / Ondo / QFEX, 0.15.1). Without it they reject `CAPABILITY_UNSUPPORTED` in a browser.
422
+ (Aster / Ondo / QFEX — planned, not yet available). Without it they reject `CAPABILITY_UNSUPPORTED` in a browser.
404
423
  - `UserRef` per venue: hyperliquid `{ address }` (no auth), pacifica `{ account }` (Solana address, no
405
424
  auth), lighter / lighter-rh `{ accountIndex }`, arcus `{ address, subaccount }` (`null` = account 0, no auth).
406
- - **KNOWN GAP (0.15.0): Aster user channels are not implemented** — `kind: "user"` on aster rejects
407
- `CAPABILITY_UNSUPPORTED` (no parity; 0.15.1).
425
+ - **Any account, keyless:** the Hyperliquid (`{ address }`) and Pacifica (`{ account }`) user channels
426
+ need no auth, so `user()` may return ANY public address — a live positions / fills feed of someone
427
+ else's account. The REST counterpart (one snapshot, also Lighter / Lighter RH / RISEx) is
428
+ `perp --json -e <venue> account positions --address <addr>` / MCP `get_positions_by_address`.
429
+ - **KNOWN GAP: Aster user channels are not implemented** — `kind: "user"` on aster rejects
430
+ `CAPABILITY_UNSUPPORTED` (no parity; planned, not yet available).
408
431
 
409
432
  Events:
410
433
  - `account` — `equity, available, marginUsed, unrealizedPnl` (+ `dex` on hyperliquid, one per perp dex);
411
- `source: "snapshot"` when the frame was a full snapshot.
434
+ `source: "snapshot"` when the frame was a full snapshot. `unrealizedPnl` per venue:
435
+ hyperliquid = the exact sum of that dex's position `unrealizedPnl` in the same `clearinghouseState`
436
+ frame (`"0"` with no position; `null` if a position lacks it — the frame has no account-level field);
437
+ pacifica = `null` (`account_info` publishes none, and the position stream carries no unrealized PnL
438
+ either); lighter / lighter-rh = `null` on `account` (`user_stats` publishes none) — per position it is
439
+ `PositionRow.unrealizedPnl` (`unrealized_pnl` on the positions channel); arcus = `null` on `account`,
440
+ per position on `PositionRow`.
412
441
  - `positions` / `orders` — always the **full current set** (snapshot + merged deltas). Pacifica
413
442
  `account_positions` snapshots older than the last applied one (envelope `li`) are dropped.
414
443
  `orders.complete` = every part of the set came from a venue snapshot. **Pacifica sends no order
@@ -436,7 +465,9 @@ Mark price in `PositionRow` is null except on Arcus — join the `MarketUpdate`
436
465
 
437
466
  `StreamControl { type: "error", code, message, remediation, raw (redacted ≤ 200 chars), fatal }` — always
438
467
  check `fatal`: a fatal error ended that subscription, a non-fatal one is informational (it keeps running);
439
- `ready` rejects with a `WsError` whose `structured = { code, message, status, retryable, remediation? }`.
468
+ `ready` rejects with a `WsError` whose `structured = { code, message, status, retryable, remediation? }`;
469
+ `err.code` and `err.remediation` (null when none) mirror `structured`, so one handler reads `code` the same
470
+ way on error events (`e.code`) and on rejections (`err.code`).
440
471
 
441
472
  Where an error surfaces:
442
473
  - **Thrown synchronously** (`WsError`, `INVALID_PARAMS`) — bad options, before anything starts:
@@ -6,16 +6,17 @@
6
6
 
7
7
  set -euo pipefail
8
8
 
9
- # Auto-detect perp command (supports npx fallback for agents without install permissions)
10
- if command -v perp &>/dev/null; then
11
- PERP="perp"
12
- else
13
- PERP="npx -y perp-cli@latest"
9
+ # perp must be installed (no npx / auto-install path: the user decides what gets installed).
10
+ if ! command -v perp &>/dev/null; then
11
+ echo "perp not found — ask the user to install perp-cli >= 0.16.0" >&2
12
+ echo '{"ok":false,"error":{"code":"PERP_NOT_FOUND","message":"perp not found — ask the user to install perp-cli >= 0.16.0"}}'
13
+ exit 1
14
14
  fi
15
+ PERP="perp"
15
16
 
16
17
  # perp exits 1 on {"ok":false} and prints exactly one JSON document: capture it as-is
17
- # (never append a fallback document); empty output (crash / not installed) → {"ok":false}.
18
- pj() { local out; out=$($PERP "$@" 2>/dev/null) || true; if [[ -n "$out" ]]; then printf '%s\n' "$out"; else echo '{"ok":false}'; fi; }
18
+ # (never append a fallback document); empty output (crash) → an explicit NO_OUTPUT error document.
19
+ pj() { local out; out=$($PERP "$@" 2>/dev/null) || true; if [[ -n "$out" ]]; then printf '%s\n' "$out"; else echo '{"ok":false,"error":{"code":"NO_OUTPUT","message":"perp printed nothing (crashed?)"}}'; fi; }
19
20
  isok() { jq -e '.ok == true' >/dev/null 2>&1 <<<"$1"; }
20
21
 
21
22
  JSON_MODE=false
@@ -30,13 +31,15 @@ done
30
31
 
31
32
  # 1. Get current arb status (already includes funding earned per period)
32
33
  ARB_STATUS=$(pj --json arb status)
34
+ # A failed read is an error, never "no positions" (arb status fails as a whole when one
35
+ # configured venue's read fails — check that venue: perp --json -e <venue> account positions).
33
36
  if ! isok "$ARB_STATUS"; then
34
37
  if $JSON_MODE; then
35
- echo '{"ok":false,"error":"No arb positions or arb status unavailable"}'
38
+ jq -c '{ok:false, error:{code:.error.code, message:.error.message, source:"perp --json arb status"}}' <<<"$ARB_STATUS"
36
39
  else
37
- echo "No open arb positions found."
40
+ echo "ERROR: arb status failed: $(jq -r '"\(.error.code): \(.error.message)"' <<<"$ARB_STATUS")" >&2
38
41
  fi
39
- exit 0
42
+ exit 1
40
43
  fi
41
44
 
42
45
  # 2. Get current spreads (all modes)
@@ -1,73 +1,103 @@
1
1
  #!/bin/bash
2
2
  # Funding rate analysis across all exchanges
3
- # Usage: ./funding-analysis.sh [--json] [--symbol ETH] [--top 10]
4
- # Fetches current + historical funding data for comparison
3
+ # Usage: ./funding-analysis.sh [--json] [--symbol ETH] [--top 10] [--hours 24] [-n mainnet|testnet]
4
+ # --symbol: cross-venue compare + each verified venue's SETTLED funding history
5
+ # (`-e <venue> market funding <SYM> -l <hours>`, averaged per venue). `arb scan --history` is only
6
+ # the locally recorded snapshots (written by earlier `arb scan --rates` runs) and is often empty.
7
+ # Testnet funding / spreads are not meaningful — use mainnet (the default) for yield analysis.
5
8
 
6
9
  set -euo pipefail
7
10
 
8
- # Auto-detect perp command (supports npx fallback for agents without install permissions)
9
- if command -v perp &>/dev/null; then
10
- PERP="perp"
11
- else
12
- PERP="npx -y perp-cli@latest"
11
+ # perp must be installed (no npx / auto-install path: the user decides what gets installed).
12
+ if ! command -v perp &>/dev/null; then
13
+ echo "perp not found — ask the user to install perp-cli >= 0.16.0" >&2
14
+ echo '{"ok":false,"error":{"code":"PERP_NOT_FOUND","message":"perp not found — ask the user to install perp-cli >= 0.16.0"}}'
15
+ exit 1
13
16
  fi
17
+ PERP="perp"
14
18
 
15
19
  # perp exits 1 on {"ok":false} and prints exactly one JSON document: capture it as-is
16
- # (never append a fallback document); empty output (crash / not installed) → {"ok":false}.
17
- pj() { local out; out=$($PERP "$@" 2>/dev/null) || true; if [[ -n "$out" ]]; then printf '%s\n' "$out"; else echo '{"ok":false}'; fi; }
20
+ # (never append a fallback document); empty output (crash) → an explicit NO_OUTPUT error document.
21
+ pj() { local out; out=$($PERP "$@" 2>/dev/null) || true; if [[ -n "$out" ]]; then printf '%s\n' "$out"; else echo '{"ok":false,"error":{"code":"NO_OUTPUT","message":"perp printed nothing (crashed?)"}}'; fi; }
18
22
  isok() { jq -e '.ok == true' >/dev/null 2>&1 <<<"$1"; }
19
23
 
20
24
  JSON_MODE=false
21
25
  SYMBOL=""
22
26
  TOP=10
27
+ HOURS=24
28
+ NETWORK="mainnet"
23
29
  while [[ $# -gt 0 ]]; do
24
30
  case "$1" in
25
31
  --json) JSON_MODE=true; shift ;;
26
32
  --symbol) SYMBOL="$2"; shift 2 ;;
27
33
  --top) TOP="$2"; shift 2 ;;
28
- *) shift ;;
34
+ --hours) HOURS="$2"; shift 2 ;;
35
+ -n|--network) NETWORK="$2"; shift 2 ;;
36
+ *) echo "unknown argument: $1" >&2; exit 2 ;;
29
37
  esac
30
38
  done
39
+ PERP="perp -n $NETWORK"
40
+
41
+ # Row label per scan mode (perp-perp rows: longExch/shortExch; spot-perp rows: spotExchanges/perpExchange/direction)
42
+ ROW_JQ='if .mode == "spot-perp" then
43
+ " \(.symbol) | spot-perp | net \(.netSpread)%/yr | \(.direction) spot:\(.spotExchanges | join("/")) perp:\(.perpExchange)"
44
+ + (if .direction == "long-spot-short-perp" then "" else " [NOT executable with arb exec]" end)
45
+ else
46
+ " \(.symbol) | perp-perp | net \(.netSpread)%/yr | long \(.longExch) / short \(.shortExch)"
47
+ end'
31
48
 
32
49
  if [[ -n "$SYMBOL" ]]; then
33
50
  # Single symbol detailed analysis
34
- FUNDING=$(pj --json arb scan --history "$SYMBOL")
35
51
  COMPARE=$(pj --json arb scan --compare "$SYMBOL")
52
+ LOCAL_HISTORY=$(pj --json arb scan --history "$SYMBOL" --hours "$HOURS")
36
53
  SCAN=$(pj --json arb scan --mode perp-perp --min 0)
37
54
  SPOT_SCAN=$(pj --json arb scan --mode spot-perp --min 0)
38
55
 
56
+ # Settled funding history per verified venue of the compare (rows newest first; rate = fraction per intervalHours)
57
+ SETTLED="[]"
58
+ if isok "$COMPARE" && [[ "$(jq -r '.data.available' <<<"$COMPARE")" != "false" ]]; then
59
+ for VENUE in $(jq -r '.data.rates[] | select(.fundingVerified) | .exchange' <<<"$COMPARE"); do
60
+ H=$(pj --json -e "$VENUE" market funding "$SYMBOL" -l "$HOURS")
61
+ ROW=$(jq -c --arg v "$VENUE" '
62
+ if .ok then
63
+ { exchange: $v, rows: (.data | length),
64
+ avgHourlyRate: (if (.data | length) > 0 and all(.data[]; .rate != null and .intervalHours != null)
65
+ then ([.data[] | ((.rate | tonumber) / .intervalHours)] | add / length) else null end) }
66
+ | .avgAnnualPct = (if .avgHourlyRate == null then null else .avgHourlyRate * 8760 * 100 end)
67
+ else { exchange: $v, error: .error.code, message: .error.message } end' <<<"$H")
68
+ SETTLED=$(jq -c --argjson r "$ROW" '. + [$r]' <<<"$SETTLED")
69
+ done
70
+ fi
71
+
39
72
  if $JSON_MODE; then
40
73
  cat <<EOF
41
74
  {
42
75
  "ok": true,
76
+ "network": "$NETWORK",
43
77
  "symbol": "$SYMBOL",
78
+ "hours": $HOURS,
44
79
  "timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
45
- "fundingHistory": $FUNDING,
46
80
  "compare": $COMPARE,
81
+ "settledHistory": $SETTLED,
82
+ "localSnapshotHistory": $LOCAL_HISTORY,
47
83
  "perpPerpArb": $SCAN,
48
84
  "spotPerpArb": $SPOT_SCAN
49
85
  }
50
86
  EOF
51
87
  else
52
- echo "=== Funding Analysis: $SYMBOL ==="
88
+ echo "=== Funding Analysis: $SYMBOL ($NETWORK) ==="
53
89
  echo ""
54
90
  echo "--- Cross-Exchange Compare ---"
55
- echo "$COMPARE" | jq -r '.data // empty' 2>/dev/null || echo "(unavailable)"
91
+ if isok "$COMPARE"; then jq -r '.data' <<<"$COMPARE"; else echo " ERROR: $(jq -r '.error.code' <<<"$COMPARE")"; fi
56
92
  echo ""
57
- echo "--- Funding History ---"
58
- echo "$FUNDING" | jq -r '.data // empty' 2>/dev/null || echo "(unavailable)"
93
+ echo "--- Settled funding, last $HOURS rows per venue (avg annualized %) ---"
94
+ jq -r '.[] | if .error then " \(.exchange): ERROR \(.error)" else " \(.exchange): \(.rows) rows, avg \(.avgAnnualPct)%/yr" end' <<<"$SETTLED"
59
95
  echo ""
60
96
  echo "--- Perp-Perp Arb ---"
61
- echo "$SCAN" | jq -r '
62
- .data // [] | map(select(.symbol == "'"$SYMBOL"'")) | .[] |
63
- " \(.longExch // .longExchange)→\(.shortExch // .shortExchange): net \(.netSpread)% annual"
64
- ' 2>/dev/null || echo " (no opportunity)"
97
+ if isok "$SCAN"; then jq -r --arg s "$SYMBOL" ".data[] | select(.symbol == \$s) | $ROW_JQ" <<<"$SCAN"; else echo " ERROR: $(jq -r '.error.code' <<<"$SCAN")"; fi
65
98
  echo ""
66
99
  echo "--- Spot+Perp Arb ---"
67
- echo "$SPOT_SCAN" | jq -r '
68
- .data // [] | map(select(.symbol == "'"$SYMBOL"'")) | .[] |
69
- " \(.direction) spot:\((.spotExchanges // []) | join("/"))→\(.perpExchange): net \(.netSpreadPct)% annual"
70
- ' 2>/dev/null || echo " (no opportunity)"
100
+ if isok "$SPOT_SCAN"; then jq -r --arg s "$SYMBOL" ".data[] | select(.symbol == \$s) | $ROW_JQ" <<<"$SPOT_SCAN"; else echo " ERROR: $(jq -r '.error.code' <<<"$SPOT_SCAN")"; fi
71
101
  fi
72
102
  else
73
103
  # All symbols scan
@@ -78,6 +108,7 @@ else
78
108
  cat <<EOF
79
109
  {
80
110
  "ok": true,
111
+ "network": "$NETWORK",
81
112
  "timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
82
113
  "topN": $TOP,
83
114
  "allOpportunities": $SCAN,
@@ -85,11 +116,8 @@ else
85
116
  }
86
117
  EOF
87
118
  else
88
- echo "=== Funding Rate Overview (top $TOP) ==="
119
+ echo "=== Funding Rate Overview (top $TOP, $NETWORK) ==="
89
120
  echo ""
90
- echo "$SCAN" | jq -r "
91
- .data // [] | .[0:$TOP] | .[] |
92
- \" \(.symbol) | \(.mode // \"perp\") | net \(.netSpread // .netSpreadPct)%/yr | \(.longExch // (\"spot:\" + ((.spotExchanges // []) | join(\"/\"))))→\(.shortExch // .perpExchange)\"
93
- " 2>/dev/null || echo "(no data)"
121
+ if isok "$SCAN"; then jq -r ".data[0:$TOP][] | $ROW_JQ" <<<"$SCAN"; else echo " ERROR: $(jq -r '.error.code' <<<"$SCAN")"; fi
94
122
  fi
95
123
  fi
@@ -6,16 +6,17 @@
6
6
 
7
7
  set -euo pipefail
8
8
 
9
- # Auto-detect perp command (supports npx fallback for agents without install permissions)
10
- if command -v perp &>/dev/null; then
11
- PERP="perp"
12
- else
13
- PERP="npx -y perp-cli@latest"
9
+ # perp must be installed (no npx / auto-install path: the user decides what gets installed).
10
+ if ! command -v perp &>/dev/null; then
11
+ echo "perp not found — ask the user to install perp-cli >= 0.16.0" >&2
12
+ echo '{"ok":false,"error":{"code":"PERP_NOT_FOUND","message":"perp not found — ask the user to install perp-cli >= 0.16.0"}}'
13
+ exit 1
14
14
  fi
15
+ PERP="perp"
15
16
 
16
17
  # perp exits 1 on {"ok":false} and prints exactly one JSON document: capture it as-is
17
- # (never append a fallback document); empty output (crash / not installed) → {"ok":false}.
18
- pj() { local out; out=$($PERP "$@" 2>/dev/null) || true; if [[ -n "$out" ]]; then printf '%s\n' "$out"; else echo '{"ok":false}'; fi; }
18
+ # (never append a fallback document); empty output (crash) → an explicit NO_OUTPUT error document.
19
+ pj() { local out; out=$($PERP "$@" 2>/dev/null) || true; if [[ -n "$out" ]]; then printf '%s\n' "$out"; else echo '{"ok":false,"error":{"code":"NO_OUTPUT","message":"perp printed nothing (crashed?)"}}'; fi; }
19
20
  isok() { jq -e '.ok == true' >/dev/null 2>&1 <<<"$1"; }
20
21
 
21
22
  JSON_MODE=false
@@ -30,28 +31,35 @@ add_check() {
30
31
  if [[ "$status" == "fail" ]]; then issues+=("$name: $detail"); fi
31
32
  }
32
33
 
33
- # 1. perp-cli installed and version check
34
- VERSION=$($PERP --version 2>/dev/null || echo "unknown")
35
- if [[ "$VERSION" != "unknown" ]]; then
36
- add_check "install" "pass" "perp-cli $VERSION ($(command -v perp &>/dev/null && echo 'global' || echo 'npx'))"
34
+ # 1. perp-cli version (must be >= 0.16.0)
35
+ VERSION=$($PERP --version 2>/dev/null || true)
36
+ MIN_VERSION="0.16.0"
37
+ if [[ -z "$VERSION" ]]; then
38
+ add_check "install" "fail" "perp --version printed nothing — ask the user to (re)install perp-cli >= $MIN_VERSION"
39
+ elif [[ "$(printf '%s\n%s\n' "$MIN_VERSION" "$VERSION" | sort -V | head -1)" != "$MIN_VERSION" ]]; then
40
+ add_check "install" "fail" "perp-cli $VERSION < $MIN_VERSION — ask the user to upgrade"
37
41
  else
38
- add_check "install" "fail" "perp-cli not found — ask the user before installing (npm i -g perp-cli@latest) or use npx"
42
+ add_check "install" "pass" "perp-cli $VERSION"
39
43
  fi
40
44
 
41
- # 2. Wallet configured
45
+ # 2. Wallet configured (with an OWS wallet, `exchanges: []` in wallet show is normal)
42
46
  WALLET_OUT=$(pj --json wallet show)
43
47
  if isok "$WALLET_OUT"; then
44
48
  add_check "wallet" "pass" "Wallet configured"
45
49
  else
46
- add_check "wallet" "fail" "No wallet configured — run: perp wallet set <exchange> (key via hidden prompt or stdin)"
50
+ add_check "wallet" "fail" "wallet show failed ($(jq -r '.error.code' <<<"$WALLET_OUT")) — the user registers a key: perp wallet set <exchange> (stdin / hidden prompt)"
47
51
  fi
48
52
 
49
- # 3. Exchange connectivity (proxy via unauthenticated market prices fetch)
50
- HEALTH_OUT=$(pj --json market prices)
51
- if isok "$HEALTH_OUT"; then
52
- add_check "connectivity" "pass" "Exchange reachable"
53
+ # 3. Venue connectivity: perp health (healthy = every OFFICIAL-API venue is up;
54
+ # allHealthy also counts unofficial APIs such as TxFlow)
55
+ HEALTH_OUT=$(pj --json health)
56
+ if ! isok "$HEALTH_OUT"; then
57
+ add_check "connectivity" "fail" "perp health failed ($(jq -r '.error.code' <<<"$HEALTH_OUT"))"
58
+ elif [[ "$(jq -r '.data.healthy' <<<"$HEALTH_OUT")" == "true" ]]; then
59
+ add_check "connectivity" "pass" "All official venues reachable"
53
60
  else
54
- add_check "connectivity" "warn" "Exchange unreachable"
61
+ DOWN=$(jq -r '[.data.exchanges[] | select(.status != "ok") | .exchange] | join(",")' <<<"$HEALTH_OUT")
62
+ add_check "connectivity" "warn" "Official venues down: $DOWN — do not use them until perp health shows them ok"
55
63
  fi
56
64
 
57
65
  # 4. Portfolio & balances