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.
Files changed (363) hide show
  1. package/README.ja.md +243 -0
  2. package/README.ko.md +243 -0
  3. package/README.md +138 -405
  4. package/README.zh.md +243 -0
  5. package/dist/agent-wallet/store.d.ts +3 -2
  6. package/dist/agent-wallet/store.js +2 -2
  7. package/dist/api/public/hyperliquid.js +4 -0
  8. package/dist/api/public/lighter.d.ts +5 -0
  9. package/dist/api/public/lighter.js +5 -0
  10. package/dist/api/public/pacifica.js +85 -15
  11. package/dist/api/public/urls.d.ts +2 -0
  12. package/dist/api/public/urls.js +3 -0
  13. package/dist/arb/sizing.js +4 -2
  14. package/dist/arb/state.d.ts +6 -0
  15. package/dist/arb/utils.d.ts +1 -2
  16. package/dist/arb/utils.js +1 -2
  17. package/dist/balance-by-address.d.ts +39 -0
  18. package/dist/balance-by-address.js +44 -0
  19. package/dist/bot/apex/pulse.js +3 -0
  20. package/dist/bot/apex/radar.js +2 -1
  21. package/dist/bot/engine.d.ts +10 -1
  22. package/dist/bot/engine.js +14 -6
  23. package/dist/bot/indicators.d.ts +7 -0
  24. package/dist/bot/indicators.js +16 -0
  25. package/dist/bot/perp-order.d.ts +4 -4
  26. package/dist/bot/perp-order.js +5 -5
  27. package/dist/bot/quarantine.d.ts +1 -1
  28. package/dist/bot/quarantine.js +1 -1
  29. package/dist/bot/quote-rules.js +1 -1
  30. package/dist/bot/strategies/apex-strategy.js +3 -2
  31. package/dist/bot/strategies/engine-mm.js +6 -5
  32. package/dist/bot/strategies/funding-arb-strategy.js +12 -5
  33. package/dist/bot/strategies/funding-arb-utils.d.ts +15 -3
  34. package/dist/bot/strategies/funding-arb-utils.js +53 -20
  35. package/dist/bot/strategies/funding-auto.js +2 -2
  36. package/dist/bot/strategies/liquidation-mm.d.ts +1 -0
  37. package/dist/bot/strategies/liquidation-mm.js +8 -2
  38. package/dist/bot/strategies/momentum-breakout.d.ts +1 -0
  39. package/dist/bot/strategies/momentum-breakout.js +11 -3
  40. package/dist/bot/strategies/spot-perp-arb-strategy.d.ts +1 -1
  41. package/dist/bot/strategies/spot-perp-arb-strategy.js +6 -4
  42. package/dist/bot/strategies/spot-perp-executor.d.ts +1 -1
  43. package/dist/bot/strategies/spot-perp-executor.js +3 -3
  44. package/dist/bot/strategy-types.d.ts +2 -1
  45. package/dist/bot/tui/BotDashboard.d.ts +1 -1
  46. package/dist/bot/tui/BotDashboard.js +1 -1
  47. package/dist/cache.js +12 -6
  48. package/dist/cli-program.d.ts +15 -0
  49. package/dist/cli-program.js +126 -6
  50. package/dist/commands/account.js +168 -24
  51. package/dist/commands/agent-import.d.ts +7 -0
  52. package/dist/commands/agent-import.js +30 -1
  53. package/dist/commands/agent.d.ts +6 -3
  54. package/dist/commands/agent.js +160 -12
  55. package/dist/commands/arb/index.js +23 -81
  56. package/dist/commands/arb-auto.d.ts +24 -0
  57. package/dist/commands/arb-auto.js +175 -97
  58. package/dist/commands/arb.d.ts +20 -0
  59. package/dist/commands/arb.js +107 -163
  60. package/dist/commands/backtest.js +23 -21
  61. package/dist/commands/bot.js +1 -1
  62. package/dist/commands/builder.js +1 -1
  63. package/dist/commands/funds.js +113 -3
  64. package/dist/commands/init.d.ts +1 -1
  65. package/dist/commands/init.js +27 -7
  66. package/dist/commands/manage.js +23 -21
  67. package/dist/commands/market.d.ts +48 -0
  68. package/dist/commands/market.js +395 -100
  69. package/dist/commands/options.d.ts +14 -0
  70. package/dist/commands/options.js +151 -0
  71. package/dist/commands/pacifica-earn.d.ts +5 -0
  72. package/dist/commands/pacifica-earn.js +455 -0
  73. package/dist/commands/pacifica-orders.d.ts +14 -0
  74. package/dist/commands/pacifica-orders.js +142 -0
  75. package/dist/commands/portfolio.d.ts +16 -0
  76. package/dist/commands/portfolio.js +83 -55
  77. package/dist/commands/rebalance.d.ts +1 -0
  78. package/dist/commands/rebalance.js +15 -0
  79. package/dist/commands/risk.d.ts +11 -2
  80. package/dist/commands/risk.js +39 -44
  81. package/dist/commands/trade.d.ts +1 -0
  82. package/dist/commands/trade.js +67 -62
  83. package/dist/commands/wallet.js +1 -1
  84. package/dist/config.d.ts +2 -2
  85. package/dist/config.js +15 -4
  86. package/dist/constants.js +6 -0
  87. package/dist/cross-chain-margin.d.ts +3 -1
  88. package/dist/cross-chain-margin.js +4 -2
  89. package/dist/dashboard/server.js +3 -3
  90. package/dist/dashboard/ui.js +9 -6
  91. package/dist/errors.d.ts +29 -1
  92. package/dist/errors.js +35 -3
  93. package/dist/exchanges/address.d.ts +8 -0
  94. package/dist/exchanges/address.js +47 -0
  95. package/dist/exchanges/arcus/adapter.d.ts +16 -12
  96. package/dist/exchanges/arcus/adapter.js +45 -18
  97. package/dist/exchanges/arcus/agent.d.ts +1 -1
  98. package/dist/exchanges/arcus/agent.js +1 -1
  99. package/dist/exchanges/arcus/client.js +1 -1
  100. package/dist/exchanges/arcus/public.js +3 -2
  101. package/dist/exchanges/arcus/signing.d.ts +2 -3
  102. package/dist/exchanges/arcus/signing.js +1 -1
  103. package/dist/exchanges/arcus/spot-chain.d.ts +1 -1
  104. package/dist/exchanges/arcus/spot-chain.js +1 -1
  105. package/dist/exchanges/arcus/spot-router.d.ts +1 -1
  106. package/dist/exchanges/arcus/spot-router.js +2 -2
  107. package/dist/exchanges/arcus/spot.d.ts +2 -3
  108. package/dist/exchanges/arcus/spot.js +2 -3
  109. package/dist/exchanges/aster-spot.d.ts +3 -3
  110. package/dist/exchanges/aster-spot.js +6 -9
  111. package/dist/exchanges/aster-typed-data.d.ts +1 -1
  112. package/dist/exchanges/aster-typed-data.js +1 -1
  113. package/dist/exchanges/aster.d.ts +30 -10
  114. package/dist/exchanges/aster.js +153 -53
  115. package/dist/exchanges/builder-fee.d.ts +1 -1
  116. package/dist/exchanges/builder-fee.js +13 -2
  117. package/dist/exchanges/canonical-symbols.d.ts +8 -2
  118. package/dist/exchanges/canonical-symbols.js +46 -2
  119. package/dist/exchanges/capabilities.d.ts +2 -0
  120. package/dist/exchanges/capabilities.js +2 -1
  121. package/dist/exchanges/derive/client.d.ts +37 -0
  122. package/dist/exchanges/derive/client.js +135 -0
  123. package/dist/exchanges/derive/options.d.ts +73 -0
  124. package/dist/exchanges/derive/options.js +379 -0
  125. package/dist/exchanges/extended/adapter.d.ts +96 -0
  126. package/dist/exchanges/extended/adapter.js +241 -0
  127. package/dist/exchanges/extended/client.d.ts +38 -0
  128. package/dist/exchanges/extended/client.js +88 -0
  129. package/dist/exchanges/extended/public.d.ts +133 -0
  130. package/dist/exchanges/extended/public.js +436 -0
  131. package/dist/exchanges/grvt/adapter.d.ts +100 -0
  132. package/dist/exchanges/grvt/adapter.js +283 -0
  133. package/dist/exchanges/grvt/client.d.ts +25 -0
  134. package/dist/exchanges/grvt/client.js +86 -0
  135. package/dist/exchanges/grvt/public.d.ts +151 -0
  136. package/dist/exchanges/grvt/public.js +438 -0
  137. package/dist/exchanges/hyperliquid-outcome.d.ts +2 -0
  138. package/dist/exchanges/hyperliquid-outcome.js +29 -1
  139. package/dist/exchanges/hyperliquid-spot.d.ts +9 -1
  140. package/dist/exchanges/hyperliquid-spot.js +24 -16
  141. package/dist/exchanges/hyperliquid.d.ts +199 -22
  142. package/dist/exchanges/hyperliquid.js +550 -202
  143. package/dist/exchanges/index.d.ts +1 -1
  144. package/dist/exchanges/index.js +1 -1
  145. package/dist/exchanges/interface.d.ts +157 -13
  146. package/dist/exchanges/interface.js +47 -0
  147. package/dist/exchanges/ioc-outcome.d.ts +1 -1
  148. package/dist/exchanges/ioc-outcome.js +1 -1
  149. package/dist/exchanges/legacy-trading-v2.d.ts +2 -2
  150. package/dist/exchanges/legacy-trading-v2.js +7 -2
  151. package/dist/exchanges/lighter-public.d.ts +67 -12
  152. package/dist/exchanges/lighter-public.js +250 -82
  153. package/dist/exchanges/lighter-signer.d.ts +1 -1
  154. package/dist/exchanges/lighter-signer.js +1 -1
  155. package/dist/exchanges/lighter-spot.d.ts +10 -5
  156. package/dist/exchanges/lighter-spot.js +105 -97
  157. package/dist/exchanges/lighter.d.ts +80 -21
  158. package/dist/exchanges/lighter.js +312 -105
  159. package/dist/exchanges/ondo/adapter.d.ts +38 -13
  160. package/dist/exchanges/ondo/adapter.js +86 -27
  161. package/dist/exchanges/ondo/agent.d.ts +1 -1
  162. package/dist/exchanges/ondo/agent.js +1 -1
  163. package/dist/exchanges/ondo/client.d.ts +1 -1
  164. package/dist/exchanges/ondo/client.js +2 -2
  165. package/dist/exchanges/ondo/public.d.ts +35 -3
  166. package/dist/exchanges/ondo/public.js +75 -32
  167. package/dist/exchanges/ondo/spot.d.ts +2 -2
  168. package/dist/exchanges/ondo/spot.js +3 -3
  169. package/dist/exchanges/options-interface.d.ts +158 -0
  170. package/dist/exchanges/options-interface.js +17 -0
  171. package/dist/exchanges/options-venues.d.ts +12 -0
  172. package/dist/exchanges/options-venues.js +35 -0
  173. package/dist/exchanges/outcome-interface.d.ts +4 -0
  174. package/dist/exchanges/pacifica-atomic.d.ts +211 -0
  175. package/dist/exchanges/pacifica-atomic.js +440 -0
  176. package/dist/exchanges/pacifica-decimal.d.ts +10 -0
  177. package/dist/exchanges/pacifica-decimal.js +51 -0
  178. package/dist/exchanges/pacifica-presigned.d.ts +53 -0
  179. package/dist/exchanges/pacifica-presigned.js +141 -0
  180. package/dist/exchanges/pacifica-print.d.ts +200 -0
  181. package/dist/exchanges/pacifica-print.js +417 -0
  182. package/dist/exchanges/pacifica-spot.d.ts +8 -2
  183. package/dist/exchanges/pacifica-spot.js +26 -18
  184. package/dist/exchanges/pacifica-typed-data.d.ts +1 -1
  185. package/dist/exchanges/pacifica-typed-data.js +1 -1
  186. package/dist/exchanges/pacifica-vaults.d.ts +130 -0
  187. package/dist/exchanges/pacifica-vaults.js +277 -0
  188. package/dist/exchanges/pacifica.d.ts +108 -10
  189. package/dist/exchanges/pacifica.js +428 -171
  190. package/dist/exchanges/precision.d.ts +3 -1
  191. package/dist/exchanges/precision.js +9 -2
  192. package/dist/exchanges/public-balances.d.ts +25 -0
  193. package/dist/exchanges/public-balances.js +158 -0
  194. package/dist/exchanges/public-positions.d.ts +43 -0
  195. package/dist/exchanges/public-positions.js +154 -0
  196. package/dist/exchanges/qfex/adapter.d.ts +69 -13
  197. package/dist/exchanges/qfex/adapter.js +102 -17
  198. package/dist/exchanges/qfex/agent.d.ts +1 -1
  199. package/dist/exchanges/qfex/agent.js +1 -1
  200. package/dist/exchanges/qfex/client.d.ts +1 -1
  201. package/dist/exchanges/qfex/client.js +1 -1
  202. package/dist/exchanges/qfex/errors.d.ts +1 -1
  203. package/dist/exchanges/qfex/errors.js +2 -2
  204. package/dist/exchanges/qfex/public.d.ts +65 -12
  205. package/dist/exchanges/qfex/public.js +131 -25
  206. package/dist/exchanges/qfex/signing.d.ts +2 -2
  207. package/dist/exchanges/qfex/signing.js +4 -5
  208. package/dist/exchanges/qfex/ws-transport.d.ts +3 -3
  209. package/dist/exchanges/qfex/ws-transport.js +3 -3
  210. package/dist/exchanges/rate-limiter.d.ts +1 -1
  211. package/dist/exchanges/rate-limiter.js +5 -2
  212. package/dist/exchanges/registry.d.ts +1 -1
  213. package/dist/exchanges/registry.js +1 -1
  214. package/dist/exchanges/risex/adapter.d.ts +160 -0
  215. package/dist/exchanges/risex/adapter.js +842 -0
  216. package/dist/exchanges/risex/agent.d.ts +154 -0
  217. package/dist/exchanges/risex/agent.js +384 -0
  218. package/dist/exchanges/risex/client.d.ts +47 -0
  219. package/dist/exchanges/risex/client.js +174 -0
  220. package/dist/exchanges/risex/nonce.d.ts +43 -0
  221. package/dist/exchanges/risex/nonce.js +212 -0
  222. package/dist/exchanges/risex/public.d.ts +64 -0
  223. package/dist/exchanges/risex/public.js +150 -0
  224. package/dist/exchanges/risex/session.d.ts +71 -0
  225. package/dist/exchanges/risex/session.js +171 -0
  226. package/dist/exchanges/risex/typed-data.d.ts +173 -0
  227. package/dist/exchanges/risex/typed-data.js +240 -0
  228. package/dist/exchanges/risex/units.d.ts +29 -0
  229. package/dist/exchanges/risex/units.js +104 -0
  230. package/dist/exchanges/slippage.d.ts +1 -1
  231. package/dist/exchanges/slippage.js +1 -1
  232. package/dist/exchanges/spot-factory.d.ts +3 -3
  233. package/dist/exchanges/spot-factory.js +8 -8
  234. package/dist/exchanges/spot-interface.d.ts +5 -5
  235. package/dist/exchanges/spot-interface.js +2 -2
  236. package/dist/exchanges/spot-result.d.ts +3 -3
  237. package/dist/exchanges/spot-result.js +3 -3
  238. package/dist/exchanges/trading-v2.d.ts +3 -3
  239. package/dist/exchanges/trading-v2.js +9 -6
  240. package/dist/exchanges/txflow/adapter.d.ts +61 -0
  241. package/dist/exchanges/txflow/adapter.js +150 -0
  242. package/dist/exchanges/txflow/client.d.ts +47 -0
  243. package/dist/exchanges/txflow/client.js +155 -0
  244. package/dist/exchanges/txflow/public.d.ts +87 -0
  245. package/dist/exchanges/txflow/public.js +355 -0
  246. package/dist/exchanges/unknown-symbol.d.ts +34 -0
  247. package/dist/exchanges/unknown-symbol.js +56 -0
  248. package/dist/exchanges/variational/adapter.d.ts +67 -0
  249. package/dist/exchanges/variational/adapter.js +137 -0
  250. package/dist/exchanges/variational/client.d.ts +21 -0
  251. package/dist/exchanges/variational/client.js +66 -0
  252. package/dist/exchanges/variational/public.d.ts +126 -0
  253. package/dist/exchanges/variational/public.js +430 -0
  254. package/dist/exchanges/venue-types.d.ts +52 -10
  255. package/dist/exchanges/venue-types.js +82 -11
  256. package/dist/exchanges/venue.d.ts +150 -11
  257. package/dist/exchanges/venue.js +126 -4
  258. package/dist/exchanges/venues/arcus.d.ts +1 -2
  259. package/dist/exchanges/venues/arcus.js +5 -4
  260. package/dist/exchanges/venues/aster.d.ts +2 -2
  261. package/dist/exchanges/venues/aster.js +6 -4
  262. package/dist/exchanges/venues/create-helpers.d.ts +1 -1
  263. package/dist/exchanges/venues/create-helpers.js +3 -3
  264. package/dist/exchanges/venues/extended.d.ts +21 -0
  265. package/dist/exchanges/venues/extended.js +96 -0
  266. package/dist/exchanges/venues/grvt.d.ts +30 -0
  267. package/dist/exchanges/venues/grvt.js +98 -0
  268. package/dist/exchanges/venues/hyperliquid.d.ts +2 -2
  269. package/dist/exchanges/venues/hyperliquid.js +19 -8
  270. package/dist/exchanges/venues/index.d.ts +7 -2
  271. package/dist/exchanges/venues/index.js +8 -2
  272. package/dist/exchanges/venues/lighter-family.d.ts +7 -9
  273. package/dist/exchanges/venues/lighter-family.js +9 -11
  274. package/dist/exchanges/venues/lighter-rh.d.ts +2 -2
  275. package/dist/exchanges/venues/lighter-rh.js +20 -8
  276. package/dist/exchanges/venues/lighter.d.ts +1 -1
  277. package/dist/exchanges/venues/lighter.js +18 -6
  278. package/dist/exchanges/venues/ondo.d.ts +1 -1
  279. package/dist/exchanges/venues/ondo.js +5 -3
  280. package/dist/exchanges/venues/pacifica.d.ts +2 -2
  281. package/dist/exchanges/venues/pacifica.js +15 -4
  282. package/dist/exchanges/venues/qfex.d.ts +1 -1
  283. package/dist/exchanges/venues/qfex.js +4 -2
  284. package/dist/exchanges/venues/risex-release.d.ts +14 -0
  285. package/dist/exchanges/venues/risex-release.js +14 -0
  286. package/dist/exchanges/venues/risex.d.ts +31 -0
  287. package/dist/exchanges/venues/risex.js +131 -0
  288. package/dist/exchanges/venues/txflow.d.ts +18 -0
  289. package/dist/exchanges/venues/txflow.js +77 -0
  290. package/dist/exchanges/venues/variational.d.ts +21 -0
  291. package/dist/exchanges/venues/variational.js +85 -0
  292. package/dist/execution-log.d.ts +1 -1
  293. package/dist/funding/legacy-public.d.ts +6 -4
  294. package/dist/funding/legacy-public.js +42 -41
  295. package/dist/funding/normalize.d.ts +1 -1
  296. package/dist/funding/normalize.js +1 -1
  297. package/dist/funding/rates.d.ts +95 -6
  298. package/dist/funding/rates.js +99 -19
  299. package/dist/index.js +7 -6
  300. package/dist/jobs.d.ts +1 -0
  301. package/dist/jobs.js +25 -1
  302. package/dist/landing.d.ts +2 -0
  303. package/dist/landing.js +1 -1
  304. package/dist/liquidity.d.ts +3 -1
  305. package/dist/liquidity.js +8 -4
  306. package/dist/mcp-pacifica-earn.d.ts +9 -0
  307. package/dist/mcp-pacifica-earn.js +64 -0
  308. package/dist/mcp-server.js +211 -68
  309. package/dist/pacifica/client.d.ts +69 -5
  310. package/dist/pacifica/client.js +181 -8
  311. package/dist/pacifica/rate-limit.d.ts +144 -0
  312. package/dist/pacifica/rate-limit.js +267 -0
  313. package/dist/pacifica/types/order.d.ts +2 -1
  314. package/dist/plan-executor.js +7 -2
  315. package/dist/positions-by-address.d.ts +34 -0
  316. package/dist/positions-by-address.js +46 -0
  317. package/dist/rebalance.js +1 -1
  318. package/dist/risk.d.ts +2 -1
  319. package/dist/risk.js +2 -4
  320. package/dist/settings.d.ts +32 -3
  321. package/dist/signer/ows-evm.d.ts +1 -1
  322. package/dist/signer/ows-evm.js +1 -1
  323. package/dist/smart-order.d.ts +1 -1
  324. package/dist/smart-order.js +1 -1
  325. package/dist/strategies/funding-arb.d.ts +1 -1
  326. package/dist/strategies/funding-arb.js +36 -63
  327. package/dist/trade-validator.js +2 -2
  328. package/dist/utils.d.ts +22 -3
  329. package/dist/utils.js +11 -3
  330. package/dist/venue-health.d.ts +40 -0
  331. package/dist/venue-health.js +75 -0
  332. package/dist/withdraw-gate.d.ts +1 -1
  333. package/dist/withdraw-gate.js +1 -1
  334. package/dist/ws/arcus-market.d.ts +9 -0
  335. package/dist/ws/arcus-market.js +9 -0
  336. package/dist/ws/aster-market.d.ts +33 -0
  337. package/dist/ws/aster-market.js +30 -0
  338. package/dist/ws/index.d.ts +980 -0
  339. package/dist/ws/index.js +4460 -0
  340. package/dist/ws/lighter-market.d.ts +14 -0
  341. package/dist/ws/lighter-market.js +13 -0
  342. package/dist/ws/node.d.ts +996 -0
  343. package/dist/ws/node.js +4554 -0
  344. package/dist/ws/pacifica-instrument.d.ts +8 -0
  345. package/dist/ws/pacifica-instrument.js +8 -0
  346. package/dist/ws/unclassified.d.ts +16 -0
  347. package/dist/ws/unclassified.js +43 -0
  348. package/dist/ws-cli/credentials.d.ts +62 -0
  349. package/dist/ws-cli/credentials.js +126 -0
  350. package/package.json +71 -10
  351. package/skills/perp-cli/SKILL.md +153 -36
  352. package/skills/perp-cli/package.json +15 -2
  353. package/skills/perp-cli/references/agent-operations.md +22 -12
  354. package/skills/perp-cli/references/commands.md +143 -17
  355. package/skills/perp-cli/references/strategies.md +38 -3
  356. package/skills/perp-cli/references/ws-library.md +523 -0
  357. package/skills/perp-cli/scripts/arb-monitor.sh +13 -10
  358. package/skills/perp-cli/scripts/funding-analysis.sh +57 -29
  359. package/skills/perp-cli/scripts/preflight.sh +27 -19
  360. package/skills/perp-cli/scripts/spot-perp-scan.sh +58 -38
  361. package/skills/perp-cli/scripts/validate-arb.sh +56 -39
  362. package/dist/cli-spec.d.ts +0 -46
  363. 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
- # Auto-detect perp command (supports npx fallback for agents without install permissions)
10
- if command -v perp &>/dev/null; then
11
- PERP="perp"
12
- else
13
- PERP="npx -y perp-cli@latest"
9
+ # perp must be installed (no npx / auto-install path: the user decides what gets installed).
10
+ if ! command -v perp &>/dev/null; then
11
+ echo "perp not found — ask the user to install perp-cli >= 0.16.0" >&2
12
+ echo '{"ok":false,"error":{"code":"PERP_NOT_FOUND","message":"perp not found — ask the user to install perp-cli >= 0.16.0"}}'
13
+ exit 1
14
14
  fi
15
+ PERP="perp"
15
16
 
16
17
  # perp exits 1 on {"ok":false} and prints exactly one JSON document: capture it as-is
17
- # (never append a fallback document); empty output (crash / not installed) → {"ok":false}.
18
- pj() { local out; out=$($PERP "$@" 2>/dev/null) || true; if [[ -n "$out" ]]; then printf '%s\n' "$out"; else echo '{"ok":false}'; fi; }
18
+ # (never append a fallback document); empty output (crash) → an explicit NO_OUTPUT error document.
19
+ pj() { local out; out=$($PERP "$@" 2>/dev/null) || true; if [[ -n "$out" ]]; then printf '%s\n' "$out"; else echo '{"ok":false,"error":{"code":"NO_OUTPUT","message":"perp printed nothing (crashed?)"}}'; fi; }
19
20
  isok() { jq -e '.ok == true' >/dev/null 2>&1 <<<"$1"; }
20
21
 
21
22
  JSON_MODE=false
@@ -30,13 +31,15 @@ done
30
31
 
31
32
  # 1. Get current arb status (already includes funding earned per period)
32
33
  ARB_STATUS=$(pj --json arb status)
34
+ # A failed read is an error, never "no positions" (arb status fails as a whole when one
35
+ # configured venue's read fails — check that venue: perp --json -e <venue> account positions).
33
36
  if ! isok "$ARB_STATUS"; then
34
37
  if $JSON_MODE; then
35
- echo '{"ok":false,"error":"No arb positions or arb status unavailable"}'
38
+ jq -c '{ok:false, error:{code:.error.code, message:.error.message, source:"perp --json arb status"}}' <<<"$ARB_STATUS"
36
39
  else
37
- echo "No open arb positions found."
40
+ echo "ERROR: arb status failed: $(jq -r '"\(.error.code): \(.error.message)"' <<<"$ARB_STATUS")" >&2
38
41
  fi
39
- exit 0
42
+ exit 1
40
43
  fi
41
44
 
42
45
  # 2. Get current spreads (all modes)