Skip to content

Commit 93fdb16

Browse files
authored
fix(hosted): unblock quickstart market orders + normalize v0 trade amounts (#1019)
The client-side economics validator rejected every server-built hosted market order pre-sign (worst_price is pinned to the tick-grid extreme by design; max_cost_usdc/shares_6dec are the binding user protections, both already validated). Market orders now get a (0,1) domain sanity check only; limit orders keep the slippage bound. Also: v0 user-trade amounts arrive in 6-dec micro-shares — normalize to decimal shares in both SDK mappers (symmetric reverse mapping); fix the stale create_order hosted docstring; make the trading quickstart use real model fields and drop the slippage workaround warning. Verified live against trade.pmxt.dev with a market buy filled and settled end-to-end (Polygon tx 0x803b96b4...).
1 parent fc615f2 commit 93fdb16

10 files changed

Lines changed: 145 additions & 27 deletions

changelog.md

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

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

5+
## [2.49.8] - 2026-06-10
6+
7+
Hosted trading quickstart actually works now. The SDKs' client-side economics validator was rejecting every server-built market order before signing (`economic mismatch: worst_price expected <= ... got 0.999`), because the hosted trading API deliberately pins market-order `worst_price` to the tick-grid extreme and caps the user with `max_cost_usdc` / `shares_6dec` instead ("textbook market semantics"). Verified live against `trade.pmxt.dev` with a real $5 fill end-to-end.
8+
9+
### Fixed
10+
11+
- **TS `pmxt/hosted-typed-data.ts` + Python `pmxt/_hosted_typeddata.py`**: `validateWorstPrice` / `_validate_worst_price` no longer apply the limit-order slippage bound to **market** orders. For market orders the binding user protection is `max_cost_usdc` (buys) / `shares_6dec` (sells) — both already strictly validated — so the validator now only sanity-checks that `worst_price` lies inside the open `(0, 1)` price domain. Limit orders keep the existing slippage-bound check. This unblocks `create_order` / `createOrder` market orders in hosted mode, which previously failed pre-sign, every time.
12+
- **TS `pmxt/hosted-mappers.ts` + Python `pmxt/_hosted_mappers.py`**: `userTradeFromV0` / `user_trade_from_v0` now normalize the v0 wire `amount` (6-dec micro-shares, e.g. `58139533.0`) to decimal shares (`58.139533`), so `UserTrade.amount` means shares — consistent with `Position.size` and the rest of the SDK. The reverse mappers scale back to micros symmetrically.
13+
- **Python `client.py`**: `create_order` docstring claimed "Not available through the hosted API" — it is; corrected to describe the hosted build → local-sign → submit flow.
14+
15+
### Changed
16+
17+
- **`docs/trading-quickstart.mdx`**: Removed the "use aggressive `slippage_pct`" workaround warning (the validator bug it papered over is fixed); market-order examples no longer pass `slippage_pct` and note that market orders are budget-capped, not price-capped. Fixed the TypeScript `createOrder` example to use `type` (the actual `CreateOrderParams` field) instead of the nonexistent `orderType`. Fixed step-6 verification snippets to use real model fields (`Position.size` / `outcome_label`, `Balance.available`) instead of nonexistent `quantity` / `notional` / `free`. Documented that hosted limit orders currently return `501`.
18+
519
## [2.49.7] - 2026-06-09
620

721
API Reference sidebar reorder: `Trading` and `Orders & Positions` move from near the bottom of the tab to immediately under `Events & Markets`, so the customer's natural path is walkable top-to-bottom — discover (Events & Markets) → act (Trading) → inspect state (Orders & Positions) → niche features.

docs/trading-quickstart.mdx

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,8 @@ deposit_tx = client.escrow.deposit_tx(amount=10.0)
9797
# Sign and send deposit_tx with your wallet library...
9898

9999
# 3. Confirm the deposit landed
100-
balance = client.fetch_balance()
101-
print(f"Escrow USDC: {balance.free}")
100+
balances = client.fetch_balance()
101+
print(f"Escrow USDC: {balances[0].available}")
102102
```
103103

104104
```typescript TypeScript
@@ -111,8 +111,8 @@ const depositTx = await client.escrow.depositTx(10);
111111
// Sign and send depositTx with your wallet library...
112112

113113
// 3. Confirm the deposit landed
114-
const balance = await client.fetchBalance();
115-
console.log(`Escrow USDC: ${balance.free}`);
114+
const balances = await client.fetchBalance();
115+
console.log(`Escrow USDC: ${balances[0].available}`);
116116
```
117117
</CodeGroup>
118118
</Accordion>
@@ -131,11 +131,9 @@ order = client.create_order(
131131
outcome=yes,
132132
side="buy",
133133
order_type="market",
134-
amount=5.0,
135-
denom="usdc",
136-
slippage_pct=30.0,
134+
amount=5.0, # market buys are denominated in USDC: spend exactly $5
137135
)
138-
print(f"Order {order.id}: {order.status}")
136+
print(f"Order {order.id}: {order.status} filled={order.filled}")
139137
```
140138

141139
```typescript TypeScript
@@ -146,12 +144,10 @@ const yes = market.outcomes.find((o) => o.label.toLowerCase() === "yes")!;
146144
const order = await client.createOrder({
147145
outcome: yes,
148146
side: "buy",
149-
orderType: "market",
150-
amount: 5,
151-
denom: "usdc",
152-
slippagePct: 30,
147+
type: "market",
148+
amount: 5, // market buys are denominated in USDC: spend exactly $5
153149
});
154-
console.log(`Order ${order.id}: ${order.status}`);
150+
console.log(`Order ${order.id}: ${order.status} filled=${order.filled}`);
155151
```
156152

157153
```bash curl
@@ -169,26 +165,32 @@ The hosted trading API accepts either the venue-native identifier (returned by `
169165
**Polymarket has a 5-share minimum per order.** At ~$0.78/share, a 5 USDC buy is ~6.4 shares — fine. But $2 at the same price is only 2.5 shares and will be rejected with `OrderSizeTooSmall`. Size up or switch to a cheaper outcome.
170166
</Warning>
171167

172-
<Warning>
173-
**Use aggressive `slippage_pct`** until the upstream economic validator tightens its `worst_price` checks. Pragmatic defaults: `slippage_pct=30` for buys, `slippage_pct=99.9` for sells. Lower values frequently trip a precision check that has nothing to do with actual slippage.
174-
</Warning>
168+
<Note>
169+
**Market orders are budget-capped, not price-capped.** A market buy spends exactly `amount` USDC and a market sell sells exactly `amount` shares — the on-chain authorization caps the spend, so `slippage_pct` is not needed (and is ignored) for market orders. Hosted **limit** orders are not yet available and currently return `501`; use the self-hosted server for resting limit orders.
170+
</Note>
175171

176172
## 6. Verify the fill
177173

178-
Hosted positions appear immediately on `fetch_positions`. The position's `quantity` and `notional` reflect the escrow-side accounting.
174+
Hosted positions appear immediately on `fetch_positions`. The position's `size` is your share count; the remaining USDC sits in `fetch_balance`.
179175

180176
<CodeGroup>
181177
```python Python
182178
positions = client.fetch_positions()
183179
for p in positions:
184-
print(f"{p.market_id} qty={p.quantity} notional={p.notional}")
180+
print(f"{p.market_id} {p.outcome_label} size={p.size}")
181+
182+
balances = client.fetch_balance()
183+
print(f"Escrow USDC left: {balances[0].available}")
185184
```
186185

187186
```typescript TypeScript
188187
const positions = await client.fetchPositions();
189188
for (const p of positions) {
190-
console.log(`${p.marketId} qty=${p.quantity} notional=${p.notional}`);
189+
console.log(`${p.marketId} ${p.outcomeLabel} size=${p.size}`);
191190
}
191+
192+
const balances = await client.fetchBalance();
193+
console.log(`Escrow USDC left: ${balances[0].available}`);
192194
```
193195
</CodeGroup>
194196

package-lock.json

Lines changed: 9 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

sdks/python/pmxt/_hosted_mappers.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,16 @@ def order_to_v0(order: Order | Mapping[str, Any]) -> dict[str, Any]:
8080
def user_trade_from_v0(payload: Mapping[str, Any] | Any) -> UserTrade:
8181
"""Map a ``UserTradeV0`` JSON object to :class:`pmxt.models.UserTrade`."""
8282
data = _as_dict(payload)
83+
raw_amount = _float_or_none(data.get("amount"))
8384
values = {
8485
"id": _str_or_none(data.get("id")),
8586
"timestamp": _timestamp_to_ms(data.get("timestamp")),
8687
"price": _float_or_none(data.get("price")),
87-
"amount": _float_or_none(data.get("amount")),
88+
# The v0 wire sends trade amounts in 6-dec micro-shares (verified
89+
# live: 58139533.0 == 58.139533 shares, matching the same position's
90+
# decimal ``shares``). Normalize so UserTrade.amount means shares,
91+
# like everywhere else in the SDK.
92+
"amount": raw_amount / 1_000_000 if raw_amount is not None else None,
8893
"side": data.get("side") or "unknown",
8994
"order_id": _str_or_none(data.get("order_id")),
9095
"market_id": _str_or_none(data.get("market_id")),
@@ -101,12 +106,14 @@ def user_trade_from_v0(payload: Mapping[str, Any] | Any) -> UserTrade:
101106
def user_trade_to_v0(trade: UserTrade | Mapping[str, Any]) -> dict[str, Any]:
102107
"""Map :class:`pmxt.models.UserTrade` back to a ``UserTradeV0`` object."""
103108
data = _as_dict(trade)
109+
decimal_amount = _float_or_none(data.get("amount"))
104110
out = {
105111
"id": _str_or_none(data.get("id")),
106112
"market_id": _str_or_none(data.get("market_id")),
107113
"outcome_id": _str_or_none(data.get("outcome_id")),
108114
"side": data.get("side"),
109-
"amount": _float_or_none(data.get("amount")),
115+
# Inverse of user_trade_from_v0: decimal shares -> 6-dec micro-shares.
116+
"amount": round(decimal_amount * 1_000_000) if decimal_amount is not None else None,
110117
"price": _float_or_none(data.get("price")),
111118
"fee": _float_or_none(data.get("fee")),
112119
"timestamp": _ms_to_timestamp(data.get("timestamp")),

sdks/python/pmxt/_hosted_typeddata.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,24 @@ def _validate_worst_price(
419419
build_response: Any,
420420
) -> None:
421421
worst_price = Decimal(_message_int(message, "worst_price", "worstPrice")) / _SIX_DEC_SCALE
422+
423+
# Hosted MARKET orders pin worst_price to the tick-grid extreme by design
424+
# ("textbook market semantics"): the binding user protection is
425+
# max_cost_usdc (buys) / shares_6dec (sells), validated above. A slippage
426+
# bound on worst_price would reject every server-built market order, so
427+
# only sanity-check the price domain here.
428+
order_type = str(
429+
_first_present(
430+
_value(build_request, "order_type"),
431+
_value(build_request, "orderType"),
432+
"market",
433+
)
434+
).lower()
435+
if order_type == "market":
436+
if not (Decimal("0") < worst_price < Decimal("1")):
437+
_economic_fail(f"worst_price expected within (0, 1) got {worst_price}")
438+
return
439+
422440
slippage_pct = _decimal(
423441
_first_present(
424442
_value(build_request, "slippage_pct"),

sdks/python/pmxt/client.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2963,7 +2963,9 @@ def create_order(
29632963
"""
29642964
Create a new order.
29652965
2966-
Not available through the hosted API — trades execute locally.
2966+
In hosted mode (``pmxt_api_key`` + ``wallet_address`` + ``private_key``)
2967+
this wraps build -> local EIP-712 sign -> submit against the hosted
2968+
trading API. Venue-direct mode executes locally with raw credentials.
29672969
29682970
You can specify the market either with explicit market_id/outcome_id,
29692971
or by passing an outcome object directly (e.g., market.yes).

sdks/python/tests/test_hosted_mappers.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,8 @@ def _user_trade_v0(timestamp="2026-06-08T10:12:13.456Z"):
8484
"market_id": "market-003",
8585
"outcome_id": "outcome-yes",
8686
"side": "sell",
87-
"amount": 4.5,
87+
# Wire amounts are 6-dec micro-shares; the SDK normalizes to decimal.
88+
"amount": 4_500_000,
8889
"price": 0.71,
8990
"fee": 0.02,
9091
"timestamp": timestamp,

sdks/python/tests/test_hosted_typeddata.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -469,15 +469,54 @@ def test_validate_economics_rejects_tampered_max_cost_usdc() -> None:
469469
)
470470

471471

472-
def test_validate_economics_rejects_worst_price_outside_slippage_bound() -> None:
472+
def test_validate_economics_rejects_worst_price_outside_slippage_bound_for_limit() -> None:
473473
fixture = _fixture("polymarket_buy")
474+
build_request = {**_copy(fixture["build_request"]), "order_type": "limit"}
474475
response = _with_response_field(
475476
fixture["build_response"],
476477
"worstPrice",
477478
"worst_price",
478479
900_000,
479480
)
480481

482+
with pytest.raises(InvalidSignature):
483+
validate_economics(
484+
route=fixture["route"],
485+
build_request=build_request,
486+
build_response=response,
487+
)
488+
489+
490+
def test_validate_economics_accepts_pinned_worst_price_for_market_orders() -> None:
491+
"""Hosted market orders pin worst_price to the tick-grid extreme by
492+
design — max_cost_usdc is the binding user protection. The validator
493+
must NOT apply the limit-order slippage bound (regression: every
494+
documented quickstart market buy was rejected pre-sign)."""
495+
fixture = _fixture("polymarket_buy")
496+
response = _with_response_field(
497+
fixture["build_response"],
498+
"worstPrice",
499+
"worst_price",
500+
999_000,
501+
)
502+
503+
validate_economics(
504+
route=fixture["route"],
505+
build_request=_copy(fixture["build_request"]),
506+
build_response=response,
507+
)
508+
509+
510+
@pytest.mark.parametrize("pinned", [0, 1_000_000, 2_000_000])
511+
def test_validate_economics_rejects_market_worst_price_outside_domain(pinned: int) -> None:
512+
fixture = _fixture("polymarket_buy")
513+
response = _with_response_field(
514+
fixture["build_response"],
515+
"worstPrice",
516+
"worst_price",
517+
pinned,
518+
)
519+
481520
with pytest.raises(InvalidSignature):
482521
validate_economics(
483522
route=fixture["route"],

sdks/typescript/pmxt/hosted-mappers.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,11 @@ export function userTradeFromV0(payload: Record<string, unknown>): UserTrade {
108108
const trade: UserTrade = {
109109
id: strOrEmpty(payload["id"]),
110110
price: floatOrZero(payload["price"]),
111-
amount: floatOrZero(payload["amount"]),
111+
// The v0 wire sends trade amounts in 6-dec micro-shares (verified
112+
// live: 58139533.0 == 58.139533 shares, matching the same position's
113+
// decimal `shares`). Normalize so UserTrade.amount means shares,
114+
// like everywhere else in the SDK.
115+
amount: floatOrZero(payload["amount"]) / 1_000_000,
112116
side,
113117
timestamp: timestampToMs(payload["timestamp"]),
114118
};
@@ -130,7 +134,8 @@ export function userTradeToV0(trade: UserTrade): Record<string, unknown> {
130134
const out: Record<string, unknown> = {
131135
id: trade.id,
132136
side: trade.side,
133-
amount: trade.amount,
137+
// Inverse of userTradeFromV0: decimal shares -> 6-dec micro-shares.
138+
amount: Math.round(trade.amount * 1_000_000),
134139
price: trade.price,
135140
timestamp: msToTimestamp(trade.timestamp),
136141
};

sdks/typescript/pmxt/hosted-typed-data.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,28 @@ function validateWorstPrice(
442442
): void {
443443
const worstPriceMicro = messageBigInt(message, "worst_price", "worstPrice");
444444
const worstPrice = Number(worstPriceMicro) / SIX_DEC_DIVISOR;
445+
446+
// Hosted MARKET orders pin worst_price to the tick-grid extreme by
447+
// design ("textbook market semantics"): the binding user protection is
448+
// max_cost_usdc (buys) / shares_6dec (sells), validated above. A
449+
// slippage bound on worst_price would reject every server-built market
450+
// order, so only sanity-check the price domain here.
451+
const orderType = String(
452+
firstPresent(
453+
getField(buildRequest, "order_type"),
454+
getField(buildRequest, "orderType"),
455+
"market",
456+
),
457+
).toLowerCase();
458+
if (orderType === "market") {
459+
if (!(worstPrice > 0 && worstPrice < 1)) {
460+
economicFail(
461+
`worst_price expected within (0, 1) got ${worstPrice}`,
462+
);
463+
}
464+
return;
465+
}
466+
445467
const slippagePctRaw = firstPresent(
446468
getField(buildRequest, "slippage_pct"),
447469
getField(buildRequest, "slippagePct"),

0 commit comments

Comments
 (0)