perp-cli 0.14.2 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (313) hide show
  1. package/README.md +152 -13
  2. package/dist/agent-wallet/store.d.ts +3 -2
  3. package/dist/agent-wallet/store.js +2 -2
  4. package/dist/api/public/hyperliquid.js +4 -0
  5. package/dist/api/public/pacifica.js +83 -13
  6. package/dist/api/public/urls.d.ts +2 -0
  7. package/dist/api/public/urls.js +3 -0
  8. package/dist/arb/utils.d.ts +1 -2
  9. package/dist/arb/utils.js +1 -2
  10. package/dist/bot/apex/pulse.js +3 -0
  11. package/dist/bot/apex/radar.js +2 -1
  12. package/dist/bot/engine.d.ts +10 -1
  13. package/dist/bot/engine.js +14 -6
  14. package/dist/bot/indicators.d.ts +7 -0
  15. package/dist/bot/indicators.js +16 -0
  16. package/dist/bot/perp-order.d.ts +4 -4
  17. package/dist/bot/perp-order.js +5 -5
  18. package/dist/bot/quarantine.d.ts +1 -1
  19. package/dist/bot/quarantine.js +1 -1
  20. package/dist/bot/strategies/apex-strategy.js +3 -2
  21. package/dist/bot/strategies/engine-mm.js +6 -5
  22. package/dist/bot/strategies/funding-arb-strategy.js +9 -9
  23. package/dist/bot/strategies/funding-arb-utils.d.ts +15 -2
  24. package/dist/bot/strategies/funding-arb-utils.js +43 -24
  25. package/dist/bot/strategies/funding-auto.js +2 -2
  26. package/dist/bot/strategies/liquidation-mm.d.ts +1 -0
  27. package/dist/bot/strategies/liquidation-mm.js +8 -2
  28. package/dist/bot/strategies/momentum-breakout.d.ts +1 -0
  29. package/dist/bot/strategies/momentum-breakout.js +11 -3
  30. package/dist/bot/strategies/spot-perp-arb-strategy.js +23 -12
  31. package/dist/bot/strategies/spot-perp-executor.d.ts +1 -1
  32. package/dist/bot/strategies/spot-perp-executor.js +1 -1
  33. package/dist/bot/strategy-types.d.ts +2 -1
  34. package/dist/bot/tui/BotDashboard.d.ts +1 -1
  35. package/dist/bot/tui/BotDashboard.js +1 -1
  36. package/dist/cache.js +12 -6
  37. package/dist/cli-program.d.ts +15 -0
  38. package/dist/cli-program.js +125 -5
  39. package/dist/commands/agent-import.d.ts +7 -0
  40. package/dist/commands/agent-import.js +30 -1
  41. package/dist/commands/agent.d.ts +6 -3
  42. package/dist/commands/agent.js +160 -12
  43. package/dist/commands/arb/index.js +4 -4
  44. package/dist/commands/arb-auto.js +21 -17
  45. package/dist/commands/arb.d.ts +12 -0
  46. package/dist/commands/arb.js +61 -143
  47. package/dist/commands/bot.js +1 -1
  48. package/dist/commands/builder.js +1 -1
  49. package/dist/commands/funds.js +113 -3
  50. package/dist/commands/init.d.ts +1 -1
  51. package/dist/commands/init.js +27 -7
  52. package/dist/commands/manage.js +23 -21
  53. package/dist/commands/market.js +168 -36
  54. package/dist/commands/options.d.ts +9 -0
  55. package/dist/commands/options.js +134 -0
  56. package/dist/commands/pacifica-earn.d.ts +5 -0
  57. package/dist/commands/pacifica-earn.js +468 -0
  58. package/dist/commands/pacifica-orders.d.ts +14 -0
  59. package/dist/commands/pacifica-orders.js +142 -0
  60. package/dist/commands/portfolio.js +32 -47
  61. package/dist/commands/rebalance.d.ts +1 -0
  62. package/dist/commands/rebalance.js +15 -0
  63. package/dist/commands/risk.d.ts +11 -2
  64. package/dist/commands/risk.js +39 -44
  65. package/dist/commands/trade.d.ts +1 -0
  66. package/dist/commands/trade.js +63 -58
  67. package/dist/commands/wallet.js +1 -1
  68. package/dist/config.d.ts +2 -2
  69. package/dist/config.js +15 -4
  70. package/dist/constants.js +6 -0
  71. package/dist/dashboard/server.js +1 -1
  72. package/dist/dashboard/ui.js +1 -1
  73. package/dist/errors.d.ts +24 -1
  74. package/dist/errors.js +25 -3
  75. package/dist/exchanges/arcus/adapter.d.ts +7 -7
  76. package/dist/exchanges/arcus/adapter.js +12 -12
  77. package/dist/exchanges/arcus/agent.d.ts +1 -1
  78. package/dist/exchanges/arcus/agent.js +1 -1
  79. package/dist/exchanges/arcus/client.js +1 -1
  80. package/dist/exchanges/arcus/signing.d.ts +2 -3
  81. package/dist/exchanges/arcus/signing.js +1 -1
  82. package/dist/exchanges/arcus/spot-chain.d.ts +1 -1
  83. package/dist/exchanges/arcus/spot-chain.js +1 -1
  84. package/dist/exchanges/arcus/spot-router.d.ts +1 -1
  85. package/dist/exchanges/arcus/spot-router.js +2 -2
  86. package/dist/exchanges/arcus/spot.d.ts +2 -3
  87. package/dist/exchanges/arcus/spot.js +2 -3
  88. package/dist/exchanges/aster-spot.d.ts +3 -3
  89. package/dist/exchanges/aster-spot.js +6 -9
  90. package/dist/exchanges/aster-typed-data.d.ts +1 -1
  91. package/dist/exchanges/aster-typed-data.js +1 -1
  92. package/dist/exchanges/aster.d.ts +9 -4
  93. package/dist/exchanges/aster.js +125 -55
  94. package/dist/exchanges/builder-fee.d.ts +1 -1
  95. package/dist/exchanges/builder-fee.js +13 -2
  96. package/dist/exchanges/canonical-symbols.d.ts +1 -2
  97. package/dist/exchanges/canonical-symbols.js +26 -2
  98. package/dist/exchanges/derive/client.d.ts +37 -0
  99. package/dist/exchanges/derive/client.js +135 -0
  100. package/dist/exchanges/derive/options.d.ts +68 -0
  101. package/dist/exchanges/derive/options.js +360 -0
  102. package/dist/exchanges/extended/adapter.d.ts +82 -0
  103. package/dist/exchanges/extended/adapter.js +179 -0
  104. package/dist/exchanges/extended/client.d.ts +38 -0
  105. package/dist/exchanges/extended/client.js +88 -0
  106. package/dist/exchanges/extended/public.d.ts +130 -0
  107. package/dist/exchanges/extended/public.js +429 -0
  108. package/dist/exchanges/grvt/adapter.d.ts +86 -0
  109. package/dist/exchanges/grvt/adapter.js +206 -0
  110. package/dist/exchanges/grvt/client.d.ts +25 -0
  111. package/dist/exchanges/grvt/client.js +86 -0
  112. package/dist/exchanges/grvt/public.d.ts +151 -0
  113. package/dist/exchanges/grvt/public.js +438 -0
  114. package/dist/exchanges/hyperliquid-spot.d.ts +1 -1
  115. package/dist/exchanges/hyperliquid-spot.js +5 -5
  116. package/dist/exchanges/hyperliquid.d.ts +22 -16
  117. package/dist/exchanges/hyperliquid.js +80 -55
  118. package/dist/exchanges/index.d.ts +1 -1
  119. package/dist/exchanges/index.js +1 -1
  120. package/dist/exchanges/interface.d.ts +97 -8
  121. package/dist/exchanges/interface.js +19 -2
  122. package/dist/exchanges/ioc-outcome.d.ts +1 -1
  123. package/dist/exchanges/ioc-outcome.js +1 -1
  124. package/dist/exchanges/legacy-trading-v2.d.ts +2 -2
  125. package/dist/exchanges/legacy-trading-v2.js +7 -2
  126. package/dist/exchanges/lighter-public.d.ts +2 -2
  127. package/dist/exchanges/lighter-public.js +2 -2
  128. package/dist/exchanges/lighter-signer.d.ts +1 -1
  129. package/dist/exchanges/lighter-signer.js +1 -1
  130. package/dist/exchanges/lighter-spot.d.ts +3 -4
  131. package/dist/exchanges/lighter-spot.js +6 -7
  132. package/dist/exchanges/lighter.d.ts +11 -11
  133. package/dist/exchanges/lighter.js +28 -14
  134. package/dist/exchanges/ondo/adapter.d.ts +5 -6
  135. package/dist/exchanges/ondo/adapter.js +16 -14
  136. package/dist/exchanges/ondo/agent.d.ts +1 -1
  137. package/dist/exchanges/ondo/agent.js +1 -1
  138. package/dist/exchanges/ondo/client.d.ts +1 -1
  139. package/dist/exchanges/ondo/client.js +2 -2
  140. package/dist/exchanges/ondo/public.d.ts +8 -1
  141. package/dist/exchanges/ondo/public.js +13 -2
  142. package/dist/exchanges/ondo/spot.d.ts +2 -2
  143. package/dist/exchanges/ondo/spot.js +3 -3
  144. package/dist/exchanges/options-interface.d.ts +158 -0
  145. package/dist/exchanges/options-interface.js +17 -0
  146. package/dist/exchanges/options-venues.d.ts +12 -0
  147. package/dist/exchanges/options-venues.js +35 -0
  148. package/dist/exchanges/pacifica-atomic.d.ts +211 -0
  149. package/dist/exchanges/pacifica-atomic.js +440 -0
  150. package/dist/exchanges/pacifica-decimal.d.ts +10 -0
  151. package/dist/exchanges/pacifica-decimal.js +51 -0
  152. package/dist/exchanges/pacifica-presigned.d.ts +53 -0
  153. package/dist/exchanges/pacifica-presigned.js +141 -0
  154. package/dist/exchanges/pacifica-print.d.ts +200 -0
  155. package/dist/exchanges/pacifica-print.js +417 -0
  156. package/dist/exchanges/pacifica-spot.d.ts +2 -2
  157. package/dist/exchanges/pacifica-spot.js +3 -3
  158. package/dist/exchanges/pacifica-typed-data.d.ts +1 -1
  159. package/dist/exchanges/pacifica-typed-data.js +1 -1
  160. package/dist/exchanges/pacifica-vaults.d.ts +121 -0
  161. package/dist/exchanges/pacifica-vaults.js +264 -0
  162. package/dist/exchanges/pacifica.d.ts +32 -5
  163. package/dist/exchanges/pacifica.js +114 -48
  164. package/dist/exchanges/precision.d.ts +3 -1
  165. package/dist/exchanges/precision.js +9 -2
  166. package/dist/exchanges/qfex/adapter.d.ts +18 -5
  167. package/dist/exchanges/qfex/adapter.js +32 -12
  168. package/dist/exchanges/qfex/agent.d.ts +1 -1
  169. package/dist/exchanges/qfex/agent.js +1 -1
  170. package/dist/exchanges/qfex/client.d.ts +1 -1
  171. package/dist/exchanges/qfex/client.js +1 -1
  172. package/dist/exchanges/qfex/errors.d.ts +1 -1
  173. package/dist/exchanges/qfex/errors.js +2 -2
  174. package/dist/exchanges/qfex/public.d.ts +3 -3
  175. package/dist/exchanges/qfex/public.js +3 -3
  176. package/dist/exchanges/qfex/signing.d.ts +2 -2
  177. package/dist/exchanges/qfex/signing.js +4 -5
  178. package/dist/exchanges/qfex/ws-transport.d.ts +2 -2
  179. package/dist/exchanges/qfex/ws-transport.js +3 -3
  180. package/dist/exchanges/rate-limiter.d.ts +1 -1
  181. package/dist/exchanges/rate-limiter.js +5 -2
  182. package/dist/exchanges/registry.d.ts +1 -1
  183. package/dist/exchanges/registry.js +1 -1
  184. package/dist/exchanges/risex/adapter.d.ts +148 -0
  185. package/dist/exchanges/risex/adapter.js +796 -0
  186. package/dist/exchanges/risex/agent.d.ts +154 -0
  187. package/dist/exchanges/risex/agent.js +384 -0
  188. package/dist/exchanges/risex/client.d.ts +47 -0
  189. package/dist/exchanges/risex/client.js +174 -0
  190. package/dist/exchanges/risex/nonce.d.ts +43 -0
  191. package/dist/exchanges/risex/nonce.js +212 -0
  192. package/dist/exchanges/risex/public.d.ts +64 -0
  193. package/dist/exchanges/risex/public.js +150 -0
  194. package/dist/exchanges/risex/session.d.ts +71 -0
  195. package/dist/exchanges/risex/session.js +171 -0
  196. package/dist/exchanges/risex/typed-data.d.ts +173 -0
  197. package/dist/exchanges/risex/typed-data.js +240 -0
  198. package/dist/exchanges/risex/units.d.ts +29 -0
  199. package/dist/exchanges/risex/units.js +104 -0
  200. package/dist/exchanges/slippage.d.ts +1 -1
  201. package/dist/exchanges/slippage.js +1 -1
  202. package/dist/exchanges/spot-factory.d.ts +3 -3
  203. package/dist/exchanges/spot-factory.js +7 -7
  204. package/dist/exchanges/spot-interface.d.ts +5 -5
  205. package/dist/exchanges/spot-interface.js +2 -2
  206. package/dist/exchanges/spot-result.d.ts +3 -3
  207. package/dist/exchanges/spot-result.js +3 -3
  208. package/dist/exchanges/trading-v2.d.ts +3 -3
  209. package/dist/exchanges/trading-v2.js +9 -6
  210. package/dist/exchanges/txflow/adapter.d.ts +54 -0
  211. package/dist/exchanges/txflow/adapter.js +118 -0
  212. package/dist/exchanges/txflow/client.d.ts +47 -0
  213. package/dist/exchanges/txflow/client.js +153 -0
  214. package/dist/exchanges/txflow/public.d.ts +76 -0
  215. package/dist/exchanges/txflow/public.js +284 -0
  216. package/dist/exchanges/unknown-symbol.d.ts +29 -0
  217. package/dist/exchanges/unknown-symbol.js +51 -0
  218. package/dist/exchanges/variational/adapter.d.ts +71 -0
  219. package/dist/exchanges/variational/adapter.js +137 -0
  220. package/dist/exchanges/variational/client.d.ts +21 -0
  221. package/dist/exchanges/variational/client.js +66 -0
  222. package/dist/exchanges/variational/public.d.ts +126 -0
  223. package/dist/exchanges/variational/public.js +430 -0
  224. package/dist/exchanges/venue-types.d.ts +6 -7
  225. package/dist/exchanges/venue-types.js +2 -2
  226. package/dist/exchanges/venue.d.ts +51 -8
  227. package/dist/exchanges/venue.js +54 -4
  228. package/dist/exchanges/venues/arcus.d.ts +1 -2
  229. package/dist/exchanges/venues/arcus.js +5 -4
  230. package/dist/exchanges/venues/aster.d.ts +2 -2
  231. package/dist/exchanges/venues/aster.js +6 -4
  232. package/dist/exchanges/venues/create-helpers.d.ts +1 -1
  233. package/dist/exchanges/venues/create-helpers.js +3 -3
  234. package/dist/exchanges/venues/extended.d.ts +21 -0
  235. package/dist/exchanges/venues/extended.js +96 -0
  236. package/dist/exchanges/venues/grvt.d.ts +30 -0
  237. package/dist/exchanges/venues/grvt.js +98 -0
  238. package/dist/exchanges/venues/hyperliquid.d.ts +2 -2
  239. package/dist/exchanges/venues/hyperliquid.js +10 -8
  240. package/dist/exchanges/venues/index.d.ts +7 -2
  241. package/dist/exchanges/venues/index.js +8 -2
  242. package/dist/exchanges/venues/lighter-family.d.ts +7 -9
  243. package/dist/exchanges/venues/lighter-family.js +9 -11
  244. package/dist/exchanges/venues/lighter-rh.d.ts +2 -2
  245. package/dist/exchanges/venues/lighter-rh.js +7 -5
  246. package/dist/exchanges/venues/lighter.d.ts +1 -1
  247. package/dist/exchanges/venues/lighter.js +5 -3
  248. package/dist/exchanges/venues/ondo.d.ts +1 -1
  249. package/dist/exchanges/venues/ondo.js +5 -3
  250. package/dist/exchanges/venues/pacifica.d.ts +2 -2
  251. package/dist/exchanges/venues/pacifica.js +6 -4
  252. package/dist/exchanges/venues/qfex.d.ts +1 -1
  253. package/dist/exchanges/venues/qfex.js +4 -2
  254. package/dist/exchanges/venues/risex-release.d.ts +14 -0
  255. package/dist/exchanges/venues/risex-release.js +14 -0
  256. package/dist/exchanges/venues/risex.d.ts +31 -0
  257. package/dist/exchanges/venues/risex.js +116 -0
  258. package/dist/exchanges/venues/txflow.d.ts +18 -0
  259. package/dist/exchanges/venues/txflow.js +77 -0
  260. package/dist/exchanges/venues/variational.d.ts +21 -0
  261. package/dist/exchanges/venues/variational.js +85 -0
  262. package/dist/execution-log.d.ts +1 -1
  263. package/dist/funding/legacy-public.d.ts +1 -1
  264. package/dist/funding/legacy-public.js +9 -4
  265. package/dist/funding/normalize.d.ts +1 -1
  266. package/dist/funding/normalize.js +1 -1
  267. package/dist/funding/rates.d.ts +44 -4
  268. package/dist/funding/rates.js +56 -12
  269. package/dist/index.js +7 -6
  270. package/dist/jobs.d.ts +1 -0
  271. package/dist/jobs.js +25 -1
  272. package/dist/landing.d.ts +2 -0
  273. package/dist/landing.js +1 -1
  274. package/dist/mcp-pacifica-earn.d.ts +9 -0
  275. package/dist/mcp-pacifica-earn.js +58 -0
  276. package/dist/mcp-server.js +113 -59
  277. package/dist/pacifica/client.d.ts +67 -4
  278. package/dist/pacifica/client.js +170 -8
  279. package/dist/pacifica/rate-limit.d.ts +144 -0
  280. package/dist/pacifica/rate-limit.js +267 -0
  281. package/dist/pacifica/types/order.d.ts +2 -1
  282. package/dist/plan-executor.js +7 -2
  283. package/dist/rebalance.js +1 -1
  284. package/dist/risk.d.ts +2 -1
  285. package/dist/risk.js +2 -4
  286. package/dist/settings.d.ts +32 -3
  287. package/dist/signer/ows-evm.d.ts +1 -1
  288. package/dist/signer/ows-evm.js +1 -1
  289. package/dist/smart-order.d.ts +1 -1
  290. package/dist/smart-order.js +1 -1
  291. package/dist/strategies/funding-arb.d.ts +1 -1
  292. package/dist/strategies/funding-arb.js +1 -1
  293. package/dist/trade-validator.d.ts +1 -1
  294. package/dist/trade-validator.js +9 -4
  295. package/dist/utils.d.ts +12 -3
  296. package/dist/utils.js +11 -3
  297. package/dist/venue-health.d.ts +40 -0
  298. package/dist/venue-health.js +75 -0
  299. package/dist/withdraw-gate.d.ts +1 -1
  300. package/dist/withdraw-gate.js +1 -1
  301. package/dist/ws/index.d.ts +959 -0
  302. package/dist/ws/index.js +4295 -0
  303. package/dist/ws/node.d.ts +975 -0
  304. package/dist/ws/node.js +4366 -0
  305. package/dist/ws/pacifica-instrument.d.ts +8 -0
  306. package/dist/ws/pacifica-instrument.js +8 -0
  307. package/dist/ws-cli/credentials.d.ts +62 -0
  308. package/dist/ws-cli/credentials.js +126 -0
  309. package/package.json +58 -10
  310. package/skills/perp-cli/SKILL.md +62 -9
  311. package/skills/perp-cli/references/agent-operations.md +7 -0
  312. package/skills/perp-cli/references/commands.md +76 -5
  313. package/skills/perp-cli/references/ws-library.md +492 -0
