Skip to content

Commit e449738

Browse files
committed
release: 2.51.0 — Hyperliquid audit + Hunch venue + test-suite cleanup
Changelog: [2.51.0] - 2026-06-22. Test-suite cleanup before the version cut: - fetch_closed_orders / fetch_all_orders: add hosted-mode NotSupported guard. The 2.50.11–14 hosted-routing series skipped these two methods; hosted callers fell through to the sidecar and got 'invalid api key' instead of a typed NotSupported. - unwatch_order_book: route through the existing _unwatch_required_via_ws helper instead of hitting HTTP. Every other (un)watch* method already used the WS transport; this was the only outlier. - test_feed_client: bump expected timeout 15 → 30 to match the current FeedClient default. - test_hosted_dispatch fetch_my_trades fixture: send amount in 6-dec micro-shares (2_000_000) to match the real v0 wire that user_trade_from_v0 normalizes against (per 2.50.13). Full pipeline (core unit + Python SDK + integration verification) now zero-failing: 28/28 Jest suites, 246/246 Python tests.
1 parent 61f13fe commit e449738

4 files changed

Lines changed: 77 additions & 20 deletions

File tree

changelog.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,60 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## [2.51.0] - 2026-06-22
6+
7+
New venue: **Hunch** (parimutuel prediction market on Base, x402 settlement). And a full assertion-based audit of the Hyperliquid SDK surface (both Python + TS) caught ten silent bugs that prior "did the call throw?" smoke tests had hidden — several since the venue was added. Read paths now have behavioral assertions on shape AND values; trading paths are verified end-to-end against a live mainnet wallet. Final HL state: every no-creds method behaviorally green in both SDKs (43/43 assertions), credentialed reads cross-checked vs HL raw API (open orders: 7/7 exact match; user trades: 2000/2000 match; synthesized closed orders: 104/104 match), and the full createOrder → openOrders → cancelOrder → openOrders-empty lifecycle proven live.
8+
9+
### Added — Hunch venue (#1150)
10+
11+
- **`core/src/exchanges/hunch/` — new adapter** for [playhunch.xyz](https://www.playhunch.xyz), a parimutuel (pool-based, not CLOB/AMM) prediction market on Base settling in USDC via x402 / EIP-3009. Inventory is crypto-native: token market-cap ladders, up/down, launchpad, date-window markets. Modeled on the Myriad adapter — market-orders only, `fetchOrderBook: 'emulated'` (single level synthesized from implied price + pool), `cancelOrder` / limit / sell unsupported.
12+
- **Surface**: `fetchMarkets` / `fetchEvents` (single-market wrap — Hunch has no event tier) / `fetchOHLCV` (flat candles from the odds tape) / `fetchOrderBook` (emulated) / `fetchTrades` / `fetchPositions` / `fetchBalance`. Binary markets → YES/NO outcomes; N-way (mcap ladders, date windows) → one outcome per bucket key. `outcomeId` encodes `${marketId}:${side}` and round-trips back to a Hunch trade side.
13+
- **`createOrder`** runs Hunch's x402 loop: `POST /trade` → 402 → sign EIP-3009 `TransferWithAuthorization` (viem) → re-POST with `X-PAYMENT` → 200 receipt. Orders > $10 fetch a price-locked quote first. Verified end-to-end against the live 402 challenge (no funds moved).
14+
- **Registration**: `core/src/index.ts` (export + registry + `Hunch`), `core/src/server/exchange-factory.ts` (`case "hunch"`), `core/src/server/openapi.yaml` (`ExchangeParam` enum), `COMPLIANCE.md`.
15+
- **Self-hosted creds**: `HUNCH_PRIVATE_KEY` / `HUNCH_WALLET_ADDRESS` (Base). Hosted-mode trading (Polygon escrow) is out of scope — Hunch settles on Base via x402 — so it lists as read-only hosted / tradeable self-hosted, like Kalshi.
16+
- **Tests**: `core/test/normalizers/hunch-normalizer.test.ts` — 47 tests covering binary + N-way mapping, the money-critical `outcomeId``side` round-trip, position/balance/OHLCV/emulated-orderbook. `tsc --noEmit` clean.
17+
- **Known gap**: `volume24h` is `0` (Hunch reports no 24h split; total `volume` is the pool size). Fast-follow on our side can add it.
18+
19+
### Fixed — Hyperliquid
20+
21+
- **`core/src/exchanges/hyperliquid/index.ts``fetchOHLCV` `params` was typed as required but server dispatch passes a single merged object as `args[0]`, leaving `args[1]` undefined.** Result: any MCP/HTTP call to HL `fetchOHLCV` crashed with `Cannot read properties of undefined (reading 'start')`. Default to `{ resolution: '1h' }` so the type-check passes (resolution is required on `OHLCVParams`) and the call survives even without explicit params. (#1161, #1162)
22+
- **`sdks/python/pmxt/client.py` + `sdks/typescript/pmxt/client.ts` — SDKs sent `?id=...` for GET reads, but server `method-verbs.json` spec for `fetchOHLCV` / `fetchTrades` declares the first arg as `outcomeId`.** GET dispatcher (`queryToArgs` in `core/src/server/app.ts`) peels primitives by spec name, then bundles remaining query keys into the object arg — so `outcomeId` arrived `undefined` and the raw `id` ended up inside `params`. Downstream `fromMarketId(undefined)` crashed with `Cannot read properties of undefined (reading 'match')`. POST was unaffected (positional args). Fixed both SDKs. (#1162)
23+
- **`core/src/exchanges/hyperliquid/normalizer.ts``volume24h` was hardcoded to `0` for every HL market and event.** Fixed by pulling `dayNtlVlm` from `spotMetaAndAssetCtxs` (outcome legs appear as `coin: '#NNNN'`), summing Yes + No notional per outcome, and threading the map through `fetchRawMarkets` / `fetchEventsImpl`. One extra batched call, no N+1; fallback to 0 if `spotMetaAndAssetCtxs` is unreachable. (#1219)
24+
- **`core/src/exchanges/hyperliquid/utils.ts``fromMarketId` only matched `hl-outcome-{N}`.** Passing an actual outcome token (e.g. `100002000`, as returned by `UnifiedMarket.outcomes[].outcomeId`) threw `Invalid Hyperliquid market ID`. Now accepts either form via `decodeAssetId`. Affects `fetchOrderBook` / `fetchOHLCV` / `fetchTrades`. (#1252)
25+
- **`core/src/exchanges/hyperliquid/fetcher.ts``fetchRawMarkets` ignored `params.marketId`, and `fetchRawEvents` ignored `params.eventId`.** `fetchMarket(marketId=X)` returned the *first* market in the venue list instead of X; `fetchEvent(eventId=X)` always returned the first question. Both filters now applied via `fromMarketId` and direct id match. (#1254)
26+
- **`core/src/exchanges/hyperliquid/fetcher.ts``fetchRawOHLCV` and `fetchRawTrades` ignored `params.limit`.** OHLCV returned the entire window regardless; trades returned a fixed page. Now slices client-side after the venue call returns (HL's `candleSnapshot` / `recentTrades` have no native limit param). (#1254)
27+
- **`core/src/exchanges/hyperliquid/normalizer.ts``normalizeUserTrade` left `marketId` / `outcomeId` / `fee` undefined on every fill.** Consumers couldn't tell which market a fill was on. Now populated via the existing `coinToMarketId` / `coinToOutcomeId` helpers and the raw `fee` field. Widened `UnifiedUserTrade` with optional `marketId`, `fee`. Verified 2000/2000 trades fully populated on an active wallet. (#1255)
28+
- **`core/src/exchanges/hyperliquid/normalizer.ts``normalizeBalance` read `crossMarginSummary` (perp margin) and hardcoded the currency label as `'USDH'`.** Outcome markets quote against USDC on the spot account, so users with deposited funds saw `USDH: 0` and assumed empty. Now reads `spotClearinghouseState` alongside the perp account; spot balances surface with their real coin label (`USDC`, `USDH`, etc.); funded perp surfaces as `USDC_PERP` so callers can tell it apart. Verified $14.90 USDC deposit now correctly returns `USDC: 14.9`. (#1280)
29+
- **`core/src/exchanges/hyperliquid/index.ts``submitOrder` silently swallowed HL rejections.** When HL returned `statuses[0].error` (e.g. `"Order must have minimum value of 10 USDC"`), the SDK reported `status='filled', id='unknown'` — fake success with no way for the caller to know the order wasn't placed. Now raises a typed `PmxtError` carrying HL's message; also correctly populates `price` / `oid` on the `filled` branch (was checked but never read). (#1281)
30+
- **`core/src/exchanges/hyperliquid/index.ts``cancelOrder` hardcoded `a: 0` (asset id).** HL rejected every cancel with `User or API Wallet 0xfaf6... does not exist` because the action hash with the wrong asset recovers a different signer address. Now looks up the open order to derive the real asset id via `encodeAssetId`. Throws a typed "Order not found" when the supplied oid isn't open. (#1281)
31+
- **`core/src/exchanges/hyperliquid/auth.ts``msgpackr` encoded positive BigInts as int64 (`0xd3`), HL's server (Python `msgpack`) encodes the same values as uint64 (`0xcf`).** Identical bit pattern, different type byte → action hash mismatch → signature recovers to wrong address. Only manifested on cancel because order actions don't carry BigInt fields; cancel does (`oid`). Added `fixInt64ToUint64` post-processor: HL actions never carry negative ints (oids, nonces, asset ids are all ≥ 0), so flipping `d3 → cf` when the following byte is `< 0x80` is safe and produces byte-identical encoding to Python msgpack. (#1281)
32+
33+
### Fixed — Python SDK (cross-venue)
34+
35+
- **`sdks/python/pmxt/errors.py``NetworkError` / `ExchangeNotAvailable` rejected the `code` / `retryable` kwargs that `from_server_error` always sets.** Any 503 from Kalshi or another venue raised `TypeError: ExchangeNotAvailable.__init__() got an unexpected keyword argument 'code'` instead of a typed `PmxtError`. Both classes now accept `**kwargs` and default-fill their hardcoded values. Caught by the cross-venue Trump search test where Kalshi briefly errored. (#1219)
36+
37+
### Added — Hyperliquid
38+
39+
- **`fetchEventsPaginated`** wrappers in both SDKs. `BaseExchange.fetchEventsPaginated` existed but neither `client.py` nor `client.ts` exposed it. Added `fetch_events_paginated` / `fetchEventsPaginated` mirroring the existing `fetchMarketsPaginated` pattern. (#1252)
40+
- **`fetchOrderBooks`** on Hyperliquid. HL has no native batch order-book endpoint. Implemented as `Promise.all` over `fetchOrderBook`; inherits the outcome-token-or-marketId resolution from the single-fetch path. `has.fetchOrderBooks` auto-flips because `BaseExchange._deriveCapabilities` introspects overrides. `ponytail:` comment marks the unbounded concurrency for future capping. (#1253)
41+
- **`fetchClosedOrders` + `fetchAllOrders`** on Hyperliquid. HL exposes no closed-orders endpoint. Synthesize by grouping `userFills` by `oid` and excluding currently-open oids — VWAP price, summed size, summed fee, earliest fill time. `fetchAllOrders = openOrders ∪ closedOrders`. `ponytail:` comment marks the limitation: cancelled-with-no-fills orders aren't reconstructable from the public info API. Verified against an active wallet: 7 open + 104 derived closed = 111 all, exact-count match. (#1255)
42+
43+
### Changed — types
44+
45+
- **`core/src/types.ts``UnifiedUserTrade`** widened with optional `marketId` and `fee` fields. Existing consumers continue to work unchanged. (#1255)
46+
47+
### Fixed — Python SDK (test-suite rot caught at version cut)
48+
49+
Running the full `npm test` before tagging surfaced six pre-existing failures untouched by the HL audit. Two were the SDK silently broken since the 2.50.11–14 hosted-routing series; the rest were stale test fixtures from earlier intentional behavior changes.
50+
51+
- **`sdks/python/pmxt/client.py``fetch_closed_orders` missing hosted-mode guard.** The 2.50.11–14 hosted-mode routing series added hosted branches to `fetch_balance` / `fetch_positions` / `fetch_order` / `fetch_my_trades` / `fetch_open_orders` / `cancel_order` but skipped `fetch_closed_orders`. Hosted-mode callers fell through to the sidecar `/api/{exchange}/fetchClosedOrders` path and received `invalid api key` (or whatever the sidecar returned) instead of the typed `NotSupported` the test asserts. Added an `if self.is_hosted` branch that raises `NotSupported` with a pointer to `fetch_my_trades` (which works in hosted mode and surfaces executed fills).
52+
- **`sdks/python/pmxt/client.py``fetch_all_orders` missing hosted-mode guard.** Identical class of bug to the above. Added the same `is_hosted` branch raising `NotSupported` with a pointer to `fetch_open_orders` + `fetch_my_trades`.
53+
- **`sdks/python/pmxt/client.py``unwatch_order_book` bypassed the WebSocket transport.** Every other `(un)watch*` method routes through `_watch_required_via_ws` / `_unwatch_required_via_ws`; `unwatch_order_book` alone hit `POST /api/{exchange}/unwatchOrderBook` over HTTP. The `_unwatch_required_via_ws` helper already existed and handles all the bookkeeping (find the active sub by `watchOrderBook:{outcomeId}` key, send unsubscribe over the WS, clean up `_active_subs` / `_subscriptions` / `_data_queues` / `_data_store`). The method now calls that helper, matching the other unwatch paths.
54+
- **`sdks/python/tests/test_feed_client.py:36` — assertion expected timeout 15, implementation has been 30 since the FeedClient default was bumped.** Updated the assertion to `30`.
55+
- **`sdks/python/tests/test_hosted_dispatch.py:285` — fixture sent `amount: 2.0` and expected `2.0` back, but per 2.50.13's `user_trade_from_v0` the v0 wire sends amounts in 6-dec micro-shares (the SDK divides by 1e6 to produce decimal shares). Updated the fixture to `2_000_000` micro-shares so the test asserts the real wire-to-model mapping.
56+
57+
After these fixes the full pipeline (core unit + Python SDK + verification) is zero-failing — 28/28 Jest suites, 246/246 Python tests, "All SDK Integration Tests Passed".
58+
559
## [2.50.16] - 2026-06-18
660

761
### Fixed

sdks/python/pmxt/client.py

Lines changed: 19 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1628,6 +1628,12 @@ def fetch_my_trades(self, params: Optional[dict] = None, **kwargs) -> List[UserT
16281628
raise self._parse_api_exception(e) from None
16291629

16301630
def fetch_closed_orders(self, params: Optional[dict] = None, **kwargs) -> List[Order]:
1631+
if self.is_hosted:
1632+
from pmxt.errors import NotSupported
1633+
raise NotSupported(
1634+
"fetch_closed_orders is not supported in hosted mode; "
1635+
"use fetch_my_trades to see executed fills instead.",
1636+
)
16311637
try:
16321638
args = []
16331639
if kwargs:
@@ -1651,6 +1657,12 @@ def fetch_closed_orders(self, params: Optional[dict] = None, **kwargs) -> List[O
16511657
raise self._parse_api_exception(e) from None
16521658

16531659
def fetch_all_orders(self, params: Optional[dict] = None, **kwargs) -> List[Order]:
1660+
if self.is_hosted:
1661+
from pmxt.errors import NotSupported
1662+
raise NotSupported(
1663+
"fetch_all_orders is not supported in hosted mode; "
1664+
"use fetch_open_orders + fetch_my_trades to reconstruct order history.",
1665+
)
16541666
try:
16551667
args = []
16561668
if kwargs:
@@ -1730,24 +1742,13 @@ def fetch_balance(self, address: Optional[str] = None) -> List[Balance]:
17301742
raise self._parse_api_exception(e) from None
17311743

17321744
def unwatch_order_book(self, outcome_id: Union[str, "MarketOutcome"] = _UNSET, **_compat_kwargs) -> None:
1733-
try:
1734-
args = []
1735-
outcome_id = _compat_id(outcome_id, _compat_kwargs)
1736-
args.append(_resolve_outcome_id(outcome_id))
1737-
body: dict = {"args": args}
1738-
creds = self._get_credentials_dict()
1739-
if creds:
1740-
body["credentials"] = creds
1741-
url = f"{self._resolve_sidecar_host()}/api/{self.exchange_name}/unwatchOrderBook"
1742-
headers = {"Content-Type": "application/json", "Accept": "application/json"}
1743-
headers.update(self._get_auth_headers())
1744-
response = self._fetch_with_retry(
1745-
lambda: self._api_client.call_api(method="POST", url=url, body=body, header_params=headers)
1746-
)
1747-
response.read()
1748-
self._handle_response(json.loads(response.data))
1749-
except ApiException as e:
1750-
raise self._parse_api_exception(e) from None
1745+
outcome_id = _compat_id(outcome_id, _compat_kwargs)
1746+
resolved = _resolve_outcome_id(outcome_id)
1747+
self._unwatch_required_via_ws(
1748+
"unwatch_order_book",
1749+
"unwatchOrderBook",
1750+
[resolved],
1751+
)
17511752

17521753
def unwatch_address(self, address: str) -> None:
17531754
try:

sdks/python/tests/test_feed_client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,5 +33,5 @@ def fake_urlopen(req, timeout=15):
3333

3434
assert client.list_feeds() == ["binance", "chainlink"]
3535
assert captured["url"] == "http://localhost:3847/api/feeds/"
36-
assert captured["timeout"] == 15
36+
assert captured["timeout"] == 30
3737
assert captured["headers"] == {}

sdks/python/tests/test_hosted_dispatch.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,9 @@ def test_fetch_my_trades_routes_to_user_trades(self, monkeypatch):
282282
"id": "trade-1",
283283
"timestamp": "2026-01-01T00:00:00Z",
284284
"price": 0.5,
285-
"amount": 2.0,
285+
# The v0 wire sends trade amounts in 6-dec micro-shares;
286+
# user_trade_from_v0 normalizes to decimal shares.
287+
"amount": 2_000_000,
286288
"side": "buy",
287289
"order_id": "order-1",
288290
"market_id": MARKET_ID,

0 commit comments

Comments
 (0)