From 4298626a01f57e91cceab9f4a06607d146529a12 Mon Sep 17 00:00:00 2001 From: Vaggelis Date: Sat, 22 Aug 2026 21:52:03 +0300 Subject: [PATCH 1/2] fix(python): forward outcome_id to SOR buildOrder on explicit-ID path Python's hosted SOR escape path dropped the outcome identifier when create_order was called with explicit market_id/outcome_id: _execute_sor_order built the /api/sor/buildOrder params without ever reading kwargs["outcome_id"], and its None-filter stripped the always-None "outcome" key, so the request went out with no outcome identifier at all. TypeScript forwards the full caller input unchanged, so outcomeId always reaches the wire there. Add outcomeId to the explicit-ID branch of _execute_sor_order. The existing None-filter keeps every other call shape byte-identical. Add a dispatch test that mocks the requests calls made by the SOR path and asserts the captured buildOrder payload contains the caller-supplied outcomeId for both the explicit-ID shape (fails before the fix) and the MarketOutcome shorthand (control). Fixes #2025 --- sdks/python/pmxt/client.py | 2 +- sdks/python/tests/test_sor_dispatch.py | 138 +++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 sdks/python/tests/test_sor_dispatch.py diff --git a/sdks/python/pmxt/client.py b/sdks/python/pmxt/client.py index 96e1e56d..6aa51580 100644 --- a/sdks/python/pmxt/client.py +++ b/sdks/python/pmxt/client.py @@ -3058,7 +3058,7 @@ def _execute_sor_order(self, **kwargs) -> "Order": if o is not None and hasattr(o, "market_id"): params = {"marketId": o.market_id, "outcomeId": o.outcome_id, "side": kwargs.get("side", "buy"), "shares": kwargs.get("amount", 0)} else: - params = {"marketId": kwargs.get("market_id"), "side": kwargs.get("side", "buy"), "outcome": kwargs.get("outcome"), "shares": kwargs.get("amount", 0)} + params = {"marketId": kwargs.get("market_id"), "outcomeId": kwargs.get("outcome_id"), "side": kwargs.get("side", "buy"), "outcome": kwargs.get("outcome"), "shares": kwargs.get("amount", 0)} if kwargs.get("price") is not None: params["price"] = kwargs["price"] if kwargs.get("tick_size") is not None: diff --git a/sdks/python/tests/test_sor_dispatch.py b/sdks/python/tests/test_sor_dispatch.py new file mode 100644 index 00000000..38c5122b --- /dev/null +++ b/sdks/python/tests/test_sor_dispatch.py @@ -0,0 +1,138 @@ +"""Dispatch wiring tests for the hosted SOR order escape path. + +These tests verify that ``create_order`` on a hosted +``Exchange(exchange_name="sor")`` client forwards the caller-supplied +market/outcome identifiers into the ``/api/sor/buildOrder`` request body for +every call shape (explicit ``market_id``/``outcome_id`` and the ``outcome=`` +``MarketOutcome`` shorthand). They deliberately mock the lowest reasonable +HTTP layer for this code path (the ``requests`` calls made inside +``_execute_sor_order`` and ``_discover_hosted_account``) so the SDK's real +request construction runs end-to-end without hitting the network. +""" + +from __future__ import annotations + +import json +from typing import Any, Dict, List, Tuple + +import pytest +import requests + +from pmxt.client import Exchange +from pmxt.models import MarketOutcome + + +PMXT_API_KEY = "test_pmxt_key_xxx" +PRIVATE_KEY = "0x" + "aa" * 32 +MARKET_ID = "663583" +OUTCOME_ID = ( + "109918491002757971128382877245540674222904658464100708363625306033376633332" +) + + +class _FakeResponse: + """Minimal stand-in for a ``requests.Response``.""" + + def __init__(self, payload: Dict[str, Any], status_code: int = 200) -> None: + self._payload = payload + self.status_code = status_code + self.ok = status_code < 400 + self.text = json.dumps(payload) + + def json(self) -> Dict[str, Any]: + return self._payload + + +def _install_sor_transport( + monkeypatch: pytest.MonkeyPatch, +) -> List[Tuple[str, Dict[str, Any]]]: + """Patch ``requests.get``/``requests.post`` and capture every POST body.""" + captured: List[Tuple[str, Dict[str, Any]]] = [] + + def fake_get(url: str, **kwargs: Any) -> _FakeResponse: + # Hosted account discovery: no deposit wallet configured. + return _FakeResponse({}, status_code=503) + + def fake_post(url: str, **kwargs: Any) -> _FakeResponse: + captured.append((url, kwargs["json"])) + if url.endswith("/api/sor/buildOrder"): + return _FakeResponse({ + "data": {"orderId": "sor-order-1", "legs": []}, + }) + return _FakeResponse({ + "data": { + "id": "sor-order-1", + "status": "filled", + "filled_shares": 5.0, + "average_price": 0.55, + "fee_amount": 0, + }, + }) + + monkeypatch.setattr(requests, "get", fake_get) + monkeypatch.setattr(requests, "post", fake_post) + return captured + + +def _make_sor_exchange(monkeypatch: pytest.MonkeyPatch) -> Exchange: + """Construct a hosted sor-mode client with a private key, no sidecar.""" + monkeypatch.delenv("PMXT_API_KEY", raising=False) + monkeypatch.delenv("PMXT_BASE_URL", raising=False) + return Exchange( + exchange_name="sor", + pmxt_api_key=PMXT_API_KEY, + private_key=PRIVATE_KEY, + # Stub signer satisfies the hosted signer gate without eth-account. + signer=object(), + auto_start_server=False, + ) + + +class TestSorCreateOrderDispatch: + """create_order must forward the outcome identifier on every call shape.""" + + def test_explicit_market_and_outcome_ids_reach_build_order(self, monkeypatch): + captured = _install_sor_transport(monkeypatch) + api = _make_sor_exchange(monkeypatch) + + api.create_order( + market_id=MARKET_ID, + outcome_id=OUTCOME_ID, + side="buy", + order_type="limit", + amount=5, + price=0.55, + ) + + assert len(captured) == 2 + build_url, build_body = captured[0] + assert build_url.endswith("/api/sor/buildOrder") + args = build_body["args"][0] + assert args["outcomeId"] == OUTCOME_ID + assert args["marketId"] == MARKET_ID + assert args["side"] == "buy" + assert args["shares"] == 5 + assert args["price"] == 0.55 + + def test_outcome_shorthand_still_reaches_build_order(self, monkeypatch): + captured = _install_sor_transport(monkeypatch) + api = _make_sor_exchange(monkeypatch) + + api.create_order( + outcome=MarketOutcome( + outcome_id=OUTCOME_ID, + label="Yes", + price=0.55, + market_id=MARKET_ID, + ), + side="buy", + order_type="market", + amount=5, + ) + + assert len(captured) == 2 + build_url, build_body = captured[0] + assert build_url.endswith("/api/sor/buildOrder") + args = build_body["args"][0] + assert args["outcomeId"] == OUTCOME_ID + assert args["marketId"] == MARKET_ID From 42ac6f023b26b29155b5cc0ea87f2085da3627b4 Mon Sep 17 00:00:00 2001 From: Vaggelis Date: Sat, 22 Aug 2026 22:18:11 +0300 Subject: [PATCH 2/2] test(python): pin SOR buildOrder payload omits null outcome keys Add a market_id-only dispatch case asserting outcomeId and outcome are absent from the request when no outcome was supplied, so removal of the None-filter in _execute_sor_order cannot regress the wire payload to an explicit null. --- sdks/python/tests/test_sor_dispatch.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/sdks/python/tests/test_sor_dispatch.py b/sdks/python/tests/test_sor_dispatch.py index 38c5122b..e4790aa4 100644 --- a/sdks/python/tests/test_sor_dispatch.py +++ b/sdks/python/tests/test_sor_dispatch.py @@ -114,6 +114,28 @@ def test_explicit_market_and_outcome_ids_reach_build_order(self, monkeypatch): assert args["shares"] == 5 assert args["price"] == 0.55 + def test_market_id_only_call_omits_outcome_key_entirely(self, monkeypatch): + # The None-filter must strip "outcome"/"outcomeId" when no outcome was + # supplied, so the hosted API never receives an explicit null. + captured = _install_sor_transport(monkeypatch) + api = _make_sor_exchange(monkeypatch) + + api.create_order( + market_id=MARKET_ID, + side="buy", + order_type="limit", + amount=5, + price=0.55, + ) + + assert len(captured) == 2 + build_url, build_body = captured[0] + assert build_url.endswith("/api/sor/buildOrder") + args = build_body["args"][0] + assert "outcomeId" not in args + assert "outcome" not in args + assert args["marketId"] == MARKET_ID + def test_outcome_shorthand_still_reaches_build_order(self, monkeypatch): captured = _install_sor_transport(monkeypatch) api = _make_sor_exchange(monkeypatch)