@@ -6,7 +6,7 @@ All commands support `--json` for structured output. Always use `--json` when ca
6
6
 
7
7
  `--json` (required for agents) | `-e <venue>` | `-n mainnet|testnet` | `--dry-run` (only commands whose `--help` says "supports --dry-run"; others refuse with `INVALID_PARAMS`) | `--fields <f1,f2>` | `--ndjson` | `-w, --wallet <name>` | `--ows <name>` (OWS wallet) | `--no-agent` (skip agent-key routing) | `--no-builder` | `--dex <name>` (HIP-3, Hyperliquid)
8
8
 
9
- Venues (`-e`, id | aliases): `pacifica|pac`, `hyperliquid|hl`, `lighter|lt`, `lighter-rh|lrh|rh`, `aster|ast`, `ondo|ondo-perps`, `arcus`, `qfex|qf`. Default = `settings.defaultExchange`, else `pacifica` — always pass `-e`.
9
+ Venues (`-e`, id | aliases): `pacifica|pac`, `hyperliquid|hl`, `lighter|lt`, `lighter-rh|lrh|rh`, `aster|ast`, `ondo|ondo-perps`, `arcus`, `qfex|qf`, `risex|rise` (READ-ONLY in this release — market data + account reads; trading is planned for a later release, every trade / agent / bot write refuses with CAPABILITY_UNSUPPORTED), `txflow|txf` (market data only, UNOFFICIAL API — every write refuses with CAPABILITY_UNSUPPORTED), `variational|var|omni` (market data only, official API, RFQ venue: `market book` = indicative RFQ quotes, `market funding` = current terms — every write refuses with CAPABILITY_UNSUPPORTED), `grvt|grv` (market data only in this version, official API — every write refuses with CAPABILITY_UNSUPPORTED), `extended|ext|x10` (Extended on Starknet, official API, market data only in this version — every write refuses with CAPABILITY_UNSUPPORTED). Default = `settings.defaultExchange`, else `pacifica` — always pass `-e`.
10
10
  `-k <key>` is rejected (keys only via stdin / OWS vault). `--passphrase` is deprecated → `OWS_PASSPHRASE` env or stdin.
11
11
 
12
12
  ## Market Data (read-only, safe)
@@ -91,6 +91,16 @@ perp --json -e <EX> trade scale-in <SYMBOL> <SIDE> --levels '<P1>:<PCT>,<P2>:<PC
91
91
  perp --json -e <EX> trade pnl-track # real-time PnL tracker
92
92
  perp --json -e <EX> trade twap <SYMBOL> <SIDE> <SIZE> <DURATION> # Pacifica: seconds (native), HL: minutes (native, min $100); others client-side: --background [--slices <n>]
93
93
 
94
+ # Pacifica Atomic basket (pacifica only; other venues → CAPABILITY_UNSUPPORTED). Legs SYMBOL[:WEIGHT] (≤ 5 per side, each market once);
95
+ # --usd is split by weight at each mark, floored to the lot, each leg ≥ the market's USD minimum. ONE POST /orders/batch of market
96
+ # orders — NOT all-or-nothing: each leg fills or fails on its own (ORDER_PARTIAL / ORDER_REJECTED / ORDER_PENDING per basket).
97
+ # --reduce-only: --long legs must close shorts, --short legs must close longs (checked before sending). Supports --dry-run (plan + unsigned body).
98
+ perp --json -e pacifica trade atomic --long BTC:50 --short ETH:50 --usd 1000 [--reduce-only] [--slippage-bps <n>] [--force]
99
+ # A leg needs notional ≥ the market minimum × (1 + slippage bound). Legs without an order_id in the ack are traced by their client_order_id.
100
+ # A batch failure other than HTTP 400 / 429 (timeout, 5xx, unreadable answer) → ORDER_PENDING and EVERY leg is quarantined.
101
+ # Pacifica Chase / OCO / Trailing Stop / Swarm / Trigger Swarm / Conditional Market|Limit: no documented API / SDK path (as of 2026-10-19) —
102
+ # nearest here: trade twap (≈ Swarm, split market execution), trade tpsl (≈ OCO bracket exit).
103
+
94
104
  # Spot helpers (any venue with spot)
95
105
  perp --json -e <EX> trade spot-buy <SYMBOL> <SIZE>
96
106
  perp --json -e <EX> trade spot-sell <SYMBOL> <SIZE>
@@ -181,6 +191,61 @@ Encoding formula: `enc = 10 * outcome + side`; asset id = `100,000,000 + enc`.
181
191
 
182
192
  Settlement at `expiryMs` is venue-side: winning side → 1 quote token per share, losing → 0. Decide before expiry whether to self-close or let the venue settle.
183
193
 
194
+ ## Options market data (read-only, `-e derive`)
195
+
196
+ Derive (derive.xyz, formerly Lyra) options through its official public v3 API — keyless, no orders, no account. Options venues are separate from the perp venues (not in `perp health`, arb or `market`). JSON objects carry `schema: 1`; decimals are strings; unknown is `null` (empty book side → `bid`/`ask` `null`; IV the venue could not compute → `null`; 24h high/low `null` without trades).
197
+
198
+ ```bash
199
+ perp --json -e derive options underlyings # underlyings with options (spot price)
200
+ perp --json -e derive options expiries ETH # active expiries: expiry (YYYY-MM-DD UTC), expiryMs, strikes, instruments
201
+ perp --json -e derive options chain ETH # nearest expiry (expirySelection: "nearest"), every strike
202
+ perp --json -e derive options chain ETH --expiry 2026-12-25 --strikes 10 # 10 strikes closest to ATM (forward, else index)
203
+ perp --json -e derive options quote ETH-20261225-3500-P # one option
204
+ ```
205
+
206
+ - Chain row: `{strike, call, put}`; each side is an option quote: `bid`/`ask` `{price, size}`, `mark`, `index`, `forward`, `discountFactor`, `iv {mark, bid, ask}`, `greeks {delta, gamma, theta, vega, rho}` (venue units, verbatim), `openInterest` (contracts), `stats24h {contracts, notional, premium, trades, high, low}`, `timestampMs`. Chain level: `index`, `forward` (both of the newest ticker of the expiry, not an average), `atm {strike, reference, price}`, `strikesListed`, `missingTickers`.
207
+ - Errors: unknown underlying / expiry / instrument → `SYMBOL_NOT_FOUND` (remediation names the listing command / active expiries); a perp name in `quote` → `INVALID_PARAMS`; an active option with no live ticker (activation / expiry race) → `EXCHANGE_ERROR` (retry); other Derive rejections → `EXCHANGE_ERROR`; no `-e` or a perp venue → `CAPABILITY_UNSUPPORTED`; Derive's rate limit → `RATE_LIMITED` (retryable).
208
+ - Cost: `expiries` 1 request, `chain` 2 (instruments + ONE tickers call for the expiry), `quote` 2. Derive's public budget is 5 requests/s per IP.
209
+ - `-n testnet` reads `testnet.api.derive.xyz`. MCP: `get_option_expiries`, `get_option_chain`, `get_option_quote` (`venue: "derive"`).
210
+
211
+ ## Pacifica Print (`perp print`) and vaults (`perp vault`)
212
+
213
+ Pacifica-only products: these commands always use Pacifica (`-e <other venue>` → `INVALID_PARAMS`). Reads are unauthenticated GETs and never sign; `--account <address>` reads any account without a key. Every write supports `--dry-run`, which prints the exact message to sign and the body (`signature: null`), never signs and sends nothing. `--account` on a write only plans (with `--dry-run`); a live write with `--account` is refused.
214
+
215
+ **Signer rule (perp-cli, stricter than the venue):** `vault deposit` is **master-only** (like `funds withdraw pacifica`) — an agent key must never be able to move the balance into a vault someone else manages; with only an agent key on the machine it is `NO_SIGNER_AVAILABLE`. `vault withdraw` (pays only into the own account), `print deposit` and `print cancel` are signed by the active signer (the agent key when approved, else the master). `wallet manage lake deposit|withdraw` were replaced by these commands (they now refuse with a pointer); `wallet manage lake create --initial-deposit` is refused with an agent key.
216
+
217
+ **Idempotency:** every deposit / withdraw sends a fresh `idempotency_key`. After `ORDER_PENDING` never resubmit with a new key; the only safe retry is the same command with `--idempotency-key <the reported key>` (the venue answers a duplicate with HTTP 409; how long it remembers a key is unverified). `error.details.before` holds the pre-write state (Print order count / vault shares) to compare with `print positions` / `vault positions`.
218
+
219
+ ### Print — strike orders that earn a premium every cycle
220
+ A Print order locks a USDC deposit (min 10) as margin at a target price: **long** = target BELOW mark, **short** = target ABOVE mark, 0.5% … 10% / 20% / 40% from mark for a 24-hour / 7-day / 30-day cycle. Leverage 1 … product max (`print games`). The premium is credited to the order at the START of each cycle. **While resting** (before a fill) it is evaluated only at the END of each cycle: it fills AT the target (size = deposit × leverage ÷ target), rolls to a new target at the same % distance, or is liquidated; max loss = deposit + premiums. **After a fill** it is an ordinary perp position in the main account — continuous mark-price liquidation, it nets with an existing position in the same market and may be cross-margined; read it with `perp -e pacifica account positions`. Product / pool deposit caps are totals across all users (the venue refuses when full). `--cycles 1…1000` caps the cycles (default: roll until cancelled). A cancel takes effect only at the end of the running cycle — funds stay locked until then (up to 30 days on BTC_1M).
221
+ ```bash
222
+ perp --json -e pacifica print games # products: cycle, max lev, caps, allowed target range
223
+ perp --json -e pacifica print quote BTC_24H long 100 --strike 79000 --leverage 10 [--cycles 7]
224
+ # read-only preview: notional, size on fill, docsLiquidationPrice, maxLoss, lock; estimatedPremium is null
225
+ # (the venue's premium preview is a signed request — never called on a read path)
226
+ perp --json -e pacifica print positions [--account <addr>] # current orders: printing | cancelling | filled | liquidated | cancelled | completed | ended
227
+ perp --json -e pacifica print history [--account <addr>] [--limit N] [--cursor C]
228
+ perp --json -e pacifica print events <printAddress> [--limit N] [--cursor C] # created / premium / end_requested / position_opened / liquidated
229
+ perp --json -e pacifica --dry-run print deposit BTC_24H long 100 --strike 79000 --leverage 10 --cycles 7
230
+ perp --json -e pacifica print deposit BTC_24H long 100 --strike 79000 --leverage 10 --cycles 7 # POST /game/deposit (deposit_to_game)
231
+ perp --json -e pacifica --dry-run print cancel <printAddress> # UNOFFICIAL: POST /game/end (end_game) is not in the API docs (apiStatus: "unofficial")
232
+ perp --json -e pacifica print cancel <printAddress> --unverified-endpoint # live run needs the flag; only a `printing` order
233
+ ```
234
+ - `docsLiquidationPrice` is the docs formula (margin fully lost) — an outer bound, not the trigger: a liquidation was observed earlier in one live case (2026-10-16, ZEC_24H short at mark 1216.84 vs formula ≈ 1269); the venue's trigger is unpublished. For a 1× long the formula gives ≤ 0: `null` = unknown, not "no liquidation". Show the user `maxLoss`.
235
+
236
+ ### Vaults (API: lakes)
237
+ A vault is a Pacifica account traded by its manager; LPs get shares at NAV (lp_balance ÷ lp_shares). Min deposit 10 USDC; `deposit_cap` caps the LP balance; the manager takes `manager_profit_share` above the high-water mark. A withdrawal redeems shares into YOUR OWN trading balance (the API has no destination) only after `deposit_min_duration_ms` since your LAST deposit (each deposit restarts the lock for your whole balance), inside the withdrawal window when one is set (open while now_s mod window < duration), and from the vault's free balance (a fully deployed vault may pay partially). APY is not published (`apy: null`).
238
+ ```bash
239
+ perp --json -e pacifica vault list [--account <addr>] # TVL, NAV/share, fee, lock-up, window, config
240
+ perp --json -e pacifica vault info <address|nickname> # + live equity, positions, deposit whitelist (UNOFFICIAL endpoint, advisory), window open now?
241
+ # a nickname must match exactly ONE vault (case-insensitive) — duplicates exist live ("Pacifica Vault" / "Pacifica vault");
242
+ # ambiguity → INVALID_PARAMS listing every candidate's address / manager / TVL. Writes: prefer the ADDRESS.
243
+ perp --json -e pacifica vault positions [--account <addr>] # your LP (or manager) shares and value
244
+ perp --json -e pacifica --dry-run vault deposit <vault> <amount> # POST /lake/deposit (deposit_to_lake) — MASTER-ONLY; refused when the LP NAV is zero / undefined
245
+ perp --json -e pacifica --dry-run vault withdraw <vault> <shares>|--all # POST /lake/withdraw (withdraw_from_lake)
246
+ ```
247
+ MCP (read-only): `pacifica_print_products`, `pacifica_print_quote`, `pacifica_print_positions`, `pacifica_vaults`. Print / vault writes are CLI-only.
248
+
184
249
  ## Funds (deposit, withdraw, transfer, bridge, rebalance)
185
250
 
186
251
  All fund movement lives under `perp funds`.
@@ -201,6 +266,8 @@ perp --json funds deposit lighter-rh <AMOUNT> # USDG on Robinhood
201
266
  # Ondo Perps: NO API withdrawals — `funds withdraw ondo` → CAPABILITY_UNSUPPORTED; withdraw in the Ondo web app
202
267
  perp --json funds withdraw arcus <AMOUNT> # USDG → the account owner (min 1)
203
268
  perp --json funds deposit qfex # prints the USDC / Arbitrum One deposit address (sends nothing)
269
+ # RISEx: deposits / withdrawals are app-only — `funds deposit|withdraw risex` → CAPABILITY_UNSUPPORTED (app.rise.trade)
270
+ # RISEx is read-only in this release (trading later): `funds faucet risex` → CAPABILITY_UNSUPPORTED too
204
271
  # QFEX: withdrawals disabled in perp-cli for now — `funds withdraw qfex` → CAPABILITY_UNSUPPORTED; withdraw in the QFEX web app
205
272
  perp --json -e qfex funds transfer <AMOUNT> primary --from <account-uuid> # between your QFEX accounts
206
273
  perp --json funds deposit aster <AMOUNT> [--chain bsc|arbitrum] [--asset USDT|USDC] # vault approve + deposit (mainnet; default BNB Chain USDT)
@@ -259,7 +326,9 @@ perp --json arb scan --compare <SYMBOL> # compare a symbol a
259
326
  perp --json arb scan --positions # position funding impact; actual24h null + fundingHistoryErrors when a venue's history is unreadable
260
327
  perp --json arb scan --live --interval 60 # continuous monitoring
261
328
  perp --json arb scan --top 10 --hold-days 7 --bridge-cost 0 --size 100 # cost-aware filtering
262
- perp --json arb scan --rates --include-unverified # also show QFEX (unverified funding; display only, never ranked)
329
+ perp --json -e extended market list # Extended (read-only): rows carry assetClass, venueSymbol (TSLA_24_5-USD), tickSize, minSize, tradable, fundingRateKind "accruing"
330
+ perp --json -e extended market funding BTC -l 24 # applied hourly prints: rateKind "lastSettled", intervalHours 1, annualizedPct
331
+ perp --json arb scan --rates --include-unverified # also show QFEX, TxFlow, Variational, GRVT and Extended (unverified funding; TxFlow rows apiStatus "unofficial"; display only, never ranked)
263
332
  # netSpread / grossSpread = annualized % (netSpread = gross minus round-trip costs amortized over --hold-days)
264
333
  # spot-perp rows: netSpreadPct / grossSpreadPct, spotExchanges[], perpExchange, direction
265
334
 
@@ -337,6 +406,9 @@ perp --json wallet agent approve <exchange> \
337
406
  [--account-index <0-9|255>] # Arcus subaccount
338
407
  [--api-key-index <n>] # Lighter slot 4-254 (default: first slot free locally AND on-chain)
339
408
  [--force] # Lighter: overwrite an --api-key-index slot that already holds an on-chain key
409
+ # risex: READ-ONLY in this release (trading is planned for a later release) — `wallet agent approve|import|revoke|rotate
410
+ # risex` → CAPABILITY_UNSUPPORTED (also with --dry-run); `wallet agent verify risex` (read-only) still works.
411
+ # txflow / variational / extended: every agent command → CAPABILITY_UNSUPPORTED (market data only).
340
412
 
341
413
  # Import a key the USER created on the exchange WEBSITE (master wallet not needed here).
342
414
  # Key on stdin only (one line; ondo: key id + secret; qfex: public key + secret) — an argv key is rejected (INVALID_PARAMS).
@@ -383,9 +455,8 @@ perp --json wallet manage account-mode [unified|standard|portfolio] # Hyperliq
383
455
  perp --json wallet manage sub create <name> # not supported (CAPABILITY_UNSUPPORTED): the subaccount wallet must co-sign — use the web app
384
456
  perp --json wallet manage sub list
385
457
  perp --json wallet manage sub transfer <from> <to> <amount> # <from> must be the signing account
386
- perp --json wallet manage lake create [--manager <address>] [--nickname <name>] [--initial-deposit <amount>]
387
- perp --json wallet manage lake deposit <lake> <amount> # <lake> = vault address
388
- perp --json wallet manage lake withdraw <lake> <shares> # shares to redeem, not USDC
458
+ perp --json wallet manage lake create [--manager <address>] [--nickname <name>] [--initial-deposit <amount>] # --initial-deposit: master-signed only
459
+ perp --json wallet manage lake deposit|withdraw … # MOVED → perp -e pacifica vault deposit|withdraw (refused here)
389
460
  perp --json wallet manage builder overview # Pacifica builder-OWNER tools only (user approvals: wallet builder)
390
461
  perp --json wallet manage builder trades <code>
391
462
  perp --json wallet manage builder leaderboard <code>
