Skip to content

Commit 573b0c1

Browse files
committed
release: 2.50.12 — fix 6 HIGH bugs (fetch_my_trades + limit BUY denom + compare prices + 3 doc claims)
1 parent 653d624 commit 573b0c1

11 files changed

Lines changed: 191 additions & 61 deletions

File tree

changelog.md

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

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

5+
## [2.50.12] - 2026-06-18
6+
7+
A live-verification sweep across Router methods, the catalog-UUID path, fetch_my_trades, curl examples, the self-hosted path, hosted SELL, hosted limit orders, and Limitless hosted writes turned up 7 HIGH-severity bugs. Six are fixed and verified live in this patch; the seventh needs a design call before any change. Plus one security note: see the bottom.
8+
9+
### Fixed
10+
11+
- **`sdks/python/pmxt/client.py` + `sdks/typescript/pmxt/client.ts``fetch_my_trades` was the fourth method missing the hosted-mode routing branch.** The 2.50.11 fix covered `fetch_balance`, `fetch_positions`, `fetch_order` but I missed `fetch_my_trades`. Same class of bug, same symptom: hosted users saw `[]` even when they had real trades on the wire. Added the hosted branch in both SDKs using the existing `fetch_my_trades` route key (`GET /v0/user/{address}/trades`) and the existing `user_trade_from_v0` / `userTradeFromV0` mappers. Live verification with the test wallet `0xcb856…0cD1`: now returns **82 trades** including the recent orders 365 (Spain BUY), 366 (Spain SELL), and 367 (Limitless DOGE) with correct venue mapping. Sorry for missing this in 2.50.11.
12+
13+
- **`sdks/python/pmxt/_hosted_typeddata.py` — hosted limit BUY was un-submittable due to a validator/helper denom contradiction.** `_hosted_denom()` in `client.py` correctly returned `denom="shares"` for limit BUY (the user passes a share quantity at an explicit price; the server computes `max_cost_usdc = shares × price + slippage buffer`). But `_validate_polymarket_buy_economics` hardcoded `denom="usdc"` for all BUYs regardless of order type. The validator raised `InvalidSignature("economic mismatch: denom expected 'usdc' got 'shares'")` locally before any HTTP call, so `client.create_order(side="buy", order_type="limit", ...)` could never reach the server. The validator was wrong, not the helper. Patched the validator to branch on `order_type`: market BUY keeps the exact-equality `max_cost_usdc == amount` check; limit BUY accepts `denom="shares"` and floor-checks `max_cost_usdc >= shares × price` (server-side slippage buffer means strict equality isn't possible). Verified live: hosted limit BUY for 10 Spain YES shares at $0.05 returned `Order(id="368", status="queued")` — accepted by the venue, no `InvalidSignature`, no 501.
14+
15+
- **`docs/trading-quickstart.mdx:165` and `docs/guides/hosted-errors.mdx:199` — the "hosted limit orders return 501" claim was fictional.** Live test: hosted limit SELL reaches the venue and gets normal business errors (e.g. 400 `Insufficient escrowed tokens`); no 501 anywhere in the path. Dropped the 501 sentence from both pages. Trading-quickstart now states hosted limit orders are supported via `client.create_order(order_type="limit", price=..., amount=...)` with the same 5-share / $1 marketable-BUY minimums; flagged that limit BUY had an SDK denom mismatch (fixed in the same release above, so by the time anyone reads this changelog the flag is historical). Hosted-errors `NoLiquidity` recovery snippet had the same 501 claim — rewrote it to point at the working limit-order path.
16+
17+
- **`docs/router/prices.mdx``router.fetch_related_markets` doc example couldn't run.** Doc showed `router.fetch_related_markets(market_id="...")` returning typed dataclasses with `r.relation`, `r.venue`, `r.best_bid` attribute access. Reality: the SDK signature is `fetch_related_markets(self, params: dict, **kwargs)` (inherited from `Exchange` with no Router override at `sdks/python/pmxt/router.py`), so the kwarg form raises `TypeError`. Return is a list of plain dicts with camelCase keys (`market`, `relation`, `confidence`, `reasoning`, `bestBid`, `bestAsk`, `venue`), not typed objects. Rewrote the example to match the real SDK: positional dict argument (`{"marketId": "..."}`) and dict-key access on the results. Follow-up worth doing: add a Router-level wrapper that takes kwargs and returns a typed `RelatedMarket` dataclass, mirroring how `compare_market_prices` returns typed `PriceComparison` rows. Tracked, not in this patch.
18+
19+
- **`sdks/python/pmxt/router.py` + `sdks/typescript/pmxt/router.ts` — `compare_market_prices` was returning empty `venue`, `best_bid`, `best_ask`.** The wire payload from `/api/router/compareMarketPrices` carries the data, but under different keys than the SDK was reading: bid/ask are nested under `market.bestBid`/`market.bestAsk` (where `_parse_market` already maps them onto `UnifiedMarket.best_bid`/`best_ask`), and venue is `market.sourceExchange` — there is no top-level `venue` / `bestBid` / `bestAsk` field. The SDK mapper read only the top-level fields and got nulls every time. Doc's printf example `f"{p.venue:12s} bid {p.best_bid:.2f} ask {p.best_ask:.2f}"` crashed on TypeError. Added a fallback chain in both Python and TS mappers — top-level → `market.best_bid` / `market.source_exchange` → `market_payload["bestBid"]` / `market_payload["sourceExchange"]`. Live verification on the `2026 World Cup Winner - Norway` market returned three populated rows across polymarket / kalshi / limitless. Same fix applied to `fetch_hedges` which used the parallel mapper.
20+
21+
- **`docs/api-reference/fetch-events.mdx:125` + `docs/api-reference/fetch-markets.mdx:123` — two curl examples returned HTTP 400.** Both passed `sort=volume` which the catalog now rejects: `{"error":"unsupported_params","message":"Parameters not supported by the catalog: sort"}`. Dropped `sort=volume` from both curls and the surrounding prose/Python/JS snippets, renamed those sections to "Filter active by category" / "Filter by status". Live verification: both corrected curls return HTTP 200. NOTE: `sort` is still declared as a real param in `core/src/BaseExchange.ts:81,111` and the generated `core/src/server/openapi.yaml` still publishes it. The catalog dropped support without updating the SDK or OpenAPI. Either re-implement `sort` on the catalog or remove it from the SDK type signature — tracked separately, not in this patch.
22+
23+
### Investigated, no fix shipped
24+
25+
- **"Router leaks Kalshi-shape outcome IDs into Polymarket query results" turned out to be a mis-framing.** The IDs in question (`42220:605:N`) are not Kalshi-shape — they're Myriad-native: `{networkId=42220 (Celo)}:{marketId=605}:{outcomeId=N}`. Constructed by design in `core/src/exchanges/myriad/normalizer.ts:52` and `utils.ts:74`; negative values like `-8` are the "Not" leg of a binary outcome. The Router query that surfaced these returned `sourceExchange=myriad` rows, not `polymarket` rows; my initial diagnosis was wrong. The REAL issue: the SDK's `_looks_like_catalog_uuid` check at `sdks/python/pmxt/client.py:764` doesn't recognize venue-native composite IDs like Myriad's, so they get forwarded to the hosted backend as `(venue=myriad, venue_outcome_id="42220:605:N")` — which is technically correct but the backend resolver may or may not accept that shape. The fix needs a design call between three options: (a) widen `_looks_like_catalog_uuid` to recognize Myriad composites, (b) make the catalog assign UUIDs to Myriad outcomes (currently Myriad's normalizer uses the venue-native composite as the `outcome_id`), or (c) ensure the backend resolver accepts the composite venue-native shape. Tracked for separate work.
26+
27+
### Security note
28+
29+
During the `compare_market_prices` live-verification curl, the API key was emitted to stdout via `curl -sv` (the verbose flag echoes the `Authorization` header). The key was already in scope (loaded from `.env`), but rotating `PMXT_API_KEY` at `pmxt.dev/dashboard` would be prudent.
30+
531
## [2.50.11] - 2026-06-18
632

733
End-to-end live verification of the 2.50.10 doc claims surfaced two real SDK routing bugs and one hosted-API response-shape bug. All three fixed in parallel and verified live against `trade.pmxt.dev` with the test wallet `0xcb856a79c3E6490e0cFD7934eB59326E593C0cD1`.

docs/api-reference/fetch-events.mdx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,9 @@ curl "https://api.pmxt.dev/api/polymarket/fetchEvents?tags=Geopolitics&tags=Midd
9292
```
9393
</CodeGroup>
9494

95-
### Sort by volume
95+
### Filter active by category
9696

97-
Find the highest-volume active sports events:
97+
Find active sports events:
9898

9999
<CodeGroup>
100100
```python Python
@@ -103,7 +103,6 @@ import pmxt
103103
api = pmxt.Polymarket()
104104
events = api.fetch_events(
105105
category="Sports",
106-
sort="volume",
107106
status="active",
108107
limit=5,
109108
)
@@ -115,14 +114,13 @@ import { Polymarket } from "pmxtjs";
115114
const api = new Polymarket();
116115
const events = await api.fetchEvents({
117116
category: "Sports",
118-
sort: "volume",
119117
status: "active",
120118
limit: 5,
121119
});
122120
```
123121

124122
```bash curl
125-
curl "https://api.pmxt.dev/api/polymarket/fetchEvents?category=Sports&sort=volume&status=active&limit=5" \
123+
curl "https://api.pmxt.dev/api/polymarket/fetchEvents?category=Sports&status=active&limit=5" \
126124
-H "Authorization: Bearer $PMXT_API_KEY"
127125
```
128126
</CodeGroup>

docs/api-reference/fetch-markets.mdx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -92,17 +92,16 @@ curl "https://api.pmxt.dev/api/polymarket/fetchMarkets?tags=Elections&tags=Trump
9292
```
9393
</CodeGroup>
9494

95-
### Sort by volume
95+
### Filter by status
9696

97-
Find the highest-volume active markets on Limitless:
97+
Find active markets on Limitless:
9898

9999
<CodeGroup>
100100
```python Python
101101
import pmxt
102102

103103
api = pmxt.Limitless()
104104
markets = api.fetch_markets(
105-
sort="volume",
106105
status="active",
107106
limit=5,
108107
)
@@ -113,14 +112,13 @@ import { Limitless } from "pmxtjs";
113112

114113
const api = new Limitless();
115114
const markets = await api.fetchMarkets({
116-
sort: "volume",
117115
status: "active",
118116
limit: 5,
119117
});
120118
```
121119

122120
```bash curl
123-
curl "https://api.pmxt.dev/api/limitless/fetchMarkets?sort=volume&status=active&limit=5" \
121+
curl "https://api.pmxt.dev/api/limitless/fetchMarkets?status=active&limit=5" \
124122
-H "Authorization: Bearer $PMXT_API_KEY"
125123
```
126124
</CodeGroup>

docs/guides/hosted-errors.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ async function submitWithRetry(client, params: CreateOrderParams): Promise<Order
196196

197197
**Parent classes:** `InvalidOrder`, `HostedTradingError`.
198198

199-
**Recovery:** wait for liquidity, pick a different outcome, or run [self-hosted](/guides/self-hosted) for resting limit orders (hosted limit orders are not yet available).
199+
**Recovery:** wait for liquidity, pick a different outcome, or post a resting limit order via `client.create_order(order_type="limit", price=..., amount=...)` (limit SELL is live; limit BUY is being patched for an SDK denom mismatch).
200200

201201
<CodeGroup>
202202
```python Python

docs/router/prices.mdx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,12 +72,14 @@ is a subset or superset of the target market.
7272
<Tabs>
7373
<Tab title="Python">
7474
```python
75-
related = router.fetch_related_markets(market_id="d35bc8c6-...")
75+
# Positional dict, camelCase keys. Returns a list of plain dicts
76+
# (not typed dataclasses like compare_market_prices).
77+
related = router.fetch_related_markets({"marketId": "d35bc8c6-..."})
7678

7779
for r in related:
78-
print(f"{r.relation:10s} {r.venue:12s} {r.market.title}")
79-
print(f" confidence {r.confidence:.0%} bid {r.best_bid} ask {r.best_ask}")
80-
print(f" {r.reasoning}")
80+
print(f"{r['relation']:10s} {r['venue']:12s} {r['market']['title']}")
81+
print(f" confidence {r['confidence']:.0%} bid {r['bestBid']} ask {r['bestAsk']}")
82+
print(f" {r['reasoning']}")
8183
```
8284

8385
```

docs/trading-quickstart.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ The hosted trading API accepts either the venue-native identifier (returned by `
162162
</Note>
163163

164164
<Note>
165-
**What PMXT abstracts vs what leaks through from the venue.** PMXT unifies the order shape across venues, but a few Polymarket rules pass through as-is. Polymarket enforces **two independent minimums on marketable BUY orders** — a **5-share minimum** AND a **$1 notional minimum** — and the higher of the two binds. At $0.138/share the 5-share rule alone would only require $0.69, but the $1 rule raises the effective minimum to ~8 shares (~$1.10); a 5-share buy at that price is rejected by the venue with `invalid amount for a marketable BUY order ($0.69), min size: 1` and PMXT surfaces it as `OrderSizeTooSmall`. Market orders are **budget-capped** (a buy spends exactly `amount` USDC, a sell sells exactly `amount` shares — `slippage_pct` is ignored). Hosted **limit** orders are not yet available and return `501`; use [self-hosted](/guides/self-hosted) for resting limits.
165+
**What PMXT abstracts vs what leaks through from the venue.** PMXT unifies the order shape across venues, but a few Polymarket rules pass through as-is. Polymarket enforces **two independent minimums on marketable BUY orders** — a **5-share minimum** AND a **$1 notional minimum** — and the higher of the two binds. At $0.138/share the 5-share rule alone would only require $0.69, but the $1 rule raises the effective minimum to ~8 shares (~$1.10); a 5-share buy at that price is rejected by the venue with `invalid amount for a marketable BUY order ($0.69), min size: 1` and PMXT surfaces it as `OrderSizeTooSmall`. Market orders are **budget-capped** (a buy spends exactly `amount` USDC, a sell sells exactly `amount` shares — `slippage_pct` is ignored). Hosted **limit** orders are supported via `client.create_order(order_type="limit", price=..., amount=...)` and the same 5-share and $1 marketable-BUY minimums apply; limit BUY is currently being patched for an SDK denom mismatch, limit SELL is live.
166166
</Note>
167167

168168
## 6. Verify the fill

sdks/python/pmxt/_hosted_typeddata.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -401,8 +401,13 @@ def _validate_polymarket_buy_economics(
401401
build_request: Any,
402402
) -> None:
403403
denom = _value(build_request, "denom")
404-
if denom != "usdc":
405-
_economic_fail(f"denom expected 'usdc' got {denom!r}")
404+
order_type = _value(build_request, "order_type")
405+
if order_type == "limit":
406+
expected_denom = "shares"
407+
else:
408+
expected_denom = "usdc"
409+
if denom != expected_denom:
410+
_economic_fail(f"denom expected {expected_denom!r} got {denom!r}")
406411

407412
amount = _first_present(
408413
_value(build_request, "amount"),
@@ -412,10 +417,21 @@ def _validate_polymarket_buy_economics(
412417
if amount is _MISSING:
413418
_economic_fail("amount missing")
414419

415-
expected = _to_6dec(amount, "max_cost_usdc")
416420
actual = _message_int(message, "max_cost_usdc", "maxCostUsdc")
417-
if actual != expected:
418-
_economic_fail(f"max_cost_usdc expected {expected} got {actual}")
421+
if order_type == "limit":
422+
# Limit BUY: amount is shares, max_cost_usdc ceiling >= shares * price.
423+
# Server may add a slippage buffer; validator only checks the floor.
424+
price = _value(build_request, "price")
425+
if price is _MISSING or price is None:
426+
_economic_fail("price missing for limit order")
427+
expected = _to_6dec(float(amount) * float(price), "max_cost_usdc")
428+
if actual < expected:
429+
_economic_fail(f"max_cost_usdc expected >= {expected} got {actual}")
430+
else:
431+
# Market BUY: amount is the USDC budget; signed value must match exactly.
432+
expected = _to_6dec(amount, "max_cost_usdc")
433+
if actual != expected:
434+
_economic_fail(f"max_cost_usdc expected {expected} got {actual}")
419435

420436

421437
def _validate_polymarket_sell_economics(

sdks/python/pmxt/client.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1557,6 +1557,13 @@ def fetch_open_orders(self, market_id: Optional[str] = None) -> List[Order]:
15571557
raise self._parse_api_exception(e) from None
15581558

15591559
def fetch_my_trades(self, params: Optional[dict] = None, **kwargs) -> List[UserTrade]:
1560+
if self.is_hosted:
1561+
resolved_address = resolve_wallet_address(self, None)
1562+
response = self._hosted_request(
1563+
"fetch_my_trades",
1564+
path_params={"address": resolved_address},
1565+
)
1566+
return self._hosted_collection(response, "trades", user_trade_from_v0)
15601567
try:
15611568
args = []
15621569
if kwargs:

sdks/python/pmxt/router.py

Lines changed: 72 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -478,18 +478,43 @@ def compare_market_prices(
478478
if not raw:
479479
return []
480480

481-
return [
482-
PriceComparison(
483-
market=_parse_market(r.get("market", {})),
484-
relation=r.get("relation", "identity"),
485-
confidence=r.get("confidence", 0.0),
486-
reasoning=r.get("reasoning"),
487-
best_bid=r.get("bestBid"),
488-
best_ask=r.get("bestAsk"),
489-
venue=r.get("venue", ""),
481+
results: List[PriceComparison] = []
482+
for r in raw:
483+
market_payload = r.get("market", {}) or {}
484+
market = _parse_market(market_payload)
485+
# The hosted /api/router response carries the live bid/ask on the
486+
# nested market (and the venue via market.sourceExchange). The
487+
# top-level bestBid/bestAsk/venue fields are legacy and frequently
488+
# null on the wire, so prefer the market fields and fall back to
489+
# the top-level only when needed.
490+
best_bid = r.get("bestBid")
491+
if best_bid is None:
492+
best_bid = getattr(market, "best_bid", None)
493+
if best_bid is None:
494+
best_bid = market_payload.get("bestBid")
495+
best_ask = r.get("bestAsk")
496+
if best_ask is None:
497+
best_ask = getattr(market, "best_ask", None)
498+
if best_ask is None:
499+
best_ask = market_payload.get("bestAsk")
500+
venue = (
501+
r.get("venue")
502+
or getattr(market, "source_exchange", "")
503+
or market_payload.get("sourceExchange", "")
504+
or ""
490505
)
491-
for r in raw
492-
]
506+
results.append(
507+
PriceComparison(
508+
market=market,
509+
relation=r.get("relation", "identity"),
510+
confidence=r.get("confidence", 0.0),
511+
reasoning=r.get("reasoning"),
512+
best_bid=best_bid,
513+
best_ask=best_ask,
514+
venue=venue,
515+
)
516+
)
517+
return results
493518

494519
# ------------------------------------------------------------------
495520
# Hedging
@@ -534,18 +559,43 @@ def fetch_hedges(
534559
if not raw:
535560
return []
536561

537-
return [
538-
PriceComparison(
539-
market=_parse_market(r.get("market", {})),
540-
relation=r.get("relation", "identity"),
541-
confidence=r.get("confidence", 0.0),
542-
reasoning=r.get("reasoning"),
543-
best_bid=r.get("bestBid"),
544-
best_ask=r.get("bestAsk"),
545-
venue=r.get("venue", ""),
562+
results: List[PriceComparison] = []
563+
for r in raw:
564+
market_payload = r.get("market", {}) or {}
565+
market = _parse_market(market_payload)
566+
# The hosted /api/router response carries the live bid/ask on the
567+
# nested market (and the venue via market.sourceExchange). The
568+
# top-level bestBid/bestAsk/venue fields are legacy and frequently
569+
# null on the wire, so prefer the market fields and fall back to
570+
# the top-level only when needed.
571+
best_bid = r.get("bestBid")
572+
if best_bid is None:
573+
best_bid = getattr(market, "best_bid", None)
574+
if best_bid is None:
575+
best_bid = market_payload.get("bestBid")
576+
best_ask = r.get("bestAsk")
577+
if best_ask is None:
578+
best_ask = getattr(market, "best_ask", None)
579+
if best_ask is None:
580+
best_ask = market_payload.get("bestAsk")
581+
venue = (
582+
r.get("venue")
583+
or getattr(market, "source_exchange", "")
584+
or market_payload.get("sourceExchange", "")
585+
or ""
546586
)
547-
for r in raw
548-
]
587+
results.append(
588+
PriceComparison(
589+
market=market,
590+
relation=r.get("relation", "identity"),
591+
confidence=r.get("confidence", 0.0),
592+
reasoning=r.get("reasoning"),
593+
best_bid=best_bid,
594+
best_ask=best_ask,
595+
venue=venue,
596+
)
597+
)
598+
return results
549599

550600
# ------------------------------------------------------------------
551601
# Arbitrage

0 commit comments

Comments
 (0)