From 19dfadafb90866affcbeb14365324bbc9be95a7c Mon Sep 17 00:00:00 2001 From: Cesare Naldi <3353250+cesarenaldi@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:51:35 +0200 Subject: [PATCH 1/7] feat(client): add perps support Port Perps support from the TypeScript SDK to the async clients: - Public reads on AsyncPublicClient and AsyncSecureClient: instruments, tickers (with statistics merge), book, fees, and paginated candles, funding history, and trades with SDK-owned opaque cursors. - Perps market data streaming through subscribe() with per-topic specs (trades, bbo, book, candles, tickers, statistics), channel dedupe, and reconnect with resubscribe. - Delegated credential lifecycle: open_perps_session creates or resumes credentials (CreateProxy typed-data signature, validation, default 7d expiry) and revoke_perps_credentials signs deleteProxy ops. - PerpsSession trading over WebSocket with EIP-712 signed msgpack ops: place_order (gtc/ioc/fok overloads, optional TP/SL triggers), post_orders, place_position_tp_sl, cancel_order(s), update_leverage, session account reads, and an async-iterable event stream with sequence-gap and reconnect resync events. - Funds: deposit_to_perps (EOA broadcast or gasless relayer call) and withdraw_from_perps (owner-signed Withdraw typed data). Signing is pinned byte-for-byte against golden vectors generated from the TypeScript implementation (msgpack encoding, Op data hash, and signatures). Verified with ruff, pyright, the unit suite, and the live integration suite including a 10 USDC deposit/withdraw roundtrip and GTC and TP/SL order place/cancel against production. --- pyproject.toml | 1 + src/polymarket/__init__.py | 44 + .../_internal/actions/perps/__init__.py | 0 .../_internal/actions/perps/account.py | 370 +++++++ .../_internal/actions/perps/credentials.py | 174 ++++ .../_internal/actions/perps/funds.py | 115 +++ .../_internal/actions/perps/paging.py | 88 ++ .../_internal/actions/perps/public.py | 298 ++++++ .../_internal/actions/perps/signing.py | 195 ++++ .../_internal/actions/perps/trading.py | 156 +++ src/polymarket/_internal/context.py | 1 + src/polymarket/_internal/perps_session.py | 949 ++++++++++++++++++ .../_internal/streams/perps/__init__.py | 0 .../_internal/streams/perps/heartbeat.py | 65 ++ .../_internal/streams/perps/market.py | 209 ++++ .../streams/perps/market_protocol.py | 111 ++ src/polymarket/clients/async_public.py | 157 ++- src/polymarket/clients/async_secure.py | 275 ++++- src/polymarket/environments.py | 3 + src/polymarket/models/__init__.py | 126 +++ src/polymarket/models/perps/__init__.py | 177 ++++ src/polymarket/models/perps/_validators.py | 64 ++ src/polymarket/models/perps/account.py | 163 +++ src/polymarket/models/perps/credentials.py | 46 + src/polymarket/models/perps/events.py | 367 +++++++ src/polymarket/models/perps/funds.py | 80 ++ src/polymarket/models/perps/market.py | 225 +++++ src/polymarket/models/perps/orders.py | 166 +++ src/polymarket/models/perps/requests.py | 122 +++ src/polymarket/models/perps/results.py | 41 + src/polymarket/models/perps/types.py | 72 ++ src/polymarket/perps.py | 18 + src/polymarket/streams/__init__.py | 54 +- src/polymarket/streams/_specs.py | 121 ++- tests/integration/test_perps.py | 283 ++++++ tests/unit/test_builder_trades.py | 1 + tests/unit/test_perps_credentials.py | 221 ++++ tests/unit/test_perps_events.py | 152 +++ tests/unit/test_perps_funds.py | 154 +++ tests/unit/test_perps_market_stream.py | 215 ++++ tests/unit/test_perps_public_actions.py | 224 +++++ tests/unit/test_perps_session.py | 366 +++++++ tests/unit/test_perps_signing_golden.py | 197 ++++ tests/unit/test_perps_trading_commands.py | 175 ++++ tests/unit/test_streams_subscribe_router.py | 56 ++ uv.lock | 65 ++ 46 files changed, 7140 insertions(+), 22 deletions(-) create mode 100644 src/polymarket/_internal/actions/perps/__init__.py create mode 100644 src/polymarket/_internal/actions/perps/account.py create mode 100644 src/polymarket/_internal/actions/perps/credentials.py create mode 100644 src/polymarket/_internal/actions/perps/funds.py create mode 100644 src/polymarket/_internal/actions/perps/paging.py create mode 100644 src/polymarket/_internal/actions/perps/public.py create mode 100644 src/polymarket/_internal/actions/perps/signing.py create mode 100644 src/polymarket/_internal/actions/perps/trading.py create mode 100644 src/polymarket/_internal/perps_session.py create mode 100644 src/polymarket/_internal/streams/perps/__init__.py create mode 100644 src/polymarket/_internal/streams/perps/heartbeat.py create mode 100644 src/polymarket/_internal/streams/perps/market.py create mode 100644 src/polymarket/_internal/streams/perps/market_protocol.py create mode 100644 src/polymarket/models/perps/__init__.py create mode 100644 src/polymarket/models/perps/_validators.py create mode 100644 src/polymarket/models/perps/account.py create mode 100644 src/polymarket/models/perps/credentials.py create mode 100644 src/polymarket/models/perps/events.py create mode 100644 src/polymarket/models/perps/funds.py create mode 100644 src/polymarket/models/perps/market.py create mode 100644 src/polymarket/models/perps/orders.py create mode 100644 src/polymarket/models/perps/requests.py create mode 100644 src/polymarket/models/perps/results.py create mode 100644 src/polymarket/models/perps/types.py create mode 100644 src/polymarket/perps.py create mode 100644 tests/integration/test_perps.py create mode 100644 tests/unit/test_perps_credentials.py create mode 100644 tests/unit/test_perps_events.py create mode 100644 tests/unit/test_perps_funds.py create mode 100644 tests/unit/test_perps_market_stream.py create mode 100644 tests/unit/test_perps_public_actions.py create mode 100644 tests/unit/test_perps_session.py create mode 100644 tests/unit/test_perps_signing_golden.py create mode 100644 tests/unit/test_perps_trading_commands.py diff --git a/pyproject.toml b/pyproject.toml index 8716732..d767508 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ dependencies = [ "eth-account>=0.13,<1", "eth-utils>=4,<6", "httpx[http2]>=0.27,<1", + "msgpack>=1,<2", "pydantic>=2,<3", "websockets>=13,<16", ] diff --git a/src/polymarket/__init__.py b/src/polymarket/__init__.py index 75b19cb..064d9a3 100644 --- a/src/polymarket/__init__.py +++ b/src/polymarket/__init__.py @@ -83,6 +83,28 @@ OrderResponseErrorCode, OrderSide, OrderType, + PerpsBalance, + PerpsBook, + PerpsCandle, + PerpsCredentials, + PerpsDeposit, + PerpsFeeScheduleEntry, + PerpsFill, + PerpsFundingPayment, + PerpsFundingRate, + PerpsInstrument, + PerpsOrder, + PerpsOrderPlacement, + PerpsOrderRequest, + PerpsPlacedTpSlOrder, + PerpsPlacedTpSlOrders, + PerpsPortfolio, + PerpsPosition, + PerpsPositionTpSlTrigger, + PerpsTicker, + PerpsTpSlTrigger, + PerpsTrade, + PerpsWithdrawal, PortfolioValue, Position, PositionId, @@ -247,6 +269,28 @@ "OrderType", "Page", "Paginator", + "PerpsBalance", + "PerpsBook", + "PerpsCandle", + "PerpsCredentials", + "PerpsDeposit", + "PerpsFeeScheduleEntry", + "PerpsFill", + "PerpsFundingPayment", + "PerpsFundingRate", + "PerpsInstrument", + "PerpsOrder", + "PerpsOrderPlacement", + "PerpsOrderRequest", + "PerpsPlacedTpSlOrder", + "PerpsPlacedTpSlOrders", + "PerpsPortfolio", + "PerpsPosition", + "PerpsPositionTpSlTrigger", + "PerpsTicker", + "PerpsTpSlTrigger", + "PerpsTrade", + "PerpsWithdrawal", "PolymarketError", "PortfolioValue", "Position", diff --git a/src/polymarket/_internal/actions/perps/__init__.py b/src/polymarket/_internal/actions/perps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/polymarket/_internal/actions/perps/account.py b/src/polymarket/_internal/actions/perps/account.py new file mode 100644 index 0000000..8e509c3 --- /dev/null +++ b/src/polymarket/_internal/actions/perps/account.py @@ -0,0 +1,370 @@ +"""Perps authenticated account reads.""" + +import time +from collections.abc import Callable +from datetime import datetime +from typing import Any, TypeVar, cast + +from polymarket._internal.actions.perps.paging import ( + NINETY_DAYS_MS, + ONE_DAY_MS, + as_json_dict, + decode_perps_cursor, + encode_perps_cursor, + interval_ms, + parse_data_envelope, + to_epoch_ms, +) +from polymarket.clients._transport import AsyncTransport +from polymarket.errors import UserInputError +from polymarket.models.base import BaseModel +from polymarket.models.perps.account import ( + PerpsAccountConfig, + PerpsAccountStats, + PerpsBalance, + PerpsEquityPoint, + PerpsFundingPayment, + PerpsPnlPoint, + PerpsPortfolio, +) +from polymarket.models.perps.funds import PerpsDeposit, PerpsWithdrawal +from polymarket.models.perps.orders import PerpsFill, PerpsOrder +from polymarket.models.perps.requests import validate_client_order_id +from polymarket.models.perps.types import ( + PerpsDepositStatus, + PerpsPnlInterval, + PerpsWithdrawalStatus, +) +from polymarket.pagination import AsyncPaginator, Page + +_M = TypeVar("_M", bound=BaseModel) + +_PNL_INTERVALS = ("1h", "4h", "1d", "1w") +_FUND_STATUSES = ("pending", "confirmed", "removed") + + +async def fetch_balances(api: AsyncTransport) -> tuple[PerpsBalance, ...]: + return PerpsBalance.parse_response_list(await api.get_json("/v1/account/balances")) + + +async def fetch_portfolio(api: AsyncTransport) -> PerpsPortfolio: + return PerpsPortfolio.parse_response(await api.get_json("/v1/account/portfolio")) + + +async def fetch_stats(api: AsyncTransport) -> PerpsAccountStats: + return PerpsAccountStats.parse_response(await api.get_json("/v1/account/stats")) + + +async def fetch_account_config( + api: AsyncTransport, *, instrument_id: int | None = None +) -> tuple[PerpsAccountConfig, ...]: + return PerpsAccountConfig.parse_response_list( + await api.get_json("/v1/account/config", params={"instrument_id": instrument_id}) + ) + + +async def fetch_open_orders( + api: AsyncTransport, *, instrument_id: int | None = None +) -> tuple[PerpsOrder, ...]: + return PerpsOrder.parse_response_list( + await api.get_json("/v1/account/open-orders", params={"instrument_id": instrument_id}) + ) + + +async def fetch_orders( + api: AsyncTransport, + *, + order_id: int | None = None, + client_order_id: str | None = None, + instrument_id: int | None = None, + start: datetime | int | None = None, + end: datetime | int | None = None, +) -> tuple[PerpsOrder, ...]: + if client_order_id is not None: + validate_client_order_id(client_order_id) + return PerpsOrder.parse_response_list( + await api.get_json( + "/v1/account/orders", + params={ + "order_id": order_id, + "client_order_id": client_order_id, + "instrument_id": instrument_id, + "start_timestamp": to_epoch_ms("start", start), + "end_timestamp": to_epoch_ms("end", end), + }, + ) + ) + + +def list_fills( + api: AsyncTransport, + *, + start: datetime | int | None = None, + end: datetime | int | None = None, +) -> AsyncPaginator[PerpsFill]: + return _descending_history( + api, + path="/v1/account/fills", + kind="perpsFills", + model=PerpsFill, + default_window_ms=ONE_DAY_MS, + start=start, + end=end, + get_key=lambda item: str(item.get("trade_id")), + get_timestamp=lambda item: item.get("timestamp"), + ) + + +def list_funding_payments( + api: AsyncTransport, + *, + instrument_id: int | None = None, + start: datetime | int | None = None, + end: datetime | int | None = None, +) -> AsyncPaginator[PerpsFundingPayment]: + return _descending_history( + api, + path="/v1/account/funding", + kind="perpsFundingPayments", + model=PerpsFundingPayment, + default_window_ms=ONE_DAY_MS, + start=start, + end=end, + get_key=lambda item: ( + f"{item.get('instrument_id')}:{item.get('timestamp')}:{item.get('funding')}" + ), + get_timestamp=lambda item: item.get("timestamp"), + extra_params={"instrument_id": instrument_id}, + ) + + +def list_deposits( + api: AsyncTransport, + *, + deposit_status: PerpsDepositStatus | None = None, + hash: str | None = None, + start: datetime | int | None = None, + end: datetime | int | None = None, +) -> AsyncPaginator[PerpsDeposit]: + if deposit_status is not None and deposit_status not in _FUND_STATUSES: + raise UserInputError( + f"deposit_status must be one of {list(_FUND_STATUSES)}, got {deposit_status!r}" + ) + return _descending_history( + api, + path="/v1/account/deposits", + kind="perpsDeposits", + model=PerpsDeposit, + default_window_ms=NINETY_DAYS_MS, + start=start, + end=end, + get_key=lambda item: str(item.get("hash")), + get_timestamp=lambda item: item.get("confirmed_timestamp") or item.get("created_timestamp"), + extra_params={"deposit_status": deposit_status, "hash": hash}, + ) + + +def list_withdrawals( + api: AsyncTransport, + *, + withdrawal_status: PerpsWithdrawalStatus | None = None, + hash: str | None = None, + start: datetime | int | None = None, + end: datetime | int | None = None, +) -> AsyncPaginator[PerpsWithdrawal]: + if withdrawal_status is not None and withdrawal_status not in _FUND_STATUSES: + raise UserInputError( + f"withdrawal_status must be one of {list(_FUND_STATUSES)}, got {withdrawal_status!r}" + ) + return _descending_history( + api, + path="/v1/account/withdrawals", + kind="perpsWithdrawals", + model=PerpsWithdrawal, + default_window_ms=NINETY_DAYS_MS, + start=start, + end=end, + get_key=lambda item: str(item.get("withdraw_id")), + get_timestamp=lambda item: item.get("confirmed_timestamp") or item.get("created_timestamp"), + extra_params={"withdrawal_status": withdrawal_status, "hash": hash}, + ) + + +def list_equity_history( + api: AsyncTransport, + *, + interval: PerpsPnlInterval, + start: datetime | int, + end: datetime | int | None = None, +) -> AsyncPaginator[PerpsEquityPoint]: + return _ascending_history( + api, + path="/v1/account/equity", + kind="perpsEquityHistory", + model=PerpsEquityPoint, + interval=interval, + start=start, + end=end, + ) + + +def list_pnl_history( + api: AsyncTransport, + *, + interval: PerpsPnlInterval, + start: datetime | int, + end: datetime | int | None = None, +) -> AsyncPaginator[PerpsPnlPoint]: + return _ascending_history( + api, + path="/v1/account/pnl", + kind="perpsPnlHistory", + model=PerpsPnlPoint, + interval=interval, + start=start, + end=end, + ) + + +def _descending_history( + api: AsyncTransport, + *, + path: str, + kind: str, + model: type[_M], + default_window_ms: int, + start: datetime | int | None, + end: datetime | int | None, + get_key: Callable[[dict[str, Any]], str], + get_timestamp: Callable[[dict[str, Any]], Any], + extra_params: dict[str, Any] | None = None, +) -> AsyncPaginator[_M]: + start_ms = to_epoch_ms("start", start) + end_ms = to_epoch_ms("end", end) + initial_extra = {key: value for key, value in (extra_params or {}).items() if value is not None} + + async def fetch(cursor: str | None) -> Page[_M]: + if cursor is None: + now = int(time.time() * 1000) + state: dict[str, Any] = { + "kind": kind, + "start_timestamp": start_ms if start_ms is not None else now - default_window_ms, + "end_timestamp": end_ms if end_ms is not None else now, + "seen_keys": [], + **initial_extra, + } + else: + state = decode_perps_cursor(cursor, kind=kind) + params = { + key: value + for key, value in state.items() + if key not in ("kind", "seen_keys") and value is not None + } + data, more = parse_data_envelope(await api.get_json(path, params=params)) + seen: set[str] = set(cast("list[str]", state.get("seen_keys", []))) + fresh: list[dict[str, Any]] = [] + for raw_item in data: + item = as_json_dict(raw_item) + if item is not None and get_key(item) not in seen: + fresh.append(item) + items = tuple(model.parse_response(item) for item in fresh) + raw_last = as_json_dict(data[-1]) if data else None + last = fresh[-1] if fresh else None + cursor_ts = ( + get_timestamp(last) + if last is not None + else get_timestamp(raw_last) + if raw_last is not None + else None + ) + has_more = more and isinstance(cursor_ts, int) and cursor_ts > int(state["start_timestamp"]) + if not has_more or not isinstance(cursor_ts, int): + return Page(items=items, has_more=False) + next_state: dict[str, Any] + if last is None: + next_state = {**state, "end_timestamp": cursor_ts - 1, "seen_keys": []} + else: + boundary: set[str] = ( + set(cast("list[str]", state.get("seen_keys", []))) + if state["end_timestamp"] == cursor_ts + else set() + ) + for item in fresh: + if get_timestamp(item) == cursor_ts: + boundary.add(get_key(item)) + next_state = {**state, "end_timestamp": cursor_ts, "seen_keys": sorted(boundary)} + return Page(items=items, has_more=True, next_cursor=encode_perps_cursor(next_state)) + + return AsyncPaginator(fetch=fetch) + + +def _ascending_history( + api: AsyncTransport, + *, + path: str, + kind: str, + model: type[_M], + interval: PerpsPnlInterval, + start: datetime | int, + end: datetime | int | None, +) -> AsyncPaginator[_M]: + if interval not in _PNL_INTERVALS: + raise UserInputError(f"interval must be one of {list(_PNL_INTERVALS)}, got {interval!r}") + start_ms = to_epoch_ms("start", start) + end_ms = to_epoch_ms("end", end) + + async def fetch(cursor: str | None) -> Page[_M]: + if cursor is None: + state: dict[str, Any] = { + "kind": kind, + "interval": interval, + "start_timestamp": start_ms, + "end_timestamp": end_ms if end_ms is not None else int(time.time() * 1000), + } + else: + state = decode_perps_cursor(cursor, kind=kind) + data, more = parse_data_envelope( + await api.get_json( + path, + params={ + "interval": state["interval"], + "start_timestamp": state["start_timestamp"], + "end_timestamp": state["end_timestamp"], + }, + ) + ) + items = tuple(model.parse_response(item) for item in data) + last_ts = _point_timestamp(data[-1]) if data else None + has_more = more and isinstance(last_ts, int) and last_ts < int(state["end_timestamp"]) + next_cursor = None + if has_more and isinstance(last_ts, int): + next_cursor = encode_perps_cursor( + {**state, "start_timestamp": last_ts + interval_ms(state["interval"])} + ) + return Page(items=items, has_more=has_more, next_cursor=next_cursor) + + return AsyncPaginator(fetch=fetch) + + +def _point_timestamp(item: object) -> int | None: + if isinstance(item, list): + entries = cast("list[object]", item) + if entries and isinstance(entries[0], int): + return entries[0] + return None + + +__all__ = [ + "fetch_account_config", + "fetch_balances", + "fetch_open_orders", + "fetch_orders", + "fetch_portfolio", + "fetch_stats", + "list_deposits", + "list_equity_history", + "list_fills", + "list_funding_payments", + "list_pnl_history", + "list_withdrawals", +] diff --git a/src/polymarket/_internal/actions/perps/credentials.py b/src/polymarket/_internal/actions/perps/credentials.py new file mode 100644 index 0000000..e29b5fe --- /dev/null +++ b/src/polymarket/_internal/actions/perps/credentials.py @@ -0,0 +1,174 @@ +"""Perps delegated credentials lifecycle.""" + +import secrets +from datetime import UTC, datetime, timedelta +from typing import cast + +from eth_account import Account +from eth_account.signers.local import LocalAccount + +from polymarket._internal.actions.perps.paging import as_json_dict +from polymarket._internal.actions.perps.signing import ( + build_perps_create_proxy_typed_data, + now_ms, + random_perps_salt, + sign_owner_typed_data, + sign_perps_op, +) +from polymarket.clients._transport import AsyncTransport +from polymarket.errors import ( + RequestRejectedError, + UnexpectedResponseError, + UserInputError, +) +from polymarket.models.perps.account import PerpsCredentialsInfo +from polymarket.models.perps.credentials import PerpsCredentials + +DEFAULT_CREDENTIAL_TTL = timedelta(days=7) + + +def credential_headers(credentials: PerpsCredentials) -> dict[str, str]: + return { + "POLYMARKET-PROXY": credentials.proxy, + "POLYMARKET-SECRET": credentials.secret, + } + + +async def create_credentials( + perps: AsyncTransport, + *, + signer: LocalAccount, + chain_id: int, + expires_in: timedelta, + label: str | None, +) -> PerpsCredentials: + if expires_in <= timedelta(0): + raise UserInputError("expires_in must be positive") + if label is not None and not label: + raise UserInputError("label must be non-empty when provided") + private_key = "0x" + secrets.token_bytes(32).hex() + proxy = cast(LocalAccount, Account.from_key(private_key)).address + timestamp = now_ms() + expires_at_ms = timestamp + int(expires_in.total_seconds() * 1000) + salt = random_perps_salt() + signature = sign_owner_typed_data( + signer, + build_perps_create_proxy_typed_data( + chain_id=chain_id, + proxy=proxy, + expires_at_ms=expires_at_ms, + salt=salt, + timestamp_ms=timestamp, + ), + what="Perps proxy credentials request", + ) + body: dict[str, object] = { + "op": { + "type": "createProxy", + "args": {"expiry": expires_at_ms, "owner": signer.address, "proxy": proxy}, + }, + "salt": salt, + "sig": signature, + "ts": timestamp, + } + if label is not None: + body["label"] = label + response = as_json_dict(await perps.post_json("/v1/account/proxy", json=body)) + secret = response.get("secret") if response is not None else None + if not isinstance(secret, str) or not secret: + raise UnexpectedResponseError("Perps credentials response did not match expected shape") + credentials = PerpsCredentials( + proxy=proxy, + private_key=private_key, + secret=secret, + expires_at=datetime.fromtimestamp(expires_at_ms / 1000, tz=UTC), + ) + return await validate_credentials(perps, signer_address=signer.address, credentials=credentials) + + +async def resume_credentials( + perps: AsyncTransport, + *, + signer_address: str, + credentials: PerpsCredentials, +) -> PerpsCredentials: + try: + derived = cast(LocalAccount, Account.from_key(credentials.private_key)).address + except (ValueError, TypeError) as error: + raise UserInputError(f"Invalid Perps credentials private key: {error}") from error + if derived.lower() != credentials.proxy.lower(): + raise UserInputError("Perps credentials private key does not match the proxy address.") + return await validate_credentials(perps, signer_address=signer_address, credentials=credentials) + + +async def validate_credentials( + perps: AsyncTransport, + *, + signer_address: str, + credentials: PerpsCredentials, +) -> PerpsCredentials: + response = PerpsCredentialsInfo.parse_response( + await perps.get_json("/v1/account/credentials", headers=credential_headers(credentials)) + ) + if response.address.lower() != signer_address.lower(): + raise UnexpectedResponseError("Perps credentials belong to a different signer account.") + proxy_key = next( + (key for key in response.keys if key.proxy.lower() == credentials.proxy.lower()), + None, + ) + if proxy_key is None: + raise UnexpectedResponseError("Perps credentials were not returned by the API.") + if proxy_key.expires_at <= datetime.now(tz=UTC): + raise UnexpectedResponseError("Perps credentials are expired.") + return credentials.model_copy(update={"expires_at": proxy_key.expires_at}) + + +async def revoke_credentials( + perps: AsyncTransport, + *, + signer: LocalAccount, + chain_id: int, + proxy: str, +) -> None: + if not isinstance(proxy, str) or not proxy.startswith("0x") or len(proxy) != 42: # pyright: ignore[reportUnnecessaryIsInstance] + raise UserInputError(f"proxy must be an EVM address, got {proxy!r}") + salt = random_perps_salt() + timestamp = now_ms() + signature = sign_perps_op( + signer, + chain_id=chain_id, + op=["deleteProxy", [proxy]], + salt=salt, + timestamp_ms=timestamp, + ) + response = as_json_dict( + await perps.delete_json( + "/v1/account/proxy", + json={ + "op": {"type": "deleteProxy", "args": {"proxy": proxy}}, + "salt": salt, + "sig": signature, + "ts": timestamp, + }, + ) + ) + if response is None or response.get("status") not in ("ok", "err"): + raise UnexpectedResponseError( + "Perps credentials revocation response did not match expected shape" + ) + if response["status"] == "err": + error = response.get("error") + raise RequestRejectedError( + str(error) if error else "Perps credentials revocation was rejected.", + status=200, + ) + + +__all__ = [ + "DEFAULT_CREDENTIAL_TTL", + "create_credentials", + "credential_headers", + "resume_credentials", + "revoke_credentials", + "validate_credentials", +] diff --git a/src/polymarket/_internal/actions/perps/funds.py b/src/polymarket/_internal/actions/perps/funds.py new file mode 100644 index 0000000..4e8dcd7 --- /dev/null +++ b/src/polymarket/_internal/actions/perps/funds.py @@ -0,0 +1,115 @@ +"""Perps collateral movement helpers.""" + +import time +from typing import cast + +from eth_abi.abi import encode as abi_encode +from eth_account.signers.local import LocalAccount +from eth_utils.crypto import keccak + +from polymarket._internal.actions.perps.paging import as_json_dict +from polymarket._internal.actions.perps.signing import ( + build_perps_withdraw_typed_data, + random_perps_salt, + sign_owner_typed_data, +) +from polymarket._internal.actions.relayer.calls import MAX_UINT256, TransactionCall +from polymarket.clients._transport import AsyncTransport +from polymarket.errors import ( + RequestRejectedError, + UnexpectedResponseError, + UserInputError, +) +from polymarket.models.perps.types import PerpsWithdrawalId +from polymarket.types import EvmAddress, HexString + +_PERPS_DEPOSIT_SELECTOR = keccak(b"deposit(address,uint256,address)")[:4] + + +def perps_deposit_call( + *, + deposit_contract: EvmAddress, + token: EvmAddress, + amount: int, + to: EvmAddress, +) -> TransactionCall: + if isinstance(amount, bool) or not isinstance(amount, int): # pyright: ignore[reportUnnecessaryIsInstance] + raise UserInputError("Perps deposit amount must be an int") + if amount <= 0: + raise UserInputError("Perps deposit amount must be positive") + if amount > MAX_UINT256: + raise UserInputError("Perps deposit amount exceeds uint256 range") + payload = _PERPS_DEPOSIT_SELECTOR + abi_encode( + ["address", "uint256", "address"], [str(token), amount, str(to)] + ) + return TransactionCall( + to=deposit_contract, + data=cast(HexString, "0x" + payload.hex()), + ) + + +async def withdraw_from_perps( + perps: AsyncTransport, + *, + signer: LocalAccount, + chain_id: int, + deposit_contract: str, + token: str, + amount: int, + to: str, +) -> PerpsWithdrawalId: + if isinstance(amount, bool) or not isinstance(amount, int): # pyright: ignore[reportUnnecessaryIsInstance] + raise UserInputError("Perps withdrawal amount must be an int") + if amount <= 0: + raise UserInputError("Perps withdrawal amount must be positive") + if amount > MAX_UINT256: + raise UserInputError("Perps withdrawal amount exceeds uint256 range") + timestamp_s = int(time.time()) + salt = random_perps_salt() + signature = sign_owner_typed_data( + signer, + build_perps_withdraw_typed_data( + chain_id=chain_id, + deposit_contract=deposit_contract, + account=signer.address, + token=token, + amount=amount, + to=to, + salt=salt, + timestamp_s=timestamp_s, + ), + what="Perps withdrawal request", + ) + response = as_json_dict( + await perps.post_json( + "/v1/account/withdraw", + json={ + "op": { + "type": "withdraw", + "args": { + "account": signer.address, + "token": token, + "amount": str(amount), + "to": to, + }, + }, + "salt": salt, + "sig": signature, + "ts": timestamp_s, + }, + ) + ) + if response is None or response.get("status") not in ("ok", "err"): + raise UnexpectedResponseError("Perps withdrawal response did not match expected shape") + if response["status"] == "err": + error = response.get("error") + raise RequestRejectedError( + str(error) if error else "Perps withdrawal was rejected.", status=200 + ) + withdrawal_id = response.get("withdraw_id") + if isinstance(withdrawal_id, bool) or not isinstance(withdrawal_id, int): + raise UnexpectedResponseError("Perps withdrawal response did not include a withdrawal ID.") + return PerpsWithdrawalId(withdrawal_id) + + +__all__ = ["perps_deposit_call", "withdraw_from_perps"] diff --git a/src/polymarket/_internal/actions/perps/paging.py b/src/polymarket/_internal/actions/perps/paging.py new file mode 100644 index 0000000..67352f4 --- /dev/null +++ b/src/polymarket/_internal/actions/perps/paging.py @@ -0,0 +1,88 @@ +"""Cursor and timestamp helpers for Perps pagination.""" + +import base64 +import binascii +import json +from datetime import datetime +from typing import Any, cast + +from polymarket.errors import UserInputError + +ONE_DAY_MS = 24 * 60 * 60 * 1000 +NINETY_DAYS_MS = 90 * ONE_DAY_MS + +_INTERVAL_MS: dict[str, int] = { + "1s": 1000, + "1m": 60 * 1000, + "5m": 5 * 60 * 1000, + "15m": 15 * 60 * 1000, + "1h": 60 * 60 * 1000, + "4h": 4 * 60 * 60 * 1000, + "1d": ONE_DAY_MS, + "1w": 7 * ONE_DAY_MS, +} + + +def interval_ms(interval: str) -> int: + return _INTERVAL_MS[interval] + + +def to_epoch_ms(name: str, value: datetime | int | None) -> int | None: + if value is None: + return None + if isinstance(value, datetime): + return int(value.timestamp() * 1000) + if isinstance(value, bool) or not isinstance(value, int): # pyright: ignore[reportUnnecessaryIsInstance] + raise UserInputError(f"{name} must be a datetime or epoch-ms int") + if value < 0: + raise UserInputError(f"{name} must be non-negative") + return value + + +def encode_perps_cursor(state: dict[str, Any]) -> str: + return base64.b64encode(json.dumps(state, separators=(",", ":")).encode("utf-8")).decode( + "ascii" + ) + + +def as_json_dict(value: object) -> dict[str, Any] | None: + """Return the value as a JSON object dict, or None when it is not one.""" + if isinstance(value, dict): + return cast("dict[str, Any]", value) + return None + + +def decode_perps_cursor(cursor: str, *, kind: str) -> dict[str, Any]: + try: + decoded = json.loads(base64.b64decode(cursor, validate=True)) + except (binascii.Error, ValueError, UnicodeDecodeError) as error: + raise UserInputError("Invalid Perps pagination cursor") from error + state = as_json_dict(decoded) + if state is None or state.get("kind") != kind: + raise UserInputError("Invalid Perps pagination cursor") + return state + + +def parse_data_envelope(payload: object) -> tuple[list[object], bool]: + from polymarket.errors import UnexpectedResponseError + + envelope = as_json_dict(payload) + if envelope is None: + raise UnexpectedResponseError("Perps list response did not match expected shape") + data = envelope.get("data") + more = envelope.get("more") + if not isinstance(data, list) or not isinstance(more, bool): + raise UnexpectedResponseError("Perps list response did not match expected shape") + return cast("list[object]", data), more + + +__all__ = [ + "NINETY_DAYS_MS", + "ONE_DAY_MS", + "as_json_dict", + "decode_perps_cursor", + "encode_perps_cursor", + "interval_ms", + "parse_data_envelope", + "to_epoch_ms", +] diff --git a/src/polymarket/_internal/actions/perps/public.py b/src/polymarket/_internal/actions/perps/public.py new file mode 100644 index 0000000..a128acc --- /dev/null +++ b/src/polymarket/_internal/actions/perps/public.py @@ -0,0 +1,298 @@ +"""Perps public market data reads.""" + +import asyncio +import time +from datetime import datetime +from typing import Any, cast + +from polymarket._internal.actions.perps.paging import ( + ONE_DAY_MS, + as_json_dict, + decode_perps_cursor, + encode_perps_cursor, + interval_ms, + parse_data_envelope, + to_epoch_ms, +) +from polymarket.clients._transport import AsyncTransport +from polymarket.errors import UnexpectedResponseError, UserInputError +from polymarket.models.perps.market import ( + PerpsBook, + PerpsCandle, + PerpsFeeScheduleEntry, + PerpsFundingRate, + PerpsInstrument, + PerpsStatistic, + PerpsTicker, + PerpsTrade, +) +from polymarket.models.perps.types import ( + PerpsBookDepth, + PerpsInstrumentCategory, + PerpsKlineInterval, +) +from polymarket.pagination import AsyncPaginator, Page + +_BOOK_DEPTHS = (10, 100, 500, 1000) +_CATEGORIES = ("equity", "commodity", "index", "crypto") +_KLINE_INTERVALS = ("1s", "1m", "5m", "15m", "1h", "4h", "1d", "1w") + + +def _validate_instrument_id(instrument_id: object, *, optional: bool = False) -> int | None: + if instrument_id is None and optional: + return None + if isinstance(instrument_id, bool) or not isinstance(instrument_id, int): + raise UserInputError("instrument_id must be an int") + if instrument_id < 0: + raise UserInputError("instrument_id must be non-negative") + return instrument_id + + +async def fetch_instruments( + perps: AsyncTransport, + *, + instrument_id: int | None = None, + category: PerpsInstrumentCategory | None = None, +) -> tuple[PerpsInstrument, ...]: + _validate_instrument_id(instrument_id, optional=True) + if category is not None and category not in _CATEGORIES: + raise UserInputError(f"category must be one of {list(_CATEGORIES)}, got {category!r}") + payload = await perps.get_json( + "/v1/info/instruments", + params={"instrument_id": instrument_id, "category": category}, + ) + return PerpsInstrument.parse_response_list(payload) + + +async def fetch_tickers( + perps: AsyncTransport, *, instrument_id: int | None = None +) -> tuple[PerpsTicker, ...]: + _validate_instrument_id(instrument_id, optional=True) + params = {"instrument_id": instrument_id} + tickers_payload, statistics_payload = await asyncio.gather( + perps.get_json("/v1/info/tickers", params=params), + perps.get_json("/v1/info/statistics", params=params), + ) + statistics = PerpsStatistic.parse_response_list(statistics_payload) + by_instrument = {statistic.instrument_id: statistic for statistic in statistics} + if not isinstance(tickers_payload, list): + raise UnexpectedResponseError("PerpsTicker response did not match expected shape") + tickers: list[PerpsTicker] = [] + for item in cast("list[object]", tickers_payload): + ticker = PerpsTicker.parse_response(item) + statistic = by_instrument.get(ticker.instrument_id) + if statistic is not None: + ticker = ticker.model_copy( + update={"open_price": statistic.open_price, "volume_24h": statistic.volume} + ) + tickers.append(ticker) + return tuple(tickers) + + +async def fetch_ticker(perps: AsyncTransport, *, instrument_id: int) -> PerpsTicker: + _validate_instrument_id(instrument_id) + tickers = await fetch_tickers(perps, instrument_id=instrument_id) + if not tickers: + raise UnexpectedResponseError(f"Perps ticker {instrument_id} was not returned by the API") + return tickers[0] + + +async def fetch_book( + perps: AsyncTransport, *, instrument_id: int, depth: PerpsBookDepth = 100 +) -> PerpsBook: + _validate_instrument_id(instrument_id) + if depth not in _BOOK_DEPTHS: + raise UserInputError(f"depth must be one of {list(_BOOK_DEPTHS)}, got {depth!r}") + payload = await perps.get_json( + "/v1/info/book", params={"instrument_id": instrument_id, "depth": depth} + ) + return PerpsBook.parse_response(payload) + + +async def fetch_fees(perps: AsyncTransport) -> tuple[PerpsFeeScheduleEntry, ...]: + payload = as_json_dict(await perps.get_json("/v1/info/fees")) + if payload is None or not isinstance(payload.get("fee_schedule"), list): + raise UnexpectedResponseError("Perps fees response did not match expected shape") + return PerpsFeeScheduleEntry.parse_response_list(payload["fee_schedule"]) + + +def list_candles( + perps: AsyncTransport, + *, + instrument_id: int, + interval: PerpsKlineInterval, + start: datetime | int | None = None, + end: datetime | int | None = None, +) -> AsyncPaginator[PerpsCandle]: + _validate_instrument_id(instrument_id) + if interval not in _KLINE_INTERVALS: + raise UserInputError(f"interval must be one of {list(_KLINE_INTERVALS)}, got {interval!r}") + start_ms = to_epoch_ms("start", start) + end_ms = to_epoch_ms("end", end) + + async def fetch(cursor: str | None) -> Page[PerpsCandle]: + if cursor is None: + now = int(time.time() * 1000) + state: dict[str, Any] = { + "kind": "perpsCandles", + "instrument_id": instrument_id, + "interval": interval, + "start_timestamp": start_ms if start_ms is not None else now - ONE_DAY_MS, + "end_timestamp": end_ms if end_ms is not None else now, + } + else: + state = decode_perps_cursor(cursor, kind="perpsCandles") + payload = await perps.get_json( + "/v1/info/klines", + params={ + "instrument_id": state["instrument_id"], + "interval": state["interval"], + "start_timestamp": state["start_timestamp"], + "end_timestamp": state["end_timestamp"], + }, + ) + data, more = parse_data_envelope(payload) + items = tuple(PerpsCandle.parse_response(item) for item in data) + last_ts = _tuple_timestamp(data[-1]) if data else None + has_more = more and last_ts is not None + next_cursor = None + if has_more and last_ts is not None: + next_cursor = encode_perps_cursor( + {**state, "start_timestamp": last_ts + interval_ms(state["interval"])} + ) + return Page(items=items, has_more=has_more, next_cursor=next_cursor) + + return AsyncPaginator(fetch=fetch) + + +def list_funding_history( + perps: AsyncTransport, + *, + instrument_id: int, + start: datetime | int | None = None, + end: datetime | int | None = None, +) -> AsyncPaginator[PerpsFundingRate]: + _validate_instrument_id(instrument_id) + start_ms = to_epoch_ms("start", start) + end_ms = to_epoch_ms("end", end) + + async def fetch(cursor: str | None) -> Page[PerpsFundingRate]: + if cursor is None: + now = int(time.time() * 1000) + state: dict[str, Any] = { + "kind": "perpsFundingHistory", + "instrument_id": instrument_id, + "start_timestamp": start_ms if start_ms is not None else now - ONE_DAY_MS, + "end_timestamp": end_ms if end_ms is not None else now, + } + else: + state = decode_perps_cursor(cursor, kind="perpsFundingHistory") + payload = await perps.get_json( + "/v1/info/funding", + params={ + "instrument_id": state["instrument_id"], + "start_timestamp": state["start_timestamp"], + "end_timestamp": state["end_timestamp"], + }, + ) + data, more = parse_data_envelope(payload) + items = tuple(PerpsFundingRate.parse_response(item) for item in data) + raw_last = as_json_dict(data[-1]) if data else None + last_ts = raw_last.get("timestamp") if raw_last is not None else None + has_more = more and isinstance(last_ts, int) and last_ts > int(state["start_timestamp"]) + next_cursor = None + if has_more and isinstance(last_ts, int): + next_cursor = encode_perps_cursor({**state, "end_timestamp": last_ts - 1}) + return Page(items=items, has_more=has_more, next_cursor=next_cursor) + + return AsyncPaginator(fetch=fetch) + + +def list_trades( + perps: AsyncTransport, + *, + instrument_id: int, + start: datetime | int | None = None, + end: datetime | int | None = None, +) -> AsyncPaginator[PerpsTrade]: + _validate_instrument_id(instrument_id) + start_ms = to_epoch_ms("start", start) + end_ms = to_epoch_ms("end", end) + + async def fetch(cursor: str | None) -> Page[PerpsTrade]: + if cursor is None: + now = int(time.time() * 1000) + state: dict[str, Any] = { + "kind": "perpsTrades", + "instrument_id": instrument_id, + "start_timestamp": start_ms if start_ms is not None else now - ONE_DAY_MS, + "end_timestamp": end_ms if end_ms is not None else now, + "seen_trade_ids": [], + } + else: + state = decode_perps_cursor(cursor, kind="perpsTrades") + payload = await perps.get_json( + "/v1/info/trades", + params={ + "instrument_id": state["instrument_id"], + "start_timestamp": state["start_timestamp"], + "end_timestamp": state["end_timestamp"], + }, + ) + data, more = parse_data_envelope(payload) + seen: set[int] = set(cast("list[int]", state.get("seen_trade_ids", []))) + fresh: list[dict[str, Any]] = [] + for raw_item in data: + item = as_json_dict(raw_item) + if item is not None and item.get("trade_id") not in seen: + fresh.append(item) + items = tuple(PerpsTrade.parse_response(item) for item in fresh) + raw_last = as_json_dict(data[-1]) if data else None + raw_last_ts = raw_last.get("timestamp") if raw_last is not None else None + last = fresh[-1] if fresh else None + cursor_ts = last.get("timestamp") if last is not None else raw_last_ts + has_more = more and isinstance(cursor_ts, int) and cursor_ts > int(state["start_timestamp"]) + next_cursor = None + if has_more and isinstance(cursor_ts, int): + next_state: dict[str, Any] + if last is None: + next_state = {**state, "end_timestamp": cursor_ts - 1, "seen_trade_ids": []} + else: + boundary: set[int] = ( + set(cast("list[int]", state.get("seen_trade_ids", []))) + if state["end_timestamp"] == cursor_ts + else set() + ) + for item in fresh: + trade_id = item.get("trade_id") + if item.get("timestamp") == cursor_ts and isinstance(trade_id, int): + boundary.add(trade_id) + next_state = { + **state, + "end_timestamp": cursor_ts, + "seen_trade_ids": sorted(boundary), + } + next_cursor = encode_perps_cursor(next_state) + return Page(items=items, has_more=has_more, next_cursor=next_cursor) + + return AsyncPaginator(fetch=fetch) + + +def _tuple_timestamp(item: object) -> int | None: + if isinstance(item, list): + entries = cast("list[object]", item) + if entries and isinstance(entries[0], int): + return entries[0] + return None + + +__all__ = [ + "fetch_book", + "fetch_fees", + "fetch_instruments", + "fetch_ticker", + "fetch_tickers", + "list_candles", + "list_funding_history", + "list_trades", +] diff --git a/src/polymarket/_internal/actions/perps/signing.py b/src/polymarket/_internal/actions/perps/signing.py new file mode 100644 index 0000000..7443bf4 --- /dev/null +++ b/src/polymarket/_internal/actions/perps/signing.py @@ -0,0 +1,195 @@ +"""Perps command signing. + +Trading commands and owner operations are signed as EIP-712 ``Op`` payloads +whose ``data`` field commits to the msgpack encoding of the positional +command tuple. +""" + +import secrets +import time +from collections.abc import Mapping, Sequence +from typing import Any, TypeAlias, cast + +import msgpack # pyright: ignore[reportMissingTypeStubs] +from eth_account import Account +from eth_account.signers.local import LocalAccount +from eth_utils.crypto import keccak + +from polymarket.errors import SigningError +from polymarket.types import HexString + +PerpsSignableValue: TypeAlias = ( + "bool | int | str | None | Sequence[PerpsSignableValue] | Mapping[str, PerpsSignableValue]" +) +PerpsSignedOp: TypeAlias = Sequence[PerpsSignableValue] + +_EIP712_DOMAIN_FIELDS = [ + {"name": "name", "type": "string"}, + {"name": "version", "type": "string"}, + {"name": "chainId", "type": "uint256"}, +] +_OP_FIELDS = [ + {"name": "data", "type": "bytes32"}, + {"name": "salt", "type": "uint64"}, + {"name": "ts", "type": "uint64"}, +] + + +def compact_signable_value(value: PerpsSignableValue) -> PerpsSignableValue: + """Drop ``None`` entries from sequences recursively, matching the server.""" + if isinstance(value, (list, tuple)): + return [compact_signable_value(item) for item in value if item is not None] + return value + + +def hash_perps_op(op: PerpsSignableValue) -> HexString: + packed = cast( + bytes, + msgpack.packb(compact_signable_value(op)), # pyright: ignore[reportUnknownMemberType] + ) + return HexString("0x" + keccak(packed).hex()) + + +def build_perps_op_typed_data( + *, chain_id: int, op: PerpsSignableValue, salt: int, timestamp_ms: int +) -> dict[str, Any]: + return { + "types": {"EIP712Domain": _EIP712_DOMAIN_FIELDS, "Op": _OP_FIELDS}, + "primaryType": "Op", + "domain": {"name": "Polymarket", "version": "1", "chainId": chain_id}, + "message": {"data": hash_perps_op(op), "salt": salt, "ts": timestamp_ms}, + } + + +def sign_perps_op( + signer: LocalAccount, + *, + chain_id: int, + op: PerpsSignableValue, + salt: int, + timestamp_ms: int, +) -> HexString: + payload = build_perps_op_typed_data( + chain_id=chain_id, op=op, salt=salt, timestamp_ms=timestamp_ms + ) + try: + signed = signer.sign_typed_data(full_message=payload) + except Exception as error: + raise SigningError(f"Could not sign the Perps command: {error}") from error + return _to_hex_signature(bytes(signed.signature)) + + +def sign_perps_op_with_key( + private_key: str, + *, + chain_id: int, + op: PerpsSignableValue, + salt: int, + timestamp_ms: int, +) -> HexString: + try: + signer = Account.from_key(private_key) + except (ValueError, TypeError) as error: + raise SigningError(f"Invalid Perps session private key: {error}") from error + return sign_perps_op(signer, chain_id=chain_id, op=op, salt=salt, timestamp_ms=timestamp_ms) + + +def build_perps_create_proxy_typed_data( + *, chain_id: int, proxy: str, expires_at_ms: int, salt: int, timestamp_ms: int +) -> dict[str, Any]: + return { + "types": { + "EIP712Domain": _EIP712_DOMAIN_FIELDS, + "CreateProxy": [ + {"name": "addr", "type": "address"}, + {"name": "exp", "type": "uint64"}, + {"name": "salt", "type": "uint64"}, + {"name": "ts", "type": "uint64"}, + ], + }, + "primaryType": "CreateProxy", + "domain": {"name": "Polymarket", "version": "1", "chainId": chain_id}, + "message": {"addr": proxy, "exp": expires_at_ms, "salt": salt, "ts": timestamp_ms}, + } + + +def build_perps_withdraw_typed_data( + *, + chain_id: int, + deposit_contract: str, + account: str, + token: str, + amount: int, + to: str, + salt: int, + timestamp_s: int, +) -> dict[str, Any]: + return { + "types": { + "EIP712Domain": [ + *_EIP712_DOMAIN_FIELDS, + {"name": "verifyingContract", "type": "address"}, + ], + "Withdraw": [ + {"name": "account", "type": "address"}, + {"name": "token", "type": "address"}, + {"name": "amount", "type": "uint256"}, + {"name": "fee", "type": "uint256"}, + {"name": "to", "type": "address"}, + {"name": "salt", "type": "uint64"}, + {"name": "ts", "type": "uint64"}, + ], + }, + "primaryType": "Withdraw", + "domain": { + "name": "Polymarket", + "version": "1", + "chainId": chain_id, + "verifyingContract": deposit_contract, + }, + "message": { + "account": account, + "token": token, + "amount": amount, + "fee": 0, + "to": to, + "salt": salt, + "ts": timestamp_s, + }, + } + + +def sign_owner_typed_data(signer: LocalAccount, payload: dict[str, Any], *, what: str) -> HexString: + try: + signed = signer.sign_typed_data(full_message=payload) + except Exception as error: + raise SigningError(f"Could not sign the {what}: {error}") from error + return _to_hex_signature(bytes(signed.signature)) + + +def random_perps_salt() -> int: + return secrets.randbits(32) + + +def now_ms() -> int: + return int(time.time() * 1000) + + +def _to_hex_signature(signature: bytes) -> HexString: + return HexString("0x" + signature.hex()) + + +__all__ = [ + "PerpsSignableValue", + "PerpsSignedOp", + "build_perps_create_proxy_typed_data", + "build_perps_op_typed_data", + "build_perps_withdraw_typed_data", + "compact_signable_value", + "hash_perps_op", + "now_ms", + "random_perps_salt", + "sign_owner_typed_data", + "sign_perps_op", + "sign_perps_op_with_key", +] diff --git a/src/polymarket/_internal/actions/perps/trading.py b/src/polymarket/_internal/actions/perps/trading.py new file mode 100644 index 0000000..ab81330 --- /dev/null +++ b/src/polymarket/_internal/actions/perps/trading.py @@ -0,0 +1,156 @@ +"""Perps trading command construction. + +Signed commands are positional tuples (the signed form); the WebSocket frame +body carries an equivalent keyed form. Both are produced here so the session +can sign one and send the other. +""" + +from collections.abc import Sequence +from typing import Any + +from polymarket.errors import UserInputError +from polymarket.models.perps.requests import ( + PerpsOrderRequest, + PerpsPositionTpSlTrigger, + PerpsTpSlTrigger, + to_decimal_string, + validate_client_order_id, +) +from polymarket.models.perps.types import PerpsTpSlKind, PerpsTpSlScope + +RawPerpsOrder = list[Any] +"""Positional order row: [iid, buy, price?, qty, tif?, post_only, reduce_only?, +client_order_id?, trigger?]. ``None`` holes are compacted before signing.""" + + +def to_raw_order(request: PerpsOrderRequest) -> RawPerpsOrder: + return [ + request.instrument_id, + request.side == "BUY", + None if request.price is None else to_decimal_string("price", request.price), + to_decimal_string("quantity", request.quantity), + request.time_in_force, + request.post_only, + None, + request.client_order_id, + None, + ] + + +def to_raw_tp_sl_order( + *, + buy: bool, + instrument_id: int, + kind: PerpsTpSlKind, + quantity: str, + trigger: PerpsTpSlTrigger | PerpsPositionTpSlTrigger, +) -> RawPerpsOrder: + limit_price = getattr(trigger, "limit_price", None) + return [ + instrument_id, + buy, + None if limit_price is None else to_decimal_string("limit_price", limit_price), + quantity, + None, + False, + True, + None, + [ + True if limit_price is None else None, + to_decimal_string("trigger_price", trigger.trigger_price), + kind, + ], + ] + + +def create_orders_op( + orders: Sequence[RawPerpsOrder], *, group: PerpsTpSlScope | None = None +) -> list[Any]: + op: list[Any] = ["createOrders", list(orders)] + if group is not None: + op.append(group) + return op + + +def cancel_orders_op(order_ids: Sequence[int]) -> list[Any]: + ids: list[int] = [] + for order_id in order_ids: + if isinstance(order_id, bool) or not isinstance(order_id, int): # pyright: ignore[reportUnnecessaryIsInstance] + raise UserInputError("order_ids must contain ints") + ids.append(order_id) + if not ids: + raise UserInputError("order_ids must be non-empty") + return ["cancelOrders", ids] + + +def cancel_orders_by_client_id_op(client_order_ids: Sequence[str]) -> list[Any]: + ids = [validate_client_order_id(client_order_id) for client_order_id in client_order_ids] + if not ids: + raise UserInputError("client_order_ids must be non-empty") + return ["cancelOrdersCOID", ids] + + +def update_leverage_op(*, instrument_id: int, leverage: int, cross_margin: bool) -> list[Any]: + if isinstance(instrument_id, bool) or not isinstance(instrument_id, int): # pyright: ignore[reportUnnecessaryIsInstance] + raise UserInputError("instrument_id must be an int") + if isinstance(leverage, bool) or not isinstance(leverage, int) or leverage <= 0: # pyright: ignore[reportUnnecessaryIsInstance] + raise UserInputError("leverage must be a positive int") + if not isinstance(cross_margin, bool): # pyright: ignore[reportUnnecessaryIsInstance] + raise UserInputError("cross_margin must be a bool") + return ["updateLeverage", [instrument_id, leverage, cross_margin]] + + +def to_command_body_op(op: Sequence[Any]) -> dict[str, Any]: + """Convert a signed positional op into the keyed frame body op.""" + op_type = op[0] + if op_type == "createOrders": + body: dict[str, Any] = { + "type": op_type, + "args": [_to_order_body(row) for row in op[1]], + } + if len(op) > 2: + body["grp"] = op[2] + return body + if op_type in ("cancelOrders", "cancelOrdersCOID"): + return {"type": op_type, "args": op[1]} + if op_type == "updateLeverage": + instrument_id, leverage, cross_margin = op[1] + return { + "type": op_type, + "args": {"cross": cross_margin, "iid": instrument_id, "lev": leverage}, + } + raise RuntimeError(f"Unsupported Perps command: {op_type!r}") + + +def _to_order_body(row: RawPerpsOrder) -> dict[str, Any]: + body: dict[str, Any] = {"iid": row[0], "buy": row[1], "po": row[5], "qty": row[3]} + if row[4] is not None: + body["tif"] = row[4] + if row[6]: + body["ro"] = row[6] + if row[2] is not None: + body["p"] = row[2] + if row[7] is not None: + body["c"] = row[7] + if row[8] is not None: + body["tr"] = _to_trigger_body(row[8]) + return body + + +def _to_trigger_body(trigger: list[Any]) -> dict[str, Any]: + body: dict[str, Any] = {"tpsl": trigger[2], "trp": trigger[1]} + if trigger[0] is not None: + body["market"] = trigger[0] + return body + + +__all__ = [ + "RawPerpsOrder", + "cancel_orders_by_client_id_op", + "cancel_orders_op", + "create_orders_op", + "to_command_body_op", + "to_raw_order", + "to_raw_tp_sl_order", + "update_leverage_op", +] diff --git a/src/polymarket/_internal/context.py b/src/polymarket/_internal/context.py index 8bfe098..7502a02 100644 --- a/src/polymarket/_internal/context.py +++ b/src/polymarket/_internal/context.py @@ -41,6 +41,7 @@ class AsyncClientContext: data: AsyncTransport rfq: AsyncTransport clob: AsyncTransport + perps: AsyncTransport @dataclass(frozen=True, slots=True) diff --git a/src/polymarket/_internal/perps_session.py b/src/polymarket/_internal/perps_session.py new file mode 100644 index 0000000..0151da4 --- /dev/null +++ b/src/polymarket/_internal/perps_session.py @@ -0,0 +1,949 @@ +"""Authenticated Perps trading session over WebSocket.""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass +from datetime import datetime +from types import TracebackType +from typing import Any, Literal, Self, cast, overload + +from polymarket._internal.actions.perps import account as _account +from polymarket._internal.actions.perps.paging import to_epoch_ms +from polymarket._internal.actions.perps.signing import ( + now_ms, + random_perps_salt, + sign_perps_op_with_key, +) +from polymarket._internal.actions.perps.trading import ( + RawPerpsOrder, + cancel_orders_by_client_id_op, + cancel_orders_op, + create_orders_op, + to_command_body_op, + to_raw_order, + to_raw_tp_sl_order, + update_leverage_op, +) +from polymarket._internal.streams.perps.heartbeat import PerpsWebSocketHeartbeat +from polymarket._internal.streams.reconnect import ReconnectScheduler +from polymarket._internal.ws.connection import AsyncWebSocketConnection +from polymarket.clients._transport import AsyncTransport +from polymarket.errors import ( + RequestRejectedError, + TransportError, + UserInputError, +) +from polymarket.errors import ( + TimeoutError as SDKTimeoutError, +) +from polymarket.models.perps.account import ( + PerpsAccountConfig, + PerpsAccountStats, + PerpsBalance, + PerpsEquityPoint, + PerpsFundingPayment, + PerpsPnlPoint, + PerpsPortfolio, +) +from polymarket.models.perps.credentials import PerpsCredentials +from polymarket.models.perps.events import ( + PerpsOrderEvent, + PerpsResyncEvent, + PerpsSessionEvent, + parse_perps_session_event, +) +from polymarket.models.perps.funds import PerpsDeposit, PerpsWithdrawal +from polymarket.models.perps.orders import ( + PerpsCancelOrderResult, + PerpsFill, + PerpsOrder, + PerpsPostOrderAck, + PerpsUpdateLeverageResult, +) +from polymarket.models.perps.requests import ( + DecimalInput, + PerpsOrderRequest, + PerpsPositionTpSlTrigger, + PerpsTpSlTrigger, + to_decimal_string, +) +from polymarket.models.perps.results import ( + PerpsOrderPlacement, + PerpsPlacedTpSlOrder, + PerpsPlacedTpSlOrders, +) +from polymarket.models.perps.types import ( + PerpsDepositStatus, + PerpsOrderId, + PerpsPnlInterval, + PerpsTimeInForce, + PerpsWithdrawalStatus, +) +from polymarket.models.types import OrderSide +from polymarket.pagination import AsyncPaginator + +_AUTH_TIMEOUT_S = 30.0 +_ACK_TIMEOUT_S = 30.0 +# Purposefully generous: backend order updates are expected in the ~100ms range. +_ORDER_PLACEMENT_UPDATE_TIMEOUT_S = 1.0 +_QUEUE_SIZE = 1024 + +_SESSION_CHANNELS = ( + "balances", + "portfolio", + "orders", + "fills", + "funding", + "deposits", + "withdrawals", + "tpsl", +) + + +class _EndSentinel: + __slots__ = () + + +_END = _EndSentinel() + + +@dataclass(slots=True) +class _PendingRequest: + future: asyncio.Future[Any] + parse: Callable[[object], Any] + + +@dataclass(slots=True) +class _EventWaiter: + future: asyncio.Future[PerpsSessionEvent] + predicate: Callable[[PerpsSessionEvent], bool] + + +class PerpsSession: + """An authenticated Perps account session. + + The session multiplexes trading commands, private account updates, and + account reads over one connection. Iterate over the session to receive + :class:`~polymarket.models.perps.PerpsSessionEvent` updates. + """ + + def __init__( + self, + *, + chain_id: int, + credentials: PerpsCredentials, + rest_url: str, + ws_url: str, + logger: logging.Logger | None = None, + on_close: Callable[[PerpsSession], None] | None = None, + ) -> None: + self._chain_id = chain_id + self._credentials = credentials + self._ws_url = ws_url + self._logger = logger or logging.getLogger("polymarket.perps.session") + self._on_session_close = on_close + self._api = AsyncTransport( + base_url=rest_url, + logger=logger, + header_resolver=self._resolve_auth_headers, + ) + self._connection = AsyncWebSocketConnection( + heartbeat=PerpsWebSocketHeartbeat(), logger=self._logger + ) + self._scheduler = ReconnectScheduler(logger=self._logger) + self._send_lock = asyncio.Lock() + self._queue: asyncio.Queue[PerpsSessionEvent | _EndSentinel] = asyncio.Queue( + maxsize=_QUEUE_SIZE + ) + self._pending: dict[int, _PendingRequest] = {} + self._event_waiters: list[_EventWaiter] = [] + # Order updates can arrive before the post acknowledgement resumes the + # caller; keep a bounded buffer so place_order never misses its update. + self._recent_orders: dict[PerpsOrderId, PerpsOrder] = {} + self._sequences: dict[str, int] = {} + self._next_request_id = 1 + self._closed = False + self._ended = False + self._end_error: BaseException | None = None + self._dropped_events = 0 + + @property + def credentials(self) -> PerpsCredentials: + """Delegated credentials backing this session.""" + return self._credentials + + @property + def closed(self) -> bool: + """Whether the session has been closed.""" + return self._closed + + @property + def dropped_events(self) -> int: + """Number of events dropped because the consumer fell behind.""" + return self._dropped_events + + async def open(self) -> Self: + await self._connect(emit_resync=False) + return self + + async def close(self) -> None: + """Close the session and release its connection.""" + if self._closed: + return + self._closed = True + await self._scheduler.aclose() + self._reject_pending(TransportError("Perps session closed.")) + self._reject_event_waiters(TransportError("Perps session closed.")) + self._end() + await self._connection.close() + await self._api.close() + if self._on_session_close is not None: + self._on_session_close(self) + + def __aiter__(self) -> Self: + return self + + async def __anext__(self) -> PerpsSessionEvent: + item = await self._queue.get() + if isinstance(item, _EndSentinel): + if self._end_error is not None: + raise self._end_error + raise StopAsyncIteration + return item + + async def __aenter__(self) -> Self: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> None: + await self.close() + + @overload + async def place_order( + self, + *, + instrument_id: int, + side: OrderSide, + quantity: DecimalInput, + time_in_force: Literal["gtc"], + price: DecimalInput, + post_only: bool = False, + client_order_id: str | None = None, + take_profit: PerpsTpSlTrigger | None = None, + stop_loss: PerpsTpSlTrigger | None = None, + expires_at: datetime | int | None = None, + ) -> PerpsOrderPlacement: ... + @overload + async def place_order( + self, + *, + instrument_id: int, + side: OrderSide, + quantity: DecimalInput, + time_in_force: Literal["ioc", "fok"], + price: DecimalInput | None = None, + client_order_id: str | None = None, + take_profit: PerpsTpSlTrigger | None = None, + stop_loss: PerpsTpSlTrigger | None = None, + expires_at: datetime | int | None = None, + ) -> PerpsOrderPlacement: ... + async def place_order( + self, + *, + instrument_id: int, + side: OrderSide, + quantity: DecimalInput, + time_in_force: PerpsTimeInForce, + price: DecimalInput | None = None, + post_only: bool = False, + client_order_id: str | None = None, + take_profit: PerpsTpSlTrigger | None = None, + stop_loss: PerpsTpSlTrigger | None = None, + expires_at: datetime | int | None = None, + ) -> PerpsOrderPlacement: + """Place one order and resolve with its first orders-channel update. + + ``gtc`` orders require ``price`` and may set ``post_only``; ``ioc`` and + ``fok`` orders may omit ``price`` for market-style execution. Pass + ``take_profit`` and/or ``stop_loss`` to place reduce-only trigger + orders together with the order. + """ + request = PerpsOrderRequest( + instrument_id=instrument_id, + side=side, + quantity=quantity, + time_in_force=time_in_force, + price=price, + post_only=post_only, + client_order_id=client_order_id, + ) + if take_profit is None and stop_loss is None: + acks = await self._send_create_orders( + [to_raw_order(request)], group=None, expires_at=expires_at + ) + entry = self._expect_ok_ack(acks[0]) + order = await self._wait_for_order_update(entry) + return PerpsOrderPlacement(order=order) + + rows: list[RawPerpsOrder] = [to_raw_order(request)] + exit_buy = request.side == "SELL" + quantity_string = to_decimal_string("quantity", request.quantity) + if take_profit is not None: + rows.append( + to_raw_tp_sl_order( + buy=exit_buy, + instrument_id=request.instrument_id, + kind="tp", + quantity=quantity_string, + trigger=take_profit, + ) + ) + if stop_loss is not None: + rows.append( + to_raw_tp_sl_order( + buy=exit_buy, + instrument_id=request.instrument_id, + kind="sl", + quantity=quantity_string, + trigger=stop_loss, + ) + ) + acks = await self._send_create_orders(rows, group="order", expires_at=expires_at) + placed = [self._expect_ok_ack(ack) for ack in acks] + order = await self._wait_for_order_update(placed[0]) + trigger_index = 1 + take_profit_order = None + stop_loss_order = None + if take_profit is not None: + take_profit_order = PerpsPlacedTpSlOrder(order_id=placed[trigger_index]) + trigger_index += 1 + if stop_loss is not None: + stop_loss_order = PerpsPlacedTpSlOrder(order_id=placed[trigger_index]) + return PerpsOrderPlacement( + order=order, + tp_sl=PerpsPlacedTpSlOrders(take_profit=take_profit_order, stop_loss=stop_loss_order), + ) + + async def post_orders( + self, + orders: Sequence[PerpsOrderRequest], + *, + expires_at: datetime | int | None = None, + ) -> tuple[PerpsPostOrderAck, ...]: + """Post one or more orders and return queue-entry acknowledgements. + + This is a low-level method; :meth:`place_order` is the common path. + """ + if not orders: + raise UserInputError("orders must be non-empty") + acks = await self._send_create_orders( + [to_raw_order(order) for order in orders], group=None, expires_at=expires_at + ) + return tuple(acks) + + async def place_position_tp_sl( + self, + *, + instrument_id: int, + take_profit: PerpsPositionTpSlTrigger | None = None, + stop_loss: PerpsPositionTpSlTrigger | None = None, + expires_at: datetime | int | None = None, + ) -> PerpsPlacedTpSlOrders: + """Protect the current position with take-profit/stop-loss triggers. + + The exit side is inferred from the open position; a flat position + raises :class:`~polymarket.errors.UserInputError`. + """ + if take_profit is None and stop_loss is None: + raise UserInputError("Provide take_profit, stop_loss, or both") + exit_buy = await self._position_exit_buy(instrument_id) + rows: list[RawPerpsOrder] = [] + if take_profit is not None: + rows.append( + to_raw_tp_sl_order( + buy=exit_buy, + instrument_id=instrument_id, + kind="tp", + quantity="0", + trigger=take_profit, + ) + ) + if stop_loss is not None: + rows.append( + to_raw_tp_sl_order( + buy=exit_buy, + instrument_id=instrument_id, + kind="sl", + quantity="0", + trigger=stop_loss, + ) + ) + acks = await self._send_create_orders(rows, group="position", expires_at=expires_at) + placed = [self._expect_ok_ack(ack) for ack in acks] + trigger_index = 0 + take_profit_order = None + stop_loss_order = None + if take_profit is not None: + take_profit_order = PerpsPlacedTpSlOrder(order_id=placed[trigger_index]) + trigger_index += 1 + if stop_loss is not None: + stop_loss_order = PerpsPlacedTpSlOrder(order_id=placed[trigger_index]) + return PerpsPlacedTpSlOrders(take_profit=take_profit_order, stop_loss=stop_loss_order) + + async def cancel_order( + self, + *, + order_id: int | None = None, + client_order_id: str | None = None, + expires_at: datetime | int | None = None, + ) -> PerpsCancelOrderResult: + """Cancel one order by ``order_id`` or ``client_order_id``. + + The returned status reflects whether the cancel happened. + """ + if (order_id is None) == (client_order_id is None): + raise UserInputError("Provide exactly one of order_id or client_order_id") + if order_id is not None: + results = await self.cancel_orders(order_ids=[order_id], expires_at=expires_at) + else: + assert client_order_id is not None + results = await self.cancel_orders( + client_order_ids=[client_order_id], expires_at=expires_at + ) + return results[0] + + async def cancel_orders( + self, + *, + order_ids: Sequence[int] | None = None, + client_order_ids: Sequence[str] | None = None, + expires_at: datetime | int | None = None, + ) -> tuple[PerpsCancelOrderResult, ...]: + """Cancel orders and return one result per requested order.""" + if (order_ids is None) == (client_order_ids is None): + raise UserInputError("Provide exactly one of order_ids or client_order_ids") + if order_ids is not None: + op = cancel_orders_op(order_ids) + else: + assert client_order_ids is not None + op = cancel_orders_by_client_id_op(client_order_ids) + results = await self._send_signed_command( + op, + parse=_parse_cancel_results, + timeout_message="Perps cancel order response timed out.", + expires_at=expires_at, + ) + return tuple(results) + + async def update_leverage( + self, *, instrument_id: int, leverage: int, cross_margin: bool + ) -> PerpsUpdateLeverageResult: + """Update leverage and margin mode for an instrument.""" + op = update_leverage_op( + instrument_id=instrument_id, leverage=leverage, cross_margin=cross_margin + ) + return await self._send_signed_command( + op, + parse=PerpsUpdateLeverageResult.parse_response, + timeout_message="Perps update leverage response timed out.", + ) + + async def fetch_balances(self) -> tuple[PerpsBalance, ...]: + """Fetch asset balances for the session account.""" + return await _account.fetch_balances(self._api) + + async def fetch_portfolio(self) -> PerpsPortfolio: + """Fetch the portfolio snapshot for the session account.""" + return await _account.fetch_portfolio(self._api) + + async def fetch_stats(self) -> PerpsAccountStats: + """Fetch rolling trading statistics for the session account.""" + return await _account.fetch_stats(self._api) + + async def fetch_account_config( + self, *, instrument_id: int | None = None + ) -> tuple[PerpsAccountConfig, ...]: + """Fetch per-instrument leverage configuration.""" + return await _account.fetch_account_config(self._api, instrument_id=instrument_id) + + async def fetch_open_orders( + self, *, instrument_id: int | None = None + ) -> tuple[PerpsOrder, ...]: + """Fetch open orders for the session account.""" + return await _account.fetch_open_orders(self._api, instrument_id=instrument_id) + + async def fetch_orders( + self, + *, + order_id: int | None = None, + client_order_id: str | None = None, + instrument_id: int | None = None, + start: datetime | int | None = None, + end: datetime | int | None = None, + ) -> tuple[PerpsOrder, ...]: + """Fetch historical orders for the session account.""" + return await _account.fetch_orders( + self._api, + order_id=order_id, + client_order_id=client_order_id, + instrument_id=instrument_id, + start=start, + end=end, + ) + + def list_fills( + self, + *, + start: datetime | int | None = None, + end: datetime | int | None = None, + ) -> AsyncPaginator[PerpsFill]: + """List fills for the session account. + + Defaults to the past 24 hours when ``start`` is omitted. + """ + return _account.list_fills(self._api, start=start, end=end) + + def list_funding_payments( + self, + *, + instrument_id: int | None = None, + start: datetime | int | None = None, + end: datetime | int | None = None, + ) -> AsyncPaginator[PerpsFundingPayment]: + """List funding payments for the session account. + + Defaults to the past 24 hours when ``start`` is omitted. + """ + return _account.list_funding_payments( + self._api, instrument_id=instrument_id, start=start, end=end + ) + + def list_deposits( + self, + *, + deposit_status: PerpsDepositStatus | None = None, + hash: str | None = None, + start: datetime | int | None = None, + end: datetime | int | None = None, + ) -> AsyncPaginator[PerpsDeposit]: + """List deposits for the session account. + + Defaults to the past 90 days when ``start`` is omitted. + """ + return _account.list_deposits( + self._api, deposit_status=deposit_status, hash=hash, start=start, end=end + ) + + def list_withdrawals( + self, + *, + withdrawal_status: PerpsWithdrawalStatus | None = None, + hash: str | None = None, + start: datetime | int | None = None, + end: datetime | int | None = None, + ) -> AsyncPaginator[PerpsWithdrawal]: + """List withdrawals for the session account. + + Defaults to the past 90 days when ``start`` is omitted. + """ + return _account.list_withdrawals( + self._api, + withdrawal_status=withdrawal_status, + hash=hash, + start=start, + end=end, + ) + + def list_equity_history( + self, + *, + interval: PerpsPnlInterval, + start: datetime | int, + end: datetime | int | None = None, + ) -> AsyncPaginator[PerpsEquityPoint]: + """List account equity history at the requested interval.""" + return _account.list_equity_history(self._api, interval=interval, start=start, end=end) + + def list_pnl_history( + self, + *, + interval: PerpsPnlInterval, + start: datetime | int, + end: datetime | int | None = None, + ) -> AsyncPaginator[PerpsPnlPoint]: + """List account profit-and-loss history at the requested interval.""" + return _account.list_pnl_history(self._api, interval=interval, start=start, end=end) + + async def _resolve_auth_headers( + self, method: str, path: str, body: str | None + ) -> Mapping[str, str]: + return { + "POLYMARKET-PROXY": self._credentials.proxy, + "POLYMARKET-SECRET": self._credentials.secret, + } + + async def _connect(self, *, emit_resync: bool) -> None: + await self._connection.connect( + url=self._ws_url, + on_message=self._on_message, + on_close=self._on_socket_close, + on_error=self._on_socket_error, + ) + await self._authenticate() + await self._subscribe_channels() + self._scheduler.reset() + if emit_resync: + self._sequences.clear() + self._push(PerpsResyncEvent(reason="reconnect")) + + async def _authenticate(self) -> None: + await self._send_request( + { + "id": self._take_request_id(), + "req": "post", + "op": { + "type": "auth", + "args": { + "proxy": self._credentials.proxy, + "secret": self._credentials.secret, + }, + }, + }, + parse=_parse_session_ack, + timeout_s=_AUTH_TIMEOUT_S, + timeout_message="Perps session authentication timed out.", + ) + + async def _subscribe_channels(self) -> None: + await self._send_request( + { + "id": self._take_request_id(), + "req": "sub", + "chs": list(_SESSION_CHANNELS), + }, + parse=_parse_session_ack, + timeout_s=_ACK_TIMEOUT_S, + timeout_message="Perps session subscription timed out.", + ) + + async def _send_create_orders( + self, + rows: list[RawPerpsOrder], + *, + group: Literal["order", "position"] | None, + expires_at: datetime | int | None, + ) -> list[PerpsPostOrderAck]: + op = create_orders_op(rows, group=group) + acks = await self._send_signed_command( + op, + parse=_parse_post_order_acks, + timeout_message="Perps post order acknowledgement timed out.", + expires_at=expires_at, + ) + if not acks: + raise TransportError("Perps session unexpected response.") + return acks + + async def _send_signed_command( + self, + op: list[Any], + *, + parse: Callable[[object], Any], + timeout_message: str, + expires_at: datetime | int | None = None, + ) -> Any: + salt = random_perps_salt() + timestamp = now_ms() + signature = sign_perps_op_with_key( + self._credentials.private_key, + chain_id=self._chain_id, + op=op, + salt=salt, + timestamp_ms=timestamp, + ) + frame: dict[str, Any] = { + "id": self._take_request_id(), + "req": "post", + "op": to_command_body_op(op), + "salt": salt, + "sig": signature, + "ts": timestamp, + } + expiry_ms = to_epoch_ms("expires_at", expires_at) + if expiry_ms is not None: + frame["exp"] = expiry_ms + return await self._send_request( + frame, parse=parse, timeout_s=_ACK_TIMEOUT_S, timeout_message=timeout_message + ) + + async def _send_request( + self, + frame: dict[str, Any], + *, + parse: Callable[[object], Any], + timeout_s: float, + timeout_message: str, + ) -> Any: + if self._closed: + raise TransportError("Perps session closed.") + request_id = frame["id"] + future: asyncio.Future[Any] = asyncio.get_running_loop().create_future() + self._pending[request_id] = _PendingRequest(future=future, parse=parse) + try: + async with self._send_lock: + sent = await self._connection.send(frame) + if not sent: + raise TransportError("Perps session transport is not open.") + return await asyncio.wait_for(future, timeout=timeout_s) + except TimeoutError as error: + raise TransportError(timeout_message) from error + finally: + self._pending.pop(request_id, None) + + def _take_request_id(self) -> int: + request_id = self._next_request_id + self._next_request_id += 1 + return request_id + + def _expect_ok_ack(self, ack: PerpsPostOrderAck) -> PerpsOrderId: + if ack.status == "err": + raise RequestRejectedError(ack.error or "Perps command was rejected.", status=200) + return cast(PerpsOrderId, ack.order_id) + + async def _wait_for_order_update(self, order_id: PerpsOrderId) -> PerpsOrder: + buffered = self._recent_orders.get(order_id) + if buffered is not None: + return buffered + + def matches(event: PerpsSessionEvent) -> bool: + return isinstance(event, PerpsOrderEvent) and event.payload.id == order_id + + event = await self._wait_for_event(matches, timeout_s=_ORDER_PLACEMENT_UPDATE_TIMEOUT_S) + assert isinstance(event, PerpsOrderEvent) + return event.payload + + async def _wait_for_event( + self, + predicate: Callable[[PerpsSessionEvent], bool], + *, + timeout_s: float, + ) -> PerpsSessionEvent: + future: asyncio.Future[PerpsSessionEvent] = asyncio.get_running_loop().create_future() + waiter = _EventWaiter(future=future, predicate=predicate) + self._event_waiters.append(waiter) + try: + return await asyncio.wait_for(future, timeout=timeout_s) + except TimeoutError as error: + raise SDKTimeoutError("Perps event wait timed out.") from error + finally: + with contextlib.suppress(ValueError): + self._event_waiters.remove(waiter) + + async def _position_exit_buy(self, instrument_id: int) -> bool: + portfolio = await self.fetch_portfolio() + position = next( + (item for item in portfolio.positions if item.instrument_id == instrument_id), + None, + ) + if position is None or position.size == 0: + raise UserInputError(f"No open Perps position for instrument {instrument_id}.") + return position.size < 0 + + def _on_message(self, raw: object) -> None: + if self._handle_response(raw): + return + try: + event = parse_perps_session_event(raw) + except Exception: + self._logger.debug("dropped malformed perps session event", exc_info=True) + return + if event is None: + return + self._push_sequence_gap_if_needed(event) + self._emit_event(event) + + def _handle_response(self, raw: object) -> bool: + if not isinstance(raw, dict): + return False + message = cast("dict[str, Any]", raw) + request_id = message.get("id") + if isinstance(request_id, bool) or not isinstance(request_id, int): + return False + pending = self._pending.pop(request_id, None) + if pending is None: + return True + data = message.get("data") + try: + result = pending.parse(data) + except RequestRejectedError as error: + self._reject_future(pending.future, error) + return True + except Exception: + error_ack = _error_ack(data if data is not None else message) + if error_ack is not None: + self._reject_future(pending.future, RequestRejectedError(error_ack, status=200)) + else: + self._reject_future( + pending.future, TransportError("Perps session unexpected response.") + ) + return True + if not pending.future.done(): + pending.future.set_result(result) + return True + + def _reject_future(self, future: asyncio.Future[Any], error: BaseException) -> None: + if not future.done(): + future.set_exception(error) + + def _on_socket_close(self) -> None: + self._reject_pending(TransportError("Perps session connection closed.")) + self._reject_event_waiters(TransportError("Perps session connection closed.")) + if self._closed: + return + self._scheduler.schedule( + reconnect=self._reconnect, + should_reconnect=lambda: not self._closed, + ) + + def _on_socket_error(self, exc: BaseException) -> None: + self._logger.warning("perps session reader error: %r", exc) + + async def _reconnect(self) -> None: + if self._closed: + return + try: + await self._connect(emit_resync=True) + except (TransportError, RequestRejectedError) as exc: + self._logger.info("perps session reconnect failed: %r; rescheduling", exc) + if not self._closed: + self._scheduler.schedule( + reconnect=self._reconnect, + should_reconnect=lambda: not self._closed, + ) + + def _push_sequence_gap_if_needed(self, event: PerpsSessionEvent) -> None: + channel = getattr(event, "channel", None) + sequence = getattr(event, "sequence", None) + if not isinstance(channel, str) or not isinstance(sequence, int): + return + previous = self._sequences.get(channel) + self._sequences[channel] = sequence + if previous is None or sequence == previous + 1: + return + self._emit_event( + PerpsResyncEvent( + reason="sequence_gap", + channel=channel, + previous_sequence=previous, + sequence=sequence, + ) + ) + + _RECENT_ORDERS_LIMIT = 256 + + def _emit_event(self, event: PerpsSessionEvent) -> None: + if isinstance(event, PerpsOrderEvent): + self._recent_orders[event.payload.id] = event.payload + while len(self._recent_orders) > self._RECENT_ORDERS_LIMIT: + self._recent_orders.pop(next(iter(self._recent_orders))) + for waiter in tuple(self._event_waiters): + try: + matched = waiter.predicate(event) + except Exception: + self._logger.exception("perps event waiter predicate raised") + continue + if matched and not waiter.future.done(): + waiter.future.set_result(event) + self._push(event) + + def _push(self, event: PerpsSessionEvent) -> None: + if self._ended: + return + try: + self._queue.put_nowait(event) + return + except asyncio.QueueFull: + pass + try: + self._queue.get_nowait() + except asyncio.QueueEmpty: + pass + else: + self._dropped_events += 1 + try: + self._queue.put_nowait(event) + except asyncio.QueueFull: + self._dropped_events += 1 + + def _end(self, error: BaseException | None = None) -> None: + if self._ended: + return + self._ended = True + self._end_error = error + try: + self._queue.put_nowait(_END) + return + except asyncio.QueueFull: + pass + with contextlib.suppress(asyncio.QueueEmpty): + self._queue.get_nowait() + self._queue.put_nowait(_END) + + def _reject_pending(self, error: BaseException) -> None: + for pending in tuple(self._pending.values()): + self._reject_future(pending.future, error) + self._pending.clear() + + def _reject_event_waiters(self, error: BaseException) -> None: + for waiter in tuple(self._event_waiters): + if not waiter.future.done(): + waiter.future.set_exception(error) + self._event_waiters.clear() + + +def _parse_session_ack(data: object) -> None: + entries: list[object] = cast("list[object]", data) if isinstance(data, list) else [data] + if not entries: + raise ValueError("empty Perps session acknowledgement") + for entry in entries: + ack = cast("dict[str, Any]", entry) if isinstance(entry, dict) else None + if ack is None or ack.get("status") not in ("ok", "err"): + raise ValueError("invalid Perps session acknowledgement") + for entry in entries: + error = _error_ack(entry) + if error is not None: + raise RequestRejectedError(error, status=200) + return None + + +def _parse_post_order_acks(data: object) -> list[PerpsPostOrderAck]: + if not isinstance(data, list): + raise ValueError("expected a list of Perps post order acknowledgements") + return [PerpsPostOrderAck.parse_response(item) for item in cast("list[object]", data)] + + +def _parse_cancel_results(data: object) -> list[PerpsCancelOrderResult]: + if not isinstance(data, list): + raise ValueError("expected a list of Perps cancel order results") + return [PerpsCancelOrderResult.parse_response(item) for item in cast("list[object]", data)] + + +def _error_ack(value: object) -> str | None: + if not isinstance(value, dict): + return None + ack = cast("dict[str, Any]", value) + if ack.get("status") != "err": + return None + error = ack.get("error") + return str(error) if isinstance(error, str) and error else "Perps command failed." + + +__all__ = ["PerpsSession"] diff --git a/src/polymarket/_internal/streams/perps/__init__.py b/src/polymarket/_internal/streams/perps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/polymarket/_internal/streams/perps/heartbeat.py b/src/polymarket/_internal/streams/perps/heartbeat.py new file mode 100644 index 0000000..0714cb8 --- /dev/null +++ b/src/polymarket/_internal/streams/perps/heartbeat.py @@ -0,0 +1,65 @@ +import asyncio +import contextlib +import json +import time +from collections.abc import Callable + +from polymarket._internal.ws.heartbeat import SendText + +PERPS_HEARTBEAT_INTERVAL_S = 25.0 +PERPS_HEARTBEAT_STALE_S = 65.0 + +_PING_FRAME = json.dumps({"id": 0, "req": "post", "op": {"type": "ping"}}, separators=(",", ":")) + + +class PerpsWebSocketHeartbeat: + """Application-level ping heartbeat for Perps WebSocket connections. + + Any inbound message counts as liveness; the connection is stale when no + message has arrived within the stale window. + """ + + def __init__( + self, + *, + interval_s: float = PERPS_HEARTBEAT_INTERVAL_S, + stale_s: float = PERPS_HEARTBEAT_STALE_S, + clock: Callable[[], float] = time.monotonic, + ) -> None: + if interval_s <= 0: + raise ValueError("interval_s must be positive") + if stale_s <= 0: + raise ValueError("stale_s must be positive") + self._interval_s = interval_s + self._stale_s = stale_s + self._clock = clock + self._timer: asyncio.Task[None] | None = None + self._last_message: float = 0.0 + + async def start(self, send: SendText) -> None: + await self.stop() + self._last_message = self._clock() + self._timer = asyncio.create_task(self._tick(send)) + + async def stop(self) -> None: + timer = self._timer + self._timer = None + if timer is not None: + timer.cancel() + with contextlib.suppress(asyncio.CancelledError): + await timer + + def handle(self, message: str) -> bool: + self._last_message = self._clock() + return False + + def is_stale(self, now: float) -> bool: + return now - self._last_message > self._stale_s + + async def _tick(self, send: SendText) -> None: + try: + while True: + await asyncio.sleep(self._interval_s) + await send(_PING_FRAME) + except asyncio.CancelledError: + return diff --git a/src/polymarket/_internal/streams/perps/market.py b/src/polymarket/_internal/streams/perps/market.py new file mode 100644 index 0000000..5ce2f72 --- /dev/null +++ b/src/polymarket/_internal/streams/perps/market.py @@ -0,0 +1,209 @@ +# pyright: reportPrivateUsage=false +import asyncio +import logging +from types import TracebackType +from typing import Self + +from polymarket._internal.streams.handle import AsyncSubscriptionHandle +from polymarket._internal.streams.perps.heartbeat import PerpsWebSocketHeartbeat +from polymarket._internal.streams.perps.market_protocol import ( + PerpsServerState, + build_channel_frame, + derive_state, + diff_channels, + match_for, + parse_events, +) +from polymarket._internal.streams.reconnect import ReconnectScheduler +from polymarket._internal.streams.registry import SubscriptionRegistry +from polymarket._internal.ws.connection import AsyncWebSocketConnection, ConnectResult +from polymarket._internal.ws.heartbeat import Heartbeat +from polymarket.errors import TransportError +from polymarket.models.perps.events import PerpsMarketEvent +from polymarket.streams._specs import PerpsSpec + +DEFAULT_QUEUE_SIZE = 1024 + + +class PerpsMarketStreamManager: + """Perps market data WebSocket stream. Multiplexes many subscriptions onto + one socket and resends the subscribed channels on reconnect.""" + + def __init__( + self, + *, + url: str, + logger: logging.Logger | None = None, + heartbeat: Heartbeat | None = None, + queue_size: int = DEFAULT_QUEUE_SIZE, + ) -> None: + if queue_size <= 0: + raise ValueError("queue_size must be positive") + self._url = url + self._logger = logger or logging.getLogger("polymarket.streams.perps.market") + self._heartbeat: Heartbeat = heartbeat or PerpsWebSocketHeartbeat() + self._queue_size = queue_size + self._connection = AsyncWebSocketConnection(heartbeat=self._heartbeat, logger=self._logger) + self._registry: SubscriptionRegistry[PerpsSpec, PerpsMarketEvent, PerpsServerState] = ( + SubscriptionRegistry(derive_state=derive_state, logger=self._logger) + ) + self._scheduler = ReconnectScheduler(logger=self._logger) + self._send_lock = asyncio.Lock() + self._next_request_id = 1 + self._closed = False + self._dropped_events = 0 + + @property + def is_open(self) -> bool: + return self._connection.is_open + + @property + def dropped_events(self) -> int: + return self._dropped_events + + async def subscribe(self, spec: PerpsSpec) -> AsyncSubscriptionHandle[PerpsMarketEvent]: + if self._closed: + raise RuntimeError("PerpsMarketStreamManager is closed") + handle: AsyncSubscriptionHandle[PerpsMarketEvent] = AsyncSubscriptionHandle( + queue_size=self._queue_size + ) + handle._bind_close(self._on_handle_close) + + async with self._send_lock: + before = self._registry.server_state() + self._registry.add(sub=spec, matcher=match_for(spec), handle=handle) + after = self._registry.server_state() + try: + result = await self._open_connection() + if result.reused: + added, removed = diff_channels(before, after) + await self._send_channel_frames(added, removed) + else: + self._scheduler.cancel_pending() + await self._send_subscribe(sorted(after)) + except BaseException: + self._registry.remove_handle(handle) + handle._end() + if self._registry.is_empty and self._connection.is_open: + self._scheduler.cancel_pending() + try: + await self._connection.close() + except Exception: + self._logger.exception("error closing socket during subscribe rollback") + raise + return handle + + async def close(self) -> None: + if self._closed: + return + self._closed = True + await self._scheduler.aclose() + self._registry.end_all() + await self._connection.close() + + async def __aenter__(self) -> Self: + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: TracebackType | None, + ) -> None: + await self.close() + + async def _open_connection(self) -> ConnectResult: + return await self._connection.connect( + url=self._url, + on_message=self._on_message, + on_close=self._on_socket_close, + on_error=self._on_socket_error, + ) + + async def _send_channel_frames(self, added: list[str], removed: list[str]) -> None: + if added: + await self._send_subscribe(added) + if removed: + await self._send_unsubscribe(removed) + + async def _send_subscribe(self, channels: list[str]) -> bool: + if not channels: + return True + return await self._connection.send( + build_channel_frame( + request_id=self._take_request_id(), operation="sub", channels=channels + ) + ) + + async def _send_unsubscribe(self, channels: list[str]) -> bool: + if not channels: + return True + return await self._connection.send( + build_channel_frame( + request_id=self._take_request_id(), operation="unsub", channels=channels + ) + ) + + def _take_request_id(self) -> int: + request_id = self._next_request_id + self._next_request_id += 1 + return request_id + + async def _on_handle_close(self, handle: AsyncSubscriptionHandle[PerpsMarketEvent]) -> None: + async with self._send_lock: + before = self._registry.server_state() + removed = self._registry.remove_handle(handle) + if not removed: + return + if self._registry.is_empty: + self._scheduler.cancel_pending() + await self._connection.close() + return + after = self._registry.server_state() + added_channels, removed_channels = diff_channels(before, after) + await self._send_channel_frames(added_channels, removed_channels) + + def _on_message(self, raw: object) -> None: + events, dropped = parse_events(raw) + if dropped: + self._dropped_events += dropped + self._logger.debug("dropped %d malformed perps market event(s)", dropped) + for event in events: + self._registry.dispatch(event) + + def _on_socket_close(self) -> None: + if self._closed: + return + self._scheduler.schedule( + reconnect=self._reconnect, + should_reconnect=self._should_reconnect, + ) + + def _on_socket_error(self, exc: BaseException) -> None: + self._logger.warning("perps market stream reader error: %r", exc) + + def _should_reconnect(self) -> bool: + return not self._closed and not self._registry.is_empty + + async def _reconnect(self) -> None: + async with self._send_lock: + if not self._should_reconnect(): + return + try: + result = await self._open_connection() + except TransportError as exc: + self._logger.info("perps market stream reconnect failed: %r; rescheduling", exc) + self._scheduler.schedule( + reconnect=self._reconnect, + should_reconnect=self._should_reconnect, + ) + return + if self._closed: + await self._connection.close() + return + if result.reused: + return + self._scheduler.reset() + state = self._registry.server_state() + if state: + await self._send_subscribe(sorted(state)) diff --git a/src/polymarket/_internal/streams/perps/market_protocol.py b/src/polymarket/_internal/streams/perps/market_protocol.py new file mode 100644 index 0000000..f1e4f84 --- /dev/null +++ b/src/polymarket/_internal/streams/perps/market_protocol.py @@ -0,0 +1,111 @@ +from collections.abc import Callable, Iterable +from typing import Any + +from polymarket.models.perps.events import ( + PerpsMarketEvent, + parse_perps_market_events, +) +from polymarket.streams._specs import ( + PerpsBboSpec, + PerpsBookSpec, + PerpsCandlesSpec, + PerpsSpec, + PerpsStatisticsSpec, + PerpsTickersSpec, + PerpsTradesSpec, +) + +PerpsServerState = frozenset[str] +"""Set of upstream channel names derived from active subscriptions.""" + + +def channel_for(spec: PerpsSpec) -> str: + if isinstance(spec, PerpsTradesSpec): + return f"trades::{spec.instrument_id}" + if isinstance(spec, PerpsBboSpec): + return f"bbo::{spec.instrument_id}" + if isinstance(spec, PerpsBookSpec): + return f"book::{spec.instrument_id}" + if isinstance(spec, PerpsCandlesSpec): + return f"klines::{spec.instrument_id}::{spec.interval}" + if isinstance(spec, PerpsTickersSpec): + return "tickers::all" if spec.instrument_id is None else f"tickers::{spec.instrument_id}" + return "statistics::all" if spec.instrument_id is None else f"statistics::{spec.instrument_id}" + + +def derive_state(specs: Iterable[PerpsSpec]) -> PerpsServerState: + channels: set[str] = set() + ticker_ids: set[int] = set() + statistic_ids: set[int] = set() + all_tickers = False + all_statistics = False + for spec in specs: + if isinstance(spec, PerpsTickersSpec): + if spec.instrument_id is None: + all_tickers = True + else: + ticker_ids.add(spec.instrument_id) + elif isinstance(spec, PerpsStatisticsSpec): + if spec.instrument_id is None: + all_statistics = True + else: + statistic_ids.add(spec.instrument_id) + else: + channels.add(channel_for(spec)) + if all_tickers: + channels.add("tickers::all") + else: + channels.update(f"tickers::{instrument_id}" for instrument_id in ticker_ids) + if all_statistics: + channels.add("statistics::all") + else: + channels.update(f"statistics::{instrument_id}" for instrument_id in statistic_ids) + return frozenset(channels) + + +def build_channel_frame( + *, request_id: int, operation: str, channels: Iterable[str] +) -> dict[str, Any]: + return {"id": request_id, "req": operation, "chs": sorted(channels)} + + +def diff_channels(before: PerpsServerState, after: PerpsServerState) -> tuple[list[str], list[str]]: + added = sorted(after - before) + removed = sorted(before - after) + return added, removed + + +def match_for(spec: PerpsSpec) -> Callable[[PerpsMarketEvent], bool]: + if isinstance(spec, PerpsCandlesSpec): + instrument_id = spec.instrument_id + interval = spec.interval + + def matches_candles(event: PerpsMarketEvent) -> bool: + return ( + event.topic == "perps.candles" + and event.payload.instrument_id == instrument_id + and event.payload.interval == interval + ) + + return matches_candles + if isinstance(spec, PerpsTickersSpec | PerpsStatisticsSpec): + topic = spec.topic + optional_id = spec.instrument_id + + def matches_optional(event: PerpsMarketEvent) -> bool: + return event.topic == topic and ( + optional_id is None or event.payload.instrument_id == optional_id + ) + + return matches_optional + topic = spec.topic + instrument_id = spec.instrument_id + + def matches(event: PerpsMarketEvent) -> bool: + return event.topic == topic and event.payload.instrument_id == instrument_id + + return matches + + +def parse_events(raw: object) -> tuple[list[PerpsMarketEvent], int]: + return parse_perps_market_events(raw) diff --git a/src/polymarket/clients/async_public.py b/src/polymarket/clients/async_public.py index a711096..23fde05 100644 --- a/src/polymarket/clients/async_public.py +++ b/src/polymarket/clients/async_public.py @@ -37,6 +37,7 @@ estimate_market_price as _estimate_market_price, ) from polymarket._internal.actions.orders.types import MarketOrderType +from polymarket._internal.actions.perps import public as _perps_actions from polymarket._internal.context import AsyncClientContext from polymarket._internal.dispatch import ( async_dispatch, @@ -93,6 +94,19 @@ TradedMarketCount, TraderLeaderboardEntry, ) +from polymarket.models.perps import ( + PerpsBook, + PerpsBookDepth, + PerpsCandle, + PerpsFeeScheduleEntry, + PerpsFundingRate, + PerpsInstrument, + PerpsInstrumentCategory, + PerpsKlineInterval, + PerpsMarketEvent, + PerpsTicker, + PerpsTrade, +) from polymarket.models.rtds_events import ( CommentsEvent, CryptoPricesEvent, @@ -107,13 +121,17 @@ CryptoPricesSpec, EquityPricesSpec, MarketSpec, + PerpsSpec, PublicSubscription, SportsSpec, normalize_specs, ) if TYPE_CHECKING: + from datetime import datetime + from polymarket._internal.streams.clob.market import ClobMarketStreamManager + from polymarket._internal.streams.perps.market import PerpsMarketStreamManager from polymarket._internal.streams.rtds.manager import RtdsStreamManager from polymarket._internal.streams.sports.manager import SportsStreamManager @@ -136,10 +154,12 @@ def __init__( data=AsyncTransport(base_url=environment.data_url, logger=logger), rfq=AsyncTransport(base_url=environment.rfq_url, logger=logger), clob=AsyncTransport(base_url=environment.clob_url, logger=logger), + perps=AsyncTransport(base_url=environment.perps_url, logger=logger), ) self._market_manager: ClobMarketStreamManager | None = None self._sports_manager: SportsStreamManager | None = None self._rtds_manager: RtdsStreamManager | None = None + self._perps_manager: PerpsMarketStreamManager | None = None self._streams_logger = logger @property @@ -162,6 +182,8 @@ async def subscribe( self, specs: EquityPricesSpec, / ) -> SubscriptionHandle[EquityPricesEvent]: ... @overload + async def subscribe(self, specs: PerpsSpec, /) -> SubscriptionHandle[PerpsMarketEvent]: ... + @overload async def subscribe( self, specs: Sequence[MarketSpec], / ) -> SubscriptionHandle[MarketEvent]: ... @@ -182,13 +204,17 @@ async def subscribe( self, specs: Sequence[EquityPricesSpec], / ) -> SubscriptionHandle[EquityPricesEvent]: ... @overload + async def subscribe( + self, specs: Sequence[PerpsSpec], / + ) -> SubscriptionHandle[PerpsMarketEvent]: ... + @overload async def subscribe( self, specs: Sequence[PublicSubscription], / - ) -> SubscriptionHandle[MarketEvent | SportsEvent | RtdsEvent]: ... + ) -> SubscriptionHandle[MarketEvent | SportsEvent | RtdsEvent | PerpsMarketEvent]: ... async def subscribe( self, specs: PublicSubscription | Sequence[PublicSubscription], - ) -> SubscriptionHandle[MarketEvent | SportsEvent | RtdsEvent]: + ) -> SubscriptionHandle[MarketEvent | SportsEvent | RtdsEvent | PerpsMarketEvent]: """Subscribe to one or more public realtime streams. Pass a single subscription spec for one stream or a sequence of specs to @@ -214,6 +240,8 @@ async def subscribe( ) elif isinstance(spec, SportsSpec): handles.append(await self._get_sports_manager().subscribe()) + elif isinstance(spec, PerpsSpec): + handles.append(await self._get_perps_manager().subscribe(spec)) elif isinstance(spec, CommentsSpec | CryptoPricesSpec | EquityPricesSpec): # pyright: ignore[reportUnnecessaryIsInstance] handles.append(await self._get_rtds_manager().subscribe(spec)) else: @@ -224,11 +252,14 @@ async def subscribe( await handle.close() raise if len(handles) == 1: - return cast(SubscriptionHandle[MarketEvent | SportsEvent | RtdsEvent], handles[0]) + return cast( + SubscriptionHandle[MarketEvent | SportsEvent | RtdsEvent | PerpsMarketEvent], + handles[0], + ) from polymarket._internal.streams.merged_handle import MergedSubscriptionHandle return cast( - SubscriptionHandle[MarketEvent | SportsEvent | RtdsEvent], + SubscriptionHandle[MarketEvent | SportsEvent | RtdsEvent | PerpsMarketEvent], MergedSubscriptionHandle(handles), ) @@ -262,6 +293,16 @@ def _get_sports_manager(self) -> "SportsStreamManager": ) return self._sports_manager + def _get_perps_manager(self) -> "PerpsMarketStreamManager": + if self._perps_manager is None: + from polymarket._internal.streams.perps.market import PerpsMarketStreamManager + + self._perps_manager = PerpsMarketStreamManager( + url=self._ctx.environment.perps_ws_url, + logger=self._streams_logger, + ) + return self._perps_manager + async def __aenter__(self) -> Self: return self @@ -288,15 +329,22 @@ async def close(self) -> None: await self._rtds_manager.close() finally: try: - await self._ctx.gamma.close() + if self._perps_manager is not None: + await self._perps_manager.close() finally: try: - await self._ctx.data.close() + await self._ctx.gamma.close() finally: try: - await self._ctx.rfq.close() + await self._ctx.data.close() finally: - await self._ctx.clob.close() + try: + await self._ctx.rfq.close() + finally: + try: + await self._ctx.clob.close() + finally: + await self._ctx.perps.close() async def get_market( self, @@ -1249,3 +1297,96 @@ async def fetch(cursor: str | None) -> Page[MarketReward]: ) return AsyncPaginator(fetch=fetch) + + async def fetch_perps_instruments( + self, + *, + instrument_id: int | None = None, + category: PerpsInstrumentCategory | None = None, + ) -> tuple[PerpsInstrument, ...]: + """Fetch Perps instruments, optionally filtered by id or category.""" + return await _perps_actions.fetch_instruments( + self._ctx.perps, instrument_id=instrument_id, category=category + ) + + async def fetch_perps_ticker(self, *, instrument_id: int) -> PerpsTicker: + """Fetch the current Perps ticker for an instrument.""" + return await _perps_actions.fetch_ticker(self._ctx.perps, instrument_id=instrument_id) + + async def fetch_perps_tickers( + self, *, instrument_id: int | None = None + ) -> tuple[PerpsTicker, ...]: + """Fetch current Perps tickers.""" + return await _perps_actions.fetch_tickers(self._ctx.perps, instrument_id=instrument_id) + + async def fetch_perps_book( + self, *, instrument_id: int, depth: PerpsBookDepth = 100 + ) -> PerpsBook: + """Fetch a Perps order book snapshot.""" + return await _perps_actions.fetch_book( + self._ctx.perps, instrument_id=instrument_id, depth=depth + ) + + async def fetch_perps_fees(self) -> tuple[PerpsFeeScheduleEntry, ...]: + """Fetch the Perps fee schedule.""" + return await _perps_actions.fetch_fees(self._ctx.perps) + + def list_perps_candles( + self, + *, + instrument_id: int, + interval: PerpsKlineInterval, + start: "datetime | int | None" = None, + end: "datetime | int | None" = None, + ) -> AsyncPaginator[PerpsCandle]: + """List Perps candles for an instrument. + + Defaults to the past 24 hours when ``start`` is omitted. ``start`` and + ``end`` accept a ``datetime`` or an epoch-milliseconds int. + + Returns: + An async paginator over matching candles. + """ + return _perps_actions.list_candles( + self._ctx.perps, + instrument_id=instrument_id, + interval=interval, + start=start, + end=end, + ) + + def list_perps_funding_history( + self, + *, + instrument_id: int, + start: "datetime | int | None" = None, + end: "datetime | int | None" = None, + ) -> AsyncPaginator[PerpsFundingRate]: + """List Perps funding-rate history for an instrument. + + Defaults to the past 24 hours when ``start`` is omitted. + + Returns: + An async paginator over funding-rate observations. + """ + return _perps_actions.list_funding_history( + self._ctx.perps, instrument_id=instrument_id, start=start, end=end + ) + + def list_perps_trades( + self, + *, + instrument_id: int, + start: "datetime | int | None" = None, + end: "datetime | int | None" = None, + ) -> AsyncPaginator[PerpsTrade]: + """List recent public Perps trades for an instrument. + + Defaults to the past 24 hours when ``start`` is omitted. + + Returns: + An async paginator over matching trades. + """ + return _perps_actions.list_trades( + self._ctx.perps, instrument_id=instrument_id, start=start, end=end + ) diff --git a/src/polymarket/clients/async_secure.py b/src/polymarket/clients/async_secure.py index 7042522..9e7ee07 100644 --- a/src/polymarket/clients/async_secure.py +++ b/src/polymarket/clients/async_secure.py @@ -74,6 +74,9 @@ build_order_typed_data, ) from polymarket._internal.actions.orders.types import OrderDraft +from polymarket._internal.actions.perps import credentials as _perps_credentials +from polymarket._internal.actions.perps import funds as _perps_funds +from polymarket._internal.actions.perps import public as _perps_actions from polymarket._internal.actions.relayer.approvals import ( resolve_missing_trading_approval_calls, ) @@ -204,6 +207,21 @@ TradedMarketCount, TraderLeaderboardEntry, ) +from polymarket.models.perps import ( + PerpsBook, + PerpsBookDepth, + PerpsCandle, + PerpsCredentials, + PerpsFeeScheduleEntry, + PerpsFundingRate, + PerpsInstrument, + PerpsInstrumentCategory, + PerpsKlineInterval, + PerpsMarketEvent, + PerpsTicker, + PerpsTrade, + PerpsWithdrawalId, +) from polymarket.models.rtds_events import ( CommentsEvent, CryptoPricesEvent, @@ -218,6 +236,7 @@ CryptoPricesSpec, EquityPricesSpec, MarketSpec, + PerpsSpec, SecureSubscription, SportsSpec, UserSpec, @@ -231,9 +250,13 @@ from polymarket.types import EvmAddress, HexString if TYPE_CHECKING: + from datetime import datetime, timedelta + + from polymarket._internal.perps_session import PerpsSession from polymarket._internal.rfq import RfqQuoterSession from polymarket._internal.streams.clob.market import ClobMarketStreamManager from polymarket._internal.streams.clob.user import ClobUserStreamManager + from polymarket._internal.streams.perps.market import PerpsMarketStreamManager from polymarket._internal.streams.rtds.manager import RtdsStreamManager from polymarket._internal.streams.sports.manager import SportsStreamManager from polymarket.rfq import RfqSession @@ -278,6 +301,8 @@ def __init__( self._sports_manager: SportsStreamManager | None = None self._rtds_manager: RtdsStreamManager | None = None self._user_manager: ClobUserStreamManager | None = None + self._perps_manager: PerpsMarketStreamManager | None = None + self._perps_sessions: set[PerpsSession] = set() self._rfq_session: RfqQuoterSession | None = None self._rfq_session_connecting: RfqQuoterSession | None = None self._rfq_session_opening: asyncio.Task[RfqQuoterSession] | None = None @@ -438,6 +463,7 @@ def _construct_for_wallet( data=data, rfq=rfq, clob=clob, + perps=AsyncTransport(base_url=environment.perps_url, logger=logger), signer=signer, credentials=credentials, secure_clob=secure_clob, @@ -489,6 +515,8 @@ async def subscribe( self, specs: EquityPricesSpec, / ) -> SubscriptionHandle[EquityPricesEvent]: ... @overload + async def subscribe(self, specs: PerpsSpec, /) -> SubscriptionHandle[PerpsMarketEvent]: ... + @overload async def subscribe(self, specs: UserSpec, /) -> SubscriptionHandle[UserEvent]: ... @overload async def subscribe( @@ -511,15 +539,21 @@ async def subscribe( self, specs: Sequence[EquityPricesSpec], / ) -> SubscriptionHandle[EquityPricesEvent]: ... @overload + async def subscribe( + self, specs: Sequence[PerpsSpec], / + ) -> SubscriptionHandle[PerpsMarketEvent]: ... + @overload async def subscribe(self, specs: Sequence[UserSpec], /) -> SubscriptionHandle[UserEvent]: ... @overload async def subscribe( self, specs: Sequence[SecureSubscription], / - ) -> SubscriptionHandle[MarketEvent | SportsEvent | RtdsEvent | UserEvent]: ... + ) -> SubscriptionHandle[ + MarketEvent | SportsEvent | RtdsEvent | PerpsMarketEvent | UserEvent + ]: ... async def subscribe( self, specs: SecureSubscription | Sequence[SecureSubscription], - ) -> SubscriptionHandle[MarketEvent | SportsEvent | RtdsEvent | UserEvent]: + ) -> SubscriptionHandle[MarketEvent | SportsEvent | RtdsEvent | PerpsMarketEvent | UserEvent]: """Subscribe to one or more public or authenticated realtime streams. Pass a single subscription spec for one stream or a sequence of specs to @@ -545,6 +579,8 @@ async def subscribe( handles.append(await self._get_sports_manager().subscribe()) elif isinstance(spec, UserSpec): handles.append(await self._get_user_manager().subscribe(markets=spec.markets)) + elif isinstance(spec, PerpsSpec): + handles.append(await self._get_perps_manager().subscribe(spec)) elif isinstance(spec, CommentsSpec | CryptoPricesSpec | EquityPricesSpec): # pyright: ignore[reportUnnecessaryIsInstance] handles.append(await self._get_rtds_manager().subscribe(spec)) else: @@ -556,13 +592,17 @@ async def subscribe( raise if len(handles) == 1: return cast( - SubscriptionHandle[MarketEvent | SportsEvent | RtdsEvent | UserEvent], + SubscriptionHandle[ + MarketEvent | SportsEvent | RtdsEvent | PerpsMarketEvent | UserEvent + ], handles[0], ) from polymarket._internal.streams.merged_handle import MergedSubscriptionHandle return cast( - SubscriptionHandle[MarketEvent | SportsEvent | RtdsEvent | UserEvent], + SubscriptionHandle[ + MarketEvent | SportsEvent | RtdsEvent | PerpsMarketEvent | UserEvent + ], MergedSubscriptionHandle(handles), ) @@ -607,9 +647,140 @@ def _get_user_manager(self) -> "ClobUserStreamManager": ) return self._user_manager + def _get_perps_manager(self) -> "PerpsMarketStreamManager": + if self._perps_manager is None: + from polymarket._internal.streams.perps.market import PerpsMarketStreamManager + + self._perps_manager = PerpsMarketStreamManager( + url=self._ctx.environment.perps_ws_url, + logger=self._streams_logger, + ) + return self._perps_manager + async def _resolve_api_key_credentials(self) -> ApiKeyCreds: return self._ctx.credentials + async def open_perps_session( + self, + *, + credentials: PerpsCredentials | None = None, + expires_in: "timedelta | None" = None, + label: str | None = None, + ) -> "PerpsSession": + """Open an authenticated Perps account session. + + With no arguments, new delegated session credentials are created with + a wallet signature and expire after one week. Pass ``expires_in`` for + a different credential lifetime, or pass previously returned + ``credentials`` to validate and resume them without a new wallet + signature. + + Returns: + A connected session. Use it to place and cancel orders, read + account state, and iterate over realtime account events. Close it + when finished. + """ + from polymarket._internal.perps_session import PerpsSession + + if credentials is not None: + if expires_in is not None or label is not None: + raise UserInputError("expires_in and label cannot be combined with credentials") + resolved = await _perps_credentials.resume_credentials( + self._ctx.perps, + signer_address=self._ctx.signer.address, + credentials=credentials, + ) + else: + resolved = await _perps_credentials.create_credentials( + self._ctx.perps, + signer=self._ctx.signer, + chain_id=self._ctx.environment.chain_id, + expires_in=( + expires_in + if expires_in is not None + else _perps_credentials.DEFAULT_CREDENTIAL_TTL + ), + label=label, + ) + session = PerpsSession( + chain_id=self._ctx.environment.chain_id, + credentials=resolved, + rest_url=self._ctx.environment.perps_url, + ws_url=self._ctx.environment.perps_ws_url, + logger=self._streams_logger, + on_close=self._perps_sessions.discard, + ) + self._perps_sessions.add(session) + try: + await session.open() + except BaseException: + await session.close() + raise + return session + + async def revoke_perps_credentials(self, *, proxy: str) -> None: + """Revoke delegated Perps session credentials by proxy address. + + The revocation is signed by the owner account and also works for + credentials that are not currently in use. + """ + await _perps_credentials.revoke_credentials( + self._ctx.perps, + signer=self._ctx.signer, + chain_id=self._ctx.environment.chain_id, + proxy=proxy, + ) + + async def deposit_to_perps( + self, *, amount: int, metadata: str | None = None + ) -> TransactionHandle: + """Deposit collateral into the Perps account. + + The deposit sends approved collateral into the Perps deposit contract + and credits the authenticated signer account. It does not approve + collateral spending; approve the Perps deposit contract first when + allowance is missing. + + Args: + amount: Base-units collateral amount to deposit. + + Returns: + A transaction handle. Await ``wait()`` to wait for a terminal outcome. + """ + env = self._ctx.environment + call = _perps_funds.perps_deposit_call( + deposit_contract=cast(EvmAddress, to_checksum_address(env.perps_deposit_contract)), + token=cast(EvmAddress, to_checksum_address(env.collateral_token)), + amount=amount, + to=cast(EvmAddress, self._ctx.signer.address), + ) + resolved_metadata = metadata if metadata is not None else f"Deposit {amount} to Perps" + return await self._dispatch_single_call(call, metadata=resolved_metadata) + + async def withdraw_from_perps(self, *, amount: int) -> PerpsWithdrawalId: + """Request a Perps withdrawal to the authenticated wallet. + + The withdrawal is signed by the owner account and sends funds to the + wallet address associated with this client. + + Args: + amount: Base-units collateral amount to withdraw. + + Returns: + The withdrawal identifier. Track its status with the session's + ``list_withdrawals``. + """ + env = self._ctx.environment + return await _perps_funds.withdraw_from_perps( + self._ctx.perps, + signer=self._ctx.signer, + chain_id=env.chain_id, + deposit_contract=env.perps_deposit_contract, + token=env.collateral_token, + amount=amount, + to=str(self._ctx.wallet), + ) + def open_rfq_session(self) -> "RfqSession": """Open an RFQ event session. @@ -687,11 +858,14 @@ async def close(self) -> None: self._sports_manager, self._rtds_manager, self._user_manager, + self._perps_manager, + *tuple(self._perps_sessions), _RfqSessionCloser(self._close_rfq_session), ctx.gamma, ctx.data, ctx.rfq, ctx.clob, + ctx.perps, ctx.secure_clob, ctx.relayer, ctx.rpc, @@ -2569,6 +2743,99 @@ async def get_reward_percentages(self) -> RewardsPercentages: await self._ctx.secure_clob.get_json(path, params=params) ) + async def fetch_perps_instruments( + self, + *, + instrument_id: int | None = None, + category: PerpsInstrumentCategory | None = None, + ) -> tuple[PerpsInstrument, ...]: + """Fetch Perps instruments, optionally filtered by id or category.""" + return await _perps_actions.fetch_instruments( + self._ctx.perps, instrument_id=instrument_id, category=category + ) + + async def fetch_perps_ticker(self, *, instrument_id: int) -> PerpsTicker: + """Fetch the current Perps ticker for an instrument.""" + return await _perps_actions.fetch_ticker(self._ctx.perps, instrument_id=instrument_id) + + async def fetch_perps_tickers( + self, *, instrument_id: int | None = None + ) -> tuple[PerpsTicker, ...]: + """Fetch current Perps tickers.""" + return await _perps_actions.fetch_tickers(self._ctx.perps, instrument_id=instrument_id) + + async def fetch_perps_book( + self, *, instrument_id: int, depth: PerpsBookDepth = 100 + ) -> PerpsBook: + """Fetch a Perps order book snapshot.""" + return await _perps_actions.fetch_book( + self._ctx.perps, instrument_id=instrument_id, depth=depth + ) + + async def fetch_perps_fees(self) -> tuple[PerpsFeeScheduleEntry, ...]: + """Fetch the Perps fee schedule.""" + return await _perps_actions.fetch_fees(self._ctx.perps) + + def list_perps_candles( + self, + *, + instrument_id: int, + interval: PerpsKlineInterval, + start: "datetime | int | None" = None, + end: "datetime | int | None" = None, + ) -> AsyncPaginator[PerpsCandle]: + """List Perps candles for an instrument. + + Defaults to the past 24 hours when ``start`` is omitted. ``start`` and + ``end`` accept a ``datetime`` or an epoch-milliseconds int. + + Returns: + An async paginator over matching candles. + """ + return _perps_actions.list_candles( + self._ctx.perps, + instrument_id=instrument_id, + interval=interval, + start=start, + end=end, + ) + + def list_perps_funding_history( + self, + *, + instrument_id: int, + start: "datetime | int | None" = None, + end: "datetime | int | None" = None, + ) -> AsyncPaginator[PerpsFundingRate]: + """List Perps funding-rate history for an instrument. + + Defaults to the past 24 hours when ``start`` is omitted. + + Returns: + An async paginator over funding-rate observations. + """ + return _perps_actions.list_funding_history( + self._ctx.perps, instrument_id=instrument_id, start=start, end=end + ) + + def list_perps_trades( + self, + *, + instrument_id: int, + start: "datetime | int | None" = None, + end: "datetime | int | None" = None, + ) -> AsyncPaginator[PerpsTrade]: + """List recent public Perps trades for an instrument. + + Defaults to the past 24 hours when ``start`` is omitted. + + Returns: + An async paginator over matching trades. + """ + return _perps_actions.list_trades( + self._ctx.perps, instrument_id=instrument_id, start=start, end=end + ) + def _validate_nonce(nonce: object) -> None: if isinstance(nonce, bool) or not isinstance(nonce, int): diff --git a/src/polymarket/environments.py b/src/polymarket/environments.py index 526eba7..88f8417 100644 --- a/src/polymarket/environments.py +++ b/src/polymarket/environments.py @@ -45,6 +45,9 @@ class Environment: position_manager: str = "0x006F54F7f9A22e0000CC2AB60031000000ae9fEF" rfq_quoter_ws_url: str = "wss://combos-rfq-gateway-quoter.polymarket.com/ws/rfq" rfq_quoter_ws_headers: dict[str, str] | None = None + perps_url: str = "https://api.perpetuals.polymarket.com" + perps_ws_url: str = "wss://ws.perpetuals.polymarket.com/v1/ws" + perps_deposit_contract: str = "0xDCa4af75705dbB50f62437045afF9921947917d2" relayer_max_polls: int = 100 relayer_poll_frequency_ms: int = 2000 diff --git a/src/polymarket/models/__init__.py b/src/polymarket/models/__init__.py index 96f1b2d..b3e8ed4 100644 --- a/src/polymarket/models/__init__.py +++ b/src/polymarket/models/__init__.py @@ -95,6 +95,71 @@ TagReference, Team, ) +from polymarket.models.perps import ( + PerpsAccountConfig, + PerpsAccountStats, + PerpsBalance, + PerpsBbo, + PerpsBook, + PerpsBookDepth, + PerpsBookLevel, + PerpsBookUpdate, + PerpsCancelOrderResult, + PerpsCandle, + PerpsCandleBatch, + PerpsClientOrderId, + PerpsCredentials, + PerpsCredentialsInfo, + PerpsDeposit, + PerpsDepositStatus, + PerpsDepositUpdate, + PerpsEntityId, + PerpsEquityPoint, + PerpsFeeScheduleEntry, + PerpsFill, + PerpsFundingPayment, + PerpsFundingRate, + PerpsInstrument, + PerpsInstrumentCategory, + PerpsInstrumentId, + PerpsKlineInterval, + PerpsMarginSummary, + PerpsOrder, + PerpsOrderId, + PerpsOrderRequest, + PerpsOrderStatus, + PerpsPnlInterval, + PerpsPnlPoint, + PerpsPortfolio, + PerpsPosition, + PerpsPositionTpSlTrigger, + PerpsPostOrderAck, + PerpsProxyKey, + PerpsRiskTier, + PerpsSide, + PerpsStatistic, + PerpsStreamCandleInterval, + PerpsTicker, + PerpsTickerUpdate, + PerpsTimeInForce, + PerpsTpSlKind, + PerpsTpSlLifecycleStatus, + PerpsTpSlOrderFields, + PerpsTpSlScope, + PerpsTpSlTrigger, + PerpsTrade, + PerpsTradeId, + PerpsUpdateLeverageResult, + PerpsWithdrawal, + PerpsWithdrawalId, + PerpsWithdrawalStatus, + PerpsWithdrawalUpdate, +) +from polymarket.models.perps.results import ( + PerpsOrderPlacement, + PerpsPlacedTpSlOrder, + PerpsPlacedTpSlOrders, +) from polymarket.models.rfq import ComboMarket, ComboMarketOutcome, ComboMarketOutcomes from polymarket.models.types import ( ComboConditionId, @@ -195,6 +260,67 @@ "MetaMarketPosition", "OpenInterest", "OrderId", + "PerpsAccountConfig", + "PerpsAccountStats", + "PerpsBalance", + "PerpsBbo", + "PerpsBook", + "PerpsBookDepth", + "PerpsBookLevel", + "PerpsBookUpdate", + "PerpsCancelOrderResult", + "PerpsCandle", + "PerpsCandleBatch", + "PerpsClientOrderId", + "PerpsCredentials", + "PerpsCredentialsInfo", + "PerpsDeposit", + "PerpsDepositStatus", + "PerpsDepositUpdate", + "PerpsEntityId", + "PerpsEquityPoint", + "PerpsFeeScheduleEntry", + "PerpsFill", + "PerpsFundingPayment", + "PerpsFundingRate", + "PerpsInstrument", + "PerpsInstrumentCategory", + "PerpsInstrumentId", + "PerpsKlineInterval", + "PerpsMarginSummary", + "PerpsOrder", + "PerpsOrderId", + "PerpsOrderPlacement", + "PerpsOrderRequest", + "PerpsOrderStatus", + "PerpsPlacedTpSlOrder", + "PerpsPlacedTpSlOrders", + "PerpsPnlInterval", + "PerpsPnlPoint", + "PerpsPortfolio", + "PerpsPosition", + "PerpsPositionTpSlTrigger", + "PerpsPostOrderAck", + "PerpsProxyKey", + "PerpsRiskTier", + "PerpsSide", + "PerpsStatistic", + "PerpsStreamCandleInterval", + "PerpsTicker", + "PerpsTickerUpdate", + "PerpsTimeInForce", + "PerpsTpSlKind", + "PerpsTpSlLifecycleStatus", + "PerpsTpSlOrderFields", + "PerpsTpSlScope", + "PerpsTpSlTrigger", + "PerpsTrade", + "PerpsTradeId", + "PerpsUpdateLeverageResult", + "PerpsWithdrawal", + "PerpsWithdrawalId", + "PerpsWithdrawalStatus", + "PerpsWithdrawalUpdate", "PortfolioValue", "PositionId", "Position", diff --git a/src/polymarket/models/perps/__init__.py b/src/polymarket/models/perps/__init__.py new file mode 100644 index 0000000..18ca1c9 --- /dev/null +++ b/src/polymarket/models/perps/__init__.py @@ -0,0 +1,177 @@ +"""Perps SDK models.""" + +from polymarket.models.perps.account import ( + PerpsAccountConfig, + PerpsAccountStats, + PerpsBalance, + PerpsCredentialsInfo, + PerpsEquityPoint, + PerpsFundingPayment, + PerpsMarginSummary, + PerpsPnlPoint, + PerpsPortfolio, + PerpsPosition, + PerpsProxyKey, +) +from polymarket.models.perps.credentials import PerpsCredentials +from polymarket.models.perps.events import ( + PerpsBalanceEvent, + PerpsBboEvent, + PerpsBookEvent, + PerpsCandleEvent, + PerpsDepositEvent, + PerpsFillEvent, + PerpsFundingEvent, + PerpsMarketEvent, + PerpsOrderEvent, + PerpsPortfolioEvent, + PerpsResyncEvent, + PerpsSessionEvent, + PerpsStatisticEvent, + PerpsTickerEvent, + PerpsTpSlEvent, + PerpsTpSlUpdate, + PerpsTradeEvent, + PerpsWithdrawalEvent, + parse_perps_market_event, + parse_perps_market_events, + parse_perps_session_event, +) +from polymarket.models.perps.funds import ( + PerpsDeposit, + PerpsDepositUpdate, + PerpsWithdrawal, + PerpsWithdrawalUpdate, +) +from polymarket.models.perps.market import ( + PerpsBbo, + PerpsBook, + PerpsBookLevel, + PerpsBookUpdate, + PerpsCandle, + PerpsCandleBatch, + PerpsFeeScheduleEntry, + PerpsFundingRate, + PerpsInstrument, + PerpsRiskTier, + PerpsStatistic, + PerpsTicker, + PerpsTickerUpdate, + PerpsTrade, +) +from polymarket.models.perps.orders import ( + PerpsCancelOrderResult, + PerpsFill, + PerpsOrder, + PerpsPostOrderAck, + PerpsTpSlOrderFields, + PerpsUpdateLeverageResult, +) +from polymarket.models.perps.requests import ( + PerpsOrderRequest, + PerpsPositionTpSlTrigger, + PerpsTpSlTrigger, +) +from polymarket.models.perps.types import ( + PerpsBookDepth, + PerpsClientOrderId, + PerpsDepositStatus, + PerpsEntityId, + PerpsInstrumentCategory, + PerpsInstrumentId, + PerpsKlineInterval, + PerpsOrderId, + PerpsOrderStatus, + PerpsPnlInterval, + PerpsSide, + PerpsStreamCandleInterval, + PerpsTimeInForce, + PerpsTpSlKind, + PerpsTpSlLifecycleStatus, + PerpsTpSlScope, + PerpsTradeId, + PerpsWithdrawalId, + PerpsWithdrawalStatus, +) + +__all__ = [ + "PerpsAccountConfig", + "PerpsAccountStats", + "PerpsBalance", + "PerpsBalanceEvent", + "PerpsBbo", + "PerpsBboEvent", + "PerpsBook", + "PerpsBookDepth", + "PerpsBookEvent", + "PerpsBookLevel", + "PerpsBookUpdate", + "PerpsCancelOrderResult", + "PerpsCandle", + "PerpsCandleBatch", + "PerpsCandleEvent", + "PerpsClientOrderId", + "PerpsCredentials", + "PerpsCredentialsInfo", + "PerpsDeposit", + "PerpsDepositEvent", + "PerpsDepositStatus", + "PerpsDepositUpdate", + "PerpsEntityId", + "PerpsEquityPoint", + "PerpsFeeScheduleEntry", + "PerpsFill", + "PerpsFillEvent", + "PerpsFundingEvent", + "PerpsFundingPayment", + "PerpsFundingRate", + "PerpsInstrument", + "PerpsInstrumentCategory", + "PerpsInstrumentId", + "PerpsKlineInterval", + "PerpsMarginSummary", + "PerpsMarketEvent", + "PerpsOrder", + "PerpsOrderEvent", + "PerpsOrderId", + "PerpsOrderRequest", + "PerpsOrderStatus", + "PerpsPnlInterval", + "PerpsPnlPoint", + "PerpsPortfolio", + "PerpsPortfolioEvent", + "PerpsPosition", + "PerpsPositionTpSlTrigger", + "PerpsPostOrderAck", + "PerpsProxyKey", + "PerpsResyncEvent", + "PerpsRiskTier", + "PerpsSessionEvent", + "PerpsSide", + "PerpsStatistic", + "PerpsStatisticEvent", + "PerpsStreamCandleInterval", + "PerpsTicker", + "PerpsTickerEvent", + "PerpsTickerUpdate", + "PerpsTimeInForce", + "PerpsTpSlEvent", + "PerpsTpSlKind", + "PerpsTpSlLifecycleStatus", + "PerpsTpSlOrderFields", + "PerpsTpSlScope", + "PerpsTpSlTrigger", + "PerpsTpSlUpdate", + "PerpsTrade", + "PerpsTradeEvent", + "PerpsTradeId", + "PerpsUpdateLeverageResult", + "PerpsWithdrawal", + "PerpsWithdrawalEvent", + "PerpsWithdrawalId", + "PerpsWithdrawalStatus", + "PerpsWithdrawalUpdate", + "parse_perps_market_event", + "parse_perps_market_events", + "parse_perps_session_event", +] diff --git a/src/polymarket/models/perps/_validators.py b/src/polymarket/models/perps/_validators.py new file mode 100644 index 0000000..3a5ddce --- /dev/null +++ b/src/polymarket/models/perps/_validators.py @@ -0,0 +1,64 @@ +from datetime import UTC, datetime +from decimal import Decimal +from typing import TYPE_CHECKING, Annotated + +from pydantic import BeforeValidator + + +def _coerce_decimalish(value: object) -> object: + if value is None: + return None + if isinstance(value, bool): + msg = f"expected decimal-ish value, got bool {value!r}" + raise ValueError(msg) + if isinstance(value, Decimal | str): + return value + if isinstance(value, int | float): + return str(value) + msg = f"expected decimal-ish value, got {type(value).__name__}" + raise ValueError(msg) + + +def _parse_epoch_ms(value: object) -> object: + if value is None or isinstance(value, datetime): + return value + if isinstance(value, bool) or not isinstance(value, int): + msg = f"expected epoch-ms timestamp, got {type(value).__name__}" + raise ValueError(msg) + try: + return datetime.fromtimestamp(value / 1000, tz=UTC) + except (OverflowError, OSError, ValueError) as error: + msg = f"invalid epoch-ms timestamp: {value!r}" + raise ValueError(msg) from error + + +def _require_epoch_ms(value: object) -> object: + parsed = _parse_epoch_ms(value) + if parsed is None: + msg = "expected epoch-ms timestamp, got None" + raise ValueError(msg) + return parsed + + +def _parse_tx_hash(value: object) -> object: + if value in ("", "0x"): + return None + return value + + +if TYPE_CHECKING: + _Decimal = Decimal +else: + _Decimal = Annotated[Decimal, BeforeValidator(_coerce_decimalish)] + +PerpsTimestamp = Annotated[datetime, BeforeValidator(_require_epoch_ms)] +OptionalPerpsTimestamp = Annotated[datetime | None, BeforeValidator(_parse_epoch_ms)] +OptionalTxHash = Annotated[str | None, BeforeValidator(_parse_tx_hash)] + + +__all__ = [ + "OptionalPerpsTimestamp", + "OptionalTxHash", + "PerpsTimestamp", + "_Decimal", +] diff --git a/src/polymarket/models/perps/account.py b/src/polymarket/models/perps/account.py new file mode 100644 index 0000000..4527756 --- /dev/null +++ b/src/polymarket/models/perps/account.py @@ -0,0 +1,163 @@ +"""Perps account models.""" + +from collections.abc import Sequence +from typing import Annotated, cast + +from pydantic import AliasChoices, BeforeValidator, Field, model_validator + +from polymarket.models.base import BaseModel +from polymarket.models.perps._validators import PerpsTimestamp, _Decimal +from polymarket.models.perps.types import PerpsEntityId, PerpsInstrumentId + +# Proxy key expiries above this magnitude are nanoseconds; convert to ms. +_MAX_EPOCH_MS = 2**53 - 1 + + +class PerpsBalance(BaseModel): + """One asset balance in the Perps account.""" + + asset: str + balance: _Decimal + value: _Decimal + + +class PerpsAccountStats(BaseModel): + """Rolling 7-day trading statistics for the Perps account.""" + + volume_7d: _Decimal + taker_volume_7d: _Decimal + maker_volume_7d: _Decimal + account_maker_share_7d: _Decimal + entity_maker_share_7d: _Decimal | None = None + entity_id: PerpsEntityId | None = None + entity_name: str | None = None + + +class PerpsPosition(BaseModel): + """One open Perps position.""" + + instrument_id: PerpsInstrumentId + symbol: str + size: _Decimal + entry_price: _Decimal + leverage: int + cross: bool + initial_margin: _Decimal + maintenance_margin: _Decimal + position_value: _Decimal + liquidation_price: _Decimal + unrealized_pnl: _Decimal + return_on_equity: _Decimal + cumulative_funding: _Decimal + + +class PerpsMarginSummary(BaseModel): + """Account-wide margin totals.""" + + total_account_value: _Decimal + total_initial_margin: _Decimal + total_maintenance_margin: _Decimal + total_position_value: _Decimal + + +class PerpsPortfolio(BaseModel): + """Portfolio snapshot for the Perps account.""" + + positions: tuple[PerpsPosition, ...] + margin: PerpsMarginSummary + withdrawable: _Decimal + in_liquidation: bool + timestamp: PerpsTimestamp + + +class PerpsFundingPayment(BaseModel): + """One funding payment applied to a position.""" + + instrument_id: PerpsInstrumentId = Field(validation_alias=AliasChoices("instrument_id", "iid")) + size: _Decimal = Field(validation_alias=AliasChoices("size", "sz")) + funding_rate: _Decimal = Field(validation_alias=AliasChoices("funding_rate", "fr")) + funding_asset: str = Field(validation_alias=AliasChoices("funding_asset", "fua")) + funding: _Decimal = Field(validation_alias=AliasChoices("funding", "fund")) + timestamp: PerpsTimestamp = Field(validation_alias=AliasChoices("timestamp", "ts")) + + +class PerpsAccountConfig(BaseModel): + """Leverage configuration for one instrument.""" + + instrument_id: PerpsInstrumentId + leverage: int + cross: bool + + +class PerpsEquityPoint(BaseModel): + """One account equity observation.""" + + timestamp: PerpsTimestamp + equity: _Decimal + + @model_validator(mode="before") + @classmethod + def _from_tuple(cls, data: object) -> object: + if not isinstance(data, (list, tuple)): + return data + entries = cast("Sequence[object]", data) + if len(entries) != 2: + return list(entries) + return {"timestamp": entries[0], "equity": entries[1]} + + +class PerpsPnlPoint(BaseModel): + """One account profit-and-loss observation.""" + + timestamp: PerpsTimestamp + pnl: _Decimal + + @model_validator(mode="before") + @classmethod + def _from_tuple(cls, data: object) -> object: + if not isinstance(data, (list, tuple)): + return data + entries = cast("Sequence[object]", data) + if len(entries) != 2: + return list(entries) + return {"timestamp": entries[0], "pnl": entries[1]} + + +def _normalize_proxy_expiry(value: object) -> object: + if isinstance(value, bool) or not isinstance(value, int): + return value + if value > _MAX_EPOCH_MS: + return value // 1_000_000 + return value + + +class PerpsProxyKey(BaseModel): + """One delegated Perps session key registered for the account.""" + + proxy: str + label: str | None = None + expires_at: Annotated[PerpsTimestamp, BeforeValidator(_normalize_proxy_expiry)] = Field( + validation_alias="expiry" + ) + + +class PerpsCredentialsInfo(BaseModel): + """Delegated session keys registered for a signer account.""" + + address: str + keys: tuple[PerpsProxyKey, ...] + + +__all__ = [ + "PerpsAccountConfig", + "PerpsAccountStats", + "PerpsBalance", + "PerpsCredentialsInfo", + "PerpsEquityPoint", + "PerpsFundingPayment", + "PerpsMarginSummary", + "PerpsPnlPoint", + "PerpsPortfolio", + "PerpsPosition", + "PerpsProxyKey", +] diff --git a/src/polymarket/models/perps/credentials.py b/src/polymarket/models/perps/credentials.py new file mode 100644 index 0000000..3bdf5c1 --- /dev/null +++ b/src/polymarket/models/perps/credentials.py @@ -0,0 +1,46 @@ +"""Delegated Perps session credentials.""" + +from datetime import datetime + +from pydantic import Field + +from polymarket.models.base import BaseModel + + +class PerpsCredentials(BaseModel): + """Delegated credentials that authorize one Perps session key. + + Store these to resume a session later without a new wallet signature. + Treat ``private_key`` and ``secret`` as secrets. + """ + + proxy: str + """Address of the delegated session key.""" + private_key: str = Field(repr=False) + """Private key of the delegated session key.""" + secret: str = Field(repr=False) + """Shared secret that authenticates the session.""" + expires_at: datetime + """When the delegated credentials expire.""" + + def __repr__(self) -> str: + return ( + f"PerpsCredentials(proxy={self.proxy!r}, private_key=, " + f"secret=, expires_at={self.expires_at!r})" + ) + + def _repr_html_(self) -> str: + from polymarket._jupyter import card + + return card( + "PerpsCredentials · redacted", + rows=[ + ("proxy", self.proxy), + ("private_key", "***"), + ("secret", "***"), + ("expires_at", str(self.expires_at)), + ], + ) + + +__all__ = ["PerpsCredentials"] diff --git a/src/polymarket/models/perps/events.py b/src/polymarket/models/perps/events.py new file mode 100644 index 0000000..5952d96 --- /dev/null +++ b/src/polymarket/models/perps/events.py @@ -0,0 +1,367 @@ +"""Perps realtime event models.""" + +import re +from typing import Annotated, Any, Literal, cast + +from pydantic import Field, TypeAdapter, ValidationError + +from polymarket.models.base import BaseModel +from polymarket.models.perps._validators import PerpsTimestamp +from polymarket.models.perps.account import PerpsBalance, PerpsFundingPayment, PerpsPortfolio +from polymarket.models.perps.funds import PerpsDepositUpdate, PerpsWithdrawalUpdate +from polymarket.models.perps.market import ( + PerpsBbo, + PerpsBookUpdate, + PerpsCandleBatch, + PerpsStatistic, + PerpsTickerUpdate, + PerpsTrade, +) +from polymarket.models.perps.orders import PerpsFill, PerpsOrder +from polymarket.models.perps.types import PerpsOrderId, PerpsTpSlLifecycleStatus + +_TRADES_CHANNEL = re.compile(r"^trades::(\d+)$") +_BBO_CHANNEL = re.compile(r"^bbo::(\d+)$") +_BOOK_CHANNEL = re.compile(r"^book::(\d+)$") +_TICKERS_CHANNEL = re.compile(r"^tickers::(all|\d+)$") +_STATISTICS_CHANNEL = re.compile(r"^statistics::(all|\d+)$") +_CANDLES_CHANNEL = re.compile(r"^klines::(\d+)::(1m|5m|15m|1h|4h|1d|1w)$") +_TPSL_CHANNEL = re.compile(r"^tpsl::\d+$") + +_SESSION_CHANNEL_TYPES: dict[str, str] = { + "balances": "balance", + "portfolio": "portfolio", + "orders": "order", + "fills": "fill", + "funding": "funding", + "deposits": "deposit", + "withdrawals": "withdrawal", +} + + +class PerpsTradeEvent(BaseModel): + """A public trade printed on a subscribed instrument.""" + + topic: Literal["perps.trades"] = "perps.trades" + type: Literal["trade"] + channel: str + timestamp: PerpsTimestamp + sequence: int + payload: PerpsTrade + + +class PerpsBboEvent(BaseModel): + """A best bid/ask update for a subscribed instrument.""" + + topic: Literal["perps.bbo"] = "perps.bbo" + type: Literal["bbo"] + channel: str + timestamp: PerpsTimestamp + sequence: int + payload: PerpsBbo + + +class PerpsBookEvent(BaseModel): + """An order book delta for a subscribed instrument.""" + + topic: Literal["perps.book"] = "perps.book" + type: Literal["book"] + channel: str + timestamp: PerpsTimestamp + sequence: int + payload: PerpsBookUpdate + + +class PerpsTickerEvent(BaseModel): + """A ticker update for a subscribed instrument.""" + + topic: Literal["perps.tickers"] = "perps.tickers" + type: Literal["ticker"] + channel: str + timestamp: PerpsTimestamp + sequence: int + payload: PerpsTickerUpdate + + +class PerpsStatisticEvent(BaseModel): + """A trading statistics update for a subscribed instrument.""" + + topic: Literal["perps.statistics"] = "perps.statistics" + type: Literal["statistic"] + channel: str + timestamp: PerpsTimestamp + sequence: int + payload: PerpsStatistic + + +class PerpsCandleEvent(BaseModel): + """A candle batch for a subscribed instrument and interval.""" + + topic: Literal["perps.candles"] = "perps.candles" + type: Literal["candle"] + channel: str + timestamp: PerpsTimestamp + sequence: int + payload: PerpsCandleBatch + + +PerpsMarketEvent = Annotated[ + PerpsTradeEvent + | PerpsBboEvent + | PerpsBookEvent + | PerpsTickerEvent + | PerpsStatisticEvent + | PerpsCandleEvent, + Field(discriminator="type"), +] + +_MARKET_EVENT_ADAPTER: TypeAdapter[PerpsMarketEvent] = TypeAdapter(PerpsMarketEvent) + + +class PerpsBalanceEvent(BaseModel): + """A balance update for the session account.""" + + type: Literal["balance"] + channel: str + timestamp: PerpsTimestamp + sequence: int + payload: PerpsBalance + + +class PerpsPortfolioEvent(BaseModel): + """A portfolio snapshot update for the session account.""" + + type: Literal["portfolio"] + channel: str + timestamp: PerpsTimestamp + sequence: int + payload: PerpsPortfolio + + +class PerpsOrderEvent(BaseModel): + """An order lifecycle update for the session account.""" + + type: Literal["order"] + channel: str + timestamp: PerpsTimestamp + sequence: int + payload: PerpsOrder + + +class PerpsFillEvent(BaseModel): + """A fill update for the session account.""" + + type: Literal["fill"] + channel: str + timestamp: PerpsTimestamp + sequence: int + payload: PerpsFill + + +class PerpsFundingEvent(BaseModel): + """A funding payment update for the session account.""" + + type: Literal["funding"] + channel: str + timestamp: PerpsTimestamp + sequence: int + payload: PerpsFundingPayment + + +class PerpsDepositEvent(BaseModel): + """A deposit status update for the session account.""" + + type: Literal["deposit"] + channel: str + timestamp: PerpsTimestamp + sequence: int + payload: PerpsDepositUpdate + + +class PerpsWithdrawalEvent(BaseModel): + """A withdrawal status update for the session account.""" + + type: Literal["withdrawal"] + channel: str + timestamp: PerpsTimestamp + sequence: int + payload: PerpsWithdrawalUpdate + + +class PerpsTpSlUpdate(BaseModel): + """Lifecycle state of one take-profit/stop-loss trigger order.""" + + order_id: PerpsOrderId = Field(validation_alias="oid") + status: PerpsTpSlLifecycleStatus = Field(validation_alias="st") + reason: str | None = None + + +class PerpsTpSlEvent(BaseModel): + """A take-profit/stop-loss lifecycle update for the session account.""" + + type: Literal["tpsl"] + channel: str + timestamp: PerpsTimestamp + sequence: int + payload: PerpsTpSlUpdate + + +class PerpsResyncEvent(BaseModel): + """A signal that session state should be refetched. + + Emitted after a reconnect and when a gap is detected in a channel's + sequence numbers. + """ + + type: Literal["resync"] = "resync" + reason: Literal["reconnect", "sequence_gap"] + channel: str | None = None + previous_sequence: int | None = None + sequence: int | None = None + + +PerpsSessionEvent = ( + PerpsBalanceEvent + | PerpsPortfolioEvent + | PerpsOrderEvent + | PerpsFillEvent + | PerpsFundingEvent + | PerpsDepositEvent + | PerpsWithdrawalEvent + | PerpsTpSlEvent + | PerpsResyncEvent +) + +_SessionUpdateEvent = Annotated[ + PerpsBalanceEvent + | PerpsPortfolioEvent + | PerpsOrderEvent + | PerpsFillEvent + | PerpsFundingEvent + | PerpsDepositEvent + | PerpsWithdrawalEvent + | PerpsTpSlEvent, + Field(discriminator="type"), +] + +_SESSION_EVENT_ADAPTER: TypeAdapter[_SessionUpdateEvent] = TypeAdapter(_SessionUpdateEvent) + + +def _market_envelope(raw: object) -> dict[str, Any] | None: + if not isinstance(raw, dict): + return None + wire = cast(dict[str, Any], raw) + channel = wire.get("ch") + if not isinstance(channel, str) or "ts" not in wire or "sq" not in wire: + return None + envelope = { + "channel": channel, + "timestamp": wire.get("ts"), + "sequence": wire.get("sq"), + } + data = wire.get("data") + if _TRADES_CHANNEL.match(channel): + return {**envelope, "type": "trade", "payload": data} + if _BBO_CHANNEL.match(channel): + return {**envelope, "type": "bbo", "payload": data} + match = _BOOK_CHANNEL.match(channel) + if match: + payload: object = data + if isinstance(data, dict): + book = dict(cast("dict[str, Any]", data)) + book["instrument_id"] = int(match.group(1)) + payload = book + return {**envelope, "type": "book", "payload": payload} + if _TICKERS_CHANNEL.match(channel): + return {**envelope, "type": "ticker", "payload": data} + if _STATISTICS_CHANNEL.match(channel): + return {**envelope, "type": "statistic", "payload": data} + match = _CANDLES_CHANNEL.match(channel) + if match: + return { + **envelope, + "type": "candle", + "payload": { + "instrument_id": int(match.group(1)), + "interval": match.group(2), + "candles": data, + }, + } + return None + + +def parse_perps_market_event(raw: object) -> PerpsMarketEvent | None: + """Parse one market data frame; returns None for non-event frames.""" + envelope = _market_envelope(raw) + if envelope is None: + return None + return _MARKET_EVENT_ADAPTER.validate_python(envelope) + + +def parse_perps_session_event(raw: object) -> PerpsSessionEvent | None: + """Parse one session update frame; returns None for non-event frames.""" + if not isinstance(raw, dict): + return None + wire = cast(dict[str, Any], raw) + channel = wire.get("ch") + if not isinstance(channel, str) or "ts" not in wire or "sq" not in wire: + return None + if _TPSL_CHANNEL.match(channel): + event_type = "tpsl" + else: + mapped = _SESSION_CHANNEL_TYPES.get(channel) + if mapped is None: + return None + event_type = mapped + return _SESSION_EVENT_ADAPTER.validate_python( + { + "type": event_type, + "channel": channel, + "timestamp": wire.get("ts"), + "sequence": wire.get("sq"), + "payload": wire.get("data"), + } + ) + + +def parse_perps_market_events(raw: object) -> tuple[list[PerpsMarketEvent], int]: + """Parse a decoded frame into market events. + + Returns ``(events, dropped_count)``. Frames that are not channel updates + (for example command acknowledgements) are ignored without counting as + dropped; malformed channel updates are dropped. + """ + items: list[object] = list(cast(list[object], raw)) if isinstance(raw, list) else [raw] + parsed: list[PerpsMarketEvent] = [] + dropped = 0 + for item in items: + try: + event = parse_perps_market_event(item) + except ValidationError: + dropped += 1 + continue + if event is not None: + parsed.append(event) + return parsed, dropped + + +__all__ = [ + "PerpsBalanceEvent", + "PerpsBboEvent", + "PerpsBookEvent", + "PerpsCandleEvent", + "PerpsDepositEvent", + "PerpsFillEvent", + "PerpsFundingEvent", + "PerpsMarketEvent", + "PerpsOrderEvent", + "PerpsPortfolioEvent", + "PerpsResyncEvent", + "PerpsSessionEvent", + "PerpsStatisticEvent", + "PerpsTickerEvent", + "PerpsTpSlEvent", + "PerpsTpSlUpdate", + "PerpsTradeEvent", + "PerpsWithdrawalEvent", +] diff --git a/src/polymarket/models/perps/funds.py b/src/polymarket/models/perps/funds.py new file mode 100644 index 0000000..53fcd9f --- /dev/null +++ b/src/polymarket/models/perps/funds.py @@ -0,0 +1,80 @@ +"""Perps deposit and withdrawal models.""" + +from pydantic import Field + +from polymarket.models.base import BaseModel +from polymarket.models.perps._validators import ( + OptionalPerpsTimestamp, + OptionalTxHash, + PerpsTimestamp, + _Decimal, +) +from polymarket.models.perps.types import ( + PerpsDepositStatus, + PerpsWithdrawalId, + PerpsWithdrawalStatus, +) + + +class PerpsDeposit(BaseModel): + """One deposit into the Perps account.""" + + hash: str + asset: str + amount: _Decimal + status: PerpsDepositStatus + from_address: str = Field(validation_alias="from") + to: str + confirmations: int + required_confirmations: int + created_at: PerpsTimestamp = Field(validation_alias="created_timestamp") + confirmed_at: OptionalPerpsTimestamp = Field( + default=None, validation_alias="confirmed_timestamp" + ) + + +class PerpsDepositUpdate(BaseModel): + """Streaming status update for one Perps deposit.""" + + hash: OptionalTxHash = None + asset: str + amount: _Decimal + status: PerpsDepositStatus + + +class PerpsWithdrawal(BaseModel): + """One withdrawal from the Perps account.""" + + withdrawal_id: PerpsWithdrawalId = Field(validation_alias="withdraw_id") + asset: str + amount: _Decimal + fee: _Decimal + status: PerpsWithdrawalStatus + to: str + hash: OptionalTxHash = None + confirmations: int + required_confirmations: int + created_at: PerpsTimestamp = Field(validation_alias="created_timestamp") + confirmed_at: OptionalPerpsTimestamp = Field( + default=None, validation_alias="confirmed_timestamp" + ) + + +class PerpsWithdrawalUpdate(BaseModel): + """Streaming status update for one Perps withdrawal.""" + + withdrawal_id: PerpsWithdrawalId = Field(validation_alias="withdraw_id") + asset: str + amount: _Decimal + fee: _Decimal + status: PerpsWithdrawalStatus + to: str + hash: OptionalTxHash = None + + +__all__ = [ + "PerpsDeposit", + "PerpsDepositUpdate", + "PerpsWithdrawal", + "PerpsWithdrawalUpdate", +] diff --git a/src/polymarket/models/perps/market.py b/src/polymarket/models/perps/market.py new file mode 100644 index 0000000..1a46ca3 --- /dev/null +++ b/src/polymarket/models/perps/market.py @@ -0,0 +1,225 @@ +"""Perps market data models.""" + +from collections.abc import Sequence +from typing import Literal, cast + +from pydantic import AliasChoices, Field, model_validator + +from polymarket.models.base import BaseModel +from polymarket.models.perps._validators import ( + OptionalPerpsTimestamp, + OptionalTxHash, + PerpsTimestamp, + _Decimal, +) +from polymarket.models.perps.types import ( + PerpsInstrumentCategory, + PerpsInstrumentId, + PerpsSide, + PerpsTradeId, +) + + +class PerpsRiskTier(BaseModel): + """One leverage risk tier for a Perps instrument.""" + + lower_bound: _Decimal + max_leverage: int + + +class PerpsInstrument(BaseModel): + """A tradable Perps instrument and its trading limits.""" + + id: PerpsInstrumentId = Field(validation_alias="instrument_id") + category: PerpsInstrumentCategory + symbol: str + base_asset: str + quote_asset: str + funding_interval: str + quantity_decimals: int + price_decimals: int + price_bounds: _Decimal + liquidation_fee: _Decimal + max_order_count: int + min_notional: _Decimal + max_market_notional: _Decimal + max_limit_notional: _Decimal + max_leverage: int + risk_tiers: tuple[PerpsRiskTier, ...] + + +class PerpsTicker(BaseModel): + """Current prices and funding state for a Perps instrument.""" + + instrument_id: PerpsInstrumentId + symbol: str + index_price: _Decimal + mark_price: _Decimal + last_price: _Decimal + mid_price: _Decimal + open_interest: _Decimal + funding_rate: _Decimal + next_funding: PerpsTimestamp + timestamp: OptionalPerpsTimestamp = None + open_price: _Decimal | None = None + volume_24h: _Decimal | None = None + + +class PerpsTickerUpdate(BaseModel): + """Streaming ticker update for a Perps instrument.""" + + instrument_id: PerpsInstrumentId = Field(validation_alias="iid") + index_price: _Decimal = Field(validation_alias="idx") + mark_price: _Decimal = Field(validation_alias="mark") + last_price: _Decimal = Field(validation_alias="last") + mid_price: _Decimal = Field(validation_alias="mid") + open_interest: _Decimal = Field(validation_alias="oi") + funding_rate: _Decimal = Field(validation_alias="fr") + next_funding: PerpsTimestamp = Field(validation_alias="nxf") + + +def _candle_from_tuple(value: object) -> object: + if not isinstance(value, (list, tuple)): + return value + entries = cast("Sequence[object]", value) + if len(entries) != 7: + return list(entries) + return { + "timestamp": entries[0], + "open": entries[1], + "high": entries[2], + "low": entries[3], + "close": entries[4], + "volume": entries[5], + "trades": entries[6], + } + + +class PerpsCandle(BaseModel): + """One OHLCV candle for a Perps instrument.""" + + timestamp: PerpsTimestamp + open: _Decimal + high: _Decimal + low: _Decimal + close: _Decimal + volume: _Decimal + trades: int + + @model_validator(mode="before") + @classmethod + def _from_tuple(cls, data: object) -> object: + return _candle_from_tuple(data) + + +class PerpsStatistic(BaseModel): + """24-hour trading statistics for a Perps instrument.""" + + instrument_id: PerpsInstrumentId = Field(validation_alias=AliasChoices("instrument_id", "iid")) + symbol: str | None = None + volume: _Decimal = Field(validation_alias=AliasChoices("volume", "vol")) + open_price: _Decimal = Field(validation_alias=AliasChoices("open_price", "open")) + klines: tuple[PerpsCandle, ...] + + +def _level_from_tuple(value: object) -> object: + if not isinstance(value, (list, tuple)): + return value + entries = cast("Sequence[object]", value) + if len(entries) != 2: + return list(entries) + return {"price": entries[0], "quantity": entries[1]} + + +class PerpsBookLevel(BaseModel): + """One price level of a Perps order book.""" + + price: _Decimal + quantity: _Decimal + + @model_validator(mode="before") + @classmethod + def _from_tuple(cls, data: object) -> object: + return _level_from_tuple(data) + + +class PerpsBook(BaseModel): + """An order book snapshot for a Perps instrument.""" + + instrument_id: PerpsInstrumentId + bids: tuple[PerpsBookLevel, ...] + asks: tuple[PerpsBookLevel, ...] + timestamp: PerpsTimestamp + sequence: int + + +class PerpsBookUpdate(BaseModel): + """Streaming order book delta for a Perps instrument.""" + + instrument_id: PerpsInstrumentId + bids: tuple[PerpsBookLevel, ...] = Field(validation_alias=AliasChoices("bids", "b")) + asks: tuple[PerpsBookLevel, ...] = Field(validation_alias=AliasChoices("asks", "a")) + + +class PerpsBbo(BaseModel): + """Best bid and ask for a Perps instrument.""" + + instrument_id: PerpsInstrumentId = Field(validation_alias=AliasChoices("instrument_id", "iid")) + bid_price: _Decimal = Field(validation_alias=AliasChoices("bid_price", "bp")) + bid_quantity: _Decimal = Field(validation_alias=AliasChoices("bid_quantity", "bq")) + ask_price: _Decimal = Field(validation_alias=AliasChoices("ask_price", "ap")) + ask_quantity: _Decimal = Field(validation_alias=AliasChoices("ask_quantity", "aq")) + timestamp: OptionalPerpsTimestamp = None + + +class PerpsTrade(BaseModel): + """One public Perps trade.""" + + trade_id: PerpsTradeId = Field(validation_alias=AliasChoices("trade_id", "tid")) + instrument_id: PerpsInstrumentId = Field(validation_alias=AliasChoices("instrument_id", "iid")) + side: PerpsSide + price: _Decimal = Field(validation_alias=AliasChoices("price", "p")) + quantity: _Decimal = Field(validation_alias=AliasChoices("quantity", "qty")) + timestamp: PerpsTimestamp = Field(validation_alias=AliasChoices("timestamp", "ts")) + hash: OptionalTxHash = None + + +class PerpsFundingRate(BaseModel): + """One historical funding rate observation.""" + + funding_rate: _Decimal + timestamp: PerpsTimestamp + + +class PerpsFeeScheduleEntry(BaseModel): + """Maker and taker fee rates for a Perps instrument category.""" + + category: PerpsInstrumentCategory + taker_fee_rate: _Decimal + maker_fee_rate: _Decimal + + +class PerpsCandleBatch(BaseModel): + """Streaming candle batch for one instrument and interval.""" + + instrument_id: PerpsInstrumentId + interval: Literal["1m", "5m", "15m", "1h", "4h", "1d", "1w"] + candles: tuple[PerpsCandle, ...] + + +__all__ = [ + "PerpsBbo", + "PerpsBook", + "PerpsBookLevel", + "PerpsBookUpdate", + "PerpsCandle", + "PerpsCandleBatch", + "PerpsFeeScheduleEntry", + "PerpsFundingRate", + "PerpsInstrument", + "PerpsRiskTier", + "PerpsStatistic", + "PerpsTicker", + "PerpsTickerUpdate", + "PerpsTrade", +] diff --git a/src/polymarket/models/perps/orders.py b/src/polymarket/models/perps/orders.py new file mode 100644 index 0000000..7664a81 --- /dev/null +++ b/src/polymarket/models/perps/orders.py @@ -0,0 +1,166 @@ +"""Perps order and fill models.""" + +from typing import Any, Literal, cast + +from pydantic import AliasChoices, Field, model_validator + +from polymarket.models.base import BaseModel +from polymarket.models.perps._validators import ( + OptionalTxHash, + PerpsTimestamp, + _Decimal, +) +from polymarket.models.perps.types import ( + PerpsInstrumentId, + PerpsOrderId, + PerpsOrderStatus, + PerpsSide, + PerpsTimeInForce, + PerpsTpSlKind, + PerpsTpSlScope, + PerpsTradeId, +) +from polymarket.models.types import OrderSide + +_DEFAULT_ACK_ERROR = "Perps command was rejected." + + +def _side_from_buy(value: object) -> OrderSide: + if value is True: + return "BUY" + if value is False: + return "SELL" + msg = f"expected buy flag to be a bool, got {type(value).__name__}" + raise ValueError(msg) + + +class PerpsTpSlOrderFields(BaseModel): + """Take-profit/stop-loss metadata attached to a trigger order.""" + + kind: PerpsTpSlKind + scope: PerpsTpSlScope + trigger_price: _Decimal = Field(validation_alias="trp") + parent_order_id: PerpsOrderId | None = Field(default=None, validation_alias="parent_oid") + armed_quantity: _Decimal | None = Field(default=None, validation_alias="armed_qty") + slippage_bps: int | None = Field(default=None, validation_alias="slip_bps") + + +class PerpsOrder(BaseModel): + """One Perps order.""" + + id: PerpsOrderId = Field(validation_alias=AliasChoices("order_id", "oid")) + instrument_id: PerpsInstrumentId = Field(validation_alias=AliasChoices("instrument_id", "iid")) + side: OrderSide = Field(validation_alias="buy") + price: _Decimal = Field(validation_alias=AliasChoices("price", "p")) + quantity: _Decimal = Field(validation_alias=AliasChoices("quantity", "qty")) + time_in_force: PerpsTimeInForce = Field(validation_alias="tif") + post_only: bool = Field(validation_alias=AliasChoices("post_only", "po")) + status: PerpsOrderStatus + resting_quantity: _Decimal = Field(validation_alias=AliasChoices("resting_quantity", "rest")) + filled_quantity: _Decimal = Field(validation_alias=AliasChoices("filled_quantity", "fill")) + created_at: PerpsTimestamp = Field(validation_alias=AliasChoices("created_timestamp", "cts")) + updated_at: PerpsTimestamp = Field(validation_alias=AliasChoices("updated_timestamp", "uts")) + client_order_id: str | None = Field( + default=None, validation_alias=AliasChoices("client_order_id", "coid") + ) + tp_sl: PerpsTpSlOrderFields | None = Field(default=None, validation_alias="tpsl") + + @model_validator(mode="before") + @classmethod + def _normalize(cls, data: object) -> object: + if not isinstance(data, dict): + return data + wire = cast("dict[str, Any]", data) + if "buy" not in wire: + return wire + normalized = dict(wire) + normalized["buy"] = _side_from_buy(normalized["buy"]) + return normalized + + +class PerpsFill(BaseModel): + """One fill on a Perps order for the account.""" + + trade_id: PerpsTradeId = Field(validation_alias=AliasChoices("trade_id", "tid")) + order_id: PerpsOrderId = Field(validation_alias=AliasChoices("order_id", "oid")) + instrument_id: PerpsInstrumentId = Field(validation_alias=AliasChoices("instrument_id", "iid")) + side: PerpsSide + price: _Decimal = Field(validation_alias=AliasChoices("price", "p")) + quantity: _Decimal = Field(validation_alias=AliasChoices("quantity", "qty")) + taker: bool + fee: _Decimal + fee_asset: str = Field(validation_alias=AliasChoices("fee_asset", "fea")) + previous_size: _Decimal = Field(validation_alias=AliasChoices("previous_size", "psz")) + previous_entry_price: _Decimal = Field( + validation_alias=AliasChoices("previous_entry_price", "pep") + ) + pnl: _Decimal + liquidation: bool = Field(validation_alias=AliasChoices("liquidation", "liq")) + timestamp: PerpsTimestamp = Field(validation_alias=AliasChoices("timestamp", "ts")) + hash: OptionalTxHash = None + client_order_id: str | None = Field(default=None, validation_alias="coid") + + +def _default_ack_error(data: object) -> object: + if not isinstance(data, dict): + return data + wire = cast("dict[str, Any]", data) + if wire.get("status") == "err" and not wire.get("error"): + normalized = dict(wire) + normalized["error"] = _DEFAULT_ACK_ERROR + return normalized + return wire + + +class PerpsPostOrderAck(BaseModel): + """Acknowledgement for one posted Perps order.""" + + status: Literal["ok", "err"] + order_id: PerpsOrderId | None = Field(default=None, validation_alias="oid") + client_order_id: str | None = Field(default=None, validation_alias="coid") + error: str | None = None + + @model_validator(mode="before") + @classmethod + def _normalize(cls, data: object) -> object: + return _default_ack_error(data) + + @model_validator(mode="after") + def _check(self) -> "PerpsPostOrderAck": + if self.status == "ok" and self.order_id is None: + msg = "expected Perps post order acknowledgement order id" + raise ValueError(msg) + return self + + +class PerpsCancelOrderResult(BaseModel): + """Result of one Perps order cancellation.""" + + status: Literal["ok", "err"] + order_id: PerpsOrderId | None = Field(default=None, validation_alias="oid") + client_order_id: str | None = Field(default=None, validation_alias="coid") + error: str | None = None + + @model_validator(mode="before") + @classmethod + def _normalize(cls, data: object) -> object: + return _default_ack_error(data) + + +class PerpsUpdateLeverageResult(BaseModel): + """Result of a Perps leverage update.""" + + status: Literal["ok"] + instrument_id: PerpsInstrumentId + leverage: int + cross_margin: bool = Field(validation_alias="cross") + + +__all__ = [ + "PerpsCancelOrderResult", + "PerpsFill", + "PerpsOrder", + "PerpsPostOrderAck", + "PerpsTpSlOrderFields", + "PerpsUpdateLeverageResult", +] diff --git a/src/polymarket/models/perps/requests.py b/src/polymarket/models/perps/requests.py new file mode 100644 index 0000000..3472ba4 --- /dev/null +++ b/src/polymarket/models/perps/requests.py @@ -0,0 +1,122 @@ +"""Perps order request inputs.""" + +import re +from dataclasses import dataclass +from decimal import Decimal, InvalidOperation + +from polymarket.errors import UserInputError +from polymarket.models.perps.types import PerpsTimeInForce +from polymarket.models.types import OrderSide + +_CLIENT_ORDER_ID = re.compile(r"^[0-9a-f]{32}$") + +DecimalInput = Decimal | int | float | str +"""Decimal-valued input accepted for Perps prices and quantities.""" + + +def to_decimal_string(name: str, value: DecimalInput) -> str: + """Normalize a decimal input into its canonical wire string.""" + if isinstance(value, bool): + raise UserInputError(f"{name} must be a decimal value, got bool") + candidate = value if isinstance(value, str) else str(value) + try: + parsed = Decimal(candidate) + except InvalidOperation as error: + raise UserInputError(f"{name} must be a valid decimal, got {value!r}") from error + if not parsed.is_finite(): + raise UserInputError(f"{name} must be finite, got {value!r}") + return candidate + + +def validate_client_order_id(value: str) -> str: + if not isinstance(value, str) or not _CLIENT_ORDER_ID.match(value): # pyright: ignore[reportUnnecessaryIsInstance] + raise UserInputError("client_order_id must be a 32-character lowercase hex string") + return value + + +@dataclass(frozen=True, slots=True, kw_only=True) +class PerpsOrderRequest: + """One Perps order to submit. + + ``gtc`` orders require a ``price`` and may set ``post_only``. ``ioc`` and + ``fok`` orders may omit ``price`` for market-style execution and cannot be + post-only. + """ + + instrument_id: int + """Instrument to trade.""" + side: OrderSide + """Trade direction.""" + quantity: DecimalInput + """Order quantity.""" + time_in_force: PerpsTimeInForce + """Execution mode: ``gtc``, ``ioc``, or ``fok``.""" + price: DecimalInput | None = None + """Limit price. Required for ``gtc``; optional for ``ioc``/``fok``.""" + post_only: bool = False + """Whether the order must rest instead of taking liquidity.""" + client_order_id: str | None = None + """Optional caller-supplied idempotency identifier.""" + + def __post_init__(self) -> None: + if isinstance(self.instrument_id, bool) or not isinstance(self.instrument_id, int): # pyright: ignore[reportUnnecessaryIsInstance] + raise UserInputError("instrument_id must be an int") + if self.instrument_id < 0: + raise UserInputError("instrument_id must be non-negative") + if self.side not in ("BUY", "SELL"): + raise UserInputError(f"side must be 'BUY' or 'SELL', got {self.side!r}") + if self.time_in_force not in ("gtc", "ioc", "fok"): + raise UserInputError( + f"time_in_force must be 'gtc', 'ioc', or 'fok', got {self.time_in_force!r}" + ) + if not isinstance(self.post_only, bool): # pyright: ignore[reportUnnecessaryIsInstance] + raise UserInputError("post_only must be a bool") + to_decimal_string("quantity", self.quantity) + if self.time_in_force == "gtc": + if self.price is None: + raise UserInputError("price is required for gtc orders") + elif self.post_only: + raise UserInputError("post_only is only supported for gtc orders") + if self.price is not None: + to_decimal_string("price", self.price) + if self.client_order_id is not None: + validate_client_order_id(self.client_order_id) + + +@dataclass(frozen=True, slots=True, kw_only=True) +class PerpsTpSlTrigger: + """A take-profit or stop-loss trigger attached to an order. + + The trigger leg executes as a market order unless ``limit_price`` is set. + """ + + trigger_price: DecimalInput + """Mark price at which the trigger arms.""" + limit_price: DecimalInput | None = None + """Optional limit price for the trigger leg.""" + + def __post_init__(self) -> None: + to_decimal_string("trigger_price", self.trigger_price) + if self.limit_price is not None: + to_decimal_string("limit_price", self.limit_price) + + +@dataclass(frozen=True, slots=True, kw_only=True) +class PerpsPositionTpSlTrigger: + """A take-profit or stop-loss trigger protecting a full position.""" + + trigger_price: DecimalInput + """Mark price at which the trigger arms.""" + + def __post_init__(self) -> None: + to_decimal_string("trigger_price", self.trigger_price) + + +__all__ = [ + "DecimalInput", + "PerpsOrderRequest", + "PerpsPositionTpSlTrigger", + "PerpsTpSlTrigger", + "to_decimal_string", + "validate_client_order_id", +] diff --git a/src/polymarket/models/perps/results.py b/src/polymarket/models/perps/results.py new file mode 100644 index 0000000..de38f5d --- /dev/null +++ b/src/polymarket/models/perps/results.py @@ -0,0 +1,41 @@ +"""Perps session result objects.""" + +from dataclasses import dataclass + +from polymarket.models.perps.orders import PerpsOrder +from polymarket.models.perps.types import PerpsOrderId + + +@dataclass(frozen=True, slots=True, kw_only=True) +class PerpsPlacedTpSlOrder: + """One placed take-profit or stop-loss trigger order.""" + + order_id: PerpsOrderId + """Identifier of the trigger order.""" + + +@dataclass(frozen=True, slots=True, kw_only=True) +class PerpsPlacedTpSlOrders: + """Trigger orders placed alongside an order or position.""" + + take_profit: PerpsPlacedTpSlOrder | None = None + """The placed take-profit trigger, when requested.""" + stop_loss: PerpsPlacedTpSlOrder | None = None + """The placed stop-loss trigger, when requested.""" + + +@dataclass(frozen=True, slots=True, kw_only=True) +class PerpsOrderPlacement: + """Result of placing one Perps order.""" + + order: PerpsOrder + """The placed order as first reported on the orders channel.""" + tp_sl: PerpsPlacedTpSlOrders | None = None + """Trigger orders placed with the order, when requested.""" + + +__all__ = [ + "PerpsOrderPlacement", + "PerpsPlacedTpSlOrder", + "PerpsPlacedTpSlOrders", +] diff --git a/src/polymarket/models/perps/types.py b/src/polymarket/models/perps/types.py new file mode 100644 index 0000000..e36d3c8 --- /dev/null +++ b/src/polymarket/models/perps/types.py @@ -0,0 +1,72 @@ +"""Perps model-specific domain types.""" + +from typing import Literal, NewType, TypeAlias + +PerpsInstrumentId = NewType("PerpsInstrumentId", int) +PerpsOrderId = NewType("PerpsOrderId", int) +PerpsClientOrderId = NewType("PerpsClientOrderId", str) +PerpsTradeId = NewType("PerpsTradeId", int) +PerpsWithdrawalId = NewType("PerpsWithdrawalId", int) +PerpsEntityId = NewType("PerpsEntityId", int) + +PerpsInstrumentCategory: TypeAlias = Literal["equity", "commodity", "index", "crypto"] +PerpsSide: TypeAlias = Literal["long", "short"] +PerpsTimeInForce: TypeAlias = Literal["gtc", "ioc", "fok"] +PerpsTpSlKind: TypeAlias = Literal["tp", "sl"] +PerpsTpSlScope: TypeAlias = Literal["order", "position"] +PerpsDepositStatus: TypeAlias = Literal["pending", "confirmed", "removed"] +PerpsWithdrawalStatus: TypeAlias = Literal["pending", "confirmed", "removed"] +PerpsKlineInterval: TypeAlias = Literal["1s", "1m", "5m", "15m", "1h", "4h", "1d", "1w"] +PerpsStreamCandleInterval: TypeAlias = Literal["1m", "5m", "15m", "1h", "4h", "1d", "1w"] +PerpsPnlInterval: TypeAlias = Literal["1h", "4h", "1d", "1w"] +PerpsBookDepth: TypeAlias = Literal[10, 100, 500, 1000] +PerpsTpSlLifecycleStatus: TypeAlias = Literal["untriggered", "armed", "cancelled", "expired"] + +PerpsOrderStatus: TypeAlias = Literal[ + "accepted", + "open", + "partial", + "filled", + "cancelled", + "auto_cancelled", + "post_only_rejected", + "fok_unfilled", + "ioc_no_fill", + "ioc_expired", + "stp_cancelled", + "zero_quantity", + "duplicate_order", + "order_not_found", + "reduce_only_invalid", + "reduce_only_expired", + "order_expired", + "untriggered", + "armed", + "triggered", + "parent_cancelled", + "position_closed", + "position_flipped", + "reduce_only_invalid_at_trigger", + "expired", +] + +__all__ = [ + "PerpsBookDepth", + "PerpsClientOrderId", + "PerpsDepositStatus", + "PerpsEntityId", + "PerpsInstrumentCategory", + "PerpsInstrumentId", + "PerpsKlineInterval", + "PerpsOrderId", + "PerpsOrderStatus", + "PerpsPnlInterval", + "PerpsSide", + "PerpsStreamCandleInterval", + "PerpsTimeInForce", + "PerpsTpSlKind", + "PerpsTpSlLifecycleStatus", + "PerpsTpSlScope", + "PerpsTradeId", + "PerpsWithdrawalId", +] diff --git a/src/polymarket/perps.py b/src/polymarket/perps.py new file mode 100644 index 0000000..b4abc01 --- /dev/null +++ b/src/polymarket/perps.py @@ -0,0 +1,18 @@ +"""Perps trading session types. + +Open a session with :meth:`polymarket.AsyncSecureClient.open_perps_session`. +""" + +from polymarket._internal.perps_session import PerpsSession +from polymarket.models.perps.results import ( + PerpsOrderPlacement, + PerpsPlacedTpSlOrder, + PerpsPlacedTpSlOrders, +) + +__all__ = [ + "PerpsOrderPlacement", + "PerpsPlacedTpSlOrder", + "PerpsPlacedTpSlOrders", + "PerpsSession", +] diff --git a/src/polymarket/streams/__init__.py b/src/polymarket/streams/__init__.py index c73c7e8..c317a90 100644 --- a/src/polymarket/streams/__init__.py +++ b/src/polymarket/streams/__init__.py @@ -28,6 +28,26 @@ UserTradeMakerOrder, UserTradePayload, ) +from polymarket.models.perps.events import ( + PerpsBalanceEvent, + PerpsBboEvent, + PerpsBookEvent, + PerpsCandleEvent, + PerpsDepositEvent, + PerpsFillEvent, + PerpsFundingEvent, + PerpsMarketEvent, + PerpsOrderEvent, + PerpsPortfolioEvent, + PerpsResyncEvent, + PerpsSessionEvent, + PerpsStatisticEvent, + PerpsTickerEvent, + PerpsTpSlEvent, + PerpsTpSlUpdate, + PerpsTradeEvent, + PerpsWithdrawalEvent, +) from polymarket.models.rtds_events import ( Comment, CommentCreatedEvent, @@ -65,6 +85,13 @@ EquityPricesSpec, MarketSpec, ParentEntityType, + PerpsBboSpec, + PerpsBookSpec, + PerpsCandlesSpec, + PerpsSpec, + PerpsStatisticsSpec, + PerpsTickersSpec, + PerpsTradesSpec, PublicSubscription, RtdsSpec, SecureSubscription, @@ -73,7 +100,7 @@ UserSpec, ) -StreamEvent = MarketEvent | SportsEvent | RtdsEvent | UserEvent +StreamEvent = MarketEvent | SportsEvent | RtdsEvent | UserEvent | PerpsMarketEvent __all__ = [ "Comment", @@ -116,6 +143,31 @@ "NewMarketEvent", "NewMarketPayload", "ParentEntityType", + "PerpsBalanceEvent", + "PerpsBboEvent", + "PerpsBboSpec", + "PerpsBookEvent", + "PerpsBookSpec", + "PerpsCandleEvent", + "PerpsCandlesSpec", + "PerpsDepositEvent", + "PerpsFillEvent", + "PerpsFundingEvent", + "PerpsMarketEvent", + "PerpsOrderEvent", + "PerpsPortfolioEvent", + "PerpsResyncEvent", + "PerpsSessionEvent", + "PerpsSpec", + "PerpsStatisticEvent", + "PerpsStatisticsSpec", + "PerpsTickerEvent", + "PerpsTickersSpec", + "PerpsTpSlEvent", + "PerpsTpSlUpdate", + "PerpsTradeEvent", + "PerpsTradesSpec", + "PerpsWithdrawalEvent", "PriceChange", "PublicSubscription", "SecureSubscription", diff --git a/src/polymarket/streams/_specs.py b/src/polymarket/streams/_specs.py index 94d76a0..8e1d6b8 100644 --- a/src/polymarket/streams/_specs.py +++ b/src/polymarket/streams/_specs.py @@ -13,6 +13,16 @@ {"prices.crypto.binance", "prices.crypto.chainlink"} ) _EQUITY_EVENT_TYPES: frozenset[str] = frozenset({"update", "subscribe"}) +_PERPS_CANDLE_INTERVALS: frozenset[str] = frozenset({"1m", "5m", "15m", "1h", "4h", "1d", "1w"}) + + +def _validate_perps_instrument_id(instrument_id: object, *, optional: bool = False) -> None: + if instrument_id is None and optional: + return + if isinstance(instrument_id, bool) or not isinstance(instrument_id, int): + raise UserInputError("instrument_id must be an int") + if instrument_id < 0: + raise UserInputError("instrument_id must be non-negative") CommentsEventType = Literal[ @@ -184,25 +194,111 @@ def __post_init__(self) -> None: object.__setattr__(self, "markets", tuple(normalized) if normalized else None) +@dataclass(frozen=True, slots=True, kw_only=True) +class PerpsTradesSpec: + """Subscribe to public trades for one Perps instrument.""" + + instrument_id: int + topic: Literal["perps.trades"] = field(default="perps.trades", init=False) + + def __post_init__(self) -> None: + _validate_perps_instrument_id(self.instrument_id) + + +@dataclass(frozen=True, slots=True, kw_only=True) +class PerpsBboSpec: + """Subscribe to best bid/ask updates for one Perps instrument.""" + + instrument_id: int + topic: Literal["perps.bbo"] = field(default="perps.bbo", init=False) + + def __post_init__(self) -> None: + _validate_perps_instrument_id(self.instrument_id) + + +@dataclass(frozen=True, slots=True, kw_only=True) +class PerpsBookSpec: + """Subscribe to order book deltas for one Perps instrument.""" + + instrument_id: int + topic: Literal["perps.book"] = field(default="perps.book", init=False) + + def __post_init__(self) -> None: + _validate_perps_instrument_id(self.instrument_id) + + +@dataclass(frozen=True, slots=True, kw_only=True) +class PerpsCandlesSpec: + """Subscribe to streaming candles for one Perps instrument and interval.""" + + instrument_id: int + interval: Literal["1m", "5m", "15m", "1h", "4h", "1d", "1w"] + topic: Literal["perps.candles"] = field(default="perps.candles", init=False) + + def __post_init__(self) -> None: + _validate_perps_instrument_id(self.instrument_id) + if self.interval not in _PERPS_CANDLE_INTERVALS: + raise UserInputError( + f"interval must be one of {sorted(_PERPS_CANDLE_INTERVALS)}, got {self.interval!r}" + ) + + +@dataclass(frozen=True, slots=True, kw_only=True) +class PerpsTickersSpec: + """Subscribe to ticker updates for one Perps instrument or all instruments. + + When ``instrument_id`` is omitted, the subscription receives ticker + updates for every instrument. + """ + + instrument_id: int | None = None + topic: Literal["perps.tickers"] = field(default="perps.tickers", init=False) + + def __post_init__(self) -> None: + _validate_perps_instrument_id(self.instrument_id, optional=True) + + +@dataclass(frozen=True, slots=True, kw_only=True) +class PerpsStatisticsSpec: + """Subscribe to statistics updates for one Perps instrument or all instruments. + + When ``instrument_id`` is omitted, the subscription receives statistics + updates for every instrument. + """ + + instrument_id: int | None = None + topic: Literal["perps.statistics"] = field(default="perps.statistics", init=False) + + def __post_init__(self) -> None: + _validate_perps_instrument_id(self.instrument_id, optional=True) + + RtdsSpec = CommentsSpec | CryptoPricesSpec | EquityPricesSpec -PublicSubscription = MarketSpec | SportsSpec | RtdsSpec +PerpsSpec = ( + PerpsTradesSpec + | PerpsBboSpec + | PerpsBookSpec + | PerpsCandlesSpec + | PerpsTickersSpec + | PerpsStatisticsSpec +) +PublicSubscription = MarketSpec | SportsSpec | RtdsSpec | PerpsSpec SecureSubscription = PublicSubscription | UserSpec Subscription = SecureSubscription -_SPEC_TYPES: tuple[ - type[MarketSpec], - type[SportsSpec], - type[CommentsSpec], - type[CryptoPricesSpec], - type[EquityPricesSpec], - type[UserSpec], -] = ( +_SPEC_TYPES: tuple[type[Subscription], ...] = ( MarketSpec, SportsSpec, CommentsSpec, CryptoPricesSpec, EquityPricesSpec, + PerpsTradesSpec, + PerpsBboSpec, + PerpsBookSpec, + PerpsCandlesSpec, + PerpsTickersSpec, + PerpsStatisticsSpec, UserSpec, ) @@ -236,6 +332,13 @@ def normalize_specs(specs: _S | Sequence[_S]) -> list[_S]: "EquityPricesSpec", "MarketSpec", "ParentEntityType", + "PerpsBboSpec", + "PerpsBookSpec", + "PerpsCandlesSpec", + "PerpsSpec", + "PerpsStatisticsSpec", + "PerpsTickersSpec", + "PerpsTradesSpec", "RtdsSpec", "SportsSpec", "Subscription", diff --git a/tests/integration/test_perps.py b/tests/integration/test_perps.py new file mode 100644 index 0000000..de98272 --- /dev/null +++ b/tests/integration/test_perps.py @@ -0,0 +1,283 @@ +# pyright: reportPrivateUsage=false +"""Live Perps integration tests. + +Mirrors the TypeScript SDK Perps integration suite. All metered tests run +against production Perps with the Deposit Wallet configured in ``.env``. + +Metered side effects: +- ``test_deposits_and_withdraws_the_same_perps_amount`` approves collateral, + moves 10 USDC into Perps, and withdraws the same amount. +- Session tests create (and where noted revoke) delegated Perps credentials. +- Order tests place resting orders far from the mark price and cancel them. +""" + +import asyncio +import math +from collections.abc import AsyncGenerator, Callable +from datetime import UTC, datetime, timedelta +from decimal import Decimal + +import pytest + +from polymarket import ( + AsyncPublicClient, + AsyncSecureClient, + BuilderApiKey, + PerpsInstrument, + PerpsTpSlTrigger, + RequestRejectedError, +) +from polymarket.errors import UnexpectedResponseError +from polymarket.perps import PerpsSession +from polymarket.streams import PerpsBookSpec + +pytestmark = pytest.mark.anyio + +DEFAULT_PERPS_CREDENTIAL_TTL = timedelta(days=7) +_MAX_PRICE_SIGNIFICANT_FIGURES = 5 +_DEPOSIT_AMOUNT_BASE_UNITS = 10_000_000 # 10 USDC +_DEPOSIT_CONFIRM_TIMEOUT_S = 5 * 60.0 + + +@pytest.fixture +async def relayer_enabled_deposit_wallet_client( + require_env: Callable[[str], str], +) -> AsyncGenerator[AsyncSecureClient, None]: + """Deposit Wallet client with relayer access for gasless transactions.""" + api_key = BuilderApiKey( + key=require_env("POLYMARKET_BUILDER_API_KEY"), + secret=require_env("POLYMARKET_BUILDER_SECRET"), + passphrase=require_env("POLYMARKET_BUILDER_PASSPHRASE"), + ) + client = await AsyncSecureClient.create( + private_key=require_env("POLYMARKET_PRIVATE_KEY"), + wallet=require_env("POLYMARKET_DEPOSIT_WALLET"), + api_key=api_key, + ) + try: + yield client + finally: + await client.close() + + +def _format_perps_price(price: Decimal, price_decimals: int) -> str: + """Round to five significant figures and trim to the instrument's decimals.""" + rounded = float(f"{float(price):.{_MAX_PRICE_SIGNIFICANT_FIGURES}g}") + if rounded == int(rounded): + return str(int(rounded)) + return f"{rounded:.{price_decimals}f}".rstrip("0").rstrip(".") + + +def _minimal_order_quantity(instrument: PerpsInstrument, price: Decimal) -> str: + scale = 10**instrument.quantity_decimals + quantity = math.ceil(float(instrument.min_notional) / float(price) * scale) / scale + return f"{quantity:.{instrument.quantity_decimals}f}" + + +async def _first_instrument(client: AsyncSecureClient) -> PerpsInstrument: + instruments = await client.fetch_perps_instruments() + assert instruments, "expected at least one Perps instrument" + return instruments[0] + + +async def _wait_for_confirmed_deposit(session: PerpsSession, *, hash: str, amount: Decimal) -> None: + async def poll() -> None: + while True: + page = await session.list_deposits(hash=hash).first_page() + deposit = next((item for item in page.items if item.hash == hash), None) + if deposit is not None and deposit.status == "confirmed": + assert deposit.amount == amount + return + await asyncio.sleep(5.0) + + await asyncio.wait_for(poll(), timeout=_DEPOSIT_CONFIRM_TIMEOUT_S) + + +@pytest.mark.integration +async def test_public_perps_reads() -> None: + async with AsyncPublicClient() as client: + instruments = await client.fetch_perps_instruments() + assert instruments + instrument = instruments[0] + ticker = await client.fetch_perps_ticker(instrument_id=instrument.id) + assert ticker.mark_price > 0 + book = await client.fetch_perps_book(instrument_id=instrument.id, depth=10) + assert book.instrument_id == instrument.id + fees = await client.fetch_perps_fees() + assert fees + candles = await client.list_perps_candles( + instrument_id=instrument.id, interval="1h" + ).first_page() + assert candles.items + + +@pytest.mark.integration +async def test_perps_book_subscription_receives_an_event() -> None: + async with AsyncPublicClient() as client: + instruments = await client.fetch_perps_instruments() + assert instruments + handle = await client.subscribe(PerpsBookSpec(instrument_id=instruments[0].id)) + async with handle: + event = await asyncio.wait_for(handle.__anext__(), timeout=30.0) + assert event.topic == "perps.book" + assert event.payload.instrument_id == instruments[0].id + + +@pytest.mark.integration +@pytest.mark.metered +async def test_deposits_and_withdraws_the_same_perps_amount( + relayer_enabled_deposit_wallet_client: AsyncSecureClient, +) -> None: + client = relayer_enabled_deposit_wallet_client + approval = await client.approve_erc20( + amount="max", + spender_address=client.environment.perps_deposit_contract, + token_address=client.environment.collateral_token, + ) + await approval.wait() + + deposit = await client.deposit_to_perps(amount=_DEPOSIT_AMOUNT_BASE_UNITS) + outcome = await deposit.wait() + assert outcome is not None + transaction_hash = outcome.transaction_hash + assert transaction_hash is not None + assert transaction_hash.startswith("0x") + + session = await client.open_perps_session(expires_in=timedelta(minutes=30)) + try: + await _wait_for_confirmed_deposit(session, hash=transaction_hash, amount=Decimal(10)) + withdrawal_id = await client.withdraw_from_perps(amount=_DEPOSIT_AMOUNT_BASE_UNITS) + assert isinstance(withdrawal_id, int) + finally: + await session.close() + + +@pytest.mark.integration +@pytest.mark.metered +async def test_creates_delegated_perps_credentials_with_the_default_expiry( + deposit_wallet_client: AsyncSecureClient, +) -> None: + started_at = datetime.now(tz=UTC) + session = await deposit_wallet_client.open_perps_session() + try: + credentials = session.credentials + assert credentials.proxy.startswith("0x") and len(credentials.proxy) == 42 + assert credentials.private_key.startswith("0x") + assert len(credentials.private_key) == 66 + assert credentials.secret + assert credentials.expires_at >= started_at + DEFAULT_PERPS_CREDENTIAL_TTL + assert credentials.expires_at <= datetime.now(tz=UTC) + DEFAULT_PERPS_CREDENTIAL_TTL + finally: + await session.close() + + +@pytest.mark.integration +@pytest.mark.metered +async def test_places_and_cancels_one_perps_order( + deposit_wallet_client: AsyncSecureClient, +) -> None: + instrument = await _first_instrument(deposit_wallet_client) + ticker = await deposit_wallet_client.fetch_perps_ticker(instrument_id=instrument.id) + # Half the mark price so the order rests instead of filling. + price = _format_perps_price(ticker.mark_price / 2, instrument.price_decimals) + session = await deposit_wallet_client.open_perps_session() + try: + placement = await session.place_order( + instrument_id=instrument.id, + side="BUY", + price=price, + quantity=_minimal_order_quantity(instrument, Decimal(price)), + time_in_force="gtc", + ) + assert placement.order.instrument_id == instrument.id + assert placement.order.side == "BUY" + + result = await session.cancel_order(order_id=placement.order.id) + assert result.status == "ok" + finally: + await session.close() + + +@pytest.mark.integration +@pytest.mark.metered +async def test_places_and_cancels_one_perps_order_with_tp_sl( + deposit_wallet_client: AsyncSecureClient, +) -> None: + instrument = await _first_instrument(deposit_wallet_client) + ticker = await deposit_wallet_client.fetch_perps_ticker(instrument_id=instrument.id) + mark_price = ticker.mark_price + price = _format_perps_price(mark_price / 2, instrument.price_decimals) + session = await deposit_wallet_client.open_perps_session() + try: + placement = await session.place_order( + instrument_id=instrument.id, + side="BUY", + price=price, + quantity=_minimal_order_quantity(instrument, Decimal(price)), + time_in_force="gtc", + stop_loss=PerpsTpSlTrigger( + trigger_price=_format_perps_price(mark_price / 4, instrument.price_decimals) + ), + take_profit=PerpsTpSlTrigger( + trigger_price=_format_perps_price(mark_price * 2, instrument.price_decimals) + ), + ) + assert placement.tp_sl is not None + assert placement.tp_sl.take_profit is not None + assert placement.tp_sl.take_profit.order_id > 0 + assert placement.tp_sl.stop_loss is not None + assert placement.tp_sl.stop_loss.order_id > 0 + + result = await session.cancel_order(order_id=placement.order.id) + assert result.status == "ok" + finally: + await session.close() + + +@pytest.mark.integration +@pytest.mark.metered +async def test_resumes_existing_delegated_perps_credentials( + deposit_wallet_client: AsyncSecureClient, +) -> None: + initial_session = await deposit_wallet_client.open_perps_session( + expires_in=timedelta(minutes=30) + ) + try: + resumed_session = await deposit_wallet_client.open_perps_session( + credentials=initial_session.credentials + ) + try: + assert resumed_session.credentials == initial_session.credentials + finally: + await resumed_session.close() + finally: + await initial_session.close() + + +@pytest.mark.integration +@pytest.mark.metered +async def test_revokes_delegated_perps_credentials( + deposit_wallet_client: AsyncSecureClient, +) -> None: + session = await deposit_wallet_client.open_perps_session(expires_in=timedelta(minutes=30)) + credentials = session.credentials + await session.close() + + await deposit_wallet_client.revoke_perps_credentials(proxy=credentials.proxy) + + with pytest.raises((RequestRejectedError, UnexpectedResponseError)): + await deposit_wallet_client.open_perps_session(credentials=credentials) + + +@pytest.mark.integration +@pytest.mark.metered +async def test_rejects_delegated_perps_credentials_with_an_invalid_secret( + deposit_wallet_client: AsyncSecureClient, +) -> None: + session = await deposit_wallet_client.open_perps_session(expires_in=timedelta(minutes=30)) + try: + tampered = session.credentials.model_copy(update={"secret": "invalid-secret"}) + with pytest.raises(RequestRejectedError): + await deposit_wallet_client.open_perps_session(credentials=tampered) + finally: + await session.close() diff --git a/tests/unit/test_builder_trades.py b/tests/unit/test_builder_trades.py index 4244ee5..7d806c7 100644 --- a/tests/unit/test_builder_trades.py +++ b/tests/unit/test_builder_trades.py @@ -314,6 +314,7 @@ async def run() -> list[str]: base_url=PRODUCTION.clob_url, transport=httpx.MockTransport(handler) ), ), + perps=client._ctx.perps, ) ids: list[str] = [] async for trade in client.list_builder_trades( diff --git a/tests/unit/test_perps_credentials.py b/tests/unit/test_perps_credentials.py new file mode 100644 index 0000000..ea315ef --- /dev/null +++ b/tests/unit/test_perps_credentials.py @@ -0,0 +1,221 @@ +"""Perps delegated credentials lifecycle tests against a mocked transport.""" + +import asyncio +import json +from collections.abc import Callable +from datetime import UTC, datetime, timedelta +from typing import Any + +import httpx +import pytest +from eth_account import Account + +from polymarket._internal.actions.perps import credentials as perps_credentials +from polymarket._internal.actions.perps.signing import ( + build_perps_create_proxy_typed_data, + build_perps_op_typed_data, +) +from polymarket.clients._transport import AsyncTransport +from polymarket.errors import ( + RequestRejectedError, + UnexpectedResponseError, + UserInputError, +) +from polymarket.models.perps.credentials import PerpsCredentials + +_BASE_URL = "https://perps.test" +_OWNER_KEY = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d" +_OWNER = Account.from_key(_OWNER_KEY) + + +def _transport(handler: Callable[[httpx.Request], httpx.Response]) -> AsyncTransport: + return AsyncTransport( + base_url=_BASE_URL, + client=httpx.AsyncClient(base_url=_BASE_URL, transport=httpx.MockTransport(handler)), + ) + + +def _credentials_response(proxy: str, *, expiry_ms: int) -> dict[str, Any]: + return { + "address": _OWNER.address, + "keys": [{"proxy": proxy, "expiry": expiry_ms}], + } + + +def test_create_credentials_signs_create_proxy_and_validates() -> None: + captured: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + if request.url.path == "/v1/account/proxy": + return httpx.Response(200, json={"secret": "new-secret"}) + assert request.url.path == "/v1/account/credentials" + body = json.loads(captured[0].content) + expiry = body["op"]["args"]["expiry"] + return httpx.Response( + 200, json=_credentials_response(body["op"]["args"]["proxy"], expiry_ms=expiry) + ) + + async def run() -> PerpsCredentials: + transport = _transport(handler) + try: + return await perps_credentials.create_credentials( + transport, + signer=_OWNER, + chain_id=137, + expires_in=timedelta(days=7), + label="bot", + ) + finally: + await transport.close() + + credentials = asyncio.run(run()) + assert credentials.secret == "new-secret" + assert Account.from_key(credentials.private_key).address == credentials.proxy + + create_request = captured[0] + body = json.loads(create_request.content) + assert body["label"] == "bot" + assert body["op"]["type"] == "createProxy" + assert body["op"]["args"]["owner"] == _OWNER.address + # The signature must recover to the owner for the CreateProxy payload. + payload = build_perps_create_proxy_typed_data( + chain_id=137, + proxy=body["op"]["args"]["proxy"], + expires_at_ms=body["op"]["args"]["expiry"], + salt=body["salt"], + timestamp_ms=body["ts"], + ) + from eth_account.messages import encode_typed_data + + recovered = Account.recover_message( + encode_typed_data(full_message=payload), signature=body["sig"] + ) + assert recovered == _OWNER.address + + validate_request = captured[1] + assert validate_request.headers["POLYMARKET-PROXY"] == credentials.proxy + assert validate_request.headers["POLYMARKET-SECRET"] == "new-secret" + + +def test_resume_rejects_mismatched_private_key() -> None: + async def run() -> None: + transport = _transport(lambda request: httpx.Response(200, json={})) + try: + with pytest.raises(UserInputError, match="does not match"): + await perps_credentials.resume_credentials( + transport, + signer_address=_OWNER.address, + credentials=PerpsCredentials( + proxy="0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc", + private_key=_OWNER_KEY, # derives a different address + secret="secret", + expires_at=datetime.now(tz=UTC), + ), + ) + finally: + await transport.close() + + asyncio.run(run()) + + +def test_validate_rejects_expired_and_missing_keys() -> None: + proxy_account = Account.create() + credentials = PerpsCredentials( + proxy=proxy_account.address, + private_key="0x" + proxy_account.key.hex().removeprefix("0x"), + secret="secret", + expires_at=datetime.now(tz=UTC), + ) + + def expired_handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=_credentials_response(credentials.proxy, expiry_ms=1_000)) + + def missing_handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"address": _OWNER.address, "keys": []}) + + async def run() -> None: + expired = _transport(expired_handler) + missing = _transport(missing_handler) + try: + with pytest.raises(UnexpectedResponseError, match="expired"): + await perps_credentials.validate_credentials( + expired, signer_address=_OWNER.address, credentials=credentials + ) + with pytest.raises(UnexpectedResponseError, match="not returned"): + await perps_credentials.validate_credentials( + missing, signer_address=_OWNER.address, credentials=credentials + ) + finally: + await expired.close() + await missing.close() + + asyncio.run(run()) + + +def test_validate_normalizes_nanosecond_expiries() -> None: + proxy_account = Account.create() + credentials = PerpsCredentials( + proxy=proxy_account.address, + private_key="0x" + proxy_account.key.hex().removeprefix("0x"), + secret="secret", + expires_at=datetime.now(tz=UTC), + ) + expiry_ms = int((datetime.now(tz=UTC) + timedelta(days=1)).timestamp() * 1000) + + def handler(request: httpx.Request) -> httpx.Response: + # The credentials endpoint currently reports expiries in nanoseconds. + return httpx.Response( + 200, + json=_credentials_response(credentials.proxy, expiry_ms=expiry_ms * 1_000_000), + ) + + async def run() -> PerpsCredentials: + transport = _transport(handler) + try: + return await perps_credentials.validate_credentials( + transport, signer_address=_OWNER.address, credentials=credentials + ) + finally: + await transport.close() + + validated = asyncio.run(run()) + assert int(validated.expires_at.timestamp() * 1000) == expiry_ms + + +def test_revoke_sends_signed_delete_op_and_maps_err_status() -> None: + captured: list[httpx.Request] = [] + proxy = "0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc" + + def ok_handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response(200, json={"status": "ok"}) + + def err_handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"status": "err", "error": "unknown proxy"}) + + async def run() -> None: + ok = _transport(ok_handler) + err = _transport(err_handler) + try: + await perps_credentials.revoke_credentials(ok, signer=_OWNER, chain_id=137, proxy=proxy) + with pytest.raises(RequestRejectedError, match="unknown proxy"): + await perps_credentials.revoke_credentials( + err, signer=_OWNER, chain_id=137, proxy=proxy + ) + finally: + await ok.close() + await err.close() + + asyncio.run(run()) + body = json.loads(captured[0].content) + assert body["op"] == {"type": "deleteProxy", "args": {"proxy": proxy}} + from eth_account.messages import encode_typed_data + + payload = build_perps_op_typed_data( + chain_id=137, op=["deleteProxy", [proxy]], salt=body["salt"], timestamp_ms=body["ts"] + ) + recovered = Account.recover_message( + encode_typed_data(full_message=payload), signature=body["sig"] + ) + assert recovered == _OWNER.address diff --git a/tests/unit/test_perps_events.py b/tests/unit/test_perps_events.py new file mode 100644 index 0000000..973f754 --- /dev/null +++ b/tests/unit/test_perps_events.py @@ -0,0 +1,152 @@ +"""Perps realtime event parsing tests.""" + +from decimal import Decimal + +import pytest + +from polymarket.models.perps.events import ( + PerpsBookEvent, + PerpsCandleEvent, + PerpsDepositEvent, + PerpsOrderEvent, + PerpsTickerEvent, + PerpsTpSlEvent, + parse_perps_market_event, + parse_perps_market_events, + parse_perps_session_event, +) + + +def _order_update(order_id: int = 5) -> dict[str, object]: + return { + "oid": order_id, + "iid": 1, + "buy": True, + "p": "0.5", + "qty": "10", + "tif": "gtc", + "po": False, + "status": "open", + "rest": "10", + "fill": "0", + "cts": 1751500000000, + "uts": 1751500000001, + } + + +def test_book_event_injects_instrument_id_from_channel() -> None: + event = parse_perps_market_event( + { + "ch": "book::7", + "ts": 1751500000000, + "sq": 3, + "data": {"b": [["0.5", "10"]], "a": [["0.6", "4"]]}, + } + ) + assert isinstance(event, PerpsBookEvent) + assert event.topic == "perps.book" + assert event.payload.instrument_id == 7 + assert event.payload.bids[0].price == Decimal("0.5") + assert event.sequence == 3 + + +def test_candle_event_parses_interval_and_tuples() -> None: + event = parse_perps_market_event( + { + "ch": "klines::7::5m", + "ts": 1751500000000, + "sq": 1, + "data": [[1751500000000, "1", "2", "0.5", "1.5", "100", 3]], + } + ) + assert isinstance(event, PerpsCandleEvent) + assert event.payload.interval == "5m" + assert event.payload.candles[0].close == Decimal("1.5") + assert event.payload.candles[0].trades == 3 + + +def test_ticker_event_parses_compact_payload() -> None: + event = parse_perps_market_event( + { + "ch": "tickers::all", + "ts": 1751500000000, + "sq": 2, + "data": { + "iid": 4, + "idx": "100", + "mark": "101", + "last": "100.5", + "mid": "100.6", + "oi": "5000", + "fr": "0.0001", + "nxf": 1751503600000, + }, + } + ) + assert isinstance(event, PerpsTickerEvent) + assert event.payload.instrument_id == 4 + assert event.payload.mark_price == Decimal("101") + + +def test_non_event_frames_are_ignored_not_dropped() -> None: + events, dropped = parse_perps_market_events({"id": 0, "data": {"status": "ok"}}) + assert events == [] + assert dropped == 0 + + +def test_malformed_channel_frames_count_as_dropped() -> None: + events, dropped = parse_perps_market_events( + {"ch": "book::7", "ts": 1751500000000, "sq": 1, "data": {"bogus": True}} + ) + assert events == [] + assert dropped == 1 + + +def test_session_order_event_normalizes_compact_order() -> None: + event = parse_perps_session_event( + {"ch": "orders", "ts": 1751500000000, "sq": 9, "data": _order_update()} + ) + assert isinstance(event, PerpsOrderEvent) + assert event.payload.id == 5 + assert event.payload.side == "BUY" + assert event.payload.status == "open" + + +def test_session_tpsl_event_parses_lifecycle_update() -> None: + event = parse_perps_session_event( + { + "ch": "tpsl::12", + "ts": 1751500000000, + "sq": 1, + "data": {"oid": 44, "st": "armed"}, + } + ) + assert isinstance(event, PerpsTpSlEvent) + assert event.payload.order_id == 44 + assert event.payload.status == "armed" + + +def test_session_deposit_event_normalizes_placeholder_hash() -> None: + event = parse_perps_session_event( + { + "ch": "deposits", + "ts": 1751500000000, + "sq": 1, + "data": {"hash": "0x", "asset": "USDC", "amount": "10", "status": "pending"}, + } + ) + assert isinstance(event, PerpsDepositEvent) + assert event.payload.hash is None + assert event.payload.amount == Decimal("10") + + +@pytest.mark.parametrize( + "frame", + [ + {"id": 1, "data": [{"status": "ok", "oid": 2}]}, + {"ch": "unknown-channel", "ts": 1, "sq": 1, "data": {}}, + "not-a-dict", + ], +) +def test_session_parser_returns_none_for_non_events(frame: object) -> None: + assert parse_perps_session_event(frame) is None diff --git a/tests/unit/test_perps_funds.py b/tests/unit/test_perps_funds.py new file mode 100644 index 0000000..2283346 --- /dev/null +++ b/tests/unit/test_perps_funds.py @@ -0,0 +1,154 @@ +"""Perps collateral movement construction tests.""" + +import asyncio +import json +from collections.abc import Callable +from typing import cast + +import httpx +import pytest +from eth_account import Account +from eth_account.messages import encode_typed_data + +from polymarket._internal.actions.perps.funds import perps_deposit_call, withdraw_from_perps +from polymarket._internal.actions.perps.signing import build_perps_withdraw_typed_data +from polymarket.clients._transport import AsyncTransport +from polymarket.errors import RequestRejectedError, UserInputError +from polymarket.types import EvmAddress + +_BASE_URL = "https://perps.test" +_OWNER_KEY = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d" +_OWNER = Account.from_key(_OWNER_KEY) + +_DEPOSIT_CONTRACT = "0xDCa4af75705dbB50f62437045afF9921947917d2" +_COLLATERAL = "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB" + +# Pinned against the TypeScript SDK encoder for the same arguments. +_DEPOSIT_CALLDATA = ( + "0xf45346dc" + "000000000000000000000000c011a7e12a19f7b1f670d46f03b03f3342e82dfb" + "0000000000000000000000000000000000000000000000000000000000989680" + "000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266" +) + + +def test_perps_deposit_call_matches_typescript_encoding() -> None: + call = perps_deposit_call( + deposit_contract=cast(EvmAddress, _DEPOSIT_CONTRACT), + token=cast(EvmAddress, _COLLATERAL), + amount=10_000_000, + to=cast(EvmAddress, "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"), + ) + assert call.to == _DEPOSIT_CONTRACT + assert call.data == _DEPOSIT_CALLDATA + + +def test_perps_deposit_call_rejects_non_positive_amounts() -> None: + for amount in (0, -1): + with pytest.raises(UserInputError, match="positive"): + perps_deposit_call( + deposit_contract=cast(EvmAddress, _DEPOSIT_CONTRACT), + token=cast(EvmAddress, _COLLATERAL), + amount=amount, + to=cast(EvmAddress, _OWNER.address), + ) + + +def _transport(handler: Callable[[httpx.Request], httpx.Response]) -> AsyncTransport: + return AsyncTransport( + base_url=_BASE_URL, + client=httpx.AsyncClient(base_url=_BASE_URL, transport=httpx.MockTransport(handler)), + ) + + +def test_withdraw_sends_signed_request_and_returns_withdrawal_id() -> None: + captured: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response(200, json={"status": "ok", "withdraw_id": 314}) + + async def run() -> int: + transport = _transport(handler) + try: + return await withdraw_from_perps( + transport, + signer=_OWNER, + chain_id=137, + deposit_contract=_DEPOSIT_CONTRACT, + token=_COLLATERAL, + amount=10_000_000, + to="0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc", + ) + finally: + await transport.close() + + withdrawal_id = asyncio.run(run()) + assert withdrawal_id == 314 + + body = json.loads(captured[0].content) + assert body["op"] == { + "type": "withdraw", + "args": { + "account": _OWNER.address, + "token": _COLLATERAL, + "amount": "10000000", + "to": "0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc", + }, + } + # The withdrawal timestamp is expressed in seconds, not milliseconds. + assert body["ts"] < 10**11 + payload = build_perps_withdraw_typed_data( + chain_id=137, + deposit_contract=_DEPOSIT_CONTRACT, + account=_OWNER.address, + token=_COLLATERAL, + amount=10_000_000, + to="0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc", + salt=body["salt"], + timestamp_s=body["ts"], + ) + recovered = Account.recover_message( + encode_typed_data(full_message=payload), signature=body["sig"] + ) + assert recovered == _OWNER.address + + +def test_withdraw_maps_err_status_and_missing_id() -> None: + def err_handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"status": "err", "error": "insufficient balance"}) + + def missing_handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"status": "ok"}) + + async def run() -> None: + err = _transport(err_handler) + missing = _transport(missing_handler) + try: + with pytest.raises(RequestRejectedError, match="insufficient balance"): + await withdraw_from_perps( + err, + signer=_OWNER, + chain_id=137, + deposit_contract=_DEPOSIT_CONTRACT, + token=_COLLATERAL, + amount=1, + to=_OWNER.address, + ) + from polymarket.errors import UnexpectedResponseError + + with pytest.raises(UnexpectedResponseError, match="withdrawal ID"): + await withdraw_from_perps( + missing, + signer=_OWNER, + chain_id=137, + deposit_contract=_DEPOSIT_CONTRACT, + token=_COLLATERAL, + amount=1, + to=_OWNER.address, + ) + finally: + await err.close() + await missing.close() + + asyncio.run(run()) diff --git a/tests/unit/test_perps_market_stream.py b/tests/unit/test_perps_market_stream.py new file mode 100644 index 0000000..a0c0569 --- /dev/null +++ b/tests/unit/test_perps_market_stream.py @@ -0,0 +1,215 @@ +# pyright: reportPrivateUsage=false +"""Perps market data stream manager tests against a local WebSocket server.""" + +import asyncio +import contextlib +import json +from collections.abc import AsyncGenerator, Awaitable, Callable +from contextlib import asynccontextmanager +from typing import Any + +from websockets.asyncio.server import ServerConnection, serve + +from polymarket._internal.streams.perps.market import PerpsMarketStreamManager +from polymarket._internal.streams.perps.market_protocol import derive_state +from polymarket.models.perps.events import PerpsBookEvent, PerpsTradeEvent +from polymarket.streams._specs import ( + PerpsBookSpec, + PerpsCandlesSpec, + PerpsStatisticsSpec, + PerpsTickersSpec, + PerpsTradesSpec, +) + +Handler = Callable[[ServerConnection], Awaitable[None]] + + +@asynccontextmanager +async def ws_server(handler: Handler) -> AsyncGenerator[str, None]: + server = await serve(handler, host="127.0.0.1", port=0) + try: + port = next(iter(server.sockets)).getsockname()[1] + yield f"ws://127.0.0.1:{port}" + finally: + server.close() + await server.wait_closed() + + +def _is_ping(raw: str | bytes) -> bool: + with contextlib.suppress(Exception): + return json.loads(raw).get("id") == 0 + return False + + +def _book_frame(instrument_id: int) -> dict[str, Any]: + return { + "ch": f"book::{instrument_id}", + "ts": 1751500000000, + "sq": 1, + "data": {"b": [["0.5", "10"]], "a": [["0.6", "4"]]}, + } + + +def _trade_frame(instrument_id: int) -> dict[str, Any]: + return { + "ch": f"trades::{instrument_id}", + "ts": 1751500000000, + "sq": 2, + "data": { + "tid": 9, + "iid": instrument_id, + "side": "long", + "p": "0.5", + "qty": "1", + "ts": 1751500000000, + }, + } + + +def test_derive_state_collapses_all_subscriptions() -> None: + state = derive_state( + [ + PerpsTradesSpec(instrument_id=1), + PerpsBookSpec(instrument_id=1), + PerpsCandlesSpec(instrument_id=2, interval="1m"), + PerpsTickersSpec(instrument_id=3), + PerpsTickersSpec(), + PerpsStatisticsSpec(instrument_id=4), + ] + ) + # An all-instruments tickers subscription subsumes per-instrument channels. + assert state == frozenset( + {"trades::1", "book::1", "klines::2::1m", "tickers::all", "statistics::4"} + ) + + +def test_subscribe_sends_channel_frame_and_routes_matching_events() -> None: + received: list[dict[str, Any]] = [] + + async def handler(ws: ServerConnection) -> None: + async for raw in ws: + if _is_ping(raw): + continue + frame = json.loads(raw) + received.append(frame) + if frame["req"] == "sub": + await ws.send(json.dumps({"id": frame["id"], "data": {"status": "ok"}})) + await ws.send(json.dumps(_book_frame(7))) + await ws.send(json.dumps(_book_frame(8))) + await ws.send(json.dumps(_trade_frame(7))) + + async def run() -> None: + async with ws_server(handler) as url: + manager = PerpsMarketStreamManager(url=url) + try: + handle = await manager.subscribe(PerpsBookSpec(instrument_id=7)) + async with handle: + event = await asyncio.wait_for(handle.__anext__(), timeout=5.0) + assert isinstance(event, PerpsBookEvent) + assert event.payload.instrument_id == 7 + finally: + await manager.close() + + asyncio.run(asyncio.wait_for(run(), timeout=15.0)) + assert received[0]["req"] == "sub" + assert received[0]["chs"] == ["book::7"] + + +def test_closing_one_handle_unsubscribes_only_its_channels() -> None: + received: list[dict[str, Any]] = [] + + async def handler(ws: ServerConnection) -> None: + async for raw in ws: + if _is_ping(raw): + continue + frame = json.loads(raw) + received.append(frame) + + async def run() -> None: + async with ws_server(handler) as url: + manager = PerpsMarketStreamManager(url=url) + try: + book = await manager.subscribe(PerpsBookSpec(instrument_id=7)) + trades = await manager.subscribe(PerpsTradesSpec(instrument_id=7)) + await asyncio.sleep(0.05) + await trades.close() + await asyncio.sleep(0.05) + await book.close() + finally: + await manager.close() + + asyncio.run(asyncio.wait_for(run(), timeout=15.0)) + assert received[0] == {"id": 1, "req": "sub", "chs": ["book::7"]} + assert received[1] == {"id": 2, "req": "sub", "chs": ["trades::7"]} + assert received[2] == {"id": 3, "req": "unsub", "chs": ["trades::7"]} + + +def test_overlapping_subscriptions_deduplicate_channels() -> None: + received: list[dict[str, Any]] = [] + fanout: list[PerpsTradeEvent] = [] + + async def handler(ws: ServerConnection) -> None: + async for raw in ws: + if _is_ping(raw): + continue + frame = json.loads(raw) + received.append(frame) + if frame["req"] == "sub": + await ws.send(json.dumps(_trade_frame(7))) + + async def run() -> None: + async with ws_server(handler) as url: + manager = PerpsMarketStreamManager(url=url) + try: + first = await manager.subscribe(PerpsTradesSpec(instrument_id=7)) + second = await manager.subscribe(PerpsTradesSpec(instrument_id=7)) + async with first, second: + event_one = await asyncio.wait_for(first.__anext__(), timeout=5.0) + event_two = await asyncio.wait_for(second.__anext__(), timeout=5.0) + assert isinstance(event_one, PerpsTradeEvent) + assert isinstance(event_two, PerpsTradeEvent) + fanout.extend([event_one, event_two]) + finally: + await manager.close() + + asyncio.run(asyncio.wait_for(run(), timeout=15.0)) + # The second overlapping subscription must not resend the channel. + assert [frame for frame in received if frame["req"] == "sub"] == [ + {"id": 1, "req": "sub", "chs": ["trades::7"]} + ] + assert len(fanout) == 2 + + +def test_reconnect_resubscribes_active_channels() -> None: + connections = 0 + subscribe_frames: list[dict[str, Any]] = [] + resubscribed = asyncio.Event() + + async def handler(ws: ServerConnection) -> None: + nonlocal connections + connections += 1 + if connections == 1: + raw = await ws.recv() + subscribe_frames.append(json.loads(raw)) + await ws.close() + return + async for raw in ws: + if _is_ping(raw): + continue + subscribe_frames.append(json.loads(raw)) + resubscribed.set() + + async def run() -> None: + async with ws_server(handler) as url: + manager = PerpsMarketStreamManager(url=url) + try: + handle = await manager.subscribe(PerpsBookSpec(instrument_id=7)) + async with handle: + await asyncio.wait_for(resubscribed.wait(), timeout=10.0) + finally: + await manager.close() + + asyncio.run(asyncio.wait_for(run(), timeout=20.0)) + assert connections == 2 + assert subscribe_frames[-1]["req"] == "sub" + assert subscribe_frames[-1]["chs"] == ["book::7"] diff --git a/tests/unit/test_perps_public_actions.py b/tests/unit/test_perps_public_actions.py new file mode 100644 index 0000000..0f115c6 --- /dev/null +++ b/tests/unit/test_perps_public_actions.py @@ -0,0 +1,224 @@ +"""Perps public read and pagination behavior against a mocked transport.""" + +import asyncio +import json +from collections.abc import Callable +from decimal import Decimal +from typing import Any +from urllib.parse import parse_qs, urlparse + +import httpx +import pytest + +from polymarket._internal.actions.perps import public as perps_public +from polymarket.clients._transport import AsyncTransport +from polymarket.errors import UserInputError + +_BASE_URL = "https://perps.test" + + +def _transport(handler: Callable[[httpx.Request], httpx.Response]) -> AsyncTransport: + return AsyncTransport( + base_url=_BASE_URL, + client=httpx.AsyncClient(base_url=_BASE_URL, transport=httpx.MockTransport(handler)), + ) + + +def _query(request: httpx.Request) -> dict[str, str]: + return {key: values[0] for key, values in parse_qs(urlparse(str(request.url)).query).items()} + + +def test_list_candles_steps_forward_by_interval() -> None: + requests: list[dict[str, str]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + query = _query(request) + requests.append(query) + start = int(query["start_timestamp"]) + if len(requests) == 1: + candles = [ + [start, "1", "2", "0.5", "1.5", "10", 1], + [start + 60_000, "1", "2", "0.5", "1.5", "10", 1], + ] + return httpx.Response(200, json={"data": candles, "more": True}) + return httpx.Response( + 200, json={"data": [[start, "1", "2", "0.5", "1.5", "10", 1]], "more": False} + ) + + async def run() -> None: + transport = _transport(handler) + try: + paginator = perps_public.list_candles( + transport, + instrument_id=1, + interval="1m", + start=1_000_000, + end=2_000_000, + ) + items = [item async for item in paginator.iter_items()] + assert len(items) == 3 + finally: + await transport.close() + + asyncio.run(run()) + assert requests[0]["start_timestamp"] == "1000000" + # The next window starts one interval after the last returned candle. + assert requests[1]["start_timestamp"] == str(1_000_000 + 60_000 + 60_000) + assert all(request["interval"] == "1m" for request in requests) + + +def test_list_funding_history_pages_backward() -> None: + requests: list[dict[str, str]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + query = _query(request) + requests.append(query) + if len(requests) == 1: + return httpx.Response( + 200, + json={ + "data": [ + {"funding_rate": "0.001", "timestamp": 1_900_000}, + {"funding_rate": "0.002", "timestamp": 1_500_000}, + ], + "more": True, + }, + ) + return httpx.Response( + 200, + json={"data": [{"funding_rate": "0.003", "timestamp": 1_200_000}], "more": False}, + ) + + async def run() -> None: + transport = _transport(handler) + try: + paginator = perps_public.list_funding_history( + transport, instrument_id=1, start=1_000_000, end=2_000_000 + ) + items = [item async for item in paginator.iter_items()] + assert [item.funding_rate for item in items] == [ + Decimal("0.001"), + Decimal("0.002"), + Decimal("0.003"), + ] + finally: + await transport.close() + + asyncio.run(run()) + assert requests[0]["end_timestamp"] == "2000000" + # The next window ends just before the oldest returned observation. + assert requests[1]["end_timestamp"] == str(1_500_000 - 1) + + +def test_list_trades_deduplicates_boundary_trades_across_pages() -> None: + requests: list[dict[str, str]] = [] + + def _trade(trade_id: int, timestamp: int) -> dict[str, Any]: + return { + "trade_id": trade_id, + "instrument_id": 1, + "side": "long", + "price": "1", + "quantity": "1", + "timestamp": timestamp, + } + + def handler(request: httpx.Request) -> httpx.Response: + query = _query(request) + requests.append(query) + if len(requests) == 1: + return httpx.Response( + 200, + json={"data": [_trade(3, 1_900_000), _trade(2, 1_500_000)], "more": True}, + ) + # The server repeats the boundary trade in the next window. + return httpx.Response( + 200, + json={"data": [_trade(2, 1_500_000), _trade(1, 1_200_000)], "more": False}, + ) + + async def run() -> None: + transport = _transport(handler) + try: + paginator = perps_public.list_trades( + transport, instrument_id=1, start=1_000_000, end=2_000_000 + ) + items = [item async for item in paginator.iter_items()] + assert [item.trade_id for item in items] == [3, 2, 1] + finally: + await transport.close() + + asyncio.run(run()) + # The follow-up window re-requests the boundary timestamp inclusively. + assert requests[1]["end_timestamp"] == "1500000" + + +def test_trades_cursor_is_bound_to_its_endpoint() -> None: + def handler(request: httpx.Request) -> httpx.Response: + raise AssertionError("must not fetch with an invalid cursor") + + async def run() -> None: + transport = _transport(handler) + try: + paginator = perps_public.list_trades(transport, instrument_id=1) + candles_cursor = json.dumps({"kind": "perpsCandles"}) + import base64 + + bogus = base64.b64encode(candles_cursor.encode()).decode() + with pytest.raises(UserInputError, match="cursor"): + await paginator.from_cursor(bogus).first_page() + finally: + await transport.close() + + asyncio.run(run()) + + +def test_fetch_tickers_merges_statistics_fields() -> None: + def handler(request: httpx.Request) -> httpx.Response: + path = urlparse(str(request.url)).path + if path == "/v1/info/tickers": + return httpx.Response( + 200, + json=[ + { + "instrument_id": 1, + "symbol": "XYZ-PERP", + "index_price": "100", + "mark_price": "101", + "last_price": "100.5", + "mid_price": "100.6", + "open_interest": "5000", + "funding_rate": "0.0001", + "next_funding": 1751503600000, + } + ], + ) + assert path == "/v1/info/statistics" + return httpx.Response( + 200, + json=[{"instrument_id": 1, "volume": "12345", "open_price": "99", "klines": []}], + ) + + async def run() -> None: + transport = _transport(handler) + try: + tickers = await perps_public.fetch_tickers(transport) + assert tickers[0].volume_24h == Decimal("12345") + assert tickers[0].open_price == Decimal("99") + assert tickers[0].mark_price == Decimal("101") + finally: + await transport.close() + + asyncio.run(run()) + + +def test_fetch_book_rejects_unsupported_depth() -> None: + async def run() -> None: + transport = _transport(lambda request: httpx.Response(200, json={})) + try: + with pytest.raises(UserInputError, match="depth"): + await perps_public.fetch_book(transport, instrument_id=1, depth=42) # type: ignore[arg-type] + finally: + await transport.close() + + asyncio.run(run()) diff --git a/tests/unit/test_perps_session.py b/tests/unit/test_perps_session.py new file mode 100644 index 0000000..dadea39 --- /dev/null +++ b/tests/unit/test_perps_session.py @@ -0,0 +1,366 @@ +# pyright: reportPrivateUsage=false +"""Perps session behavior against a local WebSocket server.""" + +import asyncio +import contextlib +import json +from collections.abc import AsyncGenerator, Awaitable, Callable +from contextlib import asynccontextmanager +from datetime import UTC, datetime +from typing import Any + +import pytest +from websockets.asyncio.server import ServerConnection, serve + +from polymarket._internal.perps_session import PerpsSession +from polymarket.errors import RequestRejectedError, TransportError +from polymarket.models.perps.credentials import PerpsCredentials +from polymarket.models.perps.events import PerpsOrderEvent, PerpsResyncEvent + +Handler = Callable[[ServerConnection], Awaitable[None]] + +_PROXY_PRIVATE_KEY = "0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +_PROXY_ADDRESS = "0x14791697260E4c9A71f18484C9f997B308e59325" + +_CREDENTIALS = PerpsCredentials( + proxy=_PROXY_ADDRESS, + private_key=_PROXY_PRIVATE_KEY, + secret="session-secret", + expires_at=datetime(2030, 1, 1, tzinfo=UTC), +) + + +@asynccontextmanager +async def ws_server(handler: Handler) -> AsyncGenerator[str, None]: + server = await serve(handler, host="127.0.0.1", port=0) + try: + port = next(iter(server.sockets)).getsockname()[1] + yield f"ws://127.0.0.1:{port}" + finally: + server.close() + await server.wait_closed() + + +def _is_ping(message: dict[str, Any]) -> bool: + return message.get("id") == 0 + + +async def _handshake(ws: ServerConnection) -> list[dict[str, Any]]: + """Serve the auth + subscribe handshake and return the received frames.""" + frames: list[dict[str, Any]] = [] + while len(frames) < 2: + message = json.loads(await ws.recv()) + if _is_ping(message): + continue + frames.append(message) + await ws.send(json.dumps({"id": message["id"], "data": {"status": "ok"}})) + return frames + + +def _order_update(order_id: int, *, sequence: int = 1) -> dict[str, Any]: + return { + "ch": "orders", + "ts": 1751500000000, + "sq": sequence, + "data": { + "oid": order_id, + "iid": 1, + "buy": True, + "p": "0.5", + "qty": "10", + "tif": "gtc", + "po": False, + "status": "open", + "rest": "10", + "fill": "0", + "cts": 1751500000000, + "uts": 1751500000001, + }, + } + + +@asynccontextmanager +async def _open_session(url: str) -> AsyncGenerator[PerpsSession, None]: + session = PerpsSession( + chain_id=137, + credentials=_CREDENTIALS, + rest_url="http://127.0.0.1:9", # unused by these tests + ws_url=url, + ) + try: + await session.open() + yield session + finally: + await session.close() + + +def test_session_authenticates_and_subscribes_all_channels() -> None: + frames: list[dict[str, Any]] = [] + + async def handler(ws: ServerConnection) -> None: + frames.extend(await _handshake(ws)) + with contextlib.suppress(Exception): + async for _ in ws: + pass + + async def run() -> None: + async with ws_server(handler) as url, _open_session(url): + pass + + asyncio.run(asyncio.wait_for(run(), timeout=10.0)) + assert frames[0]["req"] == "post" + assert frames[0]["op"] == { + "type": "auth", + "args": {"proxy": _PROXY_ADDRESS, "secret": "session-secret"}, + } + assert frames[1]["req"] == "sub" + assert frames[1]["chs"] == [ + "balances", + "portfolio", + "orders", + "fills", + "funding", + "deposits", + "withdrawals", + "tpsl", + ] + + +def test_auth_rejection_surfaces_request_rejected_error() -> None: + async def handler(ws: ServerConnection) -> None: + message = json.loads(await ws.recv()) + await ws.send( + json.dumps({"id": message["id"], "data": {"status": "err", "error": "bad secret"}}) + ) + + async def run() -> None: + async with ws_server(handler) as url: + session = PerpsSession( + chain_id=137, + credentials=_CREDENTIALS, + rest_url="http://127.0.0.1:9", + ws_url=url, + ) + try: + with pytest.raises(RequestRejectedError, match="bad secret"): + await session.open() + finally: + await session.close() + + asyncio.run(asyncio.wait_for(run(), timeout=10.0)) + + +def test_place_order_signs_command_and_returns_order_update() -> None: + commands: list[dict[str, Any]] = [] + + async def handler(ws: ServerConnection) -> None: + await _handshake(ws) + async for raw in ws: + message = json.loads(raw) + if _is_ping(message): + continue + commands.append(message) + await ws.send(json.dumps({"id": message["id"], "data": [{"status": "ok", "oid": 77}]})) + await ws.send(json.dumps(_order_update(77))) + + async def run() -> None: + async with ws_server(handler) as url, _open_session(url) as session: + placement = await session.place_order( + instrument_id=1, + side="BUY", + price="0.5", + quantity="10", + time_in_force="gtc", + ) + assert placement.order.id == 77 + assert placement.order.status == "open" + assert placement.tp_sl is None + + asyncio.run(asyncio.wait_for(run(), timeout=10.0)) + command = commands[0] + assert command["op"]["type"] == "createOrders" + assert command["op"]["args"] == [ + {"iid": 1, "buy": True, "po": False, "qty": "10", "tif": "gtc", "p": "0.5"} + ] + assert isinstance(command["salt"], int) + assert isinstance(command["ts"], int) + assert command["sig"].startswith("0x") and len(command["sig"]) == 132 + + +def test_place_order_with_tp_sl_groups_rows_and_returns_trigger_ids() -> None: + from polymarket.models.perps.requests import PerpsTpSlTrigger + + commands: list[dict[str, Any]] = [] + + async def handler(ws: ServerConnection) -> None: + await _handshake(ws) + async for raw in ws: + message = json.loads(raw) + if _is_ping(message): + continue + commands.append(message) + await ws.send( + json.dumps( + { + "id": message["id"], + "data": [ + {"status": "ok", "oid": 100}, + {"status": "ok", "oid": 101}, + {"status": "ok", "oid": 102}, + ], + } + ) + ) + await ws.send(json.dumps(_order_update(100))) + + async def run() -> None: + async with ws_server(handler) as url, _open_session(url) as session: + placement = await session.place_order( + instrument_id=1, + side="BUY", + price="0.5", + quantity="10", + time_in_force="gtc", + take_profit=PerpsTpSlTrigger(trigger_price="1"), + stop_loss=PerpsTpSlTrigger(trigger_price="0.25"), + ) + assert placement.order.id == 100 + assert placement.tp_sl is not None + assert placement.tp_sl.take_profit is not None + assert placement.tp_sl.take_profit.order_id == 101 + assert placement.tp_sl.stop_loss is not None + assert placement.tp_sl.stop_loss.order_id == 102 + + asyncio.run(asyncio.wait_for(run(), timeout=10.0)) + op = commands[0]["op"] + assert op["grp"] == "order" + assert len(op["args"]) == 3 + assert op["args"][1]["tr"] == {"tpsl": "tp", "trp": "1", "market": True} + assert op["args"][2]["tr"] == {"tpsl": "sl", "trp": "0.25", "market": True} + # Trigger legs exit the position: entry BUY -> reduce-only SELL legs. + assert op["args"][1]["buy"] is False and op["args"][1]["ro"] is True + + +def test_place_order_rejection_raises_request_rejected() -> None: + async def handler(ws: ServerConnection) -> None: + await _handshake(ws) + async for raw in ws: + message = json.loads(raw) + if _is_ping(message): + continue + await ws.send( + json.dumps( + { + "id": message["id"], + "data": [{"status": "err", "error": "insufficient margin"}], + } + ) + ) + + async def run() -> None: + async with ws_server(handler) as url, _open_session(url) as session: + with pytest.raises(RequestRejectedError, match="insufficient margin"): + await session.place_order( + instrument_id=1, + side="BUY", + price="0.5", + quantity="10", + time_in_force="gtc", + ) + + asyncio.run(asyncio.wait_for(run(), timeout=10.0)) + + +def test_cancel_order_returns_result_without_raising_on_err_status() -> None: + async def handler(ws: ServerConnection) -> None: + await _handshake(ws) + async for raw in ws: + message = json.loads(raw) + if _is_ping(message): + continue + assert message["op"] == {"type": "cancelOrders", "args": [55]} + await ws.send( + json.dumps( + { + "id": message["id"], + "data": [{"status": "err", "oid": 55, "error": "order not found"}], + } + ) + ) + + async def run() -> None: + async with ws_server(handler) as url, _open_session(url) as session: + result = await session.cancel_order(order_id=55) + assert result.status == "err" + assert result.error == "order not found" + + asyncio.run(asyncio.wait_for(run(), timeout=10.0)) + + +def test_sequence_gap_emits_resync_event_before_update() -> None: + async def handler(ws: ServerConnection) -> None: + await _handshake(ws) + await ws.send(json.dumps(_order_update(1, sequence=1))) + await ws.send(json.dumps(_order_update(2, sequence=5))) + with contextlib.suppress(Exception): + async for _ in ws: + pass + + async def run() -> None: + async with ws_server(handler) as url, _open_session(url) as session: + first = await asyncio.wait_for(session.__anext__(), timeout=5.0) + second = await asyncio.wait_for(session.__anext__(), timeout=5.0) + third = await asyncio.wait_for(session.__anext__(), timeout=5.0) + assert isinstance(first, PerpsOrderEvent) + assert isinstance(second, PerpsResyncEvent) + assert second.reason == "sequence_gap" + assert second.channel == "orders" + assert second.previous_sequence == 1 + assert second.sequence == 5 + assert isinstance(third, PerpsOrderEvent) + + asyncio.run(asyncio.wait_for(run(), timeout=10.0)) + + +def test_session_reconnects_reauthenticates_and_emits_resync() -> None: + connections = 0 + connected = asyncio.Event() + + async def handler(ws: ServerConnection) -> None: + nonlocal connections + connections += 1 + await _handshake(ws) + if connections == 1: + await ws.close() + return + connected.set() + with contextlib.suppress(Exception): + async for _ in ws: + pass + + async def run() -> None: + async with ws_server(handler) as url, _open_session(url) as session: + event = await asyncio.wait_for(session.__anext__(), timeout=10.0) + assert isinstance(event, PerpsResyncEvent) + assert event.reason == "reconnect" + await asyncio.wait_for(connected.wait(), timeout=10.0) + assert connections == 2 + + asyncio.run(asyncio.wait_for(run(), timeout=20.0)) + + +def test_commands_fail_fast_after_close() -> None: + async def handler(ws: ServerConnection) -> None: + await _handshake(ws) + with contextlib.suppress(Exception): + async for _ in ws: + pass + + async def run() -> None: + async with ws_server(handler) as url: + async with _open_session(url) as session: + pass + with pytest.raises(TransportError, match="closed"): + await session.cancel_order(order_id=1) + + asyncio.run(asyncio.wait_for(run(), timeout=10.0)) diff --git a/tests/unit/test_perps_signing_golden.py b/tests/unit/test_perps_signing_golden.py new file mode 100644 index 0000000..442d5ec --- /dev/null +++ b/tests/unit/test_perps_signing_golden.py @@ -0,0 +1,197 @@ +"""Golden signing vectors generated from the TypeScript SDK Perps implementation. + +Each vector pins the msgpack encoding, the EIP-712 ``Op.data`` hash, and the +final signature so the Python implementation stays byte-compatible with the +backend's verification path. +""" + +from typing import Any + +import pytest +from eth_account import Account + +from polymarket._internal.actions.perps.signing import ( + build_perps_create_proxy_typed_data, + build_perps_op_typed_data, + build_perps_withdraw_typed_data, + compact_signable_value, + hash_perps_op, + sign_owner_typed_data, + sign_perps_op_with_key, +) + +_PRIVATE_KEY = "0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +_CHAIN_ID = 137 + +_OP_VECTORS: list[dict[str, Any]] = [ + { + "name": "createOrders single GTC", + "op": ["createOrders", [[1, True, "0.5", "10", "gtc", False, None, None, None]]], + "salt": 12345, + "timestamp": 1751500000000, + "data": "0x8004f264b573f0d5edd3377ef127f251a2b11e0b9463c5fb5f1be3b42c94336a", + "signature": ( + "0xdd933bdada3c14c01dbe48fc02d470f423f0e0ef0897052602c964e20f82c709" + "7c1ea1033e1e939a0633db606781f3b7160603337ab6201755cb191e99bc2d9b1c" + ), + }, + { + "name": "createOrders IOC no price with coid", + "op": [ + "createOrders", + [ + [ + 42, + False, + None, + "2.5", + "ioc", + False, + None, + "aabbccddeeff00112233445566778899", + None, + ] + ], + ], + "salt": 4294967295, + "timestamp": 1751500000001, + "data": "0x3b358621af8f4d297ea0c78b9d40aa9d33b84de0f9a619726f8f9e2f305c0157", + "signature": ( + "0x1b728cc550e2691bab29ccf183990ab53786fe86b28a5659560f6c7e92faa8f6" + "0535f2a0731265f0bdb9bf9c3649ada69857dfe33c887ff9b3fc2794d605adde1b" + ), + }, + { + "name": "createOrders grouped with tpsl triggers", + "op": [ + "createOrders", + [ + [7, True, "100", "3", "gtc", False, None, None, None], + [7, False, None, "3", None, False, True, None, [True, "200", "tp"]], + [7, False, "50", "3", None, False, True, None, [None, "49", "sl"]], + ], + "order", + ], + "salt": 1, + "timestamp": 1751500000002, + "data": "0x34b31ff82aa6d39bd6ee54a7781287d6e6dffcc0cc7dcc981f70e381c7d8a641", + "signature": ( + "0xfa7438604c02142ed9a0e3612a38d3b0b49eb21141515ac9a30be03b9f4422ea" + "5b598c70b2e733f6564eb322ac6ec5293c656c86426f389e9c5454e33d086cb21b" + ), + }, + { + "name": "cancelOrders", + "op": ["cancelOrders", [11, 22, 33]], + "salt": 999, + "timestamp": 1751500000003, + "data": "0x13f4659952efbcd144324c0a1c50cb75069635b2bbf28d3cc1e51b6e176a7617", + "signature": ( + "0xb97be11bcffaca178b29ecc42cabc732d0f436fad42a42e3a63f32131b347be5" + "11d452b14a81bdefc7cfda84bf83c94afedfb16fd8df16883cf6e261e411dfe91c" + ), + }, + { + "name": "cancelOrdersCOID", + "op": ["cancelOrdersCOID", ["aabbccddeeff00112233445566778899"]], + "salt": 1000, + "timestamp": 1751500000004, + "data": "0xdf8f1749d1b75392360c1117dcf2e0f251b0197b4db21c37a4e7d2451bc7cc95", + "signature": ( + "0xf3b23b2357864efb2cd1ac128a9b6601c85a7894d9be000243f253a37f778c39" + "525d0359d95e01350d5bfca6ebf4b84bb5735acfbcb8c8fb90978bb9f371d58a1c" + ), + }, + { + "name": "updateLeverage", + "op": ["updateLeverage", [3, 20, True]], + "salt": 7, + "timestamp": 1751500000005, + "data": "0x1daa002ce2f4e42ab0ef2d3c489b2386cccac2cbb861bdd7695761d4d93dd410", + "signature": ( + "0x5b81c182f89acc819488f30daa7a6fdb7563b0f65b2b404e9ece8a72d8077c3f" + "6bf328900ed6594d56605e564fa1e4469c8be670ac86015ab160bd78a97ea2bd1b" + ), + }, + { + "name": "deleteProxy", + "op": ["deleteProxy", ["0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc"]], + "salt": 55, + "timestamp": 1751500000006, + "data": "0x7274223decc60adbbe3ba91ceade2e6cd7901ab7ba506d07fb2d0dbef83e30d4", + "signature": ( + "0x2f6400725b8af890a69b8c47fe9d2b100d15714a73a13a92b15187909d1c1080" + "51dc906b8ec65cfc4d161c579df7b2cb9c6f7efea30dc238cfd7526fd3b797c01c" + ), + }, +] + + +@pytest.mark.parametrize("vector", _OP_VECTORS, ids=lambda vector: vector["name"]) +def test_perps_op_signing_matches_typescript_vectors(vector: dict[str, Any]) -> None: + assert hash_perps_op(vector["op"]) == vector["data"] + signature = sign_perps_op_with_key( + _PRIVATE_KEY, + chain_id=_CHAIN_ID, + op=vector["op"], + salt=vector["salt"], + timestamp_ms=vector["timestamp"], + ) + assert signature == vector["signature"] + + +def test_compact_signable_value_drops_none_entries_recursively() -> None: + assert compact_signable_value( + ["createOrders", [[1, True, None, "10", "gtc", False, None, None, None]]] + ) == ["createOrders", [[1, True, "10", "gtc", False]]] + + +def test_op_typed_data_payload_shape() -> None: + payload = build_perps_op_typed_data( + chain_id=_CHAIN_ID, op=["cancelOrders", [1]], salt=2, timestamp_ms=3 + ) + assert payload["primaryType"] == "Op" + assert payload["domain"] == {"name": "Polymarket", "version": "1", "chainId": _CHAIN_ID} + assert payload["message"]["salt"] == 2 + assert payload["message"]["ts"] == 3 + + +def test_create_proxy_signature_matches_typescript_vector() -> None: + signer = Account.from_key(_PRIVATE_KEY) + signature = sign_owner_typed_data( + signer, + build_perps_create_proxy_typed_data( + chain_id=_CHAIN_ID, + proxy="0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc", + expires_at_ms=1752000000000, + salt=4242, + timestamp_ms=1751500000007, + ), + what="Perps proxy credentials request", + ) + assert signature == ( + "0x64e2d5aef2aeb5e58072ebb63f8419ba0f89d1de34635a007ef648db9e6ac6f3" + "01997797def6e6a5b118f09fb1da0e3f58a55c34a871968e13227ae40eabf9341c" + ) + + +def test_withdraw_signature_matches_typescript_vector() -> None: + signer = Account.from_key(_PRIVATE_KEY) + signature = sign_owner_typed_data( + signer, + build_perps_withdraw_typed_data( + chain_id=_CHAIN_ID, + deposit_contract="0xDCa4af75705dbB50f62437045afF9921947917d2", + account="0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + token="0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB", + amount=10_000_000, + to="0x9965507D1a55bcC2695C58ba16FB37d819B0A4dc", + salt=77, + timestamp_s=1751500000, + ), + what="Perps withdrawal request", + ) + assert signature == ( + "0x75b6d0d9572921ed71d2a13fd3585dbd34326d9b503edaca08e3bf72dcd7bb43" + "7fe67378df001fc9e4352063a87d9e0a690599bf0ed6849ec00660e6f28cfa1c1b" + ) diff --git a/tests/unit/test_perps_trading_commands.py b/tests/unit/test_perps_trading_commands.py new file mode 100644 index 0000000..76137df --- /dev/null +++ b/tests/unit/test_perps_trading_commands.py @@ -0,0 +1,175 @@ +"""Perps trading command construction tests.""" + +import pytest + +from polymarket._internal.actions.perps.signing import build_perps_op_typed_data +from polymarket._internal.actions.perps.trading import ( + cancel_orders_by_client_id_op, + cancel_orders_op, + create_orders_op, + to_command_body_op, + to_raw_order, + to_raw_tp_sl_order, + update_leverage_op, +) +from polymarket.errors import UserInputError +from polymarket.models.perps.requests import ( + PerpsOrderRequest, + PerpsPositionTpSlTrigger, + PerpsTpSlTrigger, +) + +# Pinned by the TypeScript SDK trading suite for the same command. +_CREATE_ORDER_DATA_HASH = "0x817207b7b8b31044a8f27e43c16e24d9fd5e11d3f106feb962f104f3ef28d52a" + + +def test_create_orders_hash_matches_typescript_suite() -> None: + request = PerpsOrderRequest( + instrument_id=1, + side="BUY", + price="100.50", + quantity="10", + time_in_force="gtc", + ) + op = create_orders_op([to_raw_order(request)]) + payload = build_perps_op_typed_data( + chain_id=31_337, op=op, salt=1, timestamp_ms=1_739_491_200_000 + ) + assert payload["message"]["data"] == _CREATE_ORDER_DATA_HASH + assert to_command_body_op(op) == { + "type": "createOrders", + "args": [{"iid": 1, "buy": True, "po": False, "qty": "10", "tif": "gtc", "p": "100.50"}], + } + + +def test_market_style_order_omits_price_from_body() -> None: + request = PerpsOrderRequest(instrument_id=2, side="SELL", quantity="1.5", time_in_force="ioc") + body = to_command_body_op(create_orders_op([to_raw_order(request)])) + assert body["args"] == [{"iid": 2, "buy": False, "po": False, "qty": "1.5", "tif": "ioc"}] + + +def test_client_order_id_round_trips_into_body() -> None: + request = PerpsOrderRequest( + instrument_id=3, + side="BUY", + price="1", + quantity="2", + time_in_force="gtc", + client_order_id="aabbccddeeff00112233445566778899", + ) + body = to_command_body_op(create_orders_op([to_raw_order(request)])) + assert body["args"][0]["c"] == "aabbccddeeff00112233445566778899" + + +def test_tp_sl_rows_carry_reduce_only_and_trigger_metadata() -> None: + market_trigger = to_raw_tp_sl_order( + buy=False, + instrument_id=7, + kind="tp", + quantity="3", + trigger=PerpsTpSlTrigger(trigger_price="200"), + ) + limit_trigger = to_raw_tp_sl_order( + buy=False, + instrument_id=7, + kind="sl", + quantity="3", + trigger=PerpsTpSlTrigger(trigger_price="49", limit_price="50"), + ) + op = create_orders_op([market_trigger, limit_trigger], group="order") + body = to_command_body_op(op) + assert body["grp"] == "order" + assert body["args"][0] == { + "iid": 7, + "buy": False, + "po": False, + "qty": "3", + "ro": True, + "tr": {"tpsl": "tp", "trp": "200", "market": True}, + } + assert body["args"][1] == { + "iid": 7, + "buy": False, + "po": False, + "qty": "3", + "ro": True, + "p": "50", + "tr": {"tpsl": "sl", "trp": "49"}, + } + + +def test_position_tp_sl_uses_zero_quantity() -> None: + row = to_raw_tp_sl_order( + buy=True, + instrument_id=9, + kind="sl", + quantity="0", + trigger=PerpsPositionTpSlTrigger(trigger_price="10"), + ) + body = to_command_body_op(create_orders_op([row], group="position")) + assert body["grp"] == "position" + assert body["args"][0]["qty"] == "0" + + +def test_cancel_and_leverage_ops() -> None: + assert to_command_body_op(cancel_orders_op([11, 22])) == { + "type": "cancelOrders", + "args": [11, 22], + } + assert to_command_body_op( + cancel_orders_by_client_id_op(["aabbccddeeff00112233445566778899"]) + ) == {"type": "cancelOrdersCOID", "args": ["aabbccddeeff00112233445566778899"]} + assert to_command_body_op( + update_leverage_op(instrument_id=3, leverage=20, cross_margin=True) + ) == {"type": "updateLeverage", "args": {"cross": True, "iid": 3, "lev": 20}} + + +def test_gtc_order_requires_price() -> None: + with pytest.raises(UserInputError, match="price is required for gtc"): + PerpsOrderRequest(instrument_id=1, side="BUY", quantity="1", time_in_force="gtc") + + +def test_post_only_rejected_for_ioc_and_fok() -> None: + for tif in ("ioc", "fok"): + with pytest.raises(UserInputError, match="post_only"): + PerpsOrderRequest( + instrument_id=1, + side="BUY", + quantity="1", + time_in_force=tif, # type: ignore[arg-type] + post_only=True, + ) + + +def test_invalid_client_order_id_rejected() -> None: + with pytest.raises(UserInputError, match="client_order_id"): + PerpsOrderRequest( + instrument_id=1, + side="BUY", + price="1", + quantity="1", + time_in_force="gtc", + client_order_id="not-hex", + ) + + +def test_invalid_decimal_inputs_rejected() -> None: + with pytest.raises(UserInputError, match="quantity"): + PerpsOrderRequest( + instrument_id=1, side="BUY", price="1", quantity="abc", time_in_force="gtc" + ) + with pytest.raises(UserInputError, match="side"): + PerpsOrderRequest( + instrument_id=1, + side="LONG", # type: ignore[arg-type] + price="1", + quantity="1", + time_in_force="gtc", + ) + + +def test_empty_cancel_batches_rejected() -> None: + with pytest.raises(UserInputError): + cancel_orders_op([]) + with pytest.raises(UserInputError): + cancel_orders_by_client_id_op([]) diff --git a/tests/unit/test_streams_subscribe_router.py b/tests/unit/test_streams_subscribe_router.py index 7c2ca06..33c1ea8 100644 --- a/tests/unit/test_streams_subscribe_router.py +++ b/tests/unit/test_streams_subscribe_router.py @@ -47,11 +47,16 @@ def _env_with_all(market_url: str, sports_url: str, rtds_url: str) -> Environmen return _env_with(clob_market_ws_url=market_url, sports_ws_url=sports_url, rtds_ws_url=rtds_url) +def _env_with_perps_ws(url: str) -> Environment: + return _env_with(perps_ws_url=url) + + def _env_with( *, clob_market_ws_url: str = PRODUCTION.clob_market_ws_url, sports_ws_url: str = PRODUCTION.sports_ws_url, rtds_ws_url: str = PRODUCTION.rtds_ws_url, + perps_ws_url: str = PRODUCTION.perps_ws_url, ) -> Environment: return Environment( name="test", @@ -85,6 +90,7 @@ def _env_with( rtds_ws_url=rtds_ws_url, sports_ws_url=sports_ws_url, rpc_url=PRODUCTION.rpc_url, + perps_ws_url=perps_ws_url, ) @@ -631,3 +637,53 @@ def test_user_spec_normalizes_empty_markets_to_none() -> None: assert UserSpec(markets=[]).markets is None assert UserSpec(markets=()).markets is None assert UserSpec().markets is None + + +def test_subscribe_routes_perps_specs_to_perps_stream() -> None: + from polymarket.models.perps.events import PerpsBboEvent + from polymarket.streams import PerpsBboSpec + + received: list[dict[str, Any]] = [] + + async def handler(ws: ServerConnection) -> None: + raw = await ws.recv() + assert isinstance(raw, str) + received.append(json.loads(raw)) + await ws.send( + json.dumps( + { + "ch": "bbo::3", + "ts": 1751500000000, + "sq": 1, + "data": {"iid": 3, "bp": "0.5", "bq": "10", "ap": "0.6", "aq": "4"}, + } + ) + ) + async for _ in ws: + pass + + async def run() -> int: + async with ws_server(handler) as url: + client = AsyncPublicClient(environment=_env_with_perps_ws(url)) + try: + async with await client.subscribe(PerpsBboSpec(instrument_id=3)) as stream: + event = await asyncio.wait_for(stream.__aiter__().__anext__(), timeout=2.0) + assert isinstance(event, PerpsBboEvent) + return int(event.payload.instrument_id) + finally: + await client.close() + + instrument_id = asyncio.run(run()) + assert instrument_id == 3 + assert received[0] == {"id": 1, "req": "sub", "chs": ["bbo::3"]} + + +def test_perps_spec_validation_rejects_bad_inputs() -> None: + from polymarket.streams import PerpsCandlesSpec, PerpsTradesSpec + + with pytest.raises(UserInputError): + PerpsTradesSpec(instrument_id=-1) + with pytest.raises(UserInputError): + PerpsTradesSpec(instrument_id="1") # pyright: ignore[reportArgumentType] + with pytest.raises(UserInputError): + PerpsCandlesSpec(instrument_id=1, interval="1s") # pyright: ignore[reportArgumentType] diff --git a/uv.lock b/uv.lock index ee539e6..8944e88 100644 --- a/uv.lock +++ b/uv.lock @@ -627,6 +627,69 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "msgpack" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/31/f9/c0a1c127f9049db9155afc316952ea571720dd01833ff5e4d7e8e6352dbb/msgpack-1.2.1.tar.gz", hash = "sha256:04c721c2c7448767e9e3f2520a475663d8ee0f09c31890f6d2bd70fd636a9647", size = 183960, upload-time = "2026-06-18T16:13:52.594Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/6b/e9b1cdc042c4458801d2545ed782a95f3d6ba8e270cce8745b8603c7f748/msgpack-1.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:29a3f6e9667868429d8240dfd063ea5ffdc1321c13d783aa23827a38de0dcb22", size = 82812, upload-time = "2026-06-18T16:12:45.022Z" }, + { url = "https://files.pythonhosted.org/packages/0c/3a/dd518a1bf78ed1e9ad8afe57307c079a00eafe4b3068932a27ca1ea56b4f/msgpack-1.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:aded5bdf32609dc7987a49bbbd15a8ef096193f96dd8bbeb791de729e650acf5", size = 82739, upload-time = "2026-06-18T16:12:46.025Z" }, + { url = "https://files.pythonhosted.org/packages/70/e0/7ba9e1542bf0771a27b8b37c1316e3f95ae9d748fd765284655c476ad4ef/msgpack-1.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:146ee4e9ce80b365c6d4c47073da9da7bcec473e58194ceee5dd7620ace77e06", size = 414233, upload-time = "2026-06-18T16:12:47.029Z" }, + { url = "https://files.pythonhosted.org/packages/03/8d/671d81534ea0e2b0e8a121be100020da09eb78861fe3aa8f3ef7dcd3bed1/msgpack-1.2.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a28d076ca7c82b9c8728ad90b7147489449557038bed50e4241eb832395169b4", size = 423843, upload-time = "2026-06-18T16:12:48.19Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b6/e5c737515ed1f166664b87601b532f58cbb73d8aa6a90b99f7c2c5037e8e/msgpack-1.2.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7d31c0ac0c640f877804c67cb2bc9f4e23dc2db97e96c2e67fa27d38283b41f8", size = 390772, upload-time = "2026-06-18T16:12:49.624Z" }, + { url = "https://files.pythonhosted.org/packages/a8/46/62ed8c2e87d7021eab19921594d961ef3aa3794eec76c716dc30f3bfd433/msgpack-1.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8ff92d7feeaf5bc26c51495b69e2f99ed97ab79346fb6555f44be7dd2ac6503b", size = 409559, upload-time = "2026-06-18T16:12:50.936Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/59aa3887b860bbf43532835e192b1c388a17590d6068ae4f8b2bc74c906e/msgpack-1.2.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:779197a6513bab3c3632265e3d0f7cb3227e62510841a6f34f1eaa37efbb345e", size = 387838, upload-time = "2026-06-18T16:12:52.161Z" }, + { url = "https://files.pythonhosted.org/packages/09/11/f8563e471093420cf6478cb3271a0175d8402b82d879783d4035d2d03360/msgpack-1.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:67f6dd22fa72a93752643f07889796d62739a13415ee630169a8ce764f86cf9f", size = 421732, upload-time = "2026-06-18T16:12:53.556Z" }, + { url = "https://files.pythonhosted.org/packages/57/cf/e673683c4c6c90c1022b24c65af4b03eda72b182a1176ef6449069d66acc/msgpack-1.2.1-cp311-cp311-win32.whl", hash = "sha256:91054a783328e0ea7954b8771095705c8d2243b814743fbaadf14552c9c52c5d", size = 64091, upload-time = "2026-06-18T16:12:54.821Z" }, + { url = "https://files.pythonhosted.org/packages/3f/07/ca212739d179f9083bff2c7c08c24101c3555a334fadc2b876b18768a3ae/msgpack-1.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2eda0b7ebb1283a98d3e4492ac933c8af6aff59fd3df1c3ed024f536af4b1dc8", size = 70462, upload-time = "2026-06-18T16:12:55.898Z" }, + { url = "https://files.pythonhosted.org/packages/6d/be/6798347b425e26f35db82e69dd83c09716c856a3714e7bffc4c0860fd830/msgpack-1.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6ee967f7c7e1df2890c671ff2ee51a28ded0efc95da3e507176dee881ce36c66", size = 65059, upload-time = "2026-06-18T16:12:57.053Z" }, + { url = "https://files.pythonhosted.org/packages/bc/dd/9e8cbd8f5582ca4b590336f2b91ee5662f6a6ca562b565abaf696a0f81ff/msgpack-1.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2ef59c659f289eddf8aa6623823f19fa2f40a4029266889eac7a2505dd210c35", size = 83531, upload-time = "2026-06-18T16:12:58.249Z" }, + { url = "https://files.pythonhosted.org/packages/50/2e/ebdb85a8da151397a2790363676b7ed7c125924fe618e4c6d8befb0cc62c/msgpack-1.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3567748a5107cb40cdf66a275430c2f87c07777698f4bfd25c35f44d533258c", size = 82657, upload-time = "2026-06-18T16:12:59.396Z" }, + { url = "https://files.pythonhosted.org/packages/26/aa/753ad8b007b464e1d8aa0c8e650b9c5f4f725e658fc5ac8a7635c55b7f6e/msgpack-1.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60926b75d00c8e816ef98f3034f484a8bc64242d66839cef4cf7e503142316a0", size = 410634, upload-time = "2026-06-18T16:13:00.383Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/6adabd4f6d5e686f97dd02ce7fce3fe4cf672cbac36b8f67ff4040e8ad8b/msgpack-1.2.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:020e881a764b20d8d7ca1a54fc01b8175519d108e3c3f194fddc200bda95951a", size = 419989, upload-time = "2026-06-18T16:13:01.776Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cc/85039b7b0eb168aaad7383a23c97e291a11f08351cb45a606ce865e4e3f1/msgpack-1.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4202c74688ca06591f78cb18988228bd4cca2cc75d57b60008372892d2f1e6e6", size = 377544, upload-time = "2026-06-18T16:13:03.637Z" }, + { url = "https://files.pythonhosted.org/packages/ed/bf/35963899493b32030c85fc513b723ae66144ac70c11ebc52e889e16e3d99/msgpack-1.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8b267ce94efb76fbd1b3373511420074ee3187f0f7811bf394531de13294735a", size = 400842, upload-time = "2026-06-18T16:13:05.012Z" }, + { url = "https://files.pythonhosted.org/packages/a6/df/8e2ac970c8f99264cd9997d1c73df5466bc19da3301d7dc5500862a9b089/msgpack-1.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f1d0f8f98ade9634e01fb704a408f9336c0a8f1117b369f5db83dc7551d8b1", size = 374108, upload-time = "2026-06-18T16:13:06.232Z" }, + { url = "https://files.pythonhosted.org/packages/17/dd/fa8bd265110dfa51c20cb529f9e6d240a16fafe7e645004c6af2d01353ba/msgpack-1.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f02cf17a6ca1abe29b5f980644f7551f94d71f2011509b26d8625ce038f0df64", size = 414939, upload-time = "2026-06-18T16:13:07.478Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b9/8377a5ad8953fc0437c70cc98d9ae29f27fe5ac5109fbec0812085865735/msgpack-1.2.1-cp312-cp312-win32.whl", hash = "sha256:0c0d9802354507bcba62af19c17918e3eb437cc25e6f50657d511b5856a77aac", size = 64504, upload-time = "2026-06-18T16:13:08.822Z" }, + { url = "https://files.pythonhosted.org/packages/57/7f/ce1e377df7e62461fefd9eb23bfb93a4a523f40a517b377b8f844d836828/msgpack-1.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:5c24aa15d5963051e1a5c62b12c50cd705992502b5ec1f3bece6046f33c9fc24", size = 71421, upload-time = "2026-06-18T16:13:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/8f/32/ebfe84c9929f08f188d56c7a2fd913406a9ddad76a634697c1c43b8112e6/msgpack-1.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:4227224aaec8f7fbcbfbd4272319347b2bb4030366502600f8c45588c5187b07", size = 64775, upload-time = "2026-06-18T16:13:11.056Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ac/dcddcab6f6c20ecb387ca5e980371cdb3f87ff69aeca388be97eebc4c074/msgpack-1.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a70e3cf2804a300d921bb0940426e35f4e489a23adfb77a808892241db0a064", size = 83151, upload-time = "2026-06-18T16:13:12.173Z" }, + { url = "https://files.pythonhosted.org/packages/64/71/fbcfa83a1d6a9c6091942d1cfd070962244664b87427a9a49a6897b1b219/msgpack-1.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:491cc39455ca765fad51fb451bf2915eb2cf41192ab5801ce8d67c1d614fe056", size = 82351, upload-time = "2026-06-18T16:13:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/e3/10/ddf7b06db879e8792d13934ddda09ff20bd2a583fd84c9b59aae9b0e650b/msgpack-1.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f310233ef7fb9c14e201c93639fe5f5260b005f56f0b29048e999c30935596cc", size = 407518, upload-time = "2026-06-18T16:13:14.233Z" }, + { url = "https://files.pythonhosted.org/packages/79/d3/36a46a8ed992b781acbc05928bd5bee3c810cb0c3563bf81a7b0c04a1a76/msgpack-1.2.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:787c9bebb5833e8f6fc8abca3c0597683d8d87f56a8842b6b89c75a5f3176e2d", size = 416405, upload-time = "2026-06-18T16:13:15.435Z" }, + { url = "https://files.pythonhosted.org/packages/f9/84/e8e9598b557c0ba6ddae901a73780a4c75ac667dddf59414b1e56a42fb34/msgpack-1.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc871b997a9370d855b7394465f2f350e847a5b806dd38dcc9c989e7d87da155", size = 376257, upload-time = "2026-06-18T16:13:17.022Z" }, + { url = "https://files.pythonhosted.org/packages/40/16/738fe6d875ad7e2a9429c165322a4ec088f4f273cdfae63d96a89c467961/msgpack-1.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:85f57e960d877f2977f6430896191b04a21f8901b3b4baf2e4604329f4db5402", size = 397469, upload-time = "2026-06-18T16:13:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/ca/be/6d5952df75a7f24f35833af764c3a6860780364cb3a0030beb8099e1b2b4/msgpack-1.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1233ee2dd0cefba127583de50ea654677277047d238303521db35def3d7b2e7c", size = 372802, upload-time = "2026-06-18T16:13:19.685Z" }, + { url = "https://files.pythonhosted.org/packages/e1/39/e2ef7dbf0473bcb8dc7c50bf782a892d67414877b63e47fc88eb189ef5e6/msgpack-1.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3dc2feb0876209d9c38aa56cb1de169bd6c4348f1aa48271f241226590993e6", size = 411273, upload-time = "2026-06-18T16:13:21.028Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c5/133f4512a56e983a93445c836c9d94d88f3bc2e0980ff4b9e577bd8416ce/msgpack-1.2.1-cp313-cp313-win32.whl", hash = "sha256:6d09badf350af2be9d189184e04e64cf54ad93569ab3d96fca58bd3e84aad707", size = 64471, upload-time = "2026-06-18T16:13:22.293Z" }, + { url = "https://files.pythonhosted.org/packages/e2/98/577e10b055096a7dd40732358cabaf7180a20c79ed1dcdbb618e4b9deac7/msgpack-1.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:33f14fba63278b714efe6ad07e50ea5f03d91537aa6a1c5f1ceca4cf44013ca9", size = 71274, upload-time = "2026-06-18T16:13:23.455Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ee/0c0048e7cfbef23c6a94791b8959ab28155232e7956de8a305b5ff588f05/msgpack-1.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc5febcd4c99effbc02b528e49d6fd0760b2b7d48c05239e345a5fa6e743d9a", size = 64795, upload-time = "2026-06-18T16:13:24.687Z" }, + { url = "https://files.pythonhosted.org/packages/77/58/cce442852c6b9e1639c7c8ac8fd9143121cb32dab0f308df4d1426a8eb9c/msgpack-1.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:05f340e47e7e47d2da8db9b53e1bb1d294369e9ef45a747441309f6650b8351d", size = 83610, upload-time = "2026-06-18T16:13:25.724Z" }, + { url = "https://files.pythonhosted.org/packages/60/5c/15b4c7a0182f75ffa90751958ba36a9c01cafee367d49a3edc10ed140b01/msgpack-1.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:810b916696c86ef0deb3b74588480224df4c1b071136c34183e4a2a4284d7ac7", size = 83138, upload-time = "2026-06-18T16:13:26.781Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/99e58722feaffc5f2fbcc0c8c0d1451ab9f84097f7af87291b46af2390f4/msgpack-1.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ca0dacff965c47afdc3749a8469d7302a8f801d6a28758d55120d75e66ce6889", size = 406090, upload-time = "2026-06-18T16:13:28.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/03/8c63e8cf52958534ef688625965ab04c269a6cadd8caef16758b380a821a/msgpack-1.2.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e2bf9280bceb5efca998435904b5d3e9fdbcc11d90dc9df30aec7973252b720", size = 412106, upload-time = "2026-06-18T16:13:29.427Z" }, + { url = "https://files.pythonhosted.org/packages/63/d2/155d9e71b40e41fd934bc0c48b9b2770f22263e1ac20aad8e29fdca7be3f/msgpack-1.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6c4be5d1c02a42b066ca6ddb71adf36432868fdcdb6ee87e634e86e0674190", size = 374851, upload-time = "2026-06-18T16:13:30.631Z" }, + { url = "https://files.pythonhosted.org/packages/98/48/deaf2326262a8d5ea3295ce9649912ecd3f551ba7ec8e33c665d2ba583f3/msgpack-1.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec0e675d59150a6269ddc9139087c722292664a37d071a849c05c473350f1f2d", size = 396168, upload-time = "2026-06-18T16:13:31.977Z" }, + { url = "https://files.pythonhosted.org/packages/10/2a/b4410f906c2ec0008f1608d3ab5143afc3ad3f4e6da0fed3ea2231d0bef4/msgpack-1.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:dd3bfe82d53edfe4b7fc9a7ec9761e23a7a5b1dac22264505af428253c29ed24", size = 371959, upload-time = "2026-06-18T16:13:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/59/86/1edc67270099a528fa2093ea60fe191233cd238e4bd30cfacf7db79fc959/msgpack-1.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5ad5467fc3f68b5468e06c5f788d712e9f8ffc8b0cd1bcb160c105c1ee92dae7", size = 408457, upload-time = "2026-06-18T16:13:34.567Z" }, + { url = "https://files.pythonhosted.org/packages/82/90/8b630fef07d8c5ab457b71ff2c217910c83d333c7a68472c186e87cc504a/msgpack-1.2.1-cp314-cp314-win32.whl", hash = "sha256:98b58bdb89c46190e4609bb36abe17c6d4105ad13f9c5f8f6f64d320f8ced3fb", size = 65942, upload-time = "2026-06-18T16:13:36.056Z" }, + { url = "https://files.pythonhosted.org/packages/16/f1/467b81e98b24dd3885d7b1857728797b4ffc76a7a7483af4fb321a07de3c/msgpack-1.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:74847557e28ce71bd3c438a447ca90e4b507e997ddbdef8a12a7b283b86c156b", size = 72627, upload-time = "2026-06-18T16:13:37.079Z" }, + { url = "https://files.pythonhosted.org/packages/a7/1d/5d8c4c89985feb6acefb82a09e501c60392261856d2408d20bfe4f0360b1/msgpack-1.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:b50b727bd652bdc37d950336c848ef20ec54a4cafc38dce19b1cd86ad625d0f7", size = 66908, upload-time = "2026-06-18T16:13:38.23Z" }, + { url = "https://files.pythonhosted.org/packages/1b/02/ad2afb678b4de94496cd432b581759b756a92c1192d8c767edd6b132efdc/msgpack-1.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8d00f177ca88a77c1cf848d204a38f249751650b601cb6532acc68805d8a8273", size = 86000, upload-time = "2026-06-18T16:13:39.44Z" }, + { url = "https://files.pythonhosted.org/packages/54/74/0b797484013128837f3b1cbb6cea019277c4de4e377dc512b4d9a0f92940/msgpack-1.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5bb9c386f0a329c035ddbab4b72d1028bf9627add8dda41070288563d57ed1b1", size = 86544, upload-time = "2026-06-18T16:13:40.447Z" }, + { url = "https://files.pythonhosted.org/packages/a9/b4/b774d7eb95561739907fec675582f83203cf41c597a418c2589b4bfb8e9d/msgpack-1.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20466cca18c49c7292a8984bc15d65857b171e7264bdcb5f96baf8be238791fc", size = 427661, upload-time = "2026-06-18T16:13:41.574Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f9/3243191dc9937e00756c8bc1b0272fed8f23758e43df2a3b46f533e5090f/msgpack-1.2.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:196300e7e5d6e74d50f1607ab9c06c4a1484c383cd22defd727902591f7e8dde", size = 426375, upload-time = "2026-06-18T16:13:42.936Z" }, + { url = "https://files.pythonhosted.org/packages/23/c7/1693111db9944ba4ad4b67a1e788400d78a0b6af7a6523dc7e4e58f8274b/msgpack-1.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575957e79cd51903a4e8495a242442949641e08f1efd5197b43bebd3ea7682b4", size = 380495, upload-time = "2026-06-18T16:13:44.306Z" }, + { url = "https://files.pythonhosted.org/packages/3e/2b/92f86956a0c13e8662f7e2ad630c4eb4db07497b967589bd5245e018b2c1/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8c2ed1e48cc0f460bf3c7780e7137ff21a4e18433451916f2442c1b21036cd7d", size = 410897, upload-time = "2026-06-18T16:13:45.629Z" }, + { url = "https://files.pythonhosted.org/packages/da/ea/1479f72d200313a76fc2f823a79d1e07ed052ab7b8a0280640aa7b95de42/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5f6277e5f783c36786a145e0247fc189a03f35f84b251646e53592d2bc12b355", size = 378519, upload-time = "2026-06-18T16:13:46.998Z" }, + { url = "https://files.pythonhosted.org/packages/f5/4d/fa006060ffa1011d32bfae826fe766fe73e02982183601633b7121058ab3/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9389552ecf4784886345ead0647e4edc96bee37cbab05b75540f542f766c48c", size = 419815, upload-time = "2026-06-18T16:13:48.205Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/aab6c946570496b78e67804721f3d5e2d62a93081b9b37df77764ef56347/msgpack-1.2.1-cp314-cp314t-win32.whl", hash = "sha256:c1c79a604a2969a868a78b6ebd27a887e00c624f14f66b3038e0590cb23332d1", size = 70914, upload-time = "2026-06-18T16:13:49.385Z" }, + { url = "https://files.pythonhosted.org/packages/13/0a/e608956488a2af014cfe6e3d665e090b8ee42aa14b07f8f95b8880d66b09/msgpack-1.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f12038a35fabd52e56a3547bab42401af49a45caa6dd00b34c44de235bc93ee2", size = 77999, upload-time = "2026-06-18T16:13:50.467Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8a/27e2e57055176e366a46b85d02d68e7a5bcfbdd8474c9706375d965f24d3/msgpack-1.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0adcf06ffde0777c0e1a9b771a2b1c4226ba1bbf748c8efcc02fcdeca3299107", size = 71160, upload-time = "2026-06-18T16:13:51.498Z" }, +] + [[package]] name = "nodeenv" version = "1.10.0" @@ -836,6 +899,7 @@ dependencies = [ { name = "eth-account" }, { name = "eth-utils" }, { name = "httpx", extra = ["http2"] }, + { name = "msgpack" }, { name = "pydantic" }, { name = "websockets" }, ] @@ -878,6 +942,7 @@ requires-dist = [ { name = "eth-account", specifier = ">=0.13,<1" }, { name = "eth-utils", specifier = ">=4,<6" }, { name = "httpx", extras = ["http2"], specifier = ">=0.27,<1" }, + { name = "msgpack", specifier = ">=1,<2" }, { name = "pandas", marker = "extra == 'pandas'", specifier = ">=2,<3" }, { name = "pandas", marker = "extra == 'quant'", specifier = ">=2,<3" }, { name = "polars", marker = "extra == 'polars'", specifier = ">=1,<2" }, From c646fcd90c6c09e3a45c03a10b42ca4c5ea793b9 Mon Sep 17 00:00:00 2001 From: Cesare Naldi <3353250+cesarenaldi@users.noreply.github.com> Date: Fri, 3 Jul 2026 12:53:29 +0200 Subject: [PATCH 2/7] fix(client): harden perps setup and cursors --- .../_internal/actions/perps/account.py | 11 +- .../_internal/actions/perps/paging.py | 124 +++++++++++++++++- .../_internal/actions/perps/public.py | 10 +- .../_internal/actions/relayer/approvals.py | 5 + tests/unit/test_eoa_broadcast.py | 8 +- tests/unit/test_perps_account_actions.py | 93 +++++++++++++ tests/unit/test_perps_public_actions.py | 56 +++++++- .../test_relayer_setup_trading_approvals.py | 10 +- tests/unit/test_sync_relayer_workflows.py | 2 +- 9 files changed, 296 insertions(+), 23 deletions(-) create mode 100644 tests/unit/test_perps_account_actions.py diff --git a/src/polymarket/_internal/actions/perps/account.py b/src/polymarket/_internal/actions/perps/account.py index 8e509c3..b922042 100644 --- a/src/polymarket/_internal/actions/perps/account.py +++ b/src/polymarket/_internal/actions/perps/account.py @@ -9,7 +9,8 @@ NINETY_DAYS_MS, ONE_DAY_MS, as_json_dict, - decode_perps_cursor, + decode_perps_ascending_account_cursor, + decode_perps_descending_account_cursor, encode_perps_cursor, interval_ms, parse_data_envelope, @@ -254,7 +255,9 @@ async def fetch(cursor: str | None) -> Page[_M]: **initial_extra, } else: - state = decode_perps_cursor(cursor, kind=kind) + state = decode_perps_descending_account_cursor( + cursor, kind=kind, fund_statuses=_FUND_STATUSES + ) params = { key: value for key, value in state.items() @@ -322,7 +325,9 @@ async def fetch(cursor: str | None) -> Page[_M]: "end_timestamp": end_ms if end_ms is not None else int(time.time() * 1000), } else: - state = decode_perps_cursor(cursor, kind=kind) + state = decode_perps_ascending_account_cursor( + cursor, kind=kind, intervals=_PNL_INTERVALS + ) data, more = parse_data_envelope( await api.get_json( path, diff --git a/src/polymarket/_internal/actions/perps/paging.py b/src/polymarket/_internal/actions/perps/paging.py index 67352f4..7dfd7c8 100644 --- a/src/polymarket/_internal/actions/perps/paging.py +++ b/src/polymarket/_internal/actions/perps/paging.py @@ -3,6 +3,7 @@ import base64 import binascii import json +from collections.abc import Collection from datetime import datetime from typing import Any, cast @@ -22,6 +23,8 @@ "1w": 7 * ONE_DAY_MS, } +_INVALID_CURSOR = "Invalid Perps pagination cursor" + def interval_ms(interval: str) -> int: return _INTERVAL_MS[interval] @@ -52,17 +55,127 @@ def as_json_dict(value: object) -> dict[str, Any] | None: return None +def _invalid_cursor() -> UserInputError: + return UserInputError(_INVALID_CURSOR) + + def decode_perps_cursor(cursor: str, *, kind: str) -> dict[str, Any]: try: decoded = json.loads(base64.b64decode(cursor, validate=True)) - except (binascii.Error, ValueError, UnicodeDecodeError) as error: - raise UserInputError("Invalid Perps pagination cursor") from error + except (binascii.Error, TypeError, ValueError, UnicodeDecodeError) as error: + raise _invalid_cursor() from error state = as_json_dict(decoded) if state is None or state.get("kind") != kind: - raise UserInputError("Invalid Perps pagination cursor") + raise _invalid_cursor() + return state + + +def decode_perps_candles_cursor(cursor: str, *, intervals: Collection[str]) -> dict[str, Any]: + state = decode_perps_cursor(cursor, kind="perpsCandles") + _require_non_negative_int(state, "instrument_id") + _require_allowed_str(state, "interval", intervals) + _require_non_negative_int(state, "start_timestamp") + _require_non_negative_int(state, "end_timestamp") + return state + + +def decode_perps_funding_cursor(cursor: str) -> dict[str, Any]: + state = decode_perps_cursor(cursor, kind="perpsFundingHistory") + _require_non_negative_int(state, "instrument_id") + _require_non_negative_int(state, "start_timestamp") + _require_non_negative_int(state, "end_timestamp") + return state + + +def decode_perps_trades_cursor(cursor: str) -> dict[str, Any]: + state = decode_perps_cursor(cursor, kind="perpsTrades") + _require_non_negative_int(state, "instrument_id") + _require_non_negative_int(state, "start_timestamp") + _require_non_negative_int(state, "end_timestamp") + _require_non_negative_int_list(state, "seen_trade_ids") + return state + + +def decode_perps_descending_account_cursor( + cursor: str, *, kind: str, fund_statuses: Collection[str] +) -> dict[str, Any]: + state = decode_perps_cursor(cursor, kind=kind) + _require_non_negative_int(state, "start_timestamp") + _require_non_negative_int(state, "end_timestamp") + _require_str_list(state, "seen_keys") + _allow_optional_non_negative_int(state, "instrument_id") + _allow_optional_allowed_str(state, "deposit_status", fund_statuses) + _allow_optional_allowed_str(state, "withdrawal_status", fund_statuses) + _allow_optional_str(state, "hash") + return state + + +def decode_perps_ascending_account_cursor( + cursor: str, *, kind: str, intervals: Collection[str] +) -> dict[str, Any]: + state = decode_perps_cursor(cursor, kind=kind) + _require_allowed_str(state, "interval", intervals) + _require_non_negative_int(state, "start_timestamp") + _require_non_negative_int(state, "end_timestamp") return state +def _require_non_negative_int(state: dict[str, Any], key: str) -> None: + value = state.get(key) + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise _invalid_cursor() + + +def _allow_optional_non_negative_int(state: dict[str, Any], key: str) -> None: + value = state.get(key) + if value is None: + return + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise _invalid_cursor() + + +def _require_allowed_str(state: dict[str, Any], key: str, allowed_values: Collection[str]) -> None: + value = state.get(key) + if not isinstance(value, str) or value not in allowed_values: + raise _invalid_cursor() + + +def _allow_optional_allowed_str( + state: dict[str, Any], key: str, allowed_values: Collection[str] +) -> None: + value = state.get(key) + if value is None: + return + if not isinstance(value, str) or value not in allowed_values: + raise _invalid_cursor() + + +def _allow_optional_str(state: dict[str, Any], key: str) -> None: + value = state.get(key) + if value is None: + return + if not isinstance(value, str): + raise _invalid_cursor() + + +def _require_non_negative_int_list(state: dict[str, Any], key: str) -> None: + value = state.get(key) + if not isinstance(value, list): + raise _invalid_cursor() + items = cast("list[object]", value) + if any(isinstance(item, bool) or not isinstance(item, int) or item < 0 for item in items): + raise _invalid_cursor() + + +def _require_str_list(state: dict[str, Any], key: str) -> None: + value = state.get(key) + if not isinstance(value, list): + raise _invalid_cursor() + items = cast("list[object]", value) + if any(not isinstance(item, str) for item in items): + raise _invalid_cursor() + + def parse_data_envelope(payload: object) -> tuple[list[object], bool]: from polymarket.errors import UnexpectedResponseError @@ -80,7 +193,12 @@ def parse_data_envelope(payload: object) -> tuple[list[object], bool]: "NINETY_DAYS_MS", "ONE_DAY_MS", "as_json_dict", + "decode_perps_ascending_account_cursor", + "decode_perps_candles_cursor", "decode_perps_cursor", + "decode_perps_descending_account_cursor", + "decode_perps_funding_cursor", + "decode_perps_trades_cursor", "encode_perps_cursor", "interval_ms", "parse_data_envelope", diff --git a/src/polymarket/_internal/actions/perps/public.py b/src/polymarket/_internal/actions/perps/public.py index a128acc..39cd418 100644 --- a/src/polymarket/_internal/actions/perps/public.py +++ b/src/polymarket/_internal/actions/perps/public.py @@ -8,7 +8,9 @@ from polymarket._internal.actions.perps.paging import ( ONE_DAY_MS, as_json_dict, - decode_perps_cursor, + decode_perps_candles_cursor, + decode_perps_funding_cursor, + decode_perps_trades_cursor, encode_perps_cursor, interval_ms, parse_data_envelope, @@ -141,7 +143,7 @@ async def fetch(cursor: str | None) -> Page[PerpsCandle]: "end_timestamp": end_ms if end_ms is not None else now, } else: - state = decode_perps_cursor(cursor, kind="perpsCandles") + state = decode_perps_candles_cursor(cursor, intervals=_KLINE_INTERVALS) payload = await perps.get_json( "/v1/info/klines", params={ @@ -186,7 +188,7 @@ async def fetch(cursor: str | None) -> Page[PerpsFundingRate]: "end_timestamp": end_ms if end_ms is not None else now, } else: - state = decode_perps_cursor(cursor, kind="perpsFundingHistory") + state = decode_perps_funding_cursor(cursor) payload = await perps.get_json( "/v1/info/funding", params={ @@ -230,7 +232,7 @@ async def fetch(cursor: str | None) -> Page[PerpsTrade]: "seen_trade_ids": [], } else: - state = decode_perps_cursor(cursor, kind="perpsTrades") + state = decode_perps_trades_cursor(cursor) payload = await perps.get_json( "/v1/info/trades", params={ diff --git a/src/polymarket/_internal/actions/relayer/approvals.py b/src/polymarket/_internal/actions/relayer/approvals.py index a3b2795..4322fbf 100644 --- a/src/polymarket/_internal/actions/relayer/approvals.py +++ b/src/polymarket/_internal/actions/relayer/approvals.py @@ -175,6 +175,11 @@ def _required_trading_approvals( spender=cast(EvmAddress, environment.exchange_v3), amount=MAX_UINT256, ), + _Erc20TradingApproval( + token_address=collateral, + spender=cast(EvmAddress, environment.perps_deposit_contract), + amount=MAX_UINT256, + ), ], [ _Erc1155TradingApproval( diff --git a/tests/unit/test_eoa_broadcast.py b/tests/unit/test_eoa_broadcast.py index d7c1d65..5d83fa9 100644 --- a/tests/unit/test_eoa_broadcast.py +++ b/tests/unit/test_eoa_broadcast.py @@ -136,9 +136,9 @@ async def run() -> None: def test_eoa_setup_trading_approvals_submits_and_waits_for_required_calls_sequentially() -> None: - send_hashes = ["0x" + f"{i:02x}" * 32 for i in range(1, 17)] + send_hashes = ["0x" + f"{i:02x}" * 32 for i in range(1, 18)] send_iter = iter(send_hashes) - receipts: list[dict[str, object] | None] = [{"status": "0x1"} for _ in range(16)] + receipts: list[dict[str, object] | None] = [{"status": "0x1"} for _ in range(17)] receipt_iter = iter(receipts) calls: list[object] = [] @@ -205,8 +205,8 @@ async def run() -> object: handle = asyncio.run(run()) assert isinstance(handle, DeprecatedTransactionHandle) assert handle.transaction_hash is None - assert _rpc_method_count(calls, "eth_sendRawTransaction") == 16 - assert _rpc_method_count(calls, "eth_getTransactionReceipt") >= 16 + assert _rpc_method_count(calls, "eth_sendRawTransaction") == 17 + assert _rpc_method_count(calls, "eth_getTransactionReceipt") >= 17 def _rpc_method_count(calls: list[object], method: str) -> int: diff --git a/tests/unit/test_perps_account_actions.py b/tests/unit/test_perps_account_actions.py new file mode 100644 index 0000000..39b5963 --- /dev/null +++ b/tests/unit/test_perps_account_actions.py @@ -0,0 +1,93 @@ +"""Perps account pagination behavior against a mocked transport.""" + +import asyncio +import base64 +import json +from collections.abc import Callable +from typing import Any + +import httpx +import pytest + +from polymarket._internal.actions.perps import account as perps_account +from polymarket.clients._transport import AsyncTransport +from polymarket.errors import UserInputError + +_BASE_URL = "https://perps.test" + + +def _transport(handler: Callable[[httpx.Request], httpx.Response]) -> AsyncTransport: + return AsyncTransport( + base_url=_BASE_URL, + client=httpx.AsyncClient(base_url=_BASE_URL, transport=httpx.MockTransport(handler)), + ) + + +def _cursor(state: dict[str, Any]) -> str: + return base64.b64encode(json.dumps(state, separators=(",", ":")).encode()).decode() + + +def test_descending_account_paginators_reject_malformed_cursor_fields() -> None: + def handler(request: httpx.Request) -> httpx.Response: + raise AssertionError("must not fetch with a malformed cursor") + + async def run() -> None: + transport = _transport(handler) + try: + fills = perps_account.list_fills(transport) + bad_fill_cursors: list[dict[str, Any]] = [ + {"kind": "perpsFills", "start_timestamp": 0, "end_timestamp": 1}, + { + "kind": "perpsFills", + "start_timestamp": 0, + "end_timestamp": 1, + "seen_keys": [1], + }, + ] + for state in bad_fill_cursors: + with pytest.raises(UserInputError, match="cursor"): + await fills.from_cursor(_cursor(state)).first_page() + + deposits = perps_account.list_deposits(transport) + with pytest.raises(UserInputError, match="cursor"): + await deposits.from_cursor( + _cursor( + { + "kind": "perpsDeposits", + "start_timestamp": 0, + "end_timestamp": 1, + "seen_keys": [], + "deposit_status": "bogus", + } + ) + ).first_page() + finally: + await transport.close() + + asyncio.run(run()) + + +def test_ascending_account_paginators_reject_malformed_cursor_fields() -> None: + def handler(request: httpx.Request) -> httpx.Response: + raise AssertionError("must not fetch with a malformed cursor") + + async def run() -> None: + transport = _transport(handler) + try: + pnl = perps_account.list_pnl_history(transport, interval="1h", start=0) + bad_pnl_cursors: list[dict[str, Any]] = [ + {"kind": "perpsPnlHistory", "interval": "1h", "start_timestamp": 0}, + { + "kind": "perpsPnlHistory", + "interval": 5, + "start_timestamp": 0, + "end_timestamp": 1, + }, + ] + for state in bad_pnl_cursors: + with pytest.raises(UserInputError, match="cursor"): + await pnl.from_cursor(_cursor(state)).first_page() + finally: + await transport.close() + + asyncio.run(run()) diff --git a/tests/unit/test_perps_public_actions.py b/tests/unit/test_perps_public_actions.py index 0f115c6..4af78eb 100644 --- a/tests/unit/test_perps_public_actions.py +++ b/tests/unit/test_perps_public_actions.py @@ -1,6 +1,7 @@ """Perps public read and pagination behavior against a mocked transport.""" import asyncio +import base64 import json from collections.abc import Callable from decimal import Decimal @@ -28,6 +29,10 @@ def _query(request: httpx.Request) -> dict[str, str]: return {key: values[0] for key, values in parse_qs(urlparse(str(request.url)).query).items()} +def _cursor(state: dict[str, Any]) -> str: + return base64.b64encode(json.dumps(state, separators=(",", ":")).encode()).decode() + + def test_list_candles_steps_forward_by_interval() -> None: requests: list[dict[str, str]] = [] @@ -161,10 +166,7 @@ async def run() -> None: transport = _transport(handler) try: paginator = perps_public.list_trades(transport, instrument_id=1) - candles_cursor = json.dumps({"kind": "perpsCandles"}) - import base64 - - bogus = base64.b64encode(candles_cursor.encode()).decode() + bogus = _cursor({"kind": "perpsCandles"}) with pytest.raises(UserInputError, match="cursor"): await paginator.from_cursor(bogus).first_page() finally: @@ -173,6 +175,52 @@ async def run() -> None: asyncio.run(run()) +def test_public_paginators_reject_malformed_cursor_fields() -> None: + def handler(request: httpx.Request) -> httpx.Response: + raise AssertionError("must not fetch with a malformed cursor") + + async def run() -> None: + transport = _transport(handler) + try: + trades = perps_public.list_trades(transport, instrument_id=1) + bad_trade_cursors: list[dict[str, Any]] = [ + { + "kind": "perpsTrades", + "start_timestamp": 0, + "end_timestamp": 1, + "seen_trade_ids": [], + }, + { + "kind": "perpsTrades", + "instrument_id": 1, + "start_timestamp": 0, + "end_timestamp": 1, + "seen_trade_ids": ["bad"], + }, + ] + for state in bad_trade_cursors: + with pytest.raises(UserInputError, match="cursor"): + await trades.from_cursor(_cursor(state)).first_page() + + candles = perps_public.list_candles(transport, instrument_id=1, interval="1m") + with pytest.raises(UserInputError, match="cursor"): + await candles.from_cursor( + _cursor( + { + "kind": "perpsCandles", + "instrument_id": 1, + "interval": "30m", + "start_timestamp": 0, + "end_timestamp": 1, + } + ) + ).first_page() + finally: + await transport.close() + + asyncio.run(run()) + + def test_fetch_tickers_merges_statistics_fields() -> None: def handler(request: httpx.Request) -> httpx.Response: path = urlparse(str(request.url)).path diff --git a/tests/unit/test_relayer_setup_trading_approvals.py b/tests/unit/test_relayer_setup_trading_approvals.py index febb082..9582100 100644 --- a/tests/unit/test_relayer_setup_trading_approvals.py +++ b/tests/unit/test_relayer_setup_trading_approvals.py @@ -57,12 +57,13 @@ async def run() -> None: body = request_json(submit_calls[0]) assert body["type"] == "WALLET" inner = body["depositWalletParams"]["calls"] - assert len(inner) == 16 + assert len(inner) == 17 erc20_sel = _selector("approve(address,uint256)") erc1155_sel = _selector("setApprovalForAll(address,bool)") # ERC20 approvals: standard_exchange, neg_risk_exchange, neg_risk_adapter, - # collateral_adapter, neg_risk_collateral_adapter, protocol_v2_router, exchange_v3 + # collateral_adapter, neg_risk_collateral_adapter, protocol_v2_router, exchange_v3, + # perps_deposit_contract for index, spender in enumerate( [ PRODUCTION.standard_exchange, @@ -72,6 +73,7 @@ async def run() -> None: PRODUCTION.neg_risk_collateral_adapter, PRODUCTION.protocol_v2_router, PRODUCTION.exchange_v3, + PRODUCTION.perps_deposit_contract, ] ): assert inner[index]["target"].lower() == PRODUCTION.collateral_token.lower() @@ -89,7 +91,7 @@ async def run() -> None: PRODUCTION.auto_redeem_operator, ] ): - index = 7 + offset + index = 8 + offset assert inner[index]["target"].lower() == PRODUCTION.conditional_tokens.lower() assert inner[index]["data"].startswith(erc1155_sel) assert operator[2:].lower() in inner[index]["data"].lower() @@ -102,7 +104,7 @@ async def run() -> None: PRODUCTION.auto_redeem_operator, ] ): - index = 13 + offset + index = 14 + offset assert inner[index]["target"].lower() == PRODUCTION.position_manager.lower() assert inner[index]["data"].startswith(erc1155_sel) assert operator[2:].lower() in inner[index]["data"].lower() diff --git a/tests/unit/test_sync_relayer_workflows.py b/tests/unit/test_sync_relayer_workflows.py index 7f74995..61fd8e8 100644 --- a/tests/unit/test_sync_relayer_workflows.py +++ b/tests/unit/test_sync_relayer_workflows.py @@ -188,7 +188,7 @@ def handler(request: httpx.Request) -> httpx.Response: submit_calls = [r for r in captured if urlparse(str(r.url)).path == "/submit"] body = request_json(submit_calls[0]) inner_calls = body["depositWalletParams"]["calls"] - assert len(inner_calls) == 16 + assert len(inner_calls) == 17 def test_setup_trading_approvals_skips_submit_when_already_approved() -> None: From 4073de50b0de56f82e0f6cfe4da67007c2b4ad0e Mon Sep 17 00:00:00 2001 From: Cesare Naldi <3353250+cesarenaldi@users.noreply.github.com> Date: Fri, 3 Jul 2026 17:16:30 +0200 Subject: [PATCH 3/7] fix(client): handle batched perps session updates --- src/polymarket/_internal/perps_session.py | 4 +++ tests/unit/test_perps_session.py | 31 +++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/polymarket/_internal/perps_session.py b/src/polymarket/_internal/perps_session.py index 0151da4..27e91b4 100644 --- a/src/polymarket/_internal/perps_session.py +++ b/src/polymarket/_internal/perps_session.py @@ -758,6 +758,10 @@ async def _position_exit_buy(self, instrument_id: int) -> bool: return position.size < 0 def _on_message(self, raw: object) -> None: + if isinstance(raw, list): + for item in cast("list[object]", raw): + self._on_message(item) + return if self._handle_response(raw): return try: diff --git a/tests/unit/test_perps_session.py b/tests/unit/test_perps_session.py index dadea39..8a014c8 100644 --- a/tests/unit/test_perps_session.py +++ b/tests/unit/test_perps_session.py @@ -187,6 +187,37 @@ async def run() -> None: assert command["sig"].startswith("0x") and len(command["sig"]) == 132 +def test_batched_session_updates_feed_queue_and_order_waiters() -> None: + async def handler(ws: ServerConnection) -> None: + await _handshake(ws) + async for raw in ws: + message = json.loads(raw) + if _is_ping(message): + continue + await ws.send(json.dumps({"id": message["id"], "data": [{"status": "ok", "oid": 77}]})) + await ws.send(json.dumps([_order_update(76), _order_update(77, sequence=2)])) + + async def run() -> None: + async with ws_server(handler) as url, _open_session(url) as session: + placement = await session.place_order( + instrument_id=1, + side="BUY", + price="0.5", + quantity="10", + time_in_force="gtc", + ) + assert placement.order.id == 77 + + first = await asyncio.wait_for(session.__anext__(), timeout=5.0) + second = await asyncio.wait_for(session.__anext__(), timeout=5.0) + assert isinstance(first, PerpsOrderEvent) + assert first.payload.id == 76 + assert isinstance(second, PerpsOrderEvent) + assert second.payload.id == 77 + + asyncio.run(asyncio.wait_for(run(), timeout=10.0)) + + def test_place_order_with_tp_sl_groups_rows_and_returns_trigger_ids() -> None: from polymarket.models.perps.requests import PerpsTpSlTrigger From c63abf0197061989ef4d53b95326cd1b4697ef3a Mon Sep 17 00:00:00 2001 From: Cesare Naldi <3353250+cesarenaldi@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:24:34 +0200 Subject: [PATCH 4/7] fix(client): tighten perps request typing --- src/polymarket/_internal/perps_session.py | 109 ++++++++++++++++------ src/polymarket/clients/async_public.py | 19 ++-- src/polymarket/clients/async_secure.py | 25 +++-- src/polymarket/models/perps/requests.py | 48 +++++++++- tests/unit/test_perps_trading_commands.py | 9 +- tests/unit/test_perps_typing.py | 77 +++++++++++++++ 6 files changed, 244 insertions(+), 43 deletions(-) create mode 100644 tests/unit/test_perps_typing.py diff --git a/src/polymarket/_internal/perps_session.py b/src/polymarket/_internal/perps_session.py index 27e91b4..5680fa2 100644 --- a/src/polymarket/_internal/perps_session.py +++ b/src/polymarket/_internal/perps_session.py @@ -187,6 +187,7 @@ def dropped_events(self) -> int: return self._dropped_events async def open(self) -> Self: + """Connect and authenticate the session WebSocket.""" await self._connect(emit_resync=False) return self @@ -205,6 +206,7 @@ async def close(self) -> None: self._on_session_close(self) def __aiter__(self) -> Self: + """Iterate authenticated Perps account events emitted by this session.""" return self async def __anext__(self) -> PerpsSessionEvent: @@ -269,22 +271,36 @@ async def place_order( stop_loss: PerpsTpSlTrigger | None = None, expires_at: datetime | int | None = None, ) -> PerpsOrderPlacement: - """Place one order and resolve with its first orders-channel update. + """Place one order and resolve with its first orders update. ``gtc`` orders require ``price`` and may set ``post_only``; ``ioc`` and ``fok`` orders may omit ``price`` for market-style execution. Pass ``take_profit`` and/or ``stop_loss`` to place reduce-only trigger - orders together with the order. + orders together with the entry order. ``expires_at`` is an optional + command expiration timestamp, accepted as ``datetime`` or epoch + milliseconds. """ - request = PerpsOrderRequest( - instrument_id=instrument_id, - side=side, - quantity=quantity, - time_in_force=time_in_force, - price=price, - post_only=post_only, - client_order_id=client_order_id, - ) + if time_in_force == "gtc": + request = PerpsOrderRequest( + instrument_id=instrument_id, + side=side, + quantity=quantity, + time_in_force=time_in_force, + price=cast(DecimalInput, price), + post_only=post_only, + client_order_id=client_order_id, + ) + else: + if post_only: + raise UserInputError("post_only is only supported for gtc orders") + request = PerpsOrderRequest( + instrument_id=instrument_id, + side=side, + quantity=quantity, + time_in_force=time_in_force, + price=price, + client_order_id=client_order_id, + ) if take_profit is None and stop_loss is None: acks = await self._send_create_orders( [to_raw_order(request)], group=None, expires_at=expires_at @@ -340,7 +356,8 @@ async def post_orders( ) -> tuple[PerpsPostOrderAck, ...]: """Post one or more orders and return queue-entry acknowledgements. - This is a low-level method; :meth:`place_order` is the common path. + This is a low-level method; :meth:`place_order` is the common path when + callers want to wait for the resulting order update. """ if not orders: raise UserInputError("orders must be non-empty") @@ -360,7 +377,8 @@ async def place_position_tp_sl( """Protect the current position with take-profit/stop-loss triggers. The exit side is inferred from the open position; a flat position - raises :class:`~polymarket.errors.UserInputError`. + raises :class:`~polymarket.errors.UserInputError`. Provide + ``take_profit``, ``stop_loss``, or both. """ if take_profit is None and stop_loss is None: raise UserInputError("Provide take_profit, stop_loss, or both") @@ -398,6 +416,22 @@ async def place_position_tp_sl( stop_loss_order = PerpsPlacedTpSlOrder(order_id=placed[trigger_index]) return PerpsPlacedTpSlOrders(take_profit=take_profit_order, stop_loss=stop_loss_order) + @overload + async def cancel_order( + self, + *, + order_id: int, + client_order_id: None = None, + expires_at: datetime | int | None = None, + ) -> PerpsCancelOrderResult: ... + @overload + async def cancel_order( + self, + *, + client_order_id: str, + order_id: None = None, + expires_at: datetime | int | None = None, + ) -> PerpsCancelOrderResult: ... async def cancel_order( self, *, @@ -407,7 +441,8 @@ async def cancel_order( ) -> PerpsCancelOrderResult: """Cancel one order by ``order_id`` or ``client_order_id``. - The returned status reflects whether the cancel happened. + Provide exactly one identifier. The returned status reflects whether + the cancel happened. """ if (order_id is None) == (client_order_id is None): raise UserInputError("Provide exactly one of order_id or client_order_id") @@ -420,6 +455,22 @@ async def cancel_order( ) return results[0] + @overload + async def cancel_orders( + self, + *, + order_ids: Sequence[int], + client_order_ids: None = None, + expires_at: datetime | int | None = None, + ) -> tuple[PerpsCancelOrderResult, ...]: ... + @overload + async def cancel_orders( + self, + *, + client_order_ids: Sequence[str], + order_ids: None = None, + expires_at: datetime | int | None = None, + ) -> tuple[PerpsCancelOrderResult, ...]: ... async def cancel_orders( self, *, @@ -427,7 +478,11 @@ async def cancel_orders( client_order_ids: Sequence[str] | None = None, expires_at: datetime | int | None = None, ) -> tuple[PerpsCancelOrderResult, ...]: - """Cancel orders and return one result per requested order.""" + """Cancel orders and return one result per requested order. + + Provide exactly one identifier list: ``order_ids`` or + ``client_order_ids``. + """ if (order_ids is None) == (client_order_ids is None): raise UserInputError("Provide exactly one of order_ids or client_order_ids") if order_ids is not None: @@ -457,27 +512,27 @@ async def update_leverage( ) async def fetch_balances(self) -> tuple[PerpsBalance, ...]: - """Fetch asset balances for the session account.""" + """Fetch current Perps balances for the session account.""" return await _account.fetch_balances(self._api) async def fetch_portfolio(self) -> PerpsPortfolio: - """Fetch the portfolio snapshot for the session account.""" + """Fetch the current Perps portfolio for the session account.""" return await _account.fetch_portfolio(self._api) async def fetch_stats(self) -> PerpsAccountStats: - """Fetch rolling trading statistics for the session account.""" + """Fetch account-level Perps statistics for the session account.""" return await _account.fetch_stats(self._api) async def fetch_account_config( self, *, instrument_id: int | None = None ) -> tuple[PerpsAccountConfig, ...]: - """Fetch per-instrument leverage configuration.""" + """Fetch Perps account configuration, optionally filtered by instrument.""" return await _account.fetch_account_config(self._api, instrument_id=instrument_id) async def fetch_open_orders( self, *, instrument_id: int | None = None ) -> tuple[PerpsOrder, ...]: - """Fetch open orders for the session account.""" + """Fetch currently open Perps orders, optionally filtered by instrument.""" return await _account.fetch_open_orders(self._api, instrument_id=instrument_id) async def fetch_orders( @@ -489,7 +544,7 @@ async def fetch_orders( start: datetime | int | None = None, end: datetime | int | None = None, ) -> tuple[PerpsOrder, ...]: - """Fetch historical orders for the session account.""" + """Fetch Perps orders for the session account.""" return await _account.fetch_orders( self._api, order_id=order_id, @@ -505,7 +560,7 @@ def list_fills( start: datetime | int | None = None, end: datetime | int | None = None, ) -> AsyncPaginator[PerpsFill]: - """List fills for the session account. + """List Perps fills for the session account. Defaults to the past 24 hours when ``start`` is omitted. """ @@ -518,7 +573,7 @@ def list_funding_payments( start: datetime | int | None = None, end: datetime | int | None = None, ) -> AsyncPaginator[PerpsFundingPayment]: - """List funding payments for the session account. + """List Perps funding payments for the session account. Defaults to the past 24 hours when ``start`` is omitted. """ @@ -534,7 +589,7 @@ def list_deposits( start: datetime | int | None = None, end: datetime | int | None = None, ) -> AsyncPaginator[PerpsDeposit]: - """List deposits for the session account. + """List Perps deposits for the session account. Defaults to the past 90 days when ``start`` is omitted. """ @@ -550,7 +605,7 @@ def list_withdrawals( start: datetime | int | None = None, end: datetime | int | None = None, ) -> AsyncPaginator[PerpsWithdrawal]: - """List withdrawals for the session account. + """List Perps withdrawals for the session account. Defaults to the past 90 days when ``start`` is omitted. """ @@ -569,7 +624,7 @@ def list_equity_history( start: datetime | int, end: datetime | int | None = None, ) -> AsyncPaginator[PerpsEquityPoint]: - """List account equity history at the requested interval.""" + """List Perps equity history at the requested interval.""" return _account.list_equity_history(self._api, interval=interval, start=start, end=end) def list_pnl_history( @@ -579,7 +634,7 @@ def list_pnl_history( start: datetime | int, end: datetime | int | None = None, ) -> AsyncPaginator[PerpsPnlPoint]: - """List account profit-and-loss history at the requested interval.""" + """List Perps profit-and-loss history at the requested interval.""" return _account.list_pnl_history(self._api, interval=interval, start=start, end=end) async def _resolve_auth_headers( diff --git a/src/polymarket/clients/async_public.py b/src/polymarket/clients/async_public.py index 23fde05..f2145d4 100644 --- a/src/polymarket/clients/async_public.py +++ b/src/polymarket/clients/async_public.py @@ -1304,7 +1304,7 @@ async def fetch_perps_instruments( instrument_id: int | None = None, category: PerpsInstrumentCategory | None = None, ) -> tuple[PerpsInstrument, ...]: - """Fetch Perps instruments, optionally filtered by id or category.""" + """Fetch Perps instruments, optionally filtered by instrument or category.""" return await _perps_actions.fetch_instruments( self._ctx.perps, instrument_id=instrument_id, category=category ) @@ -1322,7 +1322,10 @@ async def fetch_perps_tickers( async def fetch_perps_book( self, *, instrument_id: int, depth: PerpsBookDepth = 100 ) -> PerpsBook: - """Fetch a Perps order book snapshot.""" + """Fetch a Perps order book snapshot. + + ``depth`` controls the number of price levels returned on each side. + """ return await _perps_actions.fetch_book( self._ctx.perps, instrument_id=instrument_id, depth=depth ) @@ -1339,7 +1342,7 @@ def list_perps_candles( start: "datetime | int | None" = None, end: "datetime | int | None" = None, ) -> AsyncPaginator[PerpsCandle]: - """List Perps candles for an instrument. + """List Perps candles for an instrument with SDK-owned pagination. Defaults to the past 24 hours when ``start`` is omitted. ``start`` and ``end`` accept a ``datetime`` or an epoch-milliseconds int. @@ -1362,9 +1365,10 @@ def list_perps_funding_history( start: "datetime | int | None" = None, end: "datetime | int | None" = None, ) -> AsyncPaginator[PerpsFundingRate]: - """List Perps funding-rate history for an instrument. + """List Perps funding-rate history with SDK-owned pagination. - Defaults to the past 24 hours when ``start`` is omitted. + Defaults to the past 24 hours when ``start`` is omitted. ``start`` and + ``end`` accept a ``datetime`` or an epoch-milliseconds int. Returns: An async paginator over funding-rate observations. @@ -1380,9 +1384,10 @@ def list_perps_trades( start: "datetime | int | None" = None, end: "datetime | int | None" = None, ) -> AsyncPaginator[PerpsTrade]: - """List recent public Perps trades for an instrument. + """List recent public Perps trades with SDK-owned pagination. - Defaults to the past 24 hours when ``start`` is omitted. + Defaults to the past 24 hours when ``start`` is omitted. ``start`` and + ``end`` accept a ``datetime`` or an epoch-milliseconds int. Returns: An async paginator over matching trades. diff --git a/src/polymarket/clients/async_secure.py b/src/polymarket/clients/async_secure.py index 9e7ee07..192bbe8 100644 --- a/src/polymarket/clients/async_secure.py +++ b/src/polymarket/clients/async_secure.py @@ -675,6 +675,11 @@ async def open_perps_session( ``credentials`` to validate and resume them without a new wallet signature. + Args: + credentials: Existing delegated credentials to validate and resume. + expires_in: Delegated credential lifetime for newly created credentials. + label: Optional label for newly created credentials. + Returns: A connected session. Use it to place and cancel orders, read account state, and iterate over realtime account events. Close it @@ -743,6 +748,7 @@ async def deposit_to_perps( Args: amount: Base-units collateral amount to deposit. + metadata: Optional wallet-visible metadata for gasless deposit workflows. Returns: A transaction handle. Await ``wait()`` to wait for a terminal outcome. @@ -2749,7 +2755,7 @@ async def fetch_perps_instruments( instrument_id: int | None = None, category: PerpsInstrumentCategory | None = None, ) -> tuple[PerpsInstrument, ...]: - """Fetch Perps instruments, optionally filtered by id or category.""" + """Fetch Perps instruments, optionally filtered by instrument or category.""" return await _perps_actions.fetch_instruments( self._ctx.perps, instrument_id=instrument_id, category=category ) @@ -2767,7 +2773,10 @@ async def fetch_perps_tickers( async def fetch_perps_book( self, *, instrument_id: int, depth: PerpsBookDepth = 100 ) -> PerpsBook: - """Fetch a Perps order book snapshot.""" + """Fetch a Perps order book snapshot. + + ``depth`` controls the number of price levels returned on each side. + """ return await _perps_actions.fetch_book( self._ctx.perps, instrument_id=instrument_id, depth=depth ) @@ -2784,7 +2793,7 @@ def list_perps_candles( start: "datetime | int | None" = None, end: "datetime | int | None" = None, ) -> AsyncPaginator[PerpsCandle]: - """List Perps candles for an instrument. + """List Perps candles for an instrument with SDK-owned pagination. Defaults to the past 24 hours when ``start`` is omitted. ``start`` and ``end`` accept a ``datetime`` or an epoch-milliseconds int. @@ -2807,9 +2816,10 @@ def list_perps_funding_history( start: "datetime | int | None" = None, end: "datetime | int | None" = None, ) -> AsyncPaginator[PerpsFundingRate]: - """List Perps funding-rate history for an instrument. + """List Perps funding-rate history with SDK-owned pagination. - Defaults to the past 24 hours when ``start`` is omitted. + Defaults to the past 24 hours when ``start`` is omitted. ``start`` and + ``end`` accept a ``datetime`` or an epoch-milliseconds int. Returns: An async paginator over funding-rate observations. @@ -2825,9 +2835,10 @@ def list_perps_trades( start: "datetime | int | None" = None, end: "datetime | int | None" = None, ) -> AsyncPaginator[PerpsTrade]: - """List recent public Perps trades for an instrument. + """List recent public Perps trades with SDK-owned pagination. - Defaults to the past 24 hours when ``start`` is omitted. + Defaults to the past 24 hours when ``start`` is omitted. ``start`` and + ``end`` accept a ``datetime`` or an epoch-milliseconds int. Returns: An async paginator over matching trades. diff --git a/src/polymarket/models/perps/requests.py b/src/polymarket/models/perps/requests.py index 3472ba4..609d768 100644 --- a/src/polymarket/models/perps/requests.py +++ b/src/polymarket/models/perps/requests.py @@ -3,6 +3,7 @@ import re from dataclasses import dataclass from decimal import Decimal, InvalidOperation +from typing import Literal, overload from polymarket.errors import UserInputError from polymarket.models.perps.types import PerpsTimeInForce @@ -34,7 +35,7 @@ def validate_client_order_id(value: str) -> str: return value -@dataclass(frozen=True, slots=True, kw_only=True) +@dataclass(frozen=True, slots=True, kw_only=True, init=False) class PerpsOrderRequest: """One Perps order to submit. @@ -58,6 +59,51 @@ class PerpsOrderRequest: client_order_id: str | None = None """Optional caller-supplied idempotency identifier.""" + @overload + def __init__( + self, + *, + instrument_id: int, + side: OrderSide, + quantity: DecimalInput, + time_in_force: Literal["gtc"], + price: DecimalInput, + post_only: bool = False, + client_order_id: str | None = None, + ) -> None: ... + + @overload + def __init__( + self, + *, + instrument_id: int, + side: OrderSide, + quantity: DecimalInput, + time_in_force: Literal["ioc", "fok"], + price: DecimalInput | None = None, + client_order_id: str | None = None, + ) -> None: ... + + def __init__( + self, + *, + instrument_id: int, + side: OrderSide, + quantity: DecimalInput, + time_in_force: PerpsTimeInForce, + price: DecimalInput | None = None, + post_only: bool = False, + client_order_id: str | None = None, + ) -> None: + object.__setattr__(self, "instrument_id", instrument_id) + object.__setattr__(self, "side", side) + object.__setattr__(self, "quantity", quantity) + object.__setattr__(self, "time_in_force", time_in_force) + object.__setattr__(self, "price", price) + object.__setattr__(self, "post_only", post_only) + object.__setattr__(self, "client_order_id", client_order_id) + self.__post_init__() + def __post_init__(self) -> None: if isinstance(self.instrument_id, bool) or not isinstance(self.instrument_id, int): # pyright: ignore[reportUnnecessaryIsInstance] raise UserInputError("instrument_id must be an int") diff --git a/tests/unit/test_perps_trading_commands.py b/tests/unit/test_perps_trading_commands.py index 76137df..5b9b5ef 100644 --- a/tests/unit/test_perps_trading_commands.py +++ b/tests/unit/test_perps_trading_commands.py @@ -1,5 +1,7 @@ """Perps trading command construction tests.""" +from typing import Any, cast + import pytest from polymarket._internal.actions.perps.signing import build_perps_op_typed_data @@ -126,7 +128,12 @@ def test_cancel_and_leverage_ops() -> None: def test_gtc_order_requires_price() -> None: with pytest.raises(UserInputError, match="price is required for gtc"): - PerpsOrderRequest(instrument_id=1, side="BUY", quantity="1", time_in_force="gtc") + PerpsOrderRequest( + instrument_id=1, + side="BUY", + quantity="1", + time_in_force=cast(Any, "gtc"), + ) def test_post_only_rejected_for_ioc_and_fok() -> None: diff --git a/tests/unit/test_perps_typing.py b/tests/unit/test_perps_typing.py new file mode 100644 index 0000000..f33b52d --- /dev/null +++ b/tests/unit/test_perps_typing.py @@ -0,0 +1,77 @@ +"""Static Perps typing examples checked by pyright.""" + +from __future__ import annotations + +from collections.abc import Callable +from types import CoroutineType +from typing import TYPE_CHECKING, Any, assert_type + +from polymarket.models.perps import ( + PerpsCancelOrderResult, + PerpsOrderRequest, + PerpsPostOrderAck, +) +from polymarket.perps import PerpsOrderPlacement, PerpsSession + +if TYPE_CHECKING: + assert_type( + PerpsOrderRequest( + instrument_id=1, + price="100", + quantity="1", + side="BUY", + time_in_force="gtc", + ), + PerpsOrderRequest, + ) + assert_type( + PerpsOrderRequest( + instrument_id=1, + quantity="1", + side="BUY", + time_in_force="ioc", + ), + PerpsOrderRequest, + ) + + async def _check_session_typing(session: PerpsSession) -> None: + assert_type( + await session.place_order( + instrument_id=1, + price="100", + quantity="1", + side="BUY", + time_in_force="gtc", + ), + PerpsOrderPlacement, + ) + assert_type( + await session.cancel_order(order_id=1), + PerpsCancelOrderResult, + ) + assert_type( + await session.cancel_order(client_order_id="aabbccddeeff00112233445566778899"), + PerpsCancelOrderResult, + ) + assert_type( + await session.cancel_orders(order_ids=[1, 2]), + tuple[PerpsCancelOrderResult, ...], + ) + assert_type( + await session.post_orders( + [ + PerpsOrderRequest( + instrument_id=1, + price="100", + quantity="1", + side="BUY", + time_in_force="gtc", + ) + ] + ), + tuple[PerpsPostOrderAck, ...], + ) + + _session_typing_check: Callable[[PerpsSession], CoroutineType[Any, Any, None]] = ( + _check_session_typing + ) From 65ed83e9fe56f46b5059c206da5e544060084c33 Mon Sep 17 00:00:00 2001 From: Cesare Naldi <3353250+cesarenaldi@users.noreply.github.com> Date: Tue, 7 Jul 2026 09:53:18 +0200 Subject: [PATCH 5/7] feat(client): support reduce-only perps orders --- .../_internal/actions/perps/trading.py | 2 +- src/polymarket/_internal/perps_session.py | 15 +++++++---- src/polymarket/models/perps/orders.py | 1 + src/polymarket/models/perps/requests.py | 10 ++++++- tests/unit/test_perps_events.py | 25 +++++++++++++++++ tests/unit/test_perps_session.py | 22 ++++++++++++--- tests/unit/test_perps_trading_commands.py | 27 +++++++++++++++++++ tests/unit/test_perps_typing.py | 2 ++ 8 files changed, 93 insertions(+), 11 deletions(-) diff --git a/src/polymarket/_internal/actions/perps/trading.py b/src/polymarket/_internal/actions/perps/trading.py index ab81330..66641a8 100644 --- a/src/polymarket/_internal/actions/perps/trading.py +++ b/src/polymarket/_internal/actions/perps/trading.py @@ -31,7 +31,7 @@ def to_raw_order(request: PerpsOrderRequest) -> RawPerpsOrder: to_decimal_string("quantity", request.quantity), request.time_in_force, request.post_only, - None, + True if request.reduce_only else None, request.client_order_id, None, ] diff --git a/src/polymarket/_internal/perps_session.py b/src/polymarket/_internal/perps_session.py index 5680fa2..c021b8d 100644 --- a/src/polymarket/_internal/perps_session.py +++ b/src/polymarket/_internal/perps_session.py @@ -238,6 +238,7 @@ async def place_order( time_in_force: Literal["gtc"], price: DecimalInput, post_only: bool = False, + reduce_only: bool = False, client_order_id: str | None = None, take_profit: PerpsTpSlTrigger | None = None, stop_loss: PerpsTpSlTrigger | None = None, @@ -252,6 +253,7 @@ async def place_order( quantity: DecimalInput, time_in_force: Literal["ioc", "fok"], price: DecimalInput | None = None, + reduce_only: bool = False, client_order_id: str | None = None, take_profit: PerpsTpSlTrigger | None = None, stop_loss: PerpsTpSlTrigger | None = None, @@ -266,6 +268,7 @@ async def place_order( time_in_force: PerpsTimeInForce, price: DecimalInput | None = None, post_only: bool = False, + reduce_only: bool = False, client_order_id: str | None = None, take_profit: PerpsTpSlTrigger | None = None, stop_loss: PerpsTpSlTrigger | None = None, @@ -274,11 +277,11 @@ async def place_order( """Place one order and resolve with its first orders update. ``gtc`` orders require ``price`` and may set ``post_only``; ``ioc`` and - ``fok`` orders may omit ``price`` for market-style execution. Pass - ``take_profit`` and/or ``stop_loss`` to place reduce-only trigger - orders together with the entry order. ``expires_at`` is an optional - command expiration timestamp, accepted as ``datetime`` or epoch - milliseconds. + ``fok`` orders may omit ``price`` for market-style execution. Set + ``reduce_only`` to prevent the order from increasing exposure. Pass + ``take_profit`` and/or ``stop_loss`` to place reduce-only trigger orders + together with the entry order. ``expires_at`` is an optional command + expiration timestamp, accepted as ``datetime`` or epoch milliseconds. """ if time_in_force == "gtc": request = PerpsOrderRequest( @@ -288,6 +291,7 @@ async def place_order( time_in_force=time_in_force, price=cast(DecimalInput, price), post_only=post_only, + reduce_only=reduce_only, client_order_id=client_order_id, ) else: @@ -299,6 +303,7 @@ async def place_order( quantity=quantity, time_in_force=time_in_force, price=price, + reduce_only=reduce_only, client_order_id=client_order_id, ) if take_profit is None and stop_loss is None: diff --git a/src/polymarket/models/perps/orders.py b/src/polymarket/models/perps/orders.py index 7664a81..11ffd5f 100644 --- a/src/polymarket/models/perps/orders.py +++ b/src/polymarket/models/perps/orders.py @@ -55,6 +55,7 @@ class PerpsOrder(BaseModel): quantity: _Decimal = Field(validation_alias=AliasChoices("quantity", "qty")) time_in_force: PerpsTimeInForce = Field(validation_alias="tif") post_only: bool = Field(validation_alias=AliasChoices("post_only", "po")) + reduce_only: bool = Field(validation_alias="ro") status: PerpsOrderStatus resting_quantity: _Decimal = Field(validation_alias=AliasChoices("resting_quantity", "rest")) filled_quantity: _Decimal = Field(validation_alias=AliasChoices("filled_quantity", "fill")) diff --git a/src/polymarket/models/perps/requests.py b/src/polymarket/models/perps/requests.py index 609d768..1e6de1a 100644 --- a/src/polymarket/models/perps/requests.py +++ b/src/polymarket/models/perps/requests.py @@ -41,7 +41,7 @@ class PerpsOrderRequest: ``gtc`` orders require a ``price`` and may set ``post_only``. ``ioc`` and ``fok`` orders may omit ``price`` for market-style execution and cannot be - post-only. + post-only. Set ``reduce_only`` to prevent the order from increasing exposure. """ instrument_id: int @@ -56,6 +56,8 @@ class PerpsOrderRequest: """Limit price. Required for ``gtc``; optional for ``ioc``/``fok``.""" post_only: bool = False """Whether the order must rest instead of taking liquidity.""" + reduce_only: bool = False + """Whether the order may only reduce or close an existing position.""" client_order_id: str | None = None """Optional caller-supplied idempotency identifier.""" @@ -69,6 +71,7 @@ def __init__( time_in_force: Literal["gtc"], price: DecimalInput, post_only: bool = False, + reduce_only: bool = False, client_order_id: str | None = None, ) -> None: ... @@ -81,6 +84,7 @@ def __init__( quantity: DecimalInput, time_in_force: Literal["ioc", "fok"], price: DecimalInput | None = None, + reduce_only: bool = False, client_order_id: str | None = None, ) -> None: ... @@ -93,6 +97,7 @@ def __init__( time_in_force: PerpsTimeInForce, price: DecimalInput | None = None, post_only: bool = False, + reduce_only: bool = False, client_order_id: str | None = None, ) -> None: object.__setattr__(self, "instrument_id", instrument_id) @@ -101,6 +106,7 @@ def __init__( object.__setattr__(self, "time_in_force", time_in_force) object.__setattr__(self, "price", price) object.__setattr__(self, "post_only", post_only) + object.__setattr__(self, "reduce_only", reduce_only) object.__setattr__(self, "client_order_id", client_order_id) self.__post_init__() @@ -117,6 +123,8 @@ def __post_init__(self) -> None: ) if not isinstance(self.post_only, bool): # pyright: ignore[reportUnnecessaryIsInstance] raise UserInputError("post_only must be a bool") + if not isinstance(self.reduce_only, bool): # pyright: ignore[reportUnnecessaryIsInstance] + raise UserInputError("reduce_only must be a bool") to_decimal_string("quantity", self.quantity) if self.time_in_force == "gtc": if self.price is None: diff --git a/tests/unit/test_perps_events.py b/tests/unit/test_perps_events.py index 973f754..43becdc 100644 --- a/tests/unit/test_perps_events.py +++ b/tests/unit/test_perps_events.py @@ -15,6 +15,7 @@ parse_perps_market_events, parse_perps_session_event, ) +from polymarket.models.perps.orders import PerpsOrder def _order_update(order_id: int = 5) -> dict[str, object]: @@ -26,6 +27,7 @@ def _order_update(order_id: int = 5) -> dict[str, object]: "qty": "10", "tif": "gtc", "po": False, + "ro": True, "status": "open", "rest": "10", "fill": "0", @@ -108,10 +110,33 @@ def test_session_order_event_normalizes_compact_order() -> None: ) assert isinstance(event, PerpsOrderEvent) assert event.payload.id == 5 + assert event.payload.reduce_only is True assert event.payload.side == "BUY" assert event.payload.status == "open" +def test_order_model_normalizes_reduce_only_response_field() -> None: + order = PerpsOrder.parse_response( + { + "order_id": 5, + "instrument_id": 1, + "buy": True, + "price": "0.5", + "quantity": "10", + "tif": "gtc", + "post_only": False, + "ro": True, + "status": "open", + "resting_quantity": "10", + "filled_quantity": "0", + "created_timestamp": 1751500000000, + "updated_timestamp": 1751500000001, + } + ) + + assert order.reduce_only is True + + def test_session_tpsl_event_parses_lifecycle_update() -> None: event = parse_perps_session_event( { diff --git a/tests/unit/test_perps_session.py b/tests/unit/test_perps_session.py index 8a014c8..05eb2c2 100644 --- a/tests/unit/test_perps_session.py +++ b/tests/unit/test_perps_session.py @@ -57,8 +57,10 @@ async def _handshake(ws: ServerConnection) -> list[dict[str, Any]]: return frames -def _order_update(order_id: int, *, sequence: int = 1) -> dict[str, Any]: - return { +def _order_update( + order_id: int, *, sequence: int = 1, reduce_only: bool | None = None +) -> dict[str, Any]: + update: dict[str, Any] = { "ch": "orders", "ts": 1751500000000, "sq": sequence, @@ -70,6 +72,7 @@ def _order_update(order_id: int, *, sequence: int = 1) -> dict[str, Any]: "qty": "10", "tif": "gtc", "po": False, + "ro": reduce_only or False, "status": "open", "rest": "10", "fill": "0", @@ -77,6 +80,7 @@ def _order_update(order_id: int, *, sequence: int = 1) -> dict[str, Any]: "uts": 1751500000001, }, } + return update @asynccontextmanager @@ -161,7 +165,7 @@ async def handler(ws: ServerConnection) -> None: continue commands.append(message) await ws.send(json.dumps({"id": message["id"], "data": [{"status": "ok", "oid": 77}]})) - await ws.send(json.dumps(_order_update(77))) + await ws.send(json.dumps(_order_update(77, reduce_only=True))) async def run() -> None: async with ws_server(handler) as url, _open_session(url) as session: @@ -170,9 +174,11 @@ async def run() -> None: side="BUY", price="0.5", quantity="10", + reduce_only=True, time_in_force="gtc", ) assert placement.order.id == 77 + assert placement.order.reduce_only is True assert placement.order.status == "open" assert placement.tp_sl is None @@ -180,7 +186,15 @@ async def run() -> None: command = commands[0] assert command["op"]["type"] == "createOrders" assert command["op"]["args"] == [ - {"iid": 1, "buy": True, "po": False, "qty": "10", "tif": "gtc", "p": "0.5"} + { + "iid": 1, + "buy": True, + "po": False, + "qty": "10", + "ro": True, + "tif": "gtc", + "p": "0.5", + } ] assert isinstance(command["salt"], int) assert isinstance(command["ts"], int) diff --git a/tests/unit/test_perps_trading_commands.py b/tests/unit/test_perps_trading_commands.py index 5b9b5ef..8087a2a 100644 --- a/tests/unit/test_perps_trading_commands.py +++ b/tests/unit/test_perps_trading_commands.py @@ -50,6 +50,21 @@ def test_market_style_order_omits_price_from_body() -> None: assert body["args"] == [{"iid": 2, "buy": False, "po": False, "qty": "1.5", "tif": "ioc"}] +def test_reduce_only_order_sets_body_flag() -> None: + request = PerpsOrderRequest( + instrument_id=2, + side="SELL", + price="99", + quantity="1.5", + reduce_only=True, + time_in_force="ioc", + ) + body = to_command_body_op(create_orders_op([to_raw_order(request)])) + assert body["args"] == [ + {"iid": 2, "buy": False, "po": False, "qty": "1.5", "ro": True, "tif": "ioc", "p": "99"} + ] + + def test_client_order_id_round_trips_into_body() -> None: request = PerpsOrderRequest( instrument_id=3, @@ -148,6 +163,18 @@ def test_post_only_rejected_for_ioc_and_fok() -> None: ) +def test_reduce_only_must_be_bool() -> None: + with pytest.raises(UserInputError, match="reduce_only"): + PerpsOrderRequest( + instrument_id=1, + side="BUY", + price="1", + quantity="1", + reduce_only=cast(Any, "yes"), + time_in_force="gtc", + ) + + def test_invalid_client_order_id_rejected() -> None: with pytest.raises(UserInputError, match="client_order_id"): PerpsOrderRequest( diff --git a/tests/unit/test_perps_typing.py b/tests/unit/test_perps_typing.py index f33b52d..eb8b3d0 100644 --- a/tests/unit/test_perps_typing.py +++ b/tests/unit/test_perps_typing.py @@ -28,6 +28,7 @@ PerpsOrderRequest( instrument_id=1, quantity="1", + reduce_only=True, side="BUY", time_in_force="ioc", ), @@ -40,6 +41,7 @@ async def _check_session_typing(session: PerpsSession) -> None: instrument_id=1, price="100", quantity="1", + reduce_only=True, side="BUY", time_in_force="gtc", ), From 3b89943f83d621e3bb41c16d3209e5d72cfc0d38 Mon Sep 17 00:00:00 2001 From: Cesare Naldi <3353250+cesarenaldi@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:47:48 +0200 Subject: [PATCH 6/7] feat(client): add perps cancel-all orders --- .../_internal/actions/perps/trading.py | 18 +++++++ src/polymarket/_internal/perps_session.py | 53 ++++++++++++++----- src/polymarket/models/perps/orders.py | 7 +++ tests/integration/test_perps.py | 44 +++++++++++++++ tests/unit/test_perps_session.py | 50 +++++++++++++++++ tests/unit/test_perps_trading_commands.py | 15 ++++++ 6 files changed, 175 insertions(+), 12 deletions(-) diff --git a/src/polymarket/_internal/actions/perps/trading.py b/src/polymarket/_internal/actions/perps/trading.py index ab81330..e0a269f 100644 --- a/src/polymarket/_internal/actions/perps/trading.py +++ b/src/polymarket/_internal/actions/perps/trading.py @@ -90,6 +90,16 @@ def cancel_orders_by_client_id_op(client_order_ids: Sequence[str]) -> list[Any]: return ["cancelOrdersCOID", ids] +def cancel_all_orders_op(*, instrument_id: int | None = None) -> list[Any]: + if instrument_id is None: + return ["cancelAll", []] + if isinstance(instrument_id, bool) or not isinstance(instrument_id, int): # pyright: ignore[reportUnnecessaryIsInstance] + raise UserInputError("instrument_id must be an int") + if instrument_id < 0: + raise UserInputError("instrument_id must be non-negative") + return ["cancelAll", [instrument_id]] + + def update_leverage_op(*, instrument_id: int, leverage: int, cross_margin: bool) -> list[Any]: if isinstance(instrument_id, bool) or not isinstance(instrument_id, int): # pyright: ignore[reportUnnecessaryIsInstance] raise UserInputError("instrument_id must be an int") @@ -113,6 +123,13 @@ def to_command_body_op(op: Sequence[Any]) -> dict[str, Any]: return body if op_type in ("cancelOrders", "cancelOrdersCOID"): return {"type": op_type, "args": op[1]} + if op_type == "cancelAll": + args = op[1] + instrument_id = args[0] if args else None + return { + "type": op_type, + "args": {} if instrument_id is None else {"iid": instrument_id}, + } if op_type == "updateLeverage": instrument_id, leverage, cross_margin = op[1] return { @@ -146,6 +163,7 @@ def _to_trigger_body(trigger: list[Any]) -> dict[str, Any]: __all__ = [ "RawPerpsOrder", + "cancel_all_orders_op", "cancel_orders_by_client_id_op", "cancel_orders_op", "create_orders_op", diff --git a/src/polymarket/_internal/perps_session.py b/src/polymarket/_internal/perps_session.py index 5680fa2..03c39e0 100644 --- a/src/polymarket/_internal/perps_session.py +++ b/src/polymarket/_internal/perps_session.py @@ -20,6 +20,7 @@ ) from polymarket._internal.actions.perps.trading import ( RawPerpsOrder, + cancel_all_orders_op, cancel_orders_by_client_id_op, cancel_orders_op, create_orders_op, @@ -58,6 +59,7 @@ ) from polymarket.models.perps.funds import PerpsDeposit, PerpsWithdrawal from polymarket.models.perps.orders import ( + PerpsCancelAllOrdersResponse, PerpsCancelOrderResult, PerpsFill, PerpsOrder, @@ -498,6 +500,28 @@ async def cancel_orders( ) return tuple(results) + async def cancel_all_orders( + self, + *, + instrument_id: int | None = None, + expires_at: datetime | int | None = None, + ) -> None: + """Cancel all open Perps orders for the session account. + + Omit ``instrument_id`` to cancel open orders across all instruments. + The request returns once accepted; individual orders can still race with + fills or other cancels. + """ + op = cancel_all_orders_op(instrument_id=instrument_id) + response = await self._api.delete_json( + "/v1/trade/orders/all", + json={ + **self._create_signed_command(op, expires_at=expires_at), + "op": to_command_body_op(op), + }, + ) + PerpsCancelAllOrdersResponse.parse_response(response) + async def update_leverage( self, *, instrument_id: int, leverage: int, cross_margin: bool ) -> PerpsUpdateLeverageResult: @@ -715,6 +739,20 @@ async def _send_signed_command( timeout_message: str, expires_at: datetime | int | None = None, ) -> Any: + command = self._create_signed_command(op, expires_at=expires_at) + frame: dict[str, Any] = { + "id": self._take_request_id(), + "req": "post", + "op": to_command_body_op(op), + **command, + } + return await self._send_request( + frame, parse=parse, timeout_s=_ACK_TIMEOUT_S, timeout_message=timeout_message + ) + + def _create_signed_command( + self, op: list[Any], *, expires_at: datetime | int | None = None + ) -> dict[str, Any]: salt = random_perps_salt() timestamp = now_ms() signature = sign_perps_op_with_key( @@ -724,20 +762,11 @@ async def _send_signed_command( salt=salt, timestamp_ms=timestamp, ) - frame: dict[str, Any] = { - "id": self._take_request_id(), - "req": "post", - "op": to_command_body_op(op), - "salt": salt, - "sig": signature, - "ts": timestamp, - } + command: dict[str, Any] = {"salt": salt, "sig": signature, "ts": timestamp} expiry_ms = to_epoch_ms("expires_at", expires_at) if expiry_ms is not None: - frame["exp"] = expiry_ms - return await self._send_request( - frame, parse=parse, timeout_s=_ACK_TIMEOUT_S, timeout_message=timeout_message - ) + command["exp"] = expiry_ms + return command async def _send_request( self, diff --git a/src/polymarket/models/perps/orders.py b/src/polymarket/models/perps/orders.py index 7664a81..d14bc08 100644 --- a/src/polymarket/models/perps/orders.py +++ b/src/polymarket/models/perps/orders.py @@ -147,6 +147,12 @@ def _normalize(cls, data: object) -> object: return _default_ack_error(data) +class PerpsCancelAllOrdersResponse(BaseModel): + """Accepted response for a Perps cancel-all request.""" + + status: Literal["ok"] + + class PerpsUpdateLeverageResult(BaseModel): """Result of a Perps leverage update.""" @@ -157,6 +163,7 @@ class PerpsUpdateLeverageResult(BaseModel): __all__ = [ + "PerpsCancelAllOrdersResponse", "PerpsCancelOrderResult", "PerpsFill", "PerpsOrder", diff --git a/tests/integration/test_perps.py b/tests/integration/test_perps.py index de98272..c515240 100644 --- a/tests/integration/test_perps.py +++ b/tests/integration/test_perps.py @@ -12,6 +12,7 @@ """ import asyncio +import contextlib import math from collections.abc import AsyncGenerator, Callable from datetime import UTC, datetime, timedelta @@ -198,6 +199,49 @@ async def test_places_and_cancels_one_perps_order( await session.close() +@pytest.mark.integration +@pytest.mark.metered +async def test_places_and_cancels_all_perps_orders_for_one_instrument( + deposit_wallet_client: AsyncSecureClient, +) -> None: + instrument = await _first_instrument(deposit_wallet_client) + ticker = await deposit_wallet_client.fetch_perps_ticker(instrument_id=instrument.id) + # Places two resting orders and cancels all open orders for this instrument. + price = _format_perps_price(ticker.mark_price / 2, instrument.price_decimals) + quantity = _minimal_order_quantity(instrument, Decimal(price)) + order_ids: list[int] = [] + session = await deposit_wallet_client.open_perps_session() + try: + for _ in range(2): + placement = await session.place_order( + instrument_id=instrument.id, + side="BUY", + price=price, + quantity=quantity, + time_in_force="gtc", + ) + order_ids.append(placement.order.id) + + await session.cancel_all_orders(instrument_id=instrument.id) + + async def poll() -> None: + while True: + open_order_ids = { + order.id + for order in await session.fetch_open_orders(instrument_id=instrument.id) + } + if all(order_id not in open_order_ids for order_id in order_ids): + return + await asyncio.sleep(1.0) + + await asyncio.wait_for(poll(), timeout=30.0) + finally: + if order_ids: + with contextlib.suppress(Exception): + await session.cancel_orders(order_ids=order_ids) + await session.close() + + @pytest.mark.integration @pytest.mark.metered async def test_places_and_cancels_one_perps_order_with_tp_sl( diff --git a/tests/unit/test_perps_session.py b/tests/unit/test_perps_session.py index 8a014c8..2b7ebbf 100644 --- a/tests/unit/test_perps_session.py +++ b/tests/unit/test_perps_session.py @@ -9,10 +9,12 @@ from datetime import UTC, datetime from typing import Any +import httpx import pytest from websockets.asyncio.server import ServerConnection, serve from polymarket._internal.perps_session import PerpsSession +from polymarket.clients._transport import AsyncTransport from polymarket.errors import RequestRejectedError, TransportError from polymarket.models.perps.credentials import PerpsCredentials from polymarket.models.perps.events import PerpsOrderEvent, PerpsResyncEvent @@ -328,6 +330,54 @@ async def run() -> None: asyncio.run(asyncio.wait_for(run(), timeout=10.0)) +def test_cancel_all_orders_uses_signed_rest_endpoint() -> None: + captured: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response(200, json={"status": "ok"}, request=request) + + async def run() -> None: + session = PerpsSession( + chain_id=137, + credentials=_CREDENTIALS, + rest_url="https://perps.test", + ws_url="ws://127.0.0.1:9", + ) + session._api = AsyncTransport( + base_url="https://perps.test", + client=httpx.AsyncClient( + base_url="https://perps.test", + transport=httpx.MockTransport(handler), + ), + header_resolver=session._resolve_auth_headers, + ) + try: + await session.cancel_all_orders(instrument_id=1, expires_at=1_700_000_005_000) + await session.cancel_all_orders() + finally: + await session.close() + + asyncio.run(asyncio.wait_for(run(), timeout=10.0)) + + assert len(captured) == 2 + assert captured[0].method == "DELETE" + assert captured[0].url.path == "/v1/trade/orders/all" + assert captured[0].headers["polymarket-proxy"] == _PROXY_ADDRESS + assert captured[0].headers["polymarket-secret"] == "session-secret" + + scoped_body = json.loads(captured[0].content) + assert scoped_body["op"] == {"type": "cancelAll", "args": {"iid": 1}} + assert scoped_body["exp"] == 1_700_000_005_000 + assert isinstance(scoped_body["salt"], int) + assert isinstance(scoped_body["ts"], int) + assert scoped_body["sig"].startswith("0x") and len(scoped_body["sig"]) == 132 + + unscoped_body = json.loads(captured[1].content) + assert unscoped_body["op"] == {"type": "cancelAll", "args": {}} + assert "exp" not in unscoped_body + + def test_sequence_gap_emits_resync_event_before_update() -> None: async def handler(ws: ServerConnection) -> None: await _handshake(ws) diff --git a/tests/unit/test_perps_trading_commands.py b/tests/unit/test_perps_trading_commands.py index 5b9b5ef..3a938a8 100644 --- a/tests/unit/test_perps_trading_commands.py +++ b/tests/unit/test_perps_trading_commands.py @@ -6,6 +6,7 @@ from polymarket._internal.actions.perps.signing import build_perps_op_typed_data from polymarket._internal.actions.perps.trading import ( + cancel_all_orders_op, cancel_orders_by_client_id_op, cancel_orders_op, create_orders_op, @@ -121,6 +122,13 @@ def test_cancel_and_leverage_ops() -> None: assert to_command_body_op( cancel_orders_by_client_id_op(["aabbccddeeff00112233445566778899"]) ) == {"type": "cancelOrdersCOID", "args": ["aabbccddeeff00112233445566778899"]} + assert cancel_all_orders_op() == ["cancelAll", []] + assert to_command_body_op(cancel_all_orders_op()) == {"type": "cancelAll", "args": {}} + assert cancel_all_orders_op(instrument_id=3) == ["cancelAll", [3]] + assert to_command_body_op(cancel_all_orders_op(instrument_id=3)) == { + "type": "cancelAll", + "args": {"iid": 3}, + } assert to_command_body_op( update_leverage_op(instrument_id=3, leverage=20, cross_margin=True) ) == {"type": "updateLeverage", "args": {"cross": True, "iid": 3, "lev": 20}} @@ -180,3 +188,10 @@ def test_empty_cancel_batches_rejected() -> None: cancel_orders_op([]) with pytest.raises(UserInputError): cancel_orders_by_client_id_op([]) + + +def test_invalid_cancel_all_instrument_id_rejected() -> None: + with pytest.raises(UserInputError, match="instrument_id"): + cancel_all_orders_op(instrument_id=True) # type: ignore[arg-type] + with pytest.raises(UserInputError, match="non-negative"): + cancel_all_orders_op(instrument_id=-1) From c52720bf52445c38cdfe883fbe7af0291be42a24 Mon Sep 17 00:00:00 2001 From: Cesare Naldi <3353250+cesarenaldi@users.noreply.github.com> Date: Tue, 7 Jul 2026 17:38:14 +0200 Subject: [PATCH 7/7] fix(client): relax perps order update timeout --- src/polymarket/_internal/perps_session.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/polymarket/_internal/perps_session.py b/src/polymarket/_internal/perps_session.py index 03c39e0..3ccf3aa 100644 --- a/src/polymarket/_internal/perps_session.py +++ b/src/polymarket/_internal/perps_session.py @@ -91,7 +91,7 @@ _AUTH_TIMEOUT_S = 30.0 _ACK_TIMEOUT_S = 30.0 # Purposefully generous: backend order updates are expected in the ~100ms range. -_ORDER_PLACEMENT_UPDATE_TIMEOUT_S = 1.0 +_ORDER_PLACEMENT_UPDATE_TIMEOUT_S = 2.0 _QUEUE_SIZE = 1024 _SESSION_CHANNELS = (