@@ -0,0 +1,492 @@
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:** ships in **0.15.0** and is **@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 for **0.15.1**: Aster user channels, Ondo / QFEX, `book` / `trades`, `perp stream`.
10
+ > The `VenueId` type already includes `"ondo"` and `"qfex"`, but there are no codecs for them until 0.15.1.
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** (~100 KB ESM) |
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@0.15.0/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 + BTCUSD1, arcus BTC-USD.
127
+ // (symbols would need each venue's directory symbols; ["BTC"] rejects on aster and arcus.)
128
+ // A canonical this venue does not list (e.g. SOL somewhere) is skipped with a non-fatal warning;
129
+ // ready rejects only when the venue lists none of them.
130
+ const sub = stream.subscribe({ kind: "allMarkets", canonicals: ["BTC", "ETH", "SOL"] });
131
+ sub.ready.then(
132
+ () => show(stream.venue, "live"),
133
+ (err: unknown) => show(stream.venue, `FAILED: ${err instanceof Error ? err.message : String(err)}`),
134
+ );
135
+ sub.on((e) => {
136
+ if (e.type === "market") show(stream.venue, `${e.symbol} ${e.mark ?? "—"}`);
137
+ else if (e.type === "error" && e.code === "SYMBOL_NOT_FOUND") {
138
+ show(stream.venue, `not listed here: ${e.details?.missing?.join(", ")}`); // non-fatal: the rest stream
139
+ } else if (e.type === "error" && e.fatal) show(stream.venue, `FAILED: ${e.code} ${e.message}`);
140
+ });
141
+ stream.onState((st) => { if (st.status === "closed") show(stream.venue, `closed: ${st.reason.message}`); });
142
+ }
143
+ ```
144
+
145
+ Funding comparison across venues (browser):
146
+
147
+ ```ts
148
+ import {
149
+ arcus, aster, createFundingComparator, createVenueStream, hyperliquid, lighter, lighterRh, pacifica, toHourly,
150
+ } from "@hypurrquant/perp-ws";
151
+
152
+ const streams = [hyperliquid, pacifica, lighter, lighterRh, aster, arcus].map((c) => createVenueStream(c));
153
+ const cmp = createFundingComparator({ streams, kind: "next", minVenues: 2 });
154
+ const off = cmp.subscribe((rows) => {
155
+ for (const row of rows) {
156
+ const hourly = row.legs
157
+ .filter((l) => !l.stale && l.rate !== null)
158
+ .map((l) => ({ venue: l.venue, kind: l.rateKind, h: toHourly(l.rate, l.funding.rateIntervalHours) }));
159
+ console.log(row.canonical, row.mixedKinds, hourly);
160
+ }
161
+ });
162
+ // …later
163
+ off();
164
+ cmp.close();
165
+ await Promise.all(streams.map((s) => s.close()));
166
+ ```
167
+
168
+ ## 3. Streams, subscriptions, connection state
169
+
170
+ - `createVenueStream(codec, opts)` — one socket per venue, opened lazily on the first subscription, closed
171
+ (`idle`) when the last one leaves. The market directory is loaded once over REST (`stream.markets()`).
172
+ - `subscribe(target)`:
173
+ - `{ kind: "allMarkets", canonicals? | symbols? }` → `MarketUpdate` for every market, or a subset:
174
+ **`canonicals`** (recommended across venues) selects every directory market whose `canonical` matches
175
+ — `["BTC"]` is `BTC` on hyperliquid / pacifica / lighter / lighter-rh (USDG), `BTCUSDT` + `BTCUSD1` on
176
+ aster and `BTC-USD` on arcus; HIP-3 markets are `"<dex>:<BASE>"` (`"xyz:TSLA"`, with `hip3Dexes`) and
177
+ `"BTC"` never pulls them in. Canonicals come from `opts.canonicalize` (default: USD-like quotes
178
+ collapse) — see `MarketDirectoryEntry.canonical` in `stream.markets()`. Canonicals the venue does
179
+ not list are **skipped and reported once** as a non-fatal control
180
+ `{ type: "error", code: "SYMBOL_NOT_FOUND", fatal: false, details: { missing: string[], suggestions:
181
+ Record<string, string[]> } }` (suggestions e.g. `"xyz:TSLA"` for `"TSLA"`; the others are subscribed and
182
+ `ready` resolves). Only when the venue lists **none** of them does `ready` reject `INVALID_PARAMS`.
183
+ **`symbols`** are this venue's exact directory symbols. Pass **one or the other** — both together
184
+ reject `INVALID_PARAMS`. Same wire subscription and budget either way (filtering is client-side);
185
+ - `{ kind: "market", symbol, channels: ["market" | "bbo"] }`. `"book"` and `"trades"` are already in
186
+ the types but **reject with `CAPABILITY_UNSUPPORTED` until 0.15.1** — don't ship UI that needs them.
187
+ Any other channel name (e.g. `"ticker"`) or an empty `channels` rejects with `INVALID_PARAMS`;
188
+ - `{ kind: "user", channels: ["account" | "positions" | "orders" | "fills"] }` (needs `credentials`).
189
+ - **`symbols` / `symbol` mean the venue's directory symbols, case-sensitive** — not canonical names. `BTC` is `"BTC"` on
190
+ hyperliquid / pacifica / lighter, `"BTC-USD"` on arcus and `"BTCUSDT"` / `"BTCUSD1"` on aster. Pass
191
+ `MarketUpdate.symbol` / `FundingLeg.symbol`, or look them up in `stream.markets()`. Unknown symbols reject
192
+ with `INVALID_PARAMS` before anything is sent; the message carries a "did you mean …" hint
193
+ (`details.suggestions`) when a directory symbol matches case-insensitively or by canonical / base name.
194
+ - `Subscription`: `on(cb)` (unbuffered; a listener that throws is isolated — the other listeners and the
195
+ stream keep running — and reported to `opts.logger` as `{ event: "listener-error" }`, not as a stream
196
+ event), `for await` (bounded buffer, `opts.buffer.size`, default 1024),
197
+ `ready` (resolves when every wire channel is acknowledged; rejects with a `WsError`), `status`,
198
+ `unsubscribe()`. Wire channels are ref-counted: two subscriptions on one venue channel = one wire
199
+ subscription.
200
+ - Connection state (`stream.state`, `stream.onState(cb)`, and `{type:"state"}` events):
201
+ `idle → connecting → live ⇄ stale → reconnecting → … → closed`. `stale` = no message on a channel for
202
+ `staleAfterMs` (from message timestamps, re-checked when a hidden tab becomes visible); 2× → forced
203
+ reconnect. Reconnect backoff 1 s → 30 s with jitter, a per-venue new-connection budget, then
204
+ resubscribe; the first frame after a (re)subscribe has `source: "snapshot"`.
205
+ - Several sockets (Pacifica, > 20 channels of one kind): each socket reconnects and resubscribes only
206
+ its own channels (gaps only for channels on it). `reconnect.maxAttempts` counts per socket, but **one
207
+ socket exhausting it closes the whole stream** (every subscription ends with that reason). The state is
208
+ the aggregate, worst first: `closed` > `reconnecting` > `stale` > `connecting` (of a socket that was
209
+ open before) > `live`. A socket opening for the first time (just added, waiting on the
210
+ new-connection budget, or retrying a failed first handshake) does not turn a live stream into
211
+ `connecting` / `reconnecting`: it stays `live` with
212
+ `sockets` = open sockets and `pending` = sockets still opening — the channels on those are not ready
213
+ yet (their `ready` resolves when they open). `pending` is present only on multi-socket streams.
214
+ - `closed` is terminal: a later `subscribe` rejects with the close reason. A handshake failure before
215
+ open = `EXCHANGE_UNREACHABLE` ("network, venue outage, or Origin/CORS block").
216
+ - Ack timeout: no acknowledgement and no data within the channel's stale threshold → a **pending**
217
+ subscription fails with `WS_PROTOCOL_ERROR`.
218
+ - Iterator buffer: market/bbo/account/positions/orders events are **state** and coalesce by key (never
219
+ fatal). Fills and control events are not coalesced; a full buffer → fatal `STREAM_OVERFLOW` and the
220
+ subscription closes. Before the iterator exists, state/error controls are kept in a ring of the last
221
+ `size` (the iterator first gets a non-fatal `STREAM_OVERFLOW` with the discarded count); fills,
222
+ gaps and the one-time `SYMBOL_NOT_FOUND` warning are never evicted.
223
+
224
+ ## 4. Event fields and nullability
225
+
226
+ Rules: **unknown = `null`, never `0`**. A field the venue never sent is `null`; a field the latest frame
227
+ did not carry keeps its last value, with `asOf[field]` = receive time. `Dec` = the venue's decimal string
228
+ (exact; parse it yourself). Check `null` before arithmetic — `null` is not zero.
229
+
230
+ `MarketUpdate`: `venue, dex, symbol, venueSymbol, quote, canonical, mark, index, bestBid, bestAsk, last,
231
+ funding{ rate, rateKind, rateSource, rateIntervalHours, settlementIntervalHours, intervalSource,
232
+ nextFundingTime, predictedRate, marketClosed }, openInterest{ value, unit: "base"|"usd"|null, sides },
233
+ volume24hNotional, venueTs, recvTs, source, changed[], asOf{}`.
234
+
235
+ Funding:
236
+ - `rate` is **raw per `rateIntervalHours`** — never pre-annualized. Use `toHourly(rate, h)` to compare and
237
+ `toApr(rate, h)` only for display (both return `null` for `null` input).
238
+ - `rateKind`: `"lastSettled"` (the last payment), `"accruing"` (the current period so far, the "if it
239
+ settled now" rate), `"predicted"` (the venue's estimate of the next payment). `predictedRate` is always
240
+ kind `"predicted"`.
241
+
242
+ | Venue | `rate` kind | `predictedRate` | interval | OI | notes |
243
+ |---|---|---|---|---|---|
244
+ | hyperliquid | accruing | null | 1 h | base, one side | bid/ask/last null → `bbo` channel |
245
+ | pacifica | lastSettled | next_funding | 1 h | base, `sides` null | bid/ask/last/nextFundingTime null |
246
+ | lighter / lighter-rh | lastSettled | current_funding_rate | 1 h | **usd** | wire values are percent/h, emitted as fractions/h |
247
+ | aster | accruing | null | per market (`rest:/fapi/v3/fundingInfo`, null if missing) | null | all quotes (USDT, USD1, …) with `quote` set |
248
+ | arcus | lastSettled | nextFundingRate | 1 h | base, one side | bid/ask null → `bbo` channel |
249
+
250
+ `stream.capabilities` carries the full matrix: `browser`, `networks`, `channels`, `fields` (false = always
251
+ null) and `notes` (why). lighter-rh has no testnet (`CAPABILITY_UNSUPPORTED`).
252
+
253
+ `BboUpdate`: `bid, bidSize, ask, askSize` (null = that side of the book is empty), `venueTs, recvTs`.
254
+ Venue sequence numbers only drop out-of-order frames (they are not contiguous; bbo never emits gaps).
255
+ Pacifica allows ≤ 20 subscriptions per channel per connection: the stream spreads further `bbo` channels
256
+ over more sockets (≤ 50 per stream; `budget.maxSockets` lowers it; `SUBSCRIPTION_LIMIT` beyond).
257
+ `live.sockets` counts the open ones.
258
+
259
+ ## 5. Funding comparator
260
+
261
+ ```ts
262
+ import { createFundingComparator, type FundingRow, type VenueStream } from "@hypurrquant/perp-ws";
263
+
264
+ declare const streams: VenueStream[];
265
+ const cmp = createFundingComparator({ streams, kind: "next", include: ["BTC", "ETH"], minVenues: 2 });
266
+ const rows: FundingRow[] = cmp.rows();
267
+ ```
268
+
269
+ Options and defaults:
270
+
271
+ | Option | Default | Meaning |
272
+ |---|---|---|
273
+ | `kind` | `"current"` | which rate each leg contributes (below) |
274
+ | `minVenues` | `2` | rows need ≥ this many **distinct venues with a non-null rate** |
275
+ | `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 |
276
+ | `include` | all | allowlist of canonical symbols |
277
+ | `canonicalize` | `defaultCanonicalize` | HIP-3 → `"<dex>:<BASE>"`; USD-like quotes (USDT, USD1, USDC, USD, USDG) → `"<BASE>"`; any other quote → `null` (not compared) |
278
+ | `onControl` | none | the streams' `state` / `error` events and `{type:"listener-error"}` |
279
+
280
+ - `kind`: `"current"` (each leg's `rate`), `"predicted"`, `"lastSettled"`, `"accruing"`, and **`"next"`** —
281
+ per venue the accruing rate when published, else the predicted one. A leg without the requested kind
282
+ has `rate: null, kindUnavailable: true` (never substituted).
283
+ - **`mixedKinds`** is true when a row's legs with a rate use different kinds. With `kind: "next"` it is
284
+ **true by design** whenever an accruing venue (hyperliquid, aster: the current period so far) meets a
285
+ predicted one (pacifica, lighter, lighter-rh, arcus: the venue's estimate of the next payment). Both
286
+ are forward-looking estimates of the coming payment, so the row is usable for ranking; care when you
287
+ need exact like-for-like numbers (e.g. P&L attribution, or an accruing rate early in its period that
288
+ can still move a lot) — then use `kind: "accruing"` or `"predicted"` and accept fewer legs.
289
+ - **Several legs per venue:** one venue can contribute more than one leg to a row. Aster lists BTC as
290
+ `BTCUSDT` (quote USDT) and `BTCUSD1` (quote USD1); both canonicalize to `"BTC"`. Pick one per venue
291
+ before ranking — by `quote`, or by the most liquid market (below) — or pass a `canonicalize` that
292
+ returns `null` for the quotes you don't want.
293
+ - **`fundingVerified`** (per leg): the CLI's verdict that the venue's funding unit and which published
294
+ rate actually settles were confirmed against its funding payments. All six venues in 0.15.0 are
295
+ `true`; a `false` leg (a future venue) is indicative only — the CLI keeps such venues out of funding
296
+ comparison and refuses them as funding-arb legs, so do the same.
297
+ - **Filter illiquid outliers before ranking spreads.** Tiny or newly listed markets often show extreme
298
+ rates (hundreds of % APR) that cannot be traded at size. Require a minimum 24 h notional and/or open
299
+ interest per leg; the comparator legs carry no volume, so read it from the same streams:
300
+
301
+ ```ts
302
+ import {
303
+ createFundingComparator, createVenueStream, aster, hyperliquid, lighter, toHourly,
304
+ type FundingLeg, type FundingRow, type MarketUpdate,
305
+ } from "@hypurrquant/perp-ws";
306
+
307
+ const streams = [hyperliquid, lighter, aster].map((c) => createVenueStream(c));
308
+ // Same wire channel as the comparator's own subscription (ref-counted): no extra venue traffic.
309
+ const notional = new Map<string, number>(); // "<venue>|<symbol>" → 24 h notional
310
+ for (const s of streams) {
311
+ s.subscribe({ kind: "allMarkets" }).on((e) => {
312
+ if (e.type !== "market") return;
313
+ const u: MarketUpdate = e;
314
+ if (u.volume24hNotional !== null) notional.set(`${u.venue}|${u.symbol}`, Number(u.volume24hNotional));
315
+ });
316
+ }
317
+
318
+ const MIN_NOTIONAL = 1_000_000;
319
+ /** One leg per venue: the most liquid market that passes the filter (null volume = unknown → skipped). */
320
+ function pickLegs(row: FundingRow): FundingLeg[] {
321
+ const best = new Map<string, { leg: FundingLeg; vol: number }>();
322
+ for (const leg of row.legs) {
323
+ if (leg.rate === null || leg.stale || !leg.fundingVerified) continue;
324
+ const vol = notional.get(`${leg.venue}|${leg.symbol}`);
325
+ if (vol === undefined || vol < MIN_NOTIONAL) continue;
326
+ const cur = best.get(leg.venue);
327
+ if (cur === undefined || vol > cur.vol) best.set(leg.venue, { leg, vol });
328
+ }
329
+ return Array.from(best.values(), (b) => b.leg);
330
+ }
331
+
332
+ const cmp = createFundingComparator({ streams, kind: "next" });
333
+ cmp.subscribe((rows) => {
334
+ for (const row of rows) {
335
+ const legs = pickLegs(row)
336
+ .map((l) => ({ venue: l.venue, h: toHourly(l.rate, l.funding.rateIntervalHours) }))
337
+ .filter((x): x is { venue: typeof x.venue; h: number } => x.h !== null)
338
+ .sort((a, b) => a.h - b.h);
339
+ const lo = legs[0];
340
+ const hi = legs[legs.length - 1];
341
+ if (lo !== undefined && hi !== undefined && legs.length >= 2) console.log(row.canonical, lo.venue, hi.venue);
342
+ }
343
+ });
344
+ ```
345
+
346
+ ## 6. User (account) channels
347
+
348
+ ```ts
349
+ import { createVenueStream, lighter, type CredentialProvider } from "@hypurrquant/perp-ws";
350
+
351
+ const credentials: CredentialProvider = {
352
+ user: async () => ({ venue: "lighter", accountIndex: 12345 }),
353
+ // Only Lighter's orders channel needs a token. Your backend mints it; the page never sees the key.
354
+ authToken: async ({ purpose, maxTtlSec }) => {
355
+ const qs = `purpose=${encodeURIComponent(purpose)}&ttl=${encodeURIComponent(String(maxTtlSec))}`;
356
+ // Same-origin request carrying the user's session cookie: the endpoint must authenticate the caller.
357
+ const res = await fetch(`/api/lighter-token?${qs}`, { credentials: "same-origin" });
358
+ if (!res.ok) throw new Error(`lighter-token: HTTP ${res.status}`);
359
+ const body = (await res.json()) as { token: string; expiresAt: number }; // expiresAt: epoch ms
360
+ // Only accept a READ-ONLY auth token in the page; a full token could be used to trade.
361
+ if (typeof body.token !== "string" || !body.token.startsWith("ro:")) throw new Error("lighter-token: expected a read-only ro: token");
362
+ return body;
363
+ },
364
+ };
365
+
366
+ // Server side (sketch of the /api/lighter-token handler; your framework and session store will differ).
367
+ interface Session { accountIndex: number }
368
+ declare function getSession(req: Request): Promise<Session | null>; // your auth (cookie → session)
369
+ declare function mintLighterReadOnlyToken(accountIndex: number, ttlSec: number): Promise<string>; // "ro:…"
370
+ const MAX_TOKEN_TTL_SEC = 600; // server-side cap: never trust the client's ttl
371
+ export async function handleLighterToken(req: Request): Promise<Response> {
372
+ const session = await getSession(req);
373
+ if (session === null) return new Response("unauthorized", { status: 401 });
374
+ const url = new URL(req.url);
375
+ // purpose is "<venue>:account_all_orders" (lighter or lighter-rh); refresh follows the expiresAt you return.
376
+ if (!/^lighter(-rh)?:account_all_orders$/.test(url.searchParams.get("purpose") ?? "")) return new Response("bad purpose", { status: 400 });
377
+ const asked = Number(url.searchParams.get("ttl"));
378
+ const ttlSec = Math.min(Number.isFinite(asked) && asked > 0 ? Math.floor(asked) : MAX_TOKEN_TTL_SEC, MAX_TOKEN_TTL_SEC);
379
+ const token = await mintLighterReadOnlyToken(session.accountIndex, ttlSec); // the session's own account only
380
+ return Response.json({ token, expiresAt: Date.now() + ttlSec * 1000 });
381
+ }
382
+ const stream = createVenueStream(lighter, { credentials });
383
+ const sub = stream.subscribe({ kind: "user", channels: ["account", "positions", "orders", "fills"] });
384
+ sub.on((e) => {
385
+ switch (e.type) {
386
+ case "account": console.log(e.equity, e.available); break;
387
+ case "positions": console.log(e.positions.length, e.snapshot); break;
388
+ case "orders": console.log(e.orders.length, e.complete); break;
389
+ case "fills": console.log("snapshot batch", e.fills.length); break;
390
+ case "fill": console.log(e.fillId, e.fill.side, e.fill.size, e.fill.price, e.fill.clientId); break;
391
+ case "gap": console.log("refetch over REST:", e.channel); break;
392
+ default: break;
393
+ }
394
+ });
395
+ ```
396
+
397
+ - **The library never holds keys.** `CredentialProvider` is async callbacks: `user()` (the account
398
+ identity), `authToken({ purpose, maxTtlSec })` (Lighter `account_all_orders`; re-called at 80 % of the
399
+ TTL with backoff retries; a read-only `ro:` token is recommended in browsers), `hmacSha256` /
400
+ `signTypedData` (reserved for 0.15.1 venues). Tokens handed in are redacted from every error, log and
401
+ raw excerpt.
402
+ - `allowInPageSecrets: true` is required for venues whose auth needs a signing secret in the page
403
+ (Aster / Ondo / QFEX, 0.15.1). Without it they reject `CAPABILITY_UNSUPPORTED` in a browser.
404
+ - `UserRef` per venue: hyperliquid `{ address }` (no auth), pacifica `{ account }` (Solana address, no
405
+ auth), lighter / lighter-rh `{ accountIndex }`, arcus `{ address, subaccount }` (`null` = account 0, no auth).
406
+ - **KNOWN GAP (0.15.0): Aster user channels are not implemented** — `kind: "user"` on aster rejects
407
+ `CAPABILITY_UNSUPPORTED` (no parity; 0.15.1).
408
+
409
+ Events:
410
+ - `account` — `equity, available, marginUsed, unrealizedPnl` (+ `dex` on hyperliquid, one per perp dex);
411
+ `source: "snapshot"` when the frame was a full snapshot.
412
+ - `positions` / `orders` — always the **full current set** (snapshot + merged deltas). Pacifica
413
+ `account_positions` snapshots older than the last applied one (envelope `li`) are dropped.
414
+ `orders.complete` = every part of the set came from a venue snapshot. **Pacifica sends no order
415
+ snapshot: `complete` stays `false` — refetch open orders over REST.**
416
+ - `fills` (`snapshot: true`) — ONE event per venue fill snapshot (HL / Arcus replay recent fills on every
417
+ (re)subscribe; up to 2000), only fills not seen before. `fill` (`snapshot: false`) — one live fill.
418
+ De-duplicated in-library by `fillId` + side + order id (both legs of a self-trade are kept).
419
+ - `gap { channel, action: "refetch-required" }` — the library cannot give a complete picture; refetch
420
+ over REST: after a reconnect on non-replayable channels (Pacifica fills + orders, Lighter fills), at
421
+ subscribe for Pacifica orders, and when a replayed fill snapshot does not reach back to the fills seen
422
+ before (outage longer than the replay window).
423
+
424
+ `clientId`, fees and liquidation price per venue:
425
+
426
+ | Venue | `OrderRow.clientId` | `FillRow.clientId` | `FillRow.fee` | `PositionRow.liquidationPrice` |
427
+ |---|---|---|---|---|
428
+ | hyperliquid | cloid (null if none) | cloid only when the frame carries one (not documented) → join by `orderId` | fee in `feeToken` | liquidationPx (null when none) |
429
+ | pacifica | `I` | `I` | `f` (feeToken null) | `l` (null when not applicable) |
430
+ | 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 |
431
+ | arcus | clientId | clientId | `fee` (feeToken null) | always null (not published) |
432
+
433
+ Mark price in `PositionRow` is null except on Arcus — join the `MarketUpdate` by symbol.
434
+
435
+ ## 7. Errors
436
+
437
+ `StreamControl { type: "error", code, message, remediation, raw (redacted ≤ 200 chars), fatal }` — always
438
+ 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? }`.
440
+
441
+ Where an error surfaces:
442
+ - **Thrown synchronously** (`WsError`, `INVALID_PARAMS`) — bad options, before anything starts:
443
+ `createVenueStream` (codec not a codec export, unknown `network`, bad `buffer.size`, no `WebSocket` /
444
+ `fetch` in the runtime and none passed) and `createFundingComparator` (unknown `kind`, bad `streams`,
445
+ `minVenues`, `include`, `staleAfterMs`, or a non-function `canonicalize` / `onControl` / `clock`).
446
+ - **`ready` rejects** (and the subscription ends with a fatal error event) — per-subscription problems:
447
+ unknown symbol / channel, `book` / `trades` or an unsupported network (`CAPABILITY_UNSUPPORTED`), budget
448
+ limits, credentials, REST bootstrap failures, ack timeout, a closed stream.
449
+ - **Error events** (`{ type: "error" }` via `on()` / the iterator / comparator `onControl`) — everything
450
+ after `ready`: malformed frames, token-refresh retries, reconnects, overflow.
451
+
452
+ | Code | Meaning / what to do |
453
+ |---|---|
454
+ | `WS_PROTOCOL_ERROR` | malformed / unrecognised frame, or no ack — reported, never silently dropped |
455
+ | `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 |
456
+ | `SUBSCRIPTION_LIMIT` | venue or `budget` limit (`details.needed` / `allowed`) — narrow the subscription |
457
+ | `STREAM_OVERFLOW` | iterator buffer full — consume faster, use `on()`, raise `buffer.size` |
458
+ | `CAPABILITY_UNSUPPORTED` | channel / network not available for the venue (see `capabilities.notes`) |
459
+ | `EXCHANGE_UNREACHABLE` | network / outage / handshake refused (reconnects automatically) |
460
+ | `INVALID_PARAMS` | bad symbol, dex, channel or option |
461
+ | `SYMBOL_NOT_FOUND` | non-fatal: requested `canonicals` this venue does not list (`details.missing`, `details.suggestions`); the rest stream |
462
+ | `RATE_LIMITED`, `EXCHANGE_ERROR` | REST bootstrap failures |
463
+
464
+ ## 8. Budgets and limits
465
+
466
+ - `budget: { maxSubscriptions, maxSockets, newConnectionsPerMin }` caps below the venue limits (checked
467
+ before sending). One socket per stream, except venues whose caps are per connection (Pacifica: 20 per
468
+ channel per connection, 100 connections per IP → up to 50 sockets per stream). The new-connection
469
+ budget is per stream (a socket waiting on it is `pending`; it never opens once its channels are
470
+ released); one socket exhausting its reconnects closes the stream.
471
+ - **Per-IP limits are shared by every tab and app on one IP** (HL: 10 connections, 1000 subscriptions,
472
+ 30 new connections/min, 10 users). Several tabs of one dashboard share them — keep one stream per venue
473
+ per page.
474
+ - HL `allMarkets` is a single `allDexsAssetCtxs` subscription; `hip3Dexes: "all"` adds every HIP-3 dex.
475
+
476
+ ## 9. Do / don't
477
+
478
+ - **Do** treat every field as nullable; `null ≠ 0`. Don't default missing data to 0.
479
+ - **Do** compare funding per hour (`toHourly`) and like-for-like kinds (comparator `kind`); **don't**
480
+ annualize in your data layer (`toApr` is for display).
481
+ - **Do** check `quote` before comparing (Aster lists USDT, USD1 and other quotes per base).
482
+ - **Don't** add a REST fallback when a field is null or a channel is unsupported — the capability matrix
483
+ says why; show it as unknown.
484
+ - **Do** handle `gap → refetch-required` (refetch fills / open orders over REST) and `orders.complete`.
485
+ - **Do** respect `SUBSCRIPTION_LIMIT` and the per-IP note above; coalesce UI updates (~1/s).
486
+ - **Don't** pass private keys to the library; return short-lived tokens from `authToken()`.
487
+ - **Do** use `canonicals` for the same markets on several venues; `symbols` are per-venue directory symbols
488
+ (`["BTC"]` rejects on aster and arcus). Show the `SYMBOL_NOT_FOUND` warning (which canonicals a venue
489
+ lacks) instead of an empty cell.
490
+ - **Do** surface every `ready` rejection and every `fatal` error event per venue (see the price-wall
491
+ pattern in §2). **Never treat `idle` (or a venue with no data) as fine**: `idle` means the stream has no
492
+ active subscription — usually because a subscribe was rejected — not "connected and quiet".