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.
- package/README.ja.md +243 -0
- package/README.ko.md +243 -0
- package/README.md +129 -535
- package/README.zh.md +243 -0
- package/dist/api/public/lighter.d.ts +5 -0
- package/dist/api/public/lighter.js +5 -0
- package/dist/api/public/pacifica.js +2 -2
- package/dist/arb/sizing.js +4 -2
- package/dist/arb/state.d.ts +6 -0
- package/dist/balance-by-address.d.ts +39 -0
- package/dist/balance-by-address.js +44 -0
- package/dist/bot/quote-rules.js +1 -1
- package/dist/bot/strategies/funding-arb-strategy.js +10 -3
- package/dist/bot/strategies/funding-arb-utils.d.ts +14 -2
- package/dist/bot/strategies/funding-arb-utils.js +48 -15
- package/dist/bot/strategies/spot-perp-arb-strategy.d.ts +1 -1
- package/dist/bot/strategies/spot-perp-arb-strategy.js +4 -2
- package/dist/bot/strategies/spot-perp-executor.js +2 -2
- package/dist/cli-program.js +1 -1
- package/dist/commands/account.js +168 -24
- package/dist/commands/arb/index.js +19 -77
- package/dist/commands/arb-auto.d.ts +24 -0
- package/dist/commands/arb-auto.js +155 -81
- package/dist/commands/arb.d.ts +8 -0
- package/dist/commands/arb.js +49 -23
- package/dist/commands/backtest.js +23 -21
- package/dist/commands/market.d.ts +48 -0
- package/dist/commands/market.js +241 -78
- package/dist/commands/options.d.ts +5 -0
- package/dist/commands/options.js +20 -3
- package/dist/commands/pacifica-earn.js +8 -21
- package/dist/commands/portfolio.d.ts +16 -0
- package/dist/commands/portfolio.js +51 -8
- package/dist/commands/trade.js +4 -4
- package/dist/cross-chain-margin.d.ts +3 -1
- package/dist/cross-chain-margin.js +4 -2
- package/dist/dashboard/server.js +2 -2
- package/dist/dashboard/ui.js +9 -6
- package/dist/errors.d.ts +5 -0
- package/dist/errors.js +10 -0
- package/dist/exchanges/address.d.ts +8 -0
- package/dist/exchanges/address.js +47 -0
- package/dist/exchanges/arcus/adapter.d.ts +10 -6
- package/dist/exchanges/arcus/adapter.js +33 -6
- package/dist/exchanges/arcus/public.js +3 -2
- package/dist/exchanges/aster.d.ts +27 -7
- package/dist/exchanges/aster.js +124 -42
- package/dist/exchanges/canonical-symbols.d.ts +7 -0
- package/dist/exchanges/canonical-symbols.js +20 -0
- package/dist/exchanges/capabilities.d.ts +2 -0
- package/dist/exchanges/capabilities.js +2 -1
- package/dist/exchanges/derive/options.d.ts +5 -0
- package/dist/exchanges/derive/options.js +20 -1
- package/dist/exchanges/extended/adapter.d.ts +16 -2
- package/dist/exchanges/extended/adapter.js +96 -34
- package/dist/exchanges/extended/public.d.ts +3 -0
- package/dist/exchanges/extended/public.js +9 -2
- package/dist/exchanges/grvt/adapter.d.ts +16 -2
- package/dist/exchanges/grvt/adapter.js +98 -21
- package/dist/exchanges/hyperliquid-outcome.d.ts +2 -0
- package/dist/exchanges/hyperliquid-outcome.js +29 -1
- package/dist/exchanges/hyperliquid-spot.d.ts +8 -0
- package/dist/exchanges/hyperliquid-spot.js +20 -12
- package/dist/exchanges/hyperliquid.d.ts +180 -13
- package/dist/exchanges/hyperliquid.js +487 -185
- package/dist/exchanges/interface.d.ts +64 -8
- package/dist/exchanges/interface.js +31 -0
- package/dist/exchanges/lighter-public.d.ts +66 -11
- package/dist/exchanges/lighter-public.js +249 -81
- package/dist/exchanges/lighter-spot.d.ts +7 -1
- package/dist/exchanges/lighter-spot.js +99 -90
- package/dist/exchanges/lighter.d.ts +70 -11
- package/dist/exchanges/lighter.js +285 -92
- package/dist/exchanges/ondo/adapter.d.ts +34 -8
- package/dist/exchanges/ondo/adapter.js +73 -16
- package/dist/exchanges/ondo/public.d.ts +27 -2
- package/dist/exchanges/ondo/public.js +63 -31
- package/dist/exchanges/outcome-interface.d.ts +4 -0
- package/dist/exchanges/pacifica-spot.d.ts +6 -0
- package/dist/exchanges/pacifica-spot.js +23 -15
- package/dist/exchanges/pacifica-vaults.d.ts +9 -0
- package/dist/exchanges/pacifica-vaults.js +14 -1
- package/dist/exchanges/pacifica.d.ts +77 -6
- package/dist/exchanges/pacifica.js +321 -130
- package/dist/exchanges/public-balances.d.ts +25 -0
- package/dist/exchanges/public-balances.js +158 -0
- package/dist/exchanges/public-positions.d.ts +43 -0
- package/dist/exchanges/public-positions.js +154 -0
- package/dist/exchanges/qfex/adapter.d.ts +52 -9
- package/dist/exchanges/qfex/adapter.js +74 -9
- package/dist/exchanges/qfex/public.d.ts +62 -9
- package/dist/exchanges/qfex/public.js +128 -22
- package/dist/exchanges/qfex/ws-transport.d.ts +1 -1
- package/dist/exchanges/risex/adapter.d.ts +19 -7
- package/dist/exchanges/risex/adapter.js +58 -12
- package/dist/exchanges/spot-factory.js +1 -1
- package/dist/exchanges/txflow/adapter.d.ts +14 -7
- package/dist/exchanges/txflow/adapter.js +51 -19
- package/dist/exchanges/txflow/client.js +3 -1
- package/dist/exchanges/txflow/public.d.ts +12 -1
- package/dist/exchanges/txflow/public.js +82 -11
- package/dist/exchanges/unknown-symbol.d.ts +6 -1
- package/dist/exchanges/unknown-symbol.js +7 -2
- package/dist/exchanges/variational/adapter.d.ts +2 -6
- package/dist/exchanges/venue-types.d.ts +46 -3
- package/dist/exchanges/venue-types.js +80 -9
- package/dist/exchanges/venue.d.ts +99 -3
- package/dist/exchanges/venue.js +72 -0
- package/dist/exchanges/venues/hyperliquid.js +9 -0
- package/dist/exchanges/venues/lighter-rh.js +13 -3
- package/dist/exchanges/venues/lighter.js +13 -3
- package/dist/exchanges/venues/pacifica.js +9 -0
- package/dist/exchanges/venues/risex.js +15 -0
- package/dist/funding/legacy-public.d.ts +5 -3
- package/dist/funding/legacy-public.js +33 -37
- package/dist/funding/rates.d.ts +54 -5
- package/dist/funding/rates.js +49 -13
- package/dist/liquidity.d.ts +3 -1
- package/dist/liquidity.js +8 -4
- package/dist/mcp-pacifica-earn.js +11 -5
- package/dist/mcp-server.js +104 -15
- package/dist/pacifica/client.d.ts +2 -1
- package/dist/pacifica/client.js +11 -0
- package/dist/positions-by-address.d.ts +34 -0
- package/dist/positions-by-address.js +46 -0
- package/dist/strategies/funding-arb.js +35 -62
- package/dist/utils.d.ts +11 -1
- package/dist/venue-health.js +1 -1
- package/dist/ws/arcus-market.d.ts +9 -0
- package/dist/ws/arcus-market.js +9 -0
- package/dist/ws/aster-market.d.ts +33 -0
- package/dist/ws/aster-market.js +30 -0
- package/dist/ws/index.d.ts +30 -9
- package/dist/ws/index.js +180 -15
- package/dist/ws/lighter-market.d.ts +14 -0
- package/dist/ws/lighter-market.js +13 -0
- package/dist/ws/node.d.ts +30 -9
- package/dist/ws/node.js +204 -16
- package/dist/ws/unclassified.d.ts +16 -0
- package/dist/ws/unclassified.js +43 -0
- package/package.json +15 -2
- package/skills/perp-cli/SKILL.md +97 -33
- package/skills/perp-cli/package.json +15 -2
- package/skills/perp-cli/references/agent-operations.md +15 -12
- package/skills/perp-cli/references/commands.md +67 -12
- package/skills/perp-cli/references/strategies.md +38 -3
- package/skills/perp-cli/references/ws-library.md +55 -24
- package/skills/perp-cli/scripts/arb-monitor.sh +13 -10
- package/skills/perp-cli/scripts/funding-analysis.sh +57 -29
- package/skills/perp-cli/scripts/preflight.sh +27 -19
- package/skills/perp-cli/scripts/spot-perp-scan.sh +58 -38
- package/skills/perp-cli/scripts/validate-arb.sh +56 -39
- package/dist/cli-spec.d.ts +0 -46
- 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
|
|
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
|
|
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 #
|
|
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:**
|
|
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
|
|
10
|
-
> The `VenueId` type already includes `"ondo"` and `"qfex"`, but there are no codecs for them
|
|
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** (~
|
|
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
|
|
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
|
|
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`
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
- **
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
returns
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
- **
|
|
407
|
-
`
|
|
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
|
-
#
|
|
10
|
-
if command -v perp &>/dev/null; then
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
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
|
-
|
|
38
|
+
jq -c '{ok:false, error:{code:.error.code, message:.error.message, source:"perp --json arb status"}}' <<<"$ARB_STATUS"
|
|
36
39
|
else
|
|
37
|
-
echo "
|
|
40
|
+
echo "ERROR: arb status failed: $(jq -r '"\(.error.code): \(.error.message)"' <<<"$ARB_STATUS")" >&2
|
|
38
41
|
fi
|
|
39
|
-
exit
|
|
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
|
-
#
|
|
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
|
-
#
|
|
9
|
-
if command -v perp &>/dev/null; then
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
91
|
+
if isok "$COMPARE"; then jq -r '.data' <<<"$COMPARE"; else echo " ERROR: $(jq -r '.error.code' <<<"$COMPARE")"; fi
|
|
56
92
|
echo ""
|
|
57
|
-
echo "---
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
10
|
-
if command -v perp &>/dev/null; then
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
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
|
|
34
|
-
VERSION=$($PERP --version 2>/dev/null ||
|
|
35
|
-
|
|
36
|
-
|
|
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" "
|
|
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" "
|
|
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.
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|