Skip to content

CORE-647: Hyperliquid WS tickers — bbo bid/ask, allMids+allDexsAssetCtxs, scoped error handling#5

Closed
ntemireyeva wants to merge 11 commits into
masterfrom
task/CORE-647-hl-ws-tickers
Closed

CORE-647: Hyperliquid WS tickers — bbo bid/ask, allMids+allDexsAssetCtxs, scoped error handling#5
ntemireyeva wants to merge 11 commits into
masterfrom
task/CORE-647-hl-ws-tickers

Conversation

@ntemireyeva

Copy link
Copy Markdown

Why

Hyperliquid removed the webData2 WS channel in its 2026-07-11 network upgrade. This fork's watchTickers still subscribed to it, so every HL ticker stream dies at connect (the error frame also blanket-rejected all in-flight futures and could panic the generated Go client). This PR rebuilds HL ticker data on live channels and hardens the WS error path.

What

  • watchBidsAsks (new): real best bid/ask via per-coin bbo subscriptions (perp + spot, mixed in one call). All subscriptions resolve through one shared bidsasks future — deliberately NOT per-symbol futures: in the generated Go, per-symbol FutureRace wrappers never unlink losing subscribers, which leaks blocked goroutines in hot consumer loops (verified against a 5-minute 100-symbol concurrent-loop run: goroutine count flat). Steady-state calls allocate exactly one future. unWatchBidsAsks awaits each coin's own unsubscribe:bbo:<coin> ack sequentially.
  • watchTickers rework: default = allMids (last/mid, all coins incl. spot @N indices via a new spot index map) + allDexsAssetCtxs (perp dayNtlVlm/dayBaseVlm/prevDayPx, zipped by index against the perp universe with a length guard — mismatched frames are dropped and a guarded forced loadMarkets(true) self-heal re-captures the universe). Field-level merge into this.tickers builds a fresh object per update (no in-place mutation — avoids map races in Go consumers). channel: 'allMids' subscribes mids only; channel: 'webData2' now throws NotSupported (breaking for anyone passing it explicitly).
  • WS error handling: HL error frames are parsed for the embedded request and reject only the affected subscription's future — method-aware (subscribe vs unsubscribe), dex-aware, with ExchangeError instances (never raw strings — the Go Client.Reject type-asserts err.(error)). Unattributable/unparseable frames log and reject nothing. Failed subscription keys are cleaned so retries re-send frames instead of hanging.
  • Tests: new WS handler fixture suite (npm run test-hyperliquid-ws, 30 assertions over captured live frames: bbo perp/spot parse, allMids merge invariants, ctxs length-mismatch drop, scoped/method-aware error attribution). test-types-go and test-base-ws-go green. Live-verified against wss://api.hyperliquid.xyz/ws throughout (streaming, unwatch/rewatch cycles, mixed-type calls, dead-channel rejection).
  • Go build regenerated from TS (transpileGO); one TS accommodation: String.lastIndexOf has no transpiler mapping, replaced with a forward scan (semantics identical).

Notes for reviewers

  • ts/src/pro/hyperliquid.ts + ts/src/hyperliquid.ts are the human-authored changes; the 4 go/ files are transpiler output.
  • The new fixture suite is wired as an npm script but not yet added to CI workflows — follow-up candidate.
  • Consumer note: watchBidsAsks resolves per bbo update with a one-entry dict; when filtering by a requested symbol subset the result can be an empty dict — consumers should loop.

🤖 Generated with Claude Code

ntemireyeva and others added 11 commits July 14, 2026 00:05
- Map dex-scoped allMids/allDexsAssetCtxs/webData2 error frames to
  messageHash 'tickers:<dex>' and subscribeHash '<subType>:<dex>' when
  the embedded subscription request carries a dex, matching the
  dex-aware registration scheme used by watchTickers.
- Bound the embedded-JSON slice by the last closing brace instead of
  taking everything after the first '{', so trailing text after the
  request payload no longer breaks parsing.
- Guard the bbo/l2Book branches: log and return early when the
  embedded subscription has no coin instead of synthesizing a bogus
  market/messageHash from an undefined coin.
- Move the ExchangeError construction past the bracketIndex/parseJson
  early-return checks so it isn't built and discarded on log-only
  paths.
- drop sameTypeOnly (4th arg) from marketSymbols in watchBidsAsks/unWatchBidsAsks
  so a single call can mix swap+spot symbols, matching watchTickers' usage and
  HL's bbo channel (which has no type restriction)
- add handleBidAskUnsubscription + a 'bbo' branch in handleSubscriptionResponse
  so an unsubscribe ack for bbo cleans client.subscriptions/this.bidsasks and
  resolves the 'unsubscribe:bidsasks' messageHash; previously unWatchBidsAsks
  never resolved
…n unwatch cleanup

Fix round 4 (two-reviewer convergence on the multi-symbol subscribe loop):

