perp-cli 0.14.3 → 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 +138 -405
- package/README.zh.md +243 -0
- package/dist/agent-wallet/store.d.ts +3 -2
- package/dist/agent-wallet/store.js +2 -2
- package/dist/api/public/hyperliquid.js +4 -0
- package/dist/api/public/lighter.d.ts +5 -0
- package/dist/api/public/lighter.js +5 -0
- package/dist/api/public/pacifica.js +85 -15
- package/dist/api/public/urls.d.ts +2 -0
- package/dist/api/public/urls.js +3 -0
- package/dist/arb/sizing.js +4 -2
- package/dist/arb/state.d.ts +6 -0
- package/dist/arb/utils.d.ts +1 -2
- package/dist/arb/utils.js +1 -2
- package/dist/balance-by-address.d.ts +39 -0
- package/dist/balance-by-address.js +44 -0
- package/dist/bot/apex/pulse.js +3 -0
- package/dist/bot/apex/radar.js +2 -1
- package/dist/bot/engine.d.ts +10 -1
- package/dist/bot/engine.js +14 -6
- package/dist/bot/indicators.d.ts +7 -0
- package/dist/bot/indicators.js +16 -0
- package/dist/bot/perp-order.d.ts +4 -4
- package/dist/bot/perp-order.js +5 -5
- package/dist/bot/quarantine.d.ts +1 -1
- package/dist/bot/quarantine.js +1 -1
- package/dist/bot/quote-rules.js +1 -1
- package/dist/bot/strategies/apex-strategy.js +3 -2
- package/dist/bot/strategies/engine-mm.js +6 -5
- package/dist/bot/strategies/funding-arb-strategy.js +12 -5
- package/dist/bot/strategies/funding-arb-utils.d.ts +15 -3
- package/dist/bot/strategies/funding-arb-utils.js +53 -20
- package/dist/bot/strategies/funding-auto.js +2 -2
- package/dist/bot/strategies/liquidation-mm.d.ts +1 -0
- package/dist/bot/strategies/liquidation-mm.js +8 -2
- package/dist/bot/strategies/momentum-breakout.d.ts +1 -0
- package/dist/bot/strategies/momentum-breakout.js +11 -3
- package/dist/bot/strategies/spot-perp-arb-strategy.d.ts +1 -1
- package/dist/bot/strategies/spot-perp-arb-strategy.js +6 -4
- package/dist/bot/strategies/spot-perp-executor.d.ts +1 -1
- package/dist/bot/strategies/spot-perp-executor.js +3 -3
- package/dist/bot/strategy-types.d.ts +2 -1
- package/dist/bot/tui/BotDashboard.d.ts +1 -1
- package/dist/bot/tui/BotDashboard.js +1 -1
- package/dist/cache.js +12 -6
- package/dist/cli-program.d.ts +15 -0
- package/dist/cli-program.js +126 -6
- package/dist/commands/account.js +168 -24
- package/dist/commands/agent-import.d.ts +7 -0
- package/dist/commands/agent-import.js +30 -1
- package/dist/commands/agent.d.ts +6 -3
- package/dist/commands/agent.js +160 -12
- package/dist/commands/arb/index.js +23 -81
- package/dist/commands/arb-auto.d.ts +24 -0
- package/dist/commands/arb-auto.js +175 -97
- package/dist/commands/arb.d.ts +20 -0
- package/dist/commands/arb.js +107 -163
- package/dist/commands/backtest.js +23 -21
- package/dist/commands/bot.js +1 -1
- package/dist/commands/builder.js +1 -1
- package/dist/commands/funds.js +113 -3
- package/dist/commands/init.d.ts +1 -1
- package/dist/commands/init.js +27 -7
- package/dist/commands/manage.js +23 -21
- package/dist/commands/market.d.ts +48 -0
- package/dist/commands/market.js +395 -100
- package/dist/commands/options.d.ts +14 -0
- package/dist/commands/options.js +151 -0
- package/dist/commands/pacifica-earn.d.ts +5 -0
- package/dist/commands/pacifica-earn.js +455 -0
- package/dist/commands/pacifica-orders.d.ts +14 -0
- package/dist/commands/pacifica-orders.js +142 -0
- package/dist/commands/portfolio.d.ts +16 -0
- package/dist/commands/portfolio.js +83 -55
- package/dist/commands/rebalance.d.ts +1 -0
- package/dist/commands/rebalance.js +15 -0
- package/dist/commands/risk.d.ts +11 -2
- package/dist/commands/risk.js +39 -44
- package/dist/commands/trade.d.ts +1 -0
- package/dist/commands/trade.js +67 -62
- package/dist/commands/wallet.js +1 -1
- package/dist/config.d.ts +2 -2
- package/dist/config.js +15 -4
- package/dist/constants.js +6 -0
- package/dist/cross-chain-margin.d.ts +3 -1
- package/dist/cross-chain-margin.js +4 -2
- package/dist/dashboard/server.js +3 -3
- package/dist/dashboard/ui.js +9 -6
- package/dist/errors.d.ts +29 -1
- package/dist/errors.js +35 -3
- package/dist/exchanges/address.d.ts +8 -0
- package/dist/exchanges/address.js +47 -0
- package/dist/exchanges/arcus/adapter.d.ts +16 -12
- package/dist/exchanges/arcus/adapter.js +45 -18
- package/dist/exchanges/arcus/agent.d.ts +1 -1
- package/dist/exchanges/arcus/agent.js +1 -1
- package/dist/exchanges/arcus/client.js +1 -1
- package/dist/exchanges/arcus/public.js +3 -2
- package/dist/exchanges/arcus/signing.d.ts +2 -3
- package/dist/exchanges/arcus/signing.js +1 -1
- package/dist/exchanges/arcus/spot-chain.d.ts +1 -1
- package/dist/exchanges/arcus/spot-chain.js +1 -1
- package/dist/exchanges/arcus/spot-router.d.ts +1 -1
- package/dist/exchanges/arcus/spot-router.js +2 -2
- package/dist/exchanges/arcus/spot.d.ts +2 -3
- package/dist/exchanges/arcus/spot.js +2 -3
- package/dist/exchanges/aster-spot.d.ts +3 -3
- package/dist/exchanges/aster-spot.js +6 -9
- package/dist/exchanges/aster-typed-data.d.ts +1 -1
- package/dist/exchanges/aster-typed-data.js +1 -1
- package/dist/exchanges/aster.d.ts +30 -10
- package/dist/exchanges/aster.js +153 -53
- package/dist/exchanges/builder-fee.d.ts +1 -1
- package/dist/exchanges/builder-fee.js +13 -2
- package/dist/exchanges/canonical-symbols.d.ts +8 -2
- package/dist/exchanges/canonical-symbols.js +46 -2
- package/dist/exchanges/capabilities.d.ts +2 -0
- package/dist/exchanges/capabilities.js +2 -1
- package/dist/exchanges/derive/client.d.ts +37 -0
- package/dist/exchanges/derive/client.js +135 -0
- package/dist/exchanges/derive/options.d.ts +73 -0
- package/dist/exchanges/derive/options.js +379 -0
- package/dist/exchanges/extended/adapter.d.ts +96 -0
- package/dist/exchanges/extended/adapter.js +241 -0
- package/dist/exchanges/extended/client.d.ts +38 -0
- package/dist/exchanges/extended/client.js +88 -0
- package/dist/exchanges/extended/public.d.ts +133 -0
- package/dist/exchanges/extended/public.js +436 -0
- package/dist/exchanges/grvt/adapter.d.ts +100 -0
- package/dist/exchanges/grvt/adapter.js +283 -0
- package/dist/exchanges/grvt/client.d.ts +25 -0
- package/dist/exchanges/grvt/client.js +86 -0
- package/dist/exchanges/grvt/public.d.ts +151 -0
- package/dist/exchanges/grvt/public.js +438 -0
- package/dist/exchanges/hyperliquid-outcome.d.ts +2 -0
- package/dist/exchanges/hyperliquid-outcome.js +29 -1
- package/dist/exchanges/hyperliquid-spot.d.ts +9 -1
- package/dist/exchanges/hyperliquid-spot.js +24 -16
- package/dist/exchanges/hyperliquid.d.ts +199 -22
- package/dist/exchanges/hyperliquid.js +550 -202
- package/dist/exchanges/index.d.ts +1 -1
- package/dist/exchanges/index.js +1 -1
- package/dist/exchanges/interface.d.ts +157 -13
- package/dist/exchanges/interface.js +47 -0
- package/dist/exchanges/ioc-outcome.d.ts +1 -1
- package/dist/exchanges/ioc-outcome.js +1 -1
- package/dist/exchanges/legacy-trading-v2.d.ts +2 -2
- package/dist/exchanges/legacy-trading-v2.js +7 -2
- package/dist/exchanges/lighter-public.d.ts +67 -12
- package/dist/exchanges/lighter-public.js +250 -82
- package/dist/exchanges/lighter-signer.d.ts +1 -1
- package/dist/exchanges/lighter-signer.js +1 -1
- package/dist/exchanges/lighter-spot.d.ts +10 -5
- package/dist/exchanges/lighter-spot.js +105 -97
- package/dist/exchanges/lighter.d.ts +80 -21
- package/dist/exchanges/lighter.js +312 -105
- package/dist/exchanges/ondo/adapter.d.ts +38 -13
- package/dist/exchanges/ondo/adapter.js +86 -27
- package/dist/exchanges/ondo/agent.d.ts +1 -1
- package/dist/exchanges/ondo/agent.js +1 -1
- package/dist/exchanges/ondo/client.d.ts +1 -1
- package/dist/exchanges/ondo/client.js +2 -2
- package/dist/exchanges/ondo/public.d.ts +35 -3
- package/dist/exchanges/ondo/public.js +75 -32
- package/dist/exchanges/ondo/spot.d.ts +2 -2
- package/dist/exchanges/ondo/spot.js +3 -3
- package/dist/exchanges/options-interface.d.ts +158 -0
- package/dist/exchanges/options-interface.js +17 -0
- package/dist/exchanges/options-venues.d.ts +12 -0
- package/dist/exchanges/options-venues.js +35 -0
- package/dist/exchanges/outcome-interface.d.ts +4 -0
- package/dist/exchanges/pacifica-atomic.d.ts +211 -0
- package/dist/exchanges/pacifica-atomic.js +440 -0
- package/dist/exchanges/pacifica-decimal.d.ts +10 -0
- package/dist/exchanges/pacifica-decimal.js +51 -0
- package/dist/exchanges/pacifica-presigned.d.ts +53 -0
- package/dist/exchanges/pacifica-presigned.js +141 -0
- package/dist/exchanges/pacifica-print.d.ts +200 -0
- package/dist/exchanges/pacifica-print.js +417 -0
- package/dist/exchanges/pacifica-spot.d.ts +8 -2
- package/dist/exchanges/pacifica-spot.js +26 -18
- package/dist/exchanges/pacifica-typed-data.d.ts +1 -1
- package/dist/exchanges/pacifica-typed-data.js +1 -1
- package/dist/exchanges/pacifica-vaults.d.ts +130 -0
- package/dist/exchanges/pacifica-vaults.js +277 -0
- package/dist/exchanges/pacifica.d.ts +108 -10
- package/dist/exchanges/pacifica.js +428 -171
- package/dist/exchanges/precision.d.ts +3 -1
- package/dist/exchanges/precision.js +9 -2
- 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 +69 -13
- package/dist/exchanges/qfex/adapter.js +102 -17
- package/dist/exchanges/qfex/agent.d.ts +1 -1
- package/dist/exchanges/qfex/agent.js +1 -1
- package/dist/exchanges/qfex/client.d.ts +1 -1
- package/dist/exchanges/qfex/client.js +1 -1
- package/dist/exchanges/qfex/errors.d.ts +1 -1
- package/dist/exchanges/qfex/errors.js +2 -2
- package/dist/exchanges/qfex/public.d.ts +65 -12
- package/dist/exchanges/qfex/public.js +131 -25
- package/dist/exchanges/qfex/signing.d.ts +2 -2
- package/dist/exchanges/qfex/signing.js +4 -5
- package/dist/exchanges/qfex/ws-transport.d.ts +3 -3
- package/dist/exchanges/qfex/ws-transport.js +3 -3
- package/dist/exchanges/rate-limiter.d.ts +1 -1
- package/dist/exchanges/rate-limiter.js +5 -2
- package/dist/exchanges/registry.d.ts +1 -1
- package/dist/exchanges/registry.js +1 -1
- package/dist/exchanges/risex/adapter.d.ts +160 -0
- package/dist/exchanges/risex/adapter.js +842 -0
- package/dist/exchanges/risex/agent.d.ts +154 -0
- package/dist/exchanges/risex/agent.js +384 -0
- package/dist/exchanges/risex/client.d.ts +47 -0
- package/dist/exchanges/risex/client.js +174 -0
- package/dist/exchanges/risex/nonce.d.ts +43 -0
- package/dist/exchanges/risex/nonce.js +212 -0
- package/dist/exchanges/risex/public.d.ts +64 -0
- package/dist/exchanges/risex/public.js +150 -0
- package/dist/exchanges/risex/session.d.ts +71 -0
- package/dist/exchanges/risex/session.js +171 -0
- package/dist/exchanges/risex/typed-data.d.ts +173 -0
- package/dist/exchanges/risex/typed-data.js +240 -0
- package/dist/exchanges/risex/units.d.ts +29 -0
- package/dist/exchanges/risex/units.js +104 -0
- package/dist/exchanges/slippage.d.ts +1 -1
- package/dist/exchanges/slippage.js +1 -1
- package/dist/exchanges/spot-factory.d.ts +3 -3
- package/dist/exchanges/spot-factory.js +8 -8
- package/dist/exchanges/spot-interface.d.ts +5 -5
- package/dist/exchanges/spot-interface.js +2 -2
- package/dist/exchanges/spot-result.d.ts +3 -3
- package/dist/exchanges/spot-result.js +3 -3
- package/dist/exchanges/trading-v2.d.ts +3 -3
- package/dist/exchanges/trading-v2.js +9 -6
- package/dist/exchanges/txflow/adapter.d.ts +61 -0
- package/dist/exchanges/txflow/adapter.js +150 -0
- package/dist/exchanges/txflow/client.d.ts +47 -0
- package/dist/exchanges/txflow/client.js +155 -0
- package/dist/exchanges/txflow/public.d.ts +87 -0
- package/dist/exchanges/txflow/public.js +355 -0
- package/dist/exchanges/unknown-symbol.d.ts +34 -0
- package/dist/exchanges/unknown-symbol.js +56 -0
- package/dist/exchanges/variational/adapter.d.ts +67 -0
- package/dist/exchanges/variational/adapter.js +137 -0
- package/dist/exchanges/variational/client.d.ts +21 -0
- package/dist/exchanges/variational/client.js +66 -0
- package/dist/exchanges/variational/public.d.ts +126 -0
- package/dist/exchanges/variational/public.js +430 -0
- package/dist/exchanges/venue-types.d.ts +52 -10
- package/dist/exchanges/venue-types.js +82 -11
- package/dist/exchanges/venue.d.ts +150 -11
- package/dist/exchanges/venue.js +126 -4
- package/dist/exchanges/venues/arcus.d.ts +1 -2
- package/dist/exchanges/venues/arcus.js +5 -4
- package/dist/exchanges/venues/aster.d.ts +2 -2
- package/dist/exchanges/venues/aster.js +6 -4
- package/dist/exchanges/venues/create-helpers.d.ts +1 -1
- package/dist/exchanges/venues/create-helpers.js +3 -3
- package/dist/exchanges/venues/extended.d.ts +21 -0
- package/dist/exchanges/venues/extended.js +96 -0
- package/dist/exchanges/venues/grvt.d.ts +30 -0
- package/dist/exchanges/venues/grvt.js +98 -0
- package/dist/exchanges/venues/hyperliquid.d.ts +2 -2
- package/dist/exchanges/venues/hyperliquid.js +19 -8
- package/dist/exchanges/venues/index.d.ts +7 -2
- package/dist/exchanges/venues/index.js +8 -2
- package/dist/exchanges/venues/lighter-family.d.ts +7 -9
- package/dist/exchanges/venues/lighter-family.js +9 -11
- package/dist/exchanges/venues/lighter-rh.d.ts +2 -2
- package/dist/exchanges/venues/lighter-rh.js +20 -8
- package/dist/exchanges/venues/lighter.d.ts +1 -1
- package/dist/exchanges/venues/lighter.js +18 -6
- package/dist/exchanges/venues/ondo.d.ts +1 -1
- package/dist/exchanges/venues/ondo.js +5 -3
- package/dist/exchanges/venues/pacifica.d.ts +2 -2
- package/dist/exchanges/venues/pacifica.js +15 -4
- package/dist/exchanges/venues/qfex.d.ts +1 -1
- package/dist/exchanges/venues/qfex.js +4 -2
- package/dist/exchanges/venues/risex-release.d.ts +14 -0
- package/dist/exchanges/venues/risex-release.js +14 -0
- package/dist/exchanges/venues/risex.d.ts +31 -0
- package/dist/exchanges/venues/risex.js +131 -0
- package/dist/exchanges/venues/txflow.d.ts +18 -0
- package/dist/exchanges/venues/txflow.js +77 -0
- package/dist/exchanges/venues/variational.d.ts +21 -0
- package/dist/exchanges/venues/variational.js +85 -0
- package/dist/execution-log.d.ts +1 -1
- package/dist/funding/legacy-public.d.ts +6 -4
- package/dist/funding/legacy-public.js +42 -41
- package/dist/funding/normalize.d.ts +1 -1
- package/dist/funding/normalize.js +1 -1
- package/dist/funding/rates.d.ts +95 -6
- package/dist/funding/rates.js +99 -19
- package/dist/index.js +7 -6
- package/dist/jobs.d.ts +1 -0
- package/dist/jobs.js +25 -1
- package/dist/landing.d.ts +2 -0
- package/dist/landing.js +1 -1
- package/dist/liquidity.d.ts +3 -1
- package/dist/liquidity.js +8 -4
- package/dist/mcp-pacifica-earn.d.ts +9 -0
- package/dist/mcp-pacifica-earn.js +64 -0
- package/dist/mcp-server.js +211 -68
- package/dist/pacifica/client.d.ts +69 -5
- package/dist/pacifica/client.js +181 -8
- package/dist/pacifica/rate-limit.d.ts +144 -0
- package/dist/pacifica/rate-limit.js +267 -0
- package/dist/pacifica/types/order.d.ts +2 -1
- package/dist/plan-executor.js +7 -2
- package/dist/positions-by-address.d.ts +34 -0
- package/dist/positions-by-address.js +46 -0
- package/dist/rebalance.js +1 -1
- package/dist/risk.d.ts +2 -1
- package/dist/risk.js +2 -4
- package/dist/settings.d.ts +32 -3
- package/dist/signer/ows-evm.d.ts +1 -1
- package/dist/signer/ows-evm.js +1 -1
- package/dist/smart-order.d.ts +1 -1
- package/dist/smart-order.js +1 -1
- package/dist/strategies/funding-arb.d.ts +1 -1
- package/dist/strategies/funding-arb.js +36 -63
- package/dist/trade-validator.js +2 -2
- package/dist/utils.d.ts +22 -3
- package/dist/utils.js +11 -3
- package/dist/venue-health.d.ts +40 -0
- package/dist/venue-health.js +75 -0
- package/dist/withdraw-gate.d.ts +1 -1
- package/dist/withdraw-gate.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 +980 -0
- package/dist/ws/index.js +4460 -0
- package/dist/ws/lighter-market.d.ts +14 -0
- package/dist/ws/lighter-market.js +13 -0
- package/dist/ws/node.d.ts +996 -0
- package/dist/ws/node.js +4554 -0
- package/dist/ws/pacifica-instrument.d.ts +8 -0
- package/dist/ws/pacifica-instrument.js +8 -0
- package/dist/ws/unclassified.d.ts +16 -0
- package/dist/ws/unclassified.js +43 -0
- package/dist/ws-cli/credentials.d.ts +62 -0
- package/dist/ws-cli/credentials.js +126 -0
- package/package.json +71 -10
- package/skills/perp-cli/SKILL.md +153 -36
- package/skills/perp-cli/package.json +15 -2
- package/skills/perp-cli/references/agent-operations.md +22 -12
- package/skills/perp-cli/references/commands.md +143 -17
- package/skills/perp-cli/references/strategies.md +38 -3
- package/skills/perp-cli/references/ws-library.md +523 -0
- 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
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
# perp-cli/ws — live market, funding and account streams (library)
|
|
2
|
+
|
|
3
|
+
`perp-cli/ws` is a composable WebSocket library inside the `perp-cli` npm package. Use it to build a
|
|
4
|
+
custom live dashboard, funding comparison or price/account feed for **hyperliquid** (native + HIP-3),
|
|
5
|
+
**pacifica**, **lighter**, **lighter-rh**, **aster** and **arcus**, in the browser or in Node.
|
|
6
|
+
|
|
7
|
+
> **Stability:** shipped since **0.15.0** and **@experimental** through 0.15.x — breaking changes only in a
|
|
8
|
+
> minor bump (CHANGELOG callouts); every event carries `schema: 1`. **@stable from 0.16.0** after consumer
|
|
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
|
+
> **GRVT** (CLI venue, market data only) has no codec: its public WS has per-instrument selectors only (no
|
|
12
|
+
> all-markets stream) and a 150-stream cap below its 184 perpetuals, so `allMarkets` cannot be served.
|
|
13
|
+
> **Extended** (CLI venue `extended`, market data only) has no codec: its public streams are one socket
|
|
14
|
+
> per stream URL (`wss://api.starknet.extended.exchange/stream.extended.exchange/v1/prices/mark`,
|
|
15
|
+
> `/orderbooks/<m>?depth=1`, `/funding`, …) pushed on connect with no subscribe frame (the bare root
|
|
16
|
+
> answers HTTP 404), while a codec here is one `url(network)` plus subscribe / unsubscribe frames. Its
|
|
17
|
+
> accruing funding rate is REST-only (`/info/markets`); the `/funding` stream is documented as applied
|
|
18
|
+
> rates only. Use the CLI (`perp -e extended market …`) for Extended.
|
|
19
|
+
|
|
20
|
+
Runnable examples: `examples/ws-funding-compare-browser`, `examples/ws-next-smoke`,
|
|
21
|
+
`examples/ws-node-stream` in the perp-cli repository.
|
|
22
|
+
|
|
23
|
+
## 1. Install and import
|
|
24
|
+
|
|
25
|
+
Two packages, **the same code and API** (built from one source):
|
|
26
|
+
|
|
27
|
+
| Package | Install | When |
|
|
28
|
+
|---|---|---|
|
|
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
|
+
| `perp-cli/ws` | `npm install perp-cli` | perp-cli is already installed (the CLI pulls ~140 MB of CLI deps) |
|
|
31
|
+
|
|
32
|
+
| Runtime | `@hypurrquant/perp-ws` | `perp-cli` | Transport |
|
|
33
|
+
|---|---|---|---|
|
|
34
|
+
| Browser (any bundler, Next.js, Vite, static page), Node ≥ 22 without `ws` | `@hypurrquant/perp-ws` | `perp-cli/ws` | the runtime's native `WebSocket` and `fetch` |
|
|
35
|
+
| Node 20+ | `@hypurrquant/perp-ws/node` | `perp-cli/ws/node` | `ws` (16 MiB message cap, protocol ping/pong); same API |
|
|
36
|
+
|
|
37
|
+
- **`ws` for the Node entry:** `@hypurrquant/perp-ws` declares `ws` (`^8.21.0`) as an **optional peer
|
|
38
|
+
dependency** — `npm install @hypurrquant/perp-ws ws`. Importing `/node` never fails; creating a stream
|
|
39
|
+
or transport without `ws` throws `INVALID_PARAMS` ("npm install ws@^8.21.0"). The Node entry uses `ws`
|
|
40
|
+
even on Node ≥ 22 (which has a global `WebSocket`) for the inbound size cap and ping/pong handling; on
|
|
41
|
+
Node ≥ 22 you may use the browser entry instead (global `WebSocket`, no `ws`, no size cap of ours).
|
|
42
|
+
`perp-cli` ships `ws` as a regular dependency.
|
|
43
|
+
- ESM only, ES2020, no side effects, no top-level await, touches no global at module load (safe in
|
|
44
|
+
SSR / prerendering). Next.js 13.5+: `transpilePackages: ["@hypurrquant/perp-ws"]` (or `["perp-cli"]`).
|
|
45
|
+
- TypeScript: `moduleResolution` `"bundler"`, `"node16"` / `"nodenext"`, or `"node"` (via `typesVersions`).
|
|
46
|
+
- Only the entries above (and `<package>/package.json`) are importable (no deep imports).
|
|
47
|
+
- The examples below import `@hypurrquant/perp-ws`; with perp-cli, replace it by `perp-cli/ws`
|
|
48
|
+
(`@hypurrquant/perp-ws/node` → `perp-cli/ws/node`). Everything else is identical.
|
|
49
|
+
- The Node entry caps one inbound message at 16 MiB (`DEFAULT_MAX_PAYLOAD`; real frames are ≤ ~150 KB).
|
|
50
|
+
An oversized message closes the socket (`reconnecting` reason `WS_PROTOCOL_ERROR`, naming
|
|
51
|
+
`WS_ERR_UNSUPPORTED_MESSAGE_LENGTH`); 3 within 10 min on one socket fail the subscriptions on it with `WS_PROTOCOL_ERROR`.
|
|
52
|
+
Raise the cap with `createVenueStream(codec, { transport: createNodeTransport({ maxPayload }) })`.
|
|
53
|
+
- The browser entry in Node 20 without a `transport` throws `INVALID_PARAMS` ("import from the /node
|
|
54
|
+
entry … or pass transport").
|
|
55
|
+
|
|
56
|
+
### Static page without a bundler
|
|
57
|
+
|
|
58
|
+
`dist/index.js` of `@hypurrquant/perp-ws` (`dist/ws/index.js` of perp-cli) is one self-contained ESM
|
|
59
|
+
file with **no bare imports**, so a plain HTML page can load it through an import map. The map points the
|
|
60
|
+
public specifier at the file; your code still imports `"@hypurrquant/perp-ws"` (that is the "no deep
|
|
61
|
+
imports" rule: never write `.../dist/...` in JS — the file path lives only in the map, which is the
|
|
62
|
+
page's job, like a bundler's resolver):
|
|
63
|
+
|
|
64
|
+
```html
|
|
65
|
+
<script type="importmap">
|
|
66
|
+
{ "imports": { "@hypurrquant/perp-ws": "/node_modules/@hypurrquant/perp-ws/dist/index.js" } }
|
|
67
|
+
</script>
|
|
68
|
+
<script type="module">
|
|
69
|
+
import { createVenueStream, hyperliquid } from "@hypurrquant/perp-ws";
|
|
70
|
+
const stream = createVenueStream(hyperliquid);
|
|
71
|
+
stream.subscribe({ kind: "allMarkets", canonicals: ["BTC"] }).on((e) => {
|
|
72
|
+
if (e.type === "market") document.title = `BTC ${e.mark ?? "—"}`;
|
|
73
|
+
});
|
|
74
|
+
</script>
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Serve the page and `node_modules` from the same static server (any origin works: the venues do not check
|
|
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@<version>/dist/index.js`). The `/node` entry is
|
|
80
|
+
Node-only (it loads `ws`); never map it in a browser.
|
|
81
|
+
|
|
82
|
+
## 2. Minimal examples
|
|
83
|
+
|
|
84
|
+
All-market state for one venue (Node):
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
import { createVenueStream, hyperliquid, toApr } from "@hypurrquant/perp-ws/node";
|
|
88
|
+
|
|
89
|
+
const stream = createVenueStream(hyperliquid, { hip3Dexes: ["xyz"] }); // HIP-3 dexes are opt-in
|
|
90
|
+
stream.onState((s) => console.log("state", s.status));
|
|
91
|
+
const sub = stream.subscribe({ kind: "allMarkets", canonicals: ["BTC", "ETH", "xyz:TSLA"] });
|
|
92
|
+
sub.on((e) => {
|
|
93
|
+
if (e.type === "market") {
|
|
94
|
+
console.log(e.symbol, e.mark, e.funding.rate, e.funding.rateKind, toApr(e.funding.rate, e.funding.rateIntervalHours));
|
|
95
|
+
} else if (e.type === "error") {
|
|
96
|
+
console.error(e.fatal ? "FATAL" : "warn", e.code, e.message, e.remediation); // fatal = this subscription ended
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
await sub.ready; // rejects with a WsError (code + remediation) — let it throw, or catch and show it
|
|
100
|
+
// …later
|
|
101
|
+
await stream.close();
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Async iteration instead of callbacks:
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
import { createVenueStream, pacifica } from "@hypurrquant/perp-ws/node";
|
|
108
|
+
|
|
109
|
+
const stream = createVenueStream(pacifica);
|
|
110
|
+
for await (const e of stream.subscribe({ kind: "market", symbol: "BTC", channels: ["market", "bbo"] })) {
|
|
111
|
+
if (e.type === "bbo") console.log(e.bid, e.bidSize, e.ask, e.askSize);
|
|
112
|
+
if (e.type === "market") break; // break → unsubscribes
|
|
113
|
+
}
|
|
114
|
+
await stream.close();
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The same markets on several venues (browser) — **`canonicals`, and every rejection made visible**:
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
import { arcus, aster, createVenueStream, hyperliquid, lighter, pacifica, type VenueStream } from "@hypurrquant/perp-ws";
|
|
121
|
+
|
|
122
|
+
declare function show(venue: string, text: string): void; // your UI: one status cell per venue
|
|
123
|
+
|
|
124
|
+
const streams: VenueStream[] = [hyperliquid, pacifica, lighter, aster, arcus].map((c) => createVenueStream(c));
|
|
125
|
+
for (const stream of streams) {
|
|
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).
|
|
128
|
+
// (symbols would need each venue's directory symbols; ["BTC"] rejects on aster and arcus.)
|
|
129
|
+
// A canonical this venue does not list (e.g. SOL somewhere) is skipped with a non-fatal warning;
|
|
130
|
+
// ready rejects only when the venue lists none of them.
|
|
131
|
+
const sub = stream.subscribe({ kind: "allMarkets", canonicals: ["BTC", "ETH", "SOL"] });
|
|
132
|
+
sub.ready.then(
|
|
133
|
+
() => show(stream.venue, "live"),
|
|
134
|
+
(err: unknown) => show(stream.venue, `FAILED: ${err instanceof Error ? err.message : String(err)}`),
|
|
135
|
+
);
|
|
136
|
+
sub.on((e) => {
|
|
137
|
+
if (e.type === "market") show(stream.venue, `${e.symbol} ${e.mark ?? "—"}`);
|
|
138
|
+
else if (e.type === "error" && e.code === "SYMBOL_NOT_FOUND") {
|
|
139
|
+
show(stream.venue, `not listed here: ${e.details?.missing?.join(", ")}`); // non-fatal: the rest stream
|
|
140
|
+
} else if (e.type === "error" && e.fatal) show(stream.venue, `FAILED: ${e.code} ${e.message}`);
|
|
141
|
+
});
|
|
142
|
+
stream.onState((st) => { if (st.status === "closed") show(stream.venue, `closed: ${st.reason.message}`); });
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Funding comparison across venues (browser):
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
import {
|
|
150
|
+
arcus, aster, createFundingComparator, createVenueStream, hyperliquid, lighter, lighterRh, pacifica, toHourly,
|
|
151
|
+
} from "@hypurrquant/perp-ws";
|
|
152
|
+
|
|
153
|
+
const streams = [hyperliquid, pacifica, lighter, lighterRh, aster, arcus].map((c) => createVenueStream(c));
|
|
154
|
+
const cmp = createFundingComparator({ streams, kind: "next", minVenues: 2 });
|
|
155
|
+
const off = cmp.subscribe((rows) => {
|
|
156
|
+
for (const row of rows) {
|
|
157
|
+
const hourly = row.legs
|
|
158
|
+
.filter((l) => !l.stale && l.rate !== null)
|
|
159
|
+
.map((l) => ({ venue: l.venue, kind: l.rateKind, h: toHourly(l.rate, l.funding.rateIntervalHours) }));
|
|
160
|
+
console.log(row.canonical, row.mixedKinds, hourly);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
// …later
|
|
164
|
+
off();
|
|
165
|
+
cmp.close();
|
|
166
|
+
await Promise.all(streams.map((s) => s.close()));
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## 3. Streams, subscriptions, connection state
|
|
170
|
+
|
|
171
|
+
- `createVenueStream(codec, opts)` — one socket per venue, opened lazily on the first subscription, closed
|
|
172
|
+
(`idle`) when the last one leaves. The market directory is loaded once over REST (`stream.markets()`).
|
|
173
|
+
- `subscribe(target)`:
|
|
174
|
+
- `{ kind: "allMarkets", canonicals? | symbols? }` → `MarketUpdate` for every market, or a subset:
|
|
175
|
+
**`canonicals`** (recommended across venues) selects every directory market whose `canonical` matches
|
|
176
|
+
— `["BTC"]` is `BTC` on hyperliquid / pacifica / lighter / lighter-rh (USDG), `BTCUSDT` on
|
|
177
|
+
aster and `BTC-USD` on arcus; HIP-3 markets are `"<dex>:<BASE>"` (`"xyz:TSLA"`, with `hip3Dexes`) and
|
|
178
|
+
`"BTC"` never pulls them in. Canonicals come from `opts.canonicalize` (default: USD-like quotes
|
|
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
|
|
182
|
+
not list are **skipped and reported once** as a non-fatal control
|
|
183
|
+
`{ type: "error", code: "SYMBOL_NOT_FOUND", fatal: false, details: { missing: string[], suggestions:
|
|
184
|
+
Record<string, string[]> } }` (suggestions e.g. `"xyz:TSLA"` for `"TSLA"`; the others are subscribed and
|
|
185
|
+
`ready` resolves). Only when the venue lists **none** of them does `ready` reject `INVALID_PARAMS`.
|
|
186
|
+
**`symbols`** are this venue's exact directory symbols. Pass **one or the other** — both together
|
|
187
|
+
reject `INVALID_PARAMS`. Same wire subscription and budget either way (filtering is client-side);
|
|
188
|
+
- `{ kind: "market", symbol, channels: ["market" | "bbo"] }`. `"book"` and `"trades"` are already in
|
|
189
|
+
the types but **reject with `CAPABILITY_UNSUPPORTED`** (planned, not yet available) — don't ship UI that needs them.
|
|
190
|
+
Any other channel name (e.g. `"ticker"`) or an empty `channels` rejects with `INVALID_PARAMS`;
|
|
191
|
+
- `{ kind: "user", channels: ["account" | "positions" | "orders" | "fills"] }` (needs `credentials`).
|
|
192
|
+
- **`symbols` / `symbol` mean the venue's directory symbols, case-sensitive** — not canonical names. `BTC` is `"BTC"` on
|
|
193
|
+
hyperliquid / pacifica / lighter, `"BTC-USD"` on arcus and `"BTCUSDT"` / `"BTCUSD1"` on aster. Pass
|
|
194
|
+
`MarketUpdate.symbol` / `FundingLeg.symbol`, or look them up in `stream.markets()`. Unknown symbols reject
|
|
195
|
+
with `INVALID_PARAMS` before anything is sent; the message carries a "did you mean …" hint
|
|
196
|
+
(`details.suggestions`) when a directory symbol matches case-insensitively or by canonical / base name.
|
|
197
|
+
- `Subscription`: `on(cb)` (unbuffered; a listener that throws is isolated — the other listeners and the
|
|
198
|
+
stream keep running — and reported to `opts.logger` as `{ event: "listener-error" }`, not as a stream
|
|
199
|
+
event), `for await` (bounded buffer, `opts.buffer.size`, default 1024),
|
|
200
|
+
`ready` (resolves when every wire channel is acknowledged; rejects with a `WsError`), `status`,
|
|
201
|
+
`unsubscribe()`. Wire channels are ref-counted: two subscriptions on one venue channel = one wire
|
|
202
|
+
subscription.
|
|
203
|
+
- Connection state (`stream.state`, `stream.onState(cb)`, and `{type:"state"}` events):
|
|
204
|
+
`idle → connecting → live ⇄ stale → reconnecting → … → closed`. `stale` = no message on a channel for
|
|
205
|
+
`staleAfterMs` (from message timestamps, re-checked when a hidden tab becomes visible); 2× → forced
|
|
206
|
+
reconnect. Reconnect backoff 1 s → 30 s with jitter, a per-venue new-connection budget, then
|
|
207
|
+
resubscribe; the first frame after a (re)subscribe has `source: "snapshot"`.
|
|
208
|
+
- Several sockets (Pacifica, > 20 channels of one kind): each socket reconnects and resubscribes only
|
|
209
|
+
its own channels (gaps only for channels on it). `reconnect.maxAttempts` counts per socket, but **one
|
|
210
|
+
socket exhausting it closes the whole stream** (every subscription ends with that reason). The state is
|
|
211
|
+
the aggregate, worst first: `closed` > `reconnecting` > `stale` > `connecting` (of a socket that was
|
|
212
|
+
open before) > `live`. A socket opening for the first time (just added, waiting on the
|
|
213
|
+
new-connection budget, or retrying a failed first handshake) does not turn a live stream into
|
|
214
|
+
`connecting` / `reconnecting`: it stays `live` with
|
|
215
|
+
`sockets` = open sockets and `pending` = sockets still opening — the channels on those are not ready
|
|
216
|
+
yet (their `ready` resolves when they open). `pending` is present only on multi-socket streams.
|
|
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
|
|
220
|
+
open = `EXCHANGE_UNREACHABLE` ("network, venue outage, or Origin/CORS block").
|
|
221
|
+
- Ack timeout: no acknowledgement and no data within the channel's stale threshold → a **pending**
|
|
222
|
+
subscription fails with `WS_PROTOCOL_ERROR`.
|
|
223
|
+
- Iterator buffer: market/bbo/account/positions/orders events are **state** and coalesce by key (never
|
|
224
|
+
fatal). Fills and control events are not coalesced; a full buffer → fatal `STREAM_OVERFLOW` and the
|
|
225
|
+
subscription closes. Before the iterator exists, state/error controls are kept in a ring of the last
|
|
226
|
+
`size` (the iterator first gets a non-fatal `STREAM_OVERFLOW` with the discarded count); fills,
|
|
227
|
+
gaps and the one-time `SYMBOL_NOT_FOUND` warning are never evicted.
|
|
228
|
+
|
|
229
|
+
## 4. Event fields and nullability
|
|
230
|
+
|
|
231
|
+
Rules: **unknown = `null`, never `0`**. A field the venue never sent is `null`; a field the latest frame
|
|
232
|
+
did not carry keeps its last value, with `asOf[field]` = receive time. `Dec` = the venue's decimal string
|
|
233
|
+
(exact; parse it yourself). Check `null` before arithmetic — `null` is not zero.
|
|
234
|
+
|
|
235
|
+
`MarketUpdate`: `venue, dex, symbol, venueSymbol, quote, canonical, mark, index, bestBid, bestAsk, last,
|
|
236
|
+
funding{ rate, rateKind, rateSource, rateIntervalHours, settlementIntervalHours, intervalSource,
|
|
237
|
+
nextFundingTime, predictedRate, marketClosed }, openInterest{ value, unit: "base"|"usd"|null, sides },
|
|
238
|
+
volume24hNotional, venueTs, recvTs, source, changed[], asOf{}`.
|
|
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
|
+
|
|
245
|
+
Funding:
|
|
246
|
+
- `rate` is **raw per `rateIntervalHours`** — never pre-annualized. Use `toHourly(rate, h)` to compare and
|
|
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 "%".
|
|
251
|
+
- `rateKind`: `"lastSettled"` (the last payment), `"accruing"` (the current period so far, the "if it
|
|
252
|
+
settled now" rate), `"predicted"` (the venue's estimate of the next payment). `predictedRate` is always
|
|
253
|
+
kind `"predicted"`.
|
|
254
|
+
|
|
255
|
+
| Venue | `rate` kind | `predictedRate` | interval | OI | notes |
|
|
256
|
+
|---|---|---|---|---|---|
|
|
257
|
+
| hyperliquid | accruing | null | 1 h | base, one side | bid/ask/last null → `bbo` channel |
|
|
258
|
+
| pacifica | lastSettled | next_funding | 1 h | base, `sides` null | bid/ask/last/nextFundingTime null |
|
|
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`) |
|
|
260
|
+
| aster | accruing | null | per market (`rest:/fapi/v3/fundingInfo`, null if missing) | null | all quotes (USDT, USD1, …) with `quote` set |
|
|
261
|
+
| arcus | lastSettled | nextFundingRate | 1 h | base, one side | bid/ask null → `bbo` channel |
|
|
262
|
+
|
|
263
|
+
`stream.capabilities` carries the full matrix: `browser`, `networks`, `channels`, `fields` (false = always
|
|
264
|
+
null) and `notes` (why). lighter-rh has no testnet (`CAPABILITY_UNSUPPORTED`).
|
|
265
|
+
|
|
266
|
+
`BboUpdate`: `bid, bidSize, ask, askSize` (null = that side of the book is empty), `venueTs, recvTs`.
|
|
267
|
+
Venue sequence numbers only drop out-of-order frames (they are not contiguous; bbo never emits gaps).
|
|
268
|
+
Pacifica allows ≤ 20 subscriptions per channel per connection: the stream spreads further `bbo` channels
|
|
269
|
+
over more sockets (≤ 50 per stream; `budget.maxSockets` lowers it; `SUBSCRIPTION_LIMIT` beyond).
|
|
270
|
+
`live.sockets` counts the open ones.
|
|
271
|
+
|
|
272
|
+
## 5. Funding comparator
|
|
273
|
+
|
|
274
|
+
```ts
|
|
275
|
+
import { createFundingComparator, type FundingRow, type VenueStream } from "@hypurrquant/perp-ws";
|
|
276
|
+
|
|
277
|
+
declare const streams: VenueStream[];
|
|
278
|
+
const cmp = createFundingComparator({ streams, kind: "next", include: ["BTC", "ETH"], minVenues: 2 });
|
|
279
|
+
const rows: FundingRow[] = cmp.rows();
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
Options and defaults:
|
|
283
|
+
|
|
284
|
+
| Option | Default | Meaning |
|
|
285
|
+
|---|---|---|
|
|
286
|
+
| `kind` | `"current"` | which rate each leg contributes (below) |
|
|
287
|
+
| `minVenues` | `2` | rows need ≥ this many **distinct venues with a non-null rate** |
|
|
288
|
+
| `staleAfterMs` | 2 × the venue's `stream.staleAfterMs` (Arcus 3×) | a leg is `stale` when its stream is not `live` or its rate is older than this. Venue defaults: hyperliquid 30 s (all markets), pacifica 10 s, lighter / lighter-rh 10 s, aster 5 s, arcus 15 s |
|
|
289
|
+
| `include` | all | allowlist of canonical symbols |
|
|
290
|
+
| `canonicalize` | `defaultCanonicalize` | HIP-3 → `"<dex>:<BASE>"`; USD-like quotes (USDT, USD1, USDC, USD, USDG) → `"<BASE>"`; any other quote → `null` (not compared) |
|
|
291
|
+
| `onControl` | none | the streams' `state` / `error` events and `{type:"listener-error"}` |
|
|
292
|
+
|
|
293
|
+
- `kind`: `"current"` (each leg's `rate`), `"predicted"`, `"lastSettled"`, `"accruing"`, and **`"next"`** —
|
|
294
|
+
per venue the accruing rate when published, else the predicted one. A leg without the requested kind
|
|
295
|
+
has `rate: null, kindUnavailable: true` (never substituted).
|
|
296
|
+
- **`mixedKinds`** is true when a row's legs with a rate use different kinds. With `kind: "next"` it is
|
|
297
|
+
**true by design** whenever an accruing venue (hyperliquid, aster: the current period so far) meets a
|
|
298
|
+
predicted one (pacifica, lighter, lighter-rh, arcus: the venue's estimate of the next payment). Both
|
|
299
|
+
are forward-looking estimates of the coming payment, so the row is usable for ranking; care when you
|
|
300
|
+
need exact like-for-like numbers (e.g. P&L attribution, or an accruing rate early in its period that
|
|
301
|
+
can still move a lot) — then use `kind: "accruing"` or `"predicted"` and accept fewer legs.
|
|
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.
|
|
306
|
+
- **`fundingVerified`** (per leg): the CLI's verdict that the venue's funding unit and which published
|
|
307
|
+
rate actually settles were confirmed against its funding payments. All six WS venues are
|
|
308
|
+
`true`; a `false` leg (a future venue) is indicative only — the CLI keeps such venues out of funding
|
|
309
|
+
comparison and refuses them as funding-arb legs, so do the same.
|
|
310
|
+
- **Filter illiquid outliers before ranking spreads.** Tiny or newly listed markets often show extreme
|
|
311
|
+
rates (hundreds of % APR) that cannot be traded at size. Require a minimum 24 h notional and/or open
|
|
312
|
+
interest per leg; the comparator legs carry no volume, so read it from the same streams:
|
|
313
|
+
|
|
314
|
+
```ts
|
|
315
|
+
import {
|
|
316
|
+
createFundingComparator, createVenueStream, aster, hyperliquid, lighter, toHourly,
|
|
317
|
+
type FundingLeg, type FundingRow, type MarketUpdate,
|
|
318
|
+
} from "@hypurrquant/perp-ws";
|
|
319
|
+
|
|
320
|
+
const streams = [hyperliquid, lighter, aster].map((c) => createVenueStream(c));
|
|
321
|
+
// Same wire channel as the comparator's own subscription (ref-counted): no extra venue traffic.
|
|
322
|
+
const notional = new Map<string, number>(); // "<venue>|<symbol>" → 24 h notional
|
|
323
|
+
for (const s of streams) {
|
|
324
|
+
s.subscribe({ kind: "allMarkets" }).on((e) => {
|
|
325
|
+
if (e.type !== "market") return;
|
|
326
|
+
const u: MarketUpdate = e;
|
|
327
|
+
if (u.volume24hNotional !== null) notional.set(`${u.venue}|${u.symbol}`, Number(u.volume24hNotional));
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const MIN_NOTIONAL = 1_000_000;
|
|
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;
|
|
338
|
+
function pickLegs(row: FundingRow): FundingLeg[] {
|
|
339
|
+
const best = new Map<string, { leg: FundingLeg; vol: number }>();
|
|
340
|
+
for (const leg of row.legs) {
|
|
341
|
+
if (leg.rate === null || leg.stale || !leg.fundingVerified) continue;
|
|
342
|
+
const vol = notional.get(`${leg.venue}|${leg.symbol}`);
|
|
343
|
+
if (vol === undefined) { unknownVolume++; continue; } // not received yet — unknown, not 0
|
|
344
|
+
if (vol < MIN_NOTIONAL) continue;
|
|
345
|
+
const cur = best.get(leg.venue);
|
|
346
|
+
if (cur === undefined || vol > cur.vol) best.set(leg.venue, { leg, vol });
|
|
347
|
+
}
|
|
348
|
+
return Array.from(best.values(), (b) => b.leg);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const cmp = createFundingComparator({ streams, kind: "next" });
|
|
352
|
+
cmp.subscribe((rows) => {
|
|
353
|
+
for (const row of rows) {
|
|
354
|
+
const legs = pickLegs(row)
|
|
355
|
+
.map((l) => ({ venue: l.venue, h: toHourly(l.rate, l.funding.rateIntervalHours) }))
|
|
356
|
+
.filter((x): x is { venue: typeof x.venue; h: number } => x.h !== null)
|
|
357
|
+
.sort((a, b) => a.h - b.h);
|
|
358
|
+
const lo = legs[0];
|
|
359
|
+
const hi = legs[legs.length - 1];
|
|
360
|
+
if (lo !== undefined && hi !== undefined && legs.length >= 2) console.log(row.canonical, lo.venue, hi.venue);
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
## 6. User (account) channels
|
|
366
|
+
|
|
367
|
+
```ts
|
|
368
|
+
import { createVenueStream, lighter, type CredentialProvider } from "@hypurrquant/perp-ws";
|
|
369
|
+
|
|
370
|
+
const credentials: CredentialProvider = {
|
|
371
|
+
user: async () => ({ venue: "lighter", accountIndex: 12345 }),
|
|
372
|
+
// Only Lighter's orders channel needs a token. Your backend mints it; the page never sees the key.
|
|
373
|
+
authToken: async ({ purpose, maxTtlSec }) => {
|
|
374
|
+
const qs = `purpose=${encodeURIComponent(purpose)}&ttl=${encodeURIComponent(String(maxTtlSec))}`;
|
|
375
|
+
// Same-origin request carrying the user's session cookie: the endpoint must authenticate the caller.
|
|
376
|
+
const res = await fetch(`/api/lighter-token?${qs}`, { credentials: "same-origin" });
|
|
377
|
+
if (!res.ok) throw new Error(`lighter-token: HTTP ${res.status}`);
|
|
378
|
+
const body = (await res.json()) as { token: string; expiresAt: number }; // expiresAt: epoch ms
|
|
379
|
+
// Only accept a READ-ONLY auth token in the page; a full token could be used to trade.
|
|
380
|
+
if (typeof body.token !== "string" || !body.token.startsWith("ro:")) throw new Error("lighter-token: expected a read-only ro: token");
|
|
381
|
+
return body;
|
|
382
|
+
},
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
// Server side (sketch of the /api/lighter-token handler; your framework and session store will differ).
|
|
386
|
+
interface Session { accountIndex: number }
|
|
387
|
+
declare function getSession(req: Request): Promise<Session | null>; // your auth (cookie → session)
|
|
388
|
+
declare function mintLighterReadOnlyToken(accountIndex: number, ttlSec: number): Promise<string>; // "ro:…"
|
|
389
|
+
const MAX_TOKEN_TTL_SEC = 600; // server-side cap: never trust the client's ttl
|
|
390
|
+
export async function handleLighterToken(req: Request): Promise<Response> {
|
|
391
|
+
const session = await getSession(req);
|
|
392
|
+
if (session === null) return new Response("unauthorized", { status: 401 });
|
|
393
|
+
const url = new URL(req.url);
|
|
394
|
+
// purpose is "<venue>:account_all_orders" (lighter or lighter-rh); refresh follows the expiresAt you return.
|
|
395
|
+
if (!/^lighter(-rh)?:account_all_orders$/.test(url.searchParams.get("purpose") ?? "")) return new Response("bad purpose", { status: 400 });
|
|
396
|
+
const asked = Number(url.searchParams.get("ttl"));
|
|
397
|
+
const ttlSec = Math.min(Number.isFinite(asked) && asked > 0 ? Math.floor(asked) : MAX_TOKEN_TTL_SEC, MAX_TOKEN_TTL_SEC);
|
|
398
|
+
const token = await mintLighterReadOnlyToken(session.accountIndex, ttlSec); // the session's own account only
|
|
399
|
+
return Response.json({ token, expiresAt: Date.now() + ttlSec * 1000 });
|
|
400
|
+
}
|
|
401
|
+
const stream = createVenueStream(lighter, { credentials });
|
|
402
|
+
const sub = stream.subscribe({ kind: "user", channels: ["account", "positions", "orders", "fills"] });
|
|
403
|
+
sub.on((e) => {
|
|
404
|
+
switch (e.type) {
|
|
405
|
+
case "account": console.log(e.equity, e.available); break;
|
|
406
|
+
case "positions": console.log(e.positions.length, e.snapshot); break;
|
|
407
|
+
case "orders": console.log(e.orders.length, e.complete); break;
|
|
408
|
+
case "fills": console.log("snapshot batch", e.fills.length); break;
|
|
409
|
+
case "fill": console.log(e.fillId, e.fill.side, e.fill.size, e.fill.price, e.fill.clientId); break;
|
|
410
|
+
case "gap": console.log("refetch over REST:", e.channel); break;
|
|
411
|
+
default: break;
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
- **The library never holds keys.** `CredentialProvider` is async callbacks: `user()` (the account
|
|
417
|
+
identity), `authToken({ purpose, maxTtlSec })` (Lighter `account_all_orders`; re-called at 80 % of the
|
|
418
|
+
TTL with backoff retries; a read-only `ro:` token is recommended in browsers), `hmacSha256` /
|
|
419
|
+
`signTypedData` (reserved for planned venues). Tokens handed in are redacted from every error, log and
|
|
420
|
+
raw excerpt.
|
|
421
|
+
- `allowInPageSecrets: true` is required for venues whose auth needs a signing secret in the page
|
|
422
|
+
(Aster / Ondo / QFEX — planned, not yet available). Without it they reject `CAPABILITY_UNSUPPORTED` in a browser.
|
|
423
|
+
- `UserRef` per venue: hyperliquid `{ address }` (no auth), pacifica `{ account }` (Solana address, no
|
|
424
|
+
auth), lighter / lighter-rh `{ accountIndex }`, arcus `{ address, subaccount }` (`null` = account 0, no auth).
|
|
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).
|
|
431
|
+
|
|
432
|
+
Events:
|
|
433
|
+
- `account` — `equity, available, marginUsed, unrealizedPnl` (+ `dex` on hyperliquid, one per perp dex);
|
|
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`.
|
|
441
|
+
- `positions` / `orders` — always the **full current set** (snapshot + merged deltas). Pacifica
|
|
442
|
+
`account_positions` snapshots older than the last applied one (envelope `li`) are dropped.
|
|
443
|
+
`orders.complete` = every part of the set came from a venue snapshot. **Pacifica sends no order
|
|
444
|
+
snapshot: `complete` stays `false` — refetch open orders over REST.**
|
|
445
|
+
- `fills` (`snapshot: true`) — ONE event per venue fill snapshot (HL / Arcus replay recent fills on every
|
|
446
|
+
(re)subscribe; up to 2000), only fills not seen before. `fill` (`snapshot: false`) — one live fill.
|
|
447
|
+
De-duplicated in-library by `fillId` + side + order id (both legs of a self-trade are kept).
|
|
448
|
+
- `gap { channel, action: "refetch-required" }` — the library cannot give a complete picture; refetch
|
|
449
|
+
over REST: after a reconnect on non-replayable channels (Pacifica fills + orders, Lighter fills), at
|
|
450
|
+
subscribe for Pacifica orders, and when a replayed fill snapshot does not reach back to the fills seen
|
|
451
|
+
before (outage longer than the replay window).
|
|
452
|
+
|
|
453
|
+
`clientId`, fees and liquidation price per venue:
|
|
454
|
+
|
|
455
|
+
| Venue | `OrderRow.clientId` | `FillRow.clientId` | `FillRow.fee` | `PositionRow.liquidationPrice` |
|
|
456
|
+
|---|---|---|---|---|
|
|
457
|
+
| hyperliquid | cloid (null if none) | cloid only when the frame carries one (not documented) → join by `orderId` | fee in `feeToken` | liquidationPx (null when none) |
|
|
458
|
+
| pacifica | `I` | `I` | `f` (feeToken null) | `l` (null when not applicable) |
|
|
459
|
+
| lighter / lighter-rh | client_order_id (0 = none → null) | ask/bid client id of your side (0 → null) | usd_amount × rate × 1e-6 (rate in 1e-6, omitted when 0) | liquidation_price; "0" (none) → null |
|
|
460
|
+
| arcus | clientId | clientId | `fee` (feeToken null) | always null (not published) |
|
|
461
|
+
|
|
462
|
+
Mark price in `PositionRow` is null except on Arcus — join the `MarketUpdate` by symbol.
|
|
463
|
+
|
|
464
|
+
## 7. Errors
|
|
465
|
+
|
|
466
|
+
`StreamControl { type: "error", code, message, remediation, raw (redacted ≤ 200 chars), fatal }` — always
|
|
467
|
+
check `fatal`: a fatal error ended that subscription, a non-fatal one is informational (it keeps running);
|
|
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`).
|
|
471
|
+
|
|
472
|
+
Where an error surfaces:
|
|
473
|
+
- **Thrown synchronously** (`WsError`, `INVALID_PARAMS`) — bad options, before anything starts:
|
|
474
|
+
`createVenueStream` (codec not a codec export, unknown `network`, bad `buffer.size`, no `WebSocket` /
|
|
475
|
+
`fetch` in the runtime and none passed) and `createFundingComparator` (unknown `kind`, bad `streams`,
|
|
476
|
+
`minVenues`, `include`, `staleAfterMs`, or a non-function `canonicalize` / `onControl` / `clock`).
|
|
477
|
+
- **`ready` rejects** (and the subscription ends with a fatal error event) — per-subscription problems:
|
|
478
|
+
unknown symbol / channel, `book` / `trades` or an unsupported network (`CAPABILITY_UNSUPPORTED`), budget
|
|
479
|
+
limits, credentials, REST bootstrap failures, ack timeout, a closed stream.
|
|
480
|
+
- **Error events** (`{ type: "error" }` via `on()` / the iterator / comparator `onControl`) — everything
|
|
481
|
+
after `ready`: malformed frames, token-refresh retries, reconnects, overflow.
|
|
482
|
+
|
|
483
|
+
| Code | Meaning / what to do |
|
|
484
|
+
|---|---|
|
|
485
|
+
| `WS_PROTOCOL_ERROR` | malformed / unrecognised frame, or no ack — reported, never silently dropped |
|
|
486
|
+
| `WS_AUTH_FAILED` | credentials problem. **Check `fatal`:** non-fatal = a token refresh failed and is being retried (the channel still works with the current token); fatal = no / expired / definitively rejected credentials, the subscription ended |
|
|
487
|
+
| `SUBSCRIPTION_LIMIT` | venue or `budget` limit (`details.needed` / `allowed`) — narrow the subscription |
|
|
488
|
+
| `STREAM_OVERFLOW` | iterator buffer full — consume faster, use `on()`, raise `buffer.size` |
|
|
489
|
+
| `CAPABILITY_UNSUPPORTED` | channel / network not available for the venue (see `capabilities.notes`) |
|
|
490
|
+
| `EXCHANGE_UNREACHABLE` | network / outage / handshake refused (reconnects automatically) |
|
|
491
|
+
| `INVALID_PARAMS` | bad symbol, dex, channel or option |
|
|
492
|
+
| `SYMBOL_NOT_FOUND` | non-fatal: requested `canonicals` this venue does not list (`details.missing`, `details.suggestions`); the rest stream |
|
|
493
|
+
| `RATE_LIMITED`, `EXCHANGE_ERROR` | REST bootstrap failures |
|
|
494
|
+
|
|
495
|
+
## 8. Budgets and limits
|
|
496
|
+
|
|
497
|
+
- `budget: { maxSubscriptions, maxSockets, newConnectionsPerMin }` caps below the venue limits (checked
|
|
498
|
+
before sending). One socket per stream, except venues whose caps are per connection (Pacifica: 20 per
|
|
499
|
+
channel per connection, 100 connections per IP → up to 50 sockets per stream). The new-connection
|
|
500
|
+
budget is per stream (a socket waiting on it is `pending`; it never opens once its channels are
|
|
501
|
+
released); one socket exhausting its reconnects closes the stream.
|
|
502
|
+
- **Per-IP limits are shared by every tab and app on one IP** (HL: 10 connections, 1000 subscriptions,
|
|
503
|
+
30 new connections/min, 10 users). Several tabs of one dashboard share them — keep one stream per venue
|
|
504
|
+
per page.
|
|
505
|
+
- HL `allMarkets` is a single `allDexsAssetCtxs` subscription; `hip3Dexes: "all"` adds every HIP-3 dex.
|
|
506
|
+
|
|
507
|
+
## 9. Do / don't
|
|
508
|
+
|
|
509
|
+
- **Do** treat every field as nullable; `null ≠ 0`. Don't default missing data to 0.
|
|
510
|
+
- **Do** compare funding per hour (`toHourly`) and like-for-like kinds (comparator `kind`); **don't**
|
|
511
|
+
annualize in your data layer (`toApr` is for display).
|
|
512
|
+
- **Do** check `quote` before comparing (Aster lists USDT, USD1 and other quotes per base).
|
|
513
|
+
- **Don't** add a REST fallback when a field is null or a channel is unsupported — the capability matrix
|
|
514
|
+
says why; show it as unknown.
|
|
515
|
+
- **Do** handle `gap → refetch-required` (refetch fills / open orders over REST) and `orders.complete`.
|
|
516
|
+
- **Do** respect `SUBSCRIPTION_LIMIT` and the per-IP note above; coalesce UI updates (~1/s).
|
|
517
|
+
- **Don't** pass private keys to the library; return short-lived tokens from `authToken()`.
|
|
518
|
+
- **Do** use `canonicals` for the same markets on several venues; `symbols` are per-venue directory symbols
|
|
519
|
+
(`["BTC"]` rejects on aster and arcus). Show the `SYMBOL_NOT_FOUND` warning (which canonicals a venue
|
|
520
|
+
lacks) instead of an empty cell.
|
|
521
|
+
- **Do** surface every `ready` rejection and every `fatal` error event per venue (see the price-wall
|
|
522
|
+
pattern in §2). **Never treat `idle` (or a venue with no data) as fine**: `idle` means the stream has no
|
|
523
|
+
active subscription — usually because a subscribe was rejected — not "connected and quiet".
|
|
@@ -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)
|