- watchBidsAsks now dict-checks client.subscriptions per coin before calling
  watchMultiple; steady-state calls (already-subscribed coins) do zero
  watchMultiple calls in the loop and exactly one final shared-future await.
  Frame-sending watchMultiple calls happen only for genuinely new
  subscriptions. Eliminates the per-call 542x Future.race wrapper churn on
  every steady-state hot-loop invocation.

- handleBidAskUnsubscription now rejects the shared 'bidsasks' future with
  UnsubscribeError when the last remaining 'bbo:' subscription is torn down,
  so a concurrently pending watchBidsAsks settles instead of hanging forever.

- unWatchBidsAsks JSDoc @returns corrected to describe the actual boolean
  result.

Empirically verified (isolated Client/Future harness, not shipped) that a
discarded per-coin wrapper future crashes the process outright if the shared
future rejects while it's pending, and that this is the same class of risk
every bare `this.spawn (...)` call already carries throughout this codebase
(no exchange file has ever needed a closure-free way to silence a discarded
promise). Kept the bounded, now-documented sequential discard for new-
subscription bursts rather than introducing an unprecedented `.catch(...)`
construct whose cross-language (Go/Python/PHP) transpilation is unverified.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ta2 removed)

Hyperliquid's 2026-07-11 network upgrade removed the webData2 WS channel,
leaving watchTickers dark from birth on new connections. Rework the method
onto the channels that still exist:

- default path subscribes allMids (last/close, ~5s) + allDexsAssetCtxs
  (volumes/prevDayPx-derived fields, ~15s) under the shared 'tickers'
  messageHash; channel='allMids' opts out of the ctxs frame;
  channel='webData2' throws NotSupported. hip3 dex path unchanged
  (allMids-with-dex under 'tickers:'+dex).

- steady-state discipline mirrors watchBidsAsks: subscribe frames are sent
  only for hashes absent from client.subscriptions; every call awaits ONE
  shared watchMultiple future (no per-call Future.race wrapper churn).

- new handleAllDexsAssetCtxs zips main-dex ctxs by index against
  options['perpUniverse'] (captured in fetchSwapMarkets — ctx rows carry no
  coin identity); length mismatch drops the frame and logs instead of
  mis-assigning volumes.

- new mergeWsTicker does field-level merges into this.tickers so an
  allMids-only resolve never nulls ctxs-owned fields (the two sources own
  disjoint field groups on one ordered socket).

- new resolveWsCoin maps WS spot keys '@n' via options['spotIndexMap']
  (captured in fetchSpotMarkets) before falling back to coinToMarketId;
  swept into parseWsBidAsk, handleBidAskUnsubscription and the l2Book
  error-mapper branch. Builder-dex '#NNNN' mids keys are skipped.

- unWatchTickers unsubscribes both channels sequentially, each awaited on
  its own 'unsubscribe:'-prefixed hash; handleTickersUnsubscription rejects
  the shared tickers future only when BOTH default subscriptions are gone,
  and purges this.tickers entries only for the dex-scoped case (the default
  cache is shared with markets still fed by the surviving channel).

- handleMessage routing: 'webData2' removed, 'allDexsAssetCtxs' added;
  handleErrorMessage keeps its webData2 attribution branch for historical
  frames.

Live-verified against api.hyperliquid.xyz: perp merge invariant held over
60s (quoteVolume never nulled by 16 subsequent allMids resolves), spot
HYPE/USDC last via @107, channel gating, and full unwatch/rewatch cycle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ional unsubscribe, method-aware aggregate errors
…allDexsAssetCtxs

- ts/src/pro/test/test.hyperliquid.ts (new, npm run test-hyperliquid-ws): stub-client
  fixture tests feeding captured HL frame shapes into handleMessage() — bbo (perp +
  spot "@n"), allMids ("@n"/"#" keys + last-only merge invariant), allDexsAssetCtxs
  (happy + length-mismatch drop), and error-frame scoped reject incl. method-aware
  subscribe/unsubscribe hash attribution.
- ts/src/pro/hyperliquid.ts: replaced retMsg.lastIndexOf ('}') with an equivalent
  forward scan — the Go transpiler has no mapping for String.lastIndexOf (unlike
  .indexOf) and emitted an uncompilable direct method call on the any-typed string;
  a loop counter seeded from a computed value also transpiles as `any`, so native
  i-- fails too, hence the plain literal-seeded forward loop.
- go/v4/{hyperliquid.go,hyperliquid_wrapper.go,pro/hyperliquid.go,pro/hyperliquid_wrapper.go}:
  regenerated via transpileGO + buildGO. Confirms HandleErrorMessage passes a real
  ccxt.ExchangeError value into Reject (not a raw string — the Task 1 panic fix
  landing in Go) and that WatchBidsAsks/UnWatchBidsAsks/HandleBidAsk/
  HandleAllDexsAssetCtxs/ResolveWsCoin all transpiled and compile cleanly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Koltsov Koltsov closed this Jul 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants