polysimulator 0.4.1__tar.gz → 0.4.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. {polysimulator-0.4.1 → polysimulator-0.4.2}/PKG-INFO +10 -4
  2. {polysimulator-0.4.1 → polysimulator-0.4.2}/README.md +8 -3
  3. {polysimulator-0.4.1 → polysimulator-0.4.2}/polysim_clob_client/__init__.py +2 -0
  4. {polysimulator-0.4.1 → polysimulator-0.4.2}/polysim_clob_client/client.py +41 -3
  5. {polysimulator-0.4.1 → polysimulator-0.4.2}/polysim_polymarket/__init__.py +2 -0
  6. {polysimulator-0.4.1 → polysimulator-0.4.2}/polysim_polymarket/clients/_account.py +54 -4
  7. {polysimulator-0.4.1 → polysimulator-0.4.2}/polysim_polymarket/clients/_common.py +17 -0
  8. {polysimulator-0.4.1 → polysimulator-0.4.2}/polysim_polymarket/clients/_trade.py +70 -21
  9. {polysimulator-0.4.1 → polysimulator-0.4.2}/polysim_polymarket/clients/async_public.py +4 -7
  10. {polysimulator-0.4.1 → polysimulator-0.4.2}/polysim_polymarket/clients/async_secure.py +26 -11
  11. {polysimulator-0.4.1 → polysimulator-0.4.2}/polysim_polymarket/clients/public.py +4 -5
  12. {polysimulator-0.4.1 → polysimulator-0.4.2}/polysim_polymarket/clients/secure.py +32 -13
  13. {polysimulator-0.4.1 → polysimulator-0.4.2}/polysim_polymarket/models.py +2 -2
  14. {polysimulator-0.4.1 → polysimulator-0.4.2}/polysim_polymarket/rfq.py +17 -1
  15. {polysimulator-0.4.1 → polysimulator-0.4.2}/polysim_polymarket/streams/_adapt.py +89 -35
  16. {polysimulator-0.4.1 → polysimulator-0.4.2}/polysim_polymarket/streams/_handle.py +17 -1
  17. {polysimulator-0.4.1 → polysimulator-0.4.2}/polysim_polymarket/streams/_validators.py +12 -7
  18. {polysimulator-0.4.1 → polysimulator-0.4.2}/polysim_sdk/__init__.py +2 -2
  19. {polysimulator-0.4.1 → polysimulator-0.4.2}/polysim_sdk/_http.py +3 -2
  20. {polysimulator-0.4.1 → polysimulator-0.4.2}/polysim_sdk/_shared.py +11 -2
  21. polysimulator-0.4.2/polysim_sdk/_version.py +28 -0
  22. {polysimulator-0.4.1 → polysimulator-0.4.2}/polysim_sdk/aio.py +3 -2
  23. {polysimulator-0.4.1 → polysimulator-0.4.2}/polysim_sdk/client.py +3 -2
  24. {polysimulator-0.4.1 → polysimulator-0.4.2}/polysim_sdk/sse.py +3 -2
  25. {polysimulator-0.4.1 → polysimulator-0.4.2}/pyproject.toml +5 -1
  26. {polysimulator-0.4.1 → polysimulator-0.4.2}/.gitignore +0 -0
  27. {polysimulator-0.4.1 → polysimulator-0.4.2}/LICENSE +0 -0
  28. {polysimulator-0.4.1 → polysimulator-0.4.2}/NOTICE +0 -0
  29. {polysimulator-0.4.1 → polysimulator-0.4.2}/polysim_clob_client/clob_types.py +0 -0
  30. {polysimulator-0.4.1 → polysimulator-0.4.2}/polysim_clob_client/constants.py +0 -0
  31. {polysimulator-0.4.1 → polysimulator-0.4.2}/polysim_clob_client/exceptions.py +0 -0
  32. {polysimulator-0.4.1 → polysimulator-0.4.2}/polysim_clob_client/order_builder/__init__.py +0 -0
  33. {polysimulator-0.4.1 → polysimulator-0.4.2}/polysim_clob_client/order_builder/constants.py +0 -0
  34. {polysimulator-0.4.1 → polysimulator-0.4.2}/polysim_polymarket/clients/__init__.py +0 -0
  35. {polysimulator-0.4.1 → polysimulator-0.4.2}/polysim_polymarket/clients/_onchain.py +0 -0
  36. {polysimulator-0.4.1 → polysimulator-0.4.2}/polysim_polymarket/environments.py +0 -0
  37. {polysimulator-0.4.1 → polysimulator-0.4.2}/polysim_polymarket/errors.py +0 -0
  38. {polysimulator-0.4.1 → polysimulator-0.4.2}/polysim_polymarket/pagination.py +0 -0
  39. {polysimulator-0.4.1 → polysimulator-0.4.2}/polysim_polymarket/streams/__init__.py +0 -0
  40. {polysimulator-0.4.1 → polysimulator-0.4.2}/polysim_polymarket/streams/_crypto_events.py +0 -0
  41. {polysimulator-0.4.1 → polysimulator-0.4.2}/polysim_polymarket/streams/_market_events.py +0 -0
  42. {polysimulator-0.4.1 → polysimulator-0.4.2}/polysim_polymarket/streams/_runner.py +0 -0
  43. {polysimulator-0.4.1 → polysimulator-0.4.2}/polysim_polymarket/streams/_specs.py +0 -0
  44. {polysimulator-0.4.1 → polysimulator-0.4.2}/polysim_polymarket/streams/_stream_open.py +0 -0
  45. {polysimulator-0.4.1 → polysimulator-0.4.2}/polysim_polymarket/streams/_user_events.py +0 -0
  46. {polysimulator-0.4.1 → polysimulator-0.4.2}/polysim_sdk/constants.py +0 -0
  47. {polysimulator-0.4.1 → polysimulator-0.4.2}/polysim_sdk/exceptions.py +0 -0
  48. {polysimulator-0.4.1 → polysimulator-0.4.2}/polysim_sdk/pagination.py +0 -0
  49. {polysimulator-0.4.1 → polysimulator-0.4.2}/polysim_sdk/retry.py +0 -0
  50. {polysimulator-0.4.1 → polysimulator-0.4.2}/polysim_sdk/updown.py +0 -0
  51. {polysimulator-0.4.1 → polysimulator-0.4.2}/polysim_sdk/ws.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: polysimulator
3
- Version: 0.4.1
3
+ Version: 0.4.2
4
4
  Summary: Python SDK for the PolySimulator paper-trading API, with py-clob-client v1 drop-in parity (not py-clob-client-v2) and a polysim_polymarket paper-mode mirror of Polymarket's unified py-sdk (polymarket-client 0.1.0b8).
5
5
  Project-URL: Homepage, https://polysimulator.com
6
6
  Project-URL: Documentation, https://docs.polysimulator.com
@@ -21,6 +21,7 @@ Classifier: Topic :: Office/Business :: Financial
21
21
  Requires-Python: >=3.10
22
22
  Requires-Dist: httpx<1,>=0.27
23
23
  Requires-Dist: pydantic<3,>=2
24
+ Requires-Dist: typing-extensions>=4
24
25
  Requires-Dist: websockets<17,>=12
25
26
  Provides-Extra: dev
26
27
  Requires-Dist: build>=1.0; extra == 'dev'
@@ -334,7 +335,7 @@ The port is the same mechanical swap as v1 — change the import **prefix**, the
334
335
  - from polymarket import SecureClient
335
336
  + from polysim_polymarket import SecureClient
336
337
 
337
- - client = SecureClient(private_key="0x…") # real Polymarket wallet + EIP-712 signing
338
+ - client = SecureClient.create(private_key="0x…") # real Polymarket wallet + EIP-712 signing
338
339
  + client = SecureClient(host="https://api.polysimulator.com", api_key="ps_live_…")
339
340
 
340
341
  # unchanged from here on — same method names, same keyword-only call shapes,
@@ -397,8 +398,13 @@ they're getting before they rely on a surface area:
397
398
  happens entirely in PolySimulator's paper matching engine.
398
399
  - **Collateral / balances are PAPER.** Your balance is PolySimulator's paper
399
400
  balance (the `balance` from `me()` on the native client), not an on-chain USDC
400
- balance. `get_balance_allowance` reports the paper account's collateral; there
401
- is no real USDC, no allowance/approval transaction, no funder wallet.
401
+ balance. `get_balance_allowance(asset_type="COLLATERAL")` reports the paper
402
+ account's cash collateral; `get_balance_allowance(asset_type="CONDITIONAL",
403
+ token_id=…)` reports the **conditional-token** balance — the open position's
404
+ share count for that token, in base units (matching real py-sdk, where a
405
+ CONDITIONAL read returns the held conditional token, not collateral; a flat
406
+ position is `0`). There is no real USDC, no allowance/approval transaction, no
407
+ funder wallet — so `allowances` is always empty.
402
408
  - **A settled trade carries the per-fill fee RATE, not a fee amount.**
403
409
  PolySimulator's matching engine charges and records a real (paper) fee on each
404
410
  fill. What it surfaces over the typed read surface is the fee *rate*: a settled
@@ -299,7 +299,7 @@ The port is the same mechanical swap as v1 — change the import **prefix**, the
299
299
  - from polymarket import SecureClient
300
300
  + from polysim_polymarket import SecureClient
301
301
 
302
- - client = SecureClient(private_key="0x…") # real Polymarket wallet + EIP-712 signing
302
+ - client = SecureClient.create(private_key="0x…") # real Polymarket wallet + EIP-712 signing
303
303
  + client = SecureClient(host="https://api.polysimulator.com", api_key="ps_live_…")
304
304
 
305
305
  # unchanged from here on — same method names, same keyword-only call shapes,
@@ -362,8 +362,13 @@ they're getting before they rely on a surface area:
362
362
  happens entirely in PolySimulator's paper matching engine.
363
363
  - **Collateral / balances are PAPER.** Your balance is PolySimulator's paper
364
364
  balance (the `balance` from `me()` on the native client), not an on-chain USDC
365
- balance. `get_balance_allowance` reports the paper account's collateral; there
366
- is no real USDC, no allowance/approval transaction, no funder wallet.
365
+ balance. `get_balance_allowance(asset_type="COLLATERAL")` reports the paper
366
+ account's cash collateral; `get_balance_allowance(asset_type="CONDITIONAL",
367
+ token_id=…)` reports the **conditional-token** balance — the open position's
368
+ share count for that token, in base units (matching real py-sdk, where a
369
+ CONDITIONAL read returns the held conditional token, not collateral; a flat
370
+ position is `0`). There is no real USDC, no allowance/approval transaction, no
371
+ funder wallet — so `allowances` is always empty.
367
372
  - **A settled trade carries the per-fill fee RATE, not a fee amount.**
368
373
  PolySimulator's matching engine charges and records a real (paper) fee on each
369
374
  fill. What it surfaces over the typed read surface is the fee *rate*: a settled
@@ -30,9 +30,11 @@ from polysim_clob_client.constants import (
30
30
  ZERO_ADDRESS,
31
31
  )
32
32
  from polysim_clob_client.exceptions import PolyApiException, PolyException
33
+ from polysim_sdk import __version__
33
34
 
34
35
  __all__ = [
35
36
  "ClobClient",
37
+ "__version__",
36
38
  "ApiCreds",
37
39
  "AssetType",
38
40
  "BalanceAllowanceParams",
@@ -664,7 +664,8 @@ class ClobClient:
664
664
  order_type: OrderType | str | None,
665
665
  post_only: bool = False,
666
666
  ) -> dict[str, Any]:
667
- tif = order.get("time_in_force", "GTC")
667
+ embedded_tif = str(order.get("time_in_force", "GTC"))
668
+ tif = embedded_tif
668
669
  if order_type is not None:
669
670
  tif = order_type.value if hasattr(order_type, "value") else str(order_type)
670
671
  # An embedded expiration means GTD was intended (e.g. a `create_order`
@@ -674,6 +675,17 @@ class ClobClient:
674
675
  # Never downgrade an expiring order to GTC.
675
676
  if order.get("expiration") and tif.upper() == "GTC":
676
677
  tif = "GTD"
678
+ # A MARKET order is a marketable-limit FOK/FAK; `create_market_order`
679
+ # embeds that tif. `post_order` defaults `orderType` to GTC, which would
680
+ # otherwise ship the contradictory `order_type=market, time_in_force=GTC`
681
+ # shape. When the caller left `orderType` at the GTC default, preserve the
682
+ # embedded FOK/FAK; an EXPLICIT FOK/FAK/GTD (anything but GTC) still wins.
683
+ if (
684
+ order.get("order_type") == "market"
685
+ and tif.upper() == "GTC"
686
+ and embedded_tif.upper() in ("FOK", "FAK")
687
+ ):
688
+ tif = embedded_tif
677
689
  return self._client.place_order(
678
690
  market_id=order["market_id"],
679
691
  side=order["side"],
@@ -713,7 +725,24 @@ class ClobClient:
713
725
  bodies: list[dict[str, Any]] = []
714
726
  for a in args:
715
727
  order = dict(a.order)
728
+ embedded_tif = str(order.get("time_in_force", "GTC"))
716
729
  tif = a.orderType.value if hasattr(a.orderType, "value") else str(a.orderType)
730
+ # Mirror _submit's GTD guard: an embedded expiration means GTD was
731
+ # intended (a `create_order(expiration=...)` order). `PostOrdersArgs`
732
+ # defaults `orderType` to GTC, which would otherwise downgrade the
733
+ # order to a contradictory GTC+expiration shape. Never downgrade an
734
+ # expiring order to GTC (an explicit non-GTC orderType still wins —
735
+ # exactly as `_submit` orders the two guards).
736
+ if order.get("expiration") and tif.upper() == "GTC":
737
+ tif = "GTD"
738
+ # Mirror _submit: a market order's embedded FOK/FAK must survive the
739
+ # GTC default of PostOrdersArgs.orderType (an explicit non-GTC wins).
740
+ if (
741
+ order.get("order_type") == "market"
742
+ and tif.upper() == "GTC"
743
+ and embedded_tif.upper() in ("FOK", "FAK")
744
+ ):
745
+ tif = embedded_tif
717
746
  order["time_in_force"] = tif
718
747
  if getattr(a, "postOnly", False):
719
748
  order["post_only"] = True
@@ -751,12 +780,21 @@ class ClobClient:
751
780
  return self._client.cancel_all()
752
781
 
753
782
  def cancel_market_orders(self, market: str = "", asset_id: str = "") -> dict[str, Any]:
754
- """adapt — cancel all orders in a market (``DELETE /v1/cancel-market-orders``)."""
783
+ """adapt — cancel all orders in a market (``DELETE /v1/cancel-market-orders``).
784
+
785
+ When only ``asset_id`` is given it is resolved to its market id via
786
+ :meth:`_resolve_token` (NOT the local-only ``_split_token``): a real
787
+ Polymarket CLOB outcome-token id (a long all-digit string) is
788
+ reverse-resolved through ``GET /v1/markets-by-token`` so the cancel scopes
789
+ the right condition id — a local split would treat the whole digit string
790
+ as the market and cancel the wrong (or no) market. The colon form and
791
+ short/non-numeric ids still resolve locally with no network call.
792
+ """
755
793
  market_id = market or asset_id
756
794
  if not market_id:
757
795
  raise PolyApiException(400, "cancel_market_orders requires market or asset_id.")
758
796
  if asset_id and not market:
759
- market_id, _ = self._split_token(asset_id)
797
+ market_id, _ = self._resolve_token(asset_id)
760
798
  return self._client.cancel_market_orders(market_id)
761
799
 
762
800
  # ── order / trade reads ────────────────────────────────────────────────
@@ -112,6 +112,7 @@ from polysim_polymarket.rfq import (
112
112
  RfqSession,
113
113
  RfqSide,
114
114
  )
115
+ from polysim_sdk import __version__
115
116
 
116
117
  # py-sdk exports ``SyncTransactionHandle`` (a ``TypeAlias`` for the
117
118
  # gasless|EOA sync-handle union) off its root; a ported bot type-hints
@@ -151,6 +152,7 @@ from polysim_polymarket.rfq import (
151
152
  # Each is verified present on py-sdk's root by the re-export subset test.
152
153
  __all__ = [
153
154
  "PRODUCTION",
155
+ "__version__",
154
156
  "AcceptedOrder",
155
157
  "ApiKeyCreds",
156
158
  "AssetType",
@@ -17,6 +17,7 @@ authenticated-surface logic the public client has no analog for.
17
17
  from __future__ import annotations
18
18
 
19
19
  from collections.abc import Sequence
20
+ from decimal import Decimal, InvalidOperation
20
21
  from typing import Any
21
22
 
22
23
  from polysim_polymarket.errors import UnexpectedResponseError, UserInputError
@@ -69,8 +70,13 @@ def validate_asset_type(asset_type: object) -> None:
69
70
  Mirrors py-sdk's ``account._validate_asset_type`` (case-SENSITIVE — there is
70
71
  no ``.upper()``): anything else raises ``UserInputError`` with py-sdk's exact
71
72
  message, before any read.
73
+
74
+ The ``isinstance(asset_type, str)`` guard runs FIRST so an unhashable arg (a
75
+ ``list`` / ``dict`` a bot might pass by mistake) raises ``UserInputError``
76
+ rather than letting a ``TypeError`` escape the ``in`` membership test against
77
+ the frozenset.
72
78
  """
73
- if asset_type not in VALID_ASSET_TYPES:
79
+ if not isinstance(asset_type, str) or asset_type not in VALID_ASSET_TYPES:
74
80
  raise UserInputError(
75
81
  f"asset_type must be 'COLLATERAL' or 'CONDITIONAL', got {asset_type!r}."
76
82
  )
@@ -118,6 +124,11 @@ def adapt_balance_allowance(payload: Any) -> BalanceAllowance:
118
124
  nearest base unit. Paper trading has no on-chain allowance, so ``allowances``
119
125
  is empty.
120
126
 
127
+ The scaling goes through :class:`~decimal.Decimal` (``Decimal(str(raw))``),
128
+ not ``float``, so a monetary value with binary-float drift (e.g. a value
129
+ whose ``float * 1e6`` would round to the wrong base-unit count) converts
130
+ exactly — money never rides a binary float through this adapter.
131
+
121
132
  A malformed payload (not a dict, or no numeric balance field) raises
122
133
  ``UnexpectedResponseError`` rather than silently reporting zero — a porting
123
134
  author should see the shape mismatch, not a wrong $0 balance.
@@ -132,12 +143,51 @@ def adapt_balance_allowance(payload: Any) -> BalanceAllowance:
132
143
  if raw is None:
133
144
  raise UnexpectedResponseError("balance response did not match expected shape")
134
145
  try:
135
- usd = float(raw)
136
- except (TypeError, ValueError) as error:
146
+ # ``Decimal(str(raw))`` keeps full decimal precision (str() avoids the
147
+ # binary-float artefacts of Decimal(<float>)); round half-even to the
148
+ # nearest integer base unit.
149
+ usd = Decimal(str(raw))
150
+ except (TypeError, ValueError, InvalidOperation) as error:
137
151
  raise UnexpectedResponseError(
138
152
  f"balance response 'balance' was not numeric: {raw!r}"
139
153
  ) from error
140
- base_units = round(usd * USDC_BASE_UNITS_PER_USD)
154
+ base_units = int(round(usd * USDC_BASE_UNITS_PER_USD))
155
+ return BalanceAllowance(balance=base_units, allowances={})
156
+
157
+
158
+ def adapt_conditional_balance(
159
+ positions: Any, market_id: str, outcome: str
160
+ ) -> BalanceAllowance:
161
+ """Adapt the open-positions list onto a CONDITIONAL ``BalanceAllowance``.
162
+
163
+ Resolution of the CodeRabbit/Codex disagreement: the real py-sdk's
164
+ ``get_balance_allowance`` for ``asset_type='CONDITIONAL'`` returns the
165
+ **conditional TOKEN** balance the server holds for that ``token_id`` (the
166
+ position's share count), NOT collateral cash. PolySimulator is paper trading
167
+ with no on-chain CTF balance, so the faithful analog is the open position's
168
+ share count for the resolved ``(market_id, outcome)`` — the conditional token
169
+ Polymarket would settle. Both the conditional token and USDC carry 6 decimals,
170
+ so the share count scales by :data:`USDC_BASE_UNITS_PER_USD`; a flat / absent
171
+ position is a genuine 0 conditional balance (not an error). Outcome matching
172
+ is case-insensitive (positions store ``"Yes"`` / ``"No"``; the token resolver
173
+ returns ``"YES"`` / ``"NO"`` / ``"UP"`` / ``"DOWN"``). Paper trading has no
174
+ on-chain allowance, so ``allowances`` is empty.
175
+ """
176
+ rows = positions if isinstance(positions, list) else []
177
+ target_outcome = outcome.upper()
178
+ shares = Decimal("0")
179
+ for row in rows:
180
+ if not isinstance(row, dict):
181
+ continue
182
+ if str(row.get("market_id")) != str(market_id):
183
+ continue
184
+ if str(row.get("outcome", "")).upper() != target_outcome:
185
+ continue
186
+ try:
187
+ shares += Decimal(str(row.get("quantity", "0")))
188
+ except (TypeError, ValueError, InvalidOperation):
189
+ continue
190
+ base_units = int(round(shares * USDC_BASE_UNITS_PER_USD))
141
191
  return BalanceAllowance(balance=base_units, allowances={})
142
192
 
143
193
 
@@ -571,3 +571,20 @@ def list_markets_forward(
571
571
  if ascending is not None:
572
572
  forward["ascending"] = ascending
573
573
  return forward
574
+
575
+
576
+ def resolve_page_size(page_size: int) -> int:
577
+ """Validate + clamp a ``list_markets`` page size to the effective request limit.
578
+
579
+ Mirrors py-sdk's ``paginate_offset`` guard — a ``page_size < 1`` raises
580
+ ``UserInputError("page_size must be a positive integer.")`` (a non-int /
581
+ bool is also rejected). PolySimulator's ``/v1/markets`` hard-caps a page at
582
+ :data:`PAGE_LIMIT` (100) rows, so a larger ``page_size`` is clamped down to
583
+ that ceiling — the returned value is the effective ``limit`` the fetch +
584
+ cursor boundary use, so ``page_size`` is honoured rather than ignored.
585
+ """
586
+ if not isinstance(page_size, int) or isinstance(page_size, bool):
587
+ raise UserInputError("page_size must be a positive integer.")
588
+ if page_size < 1:
589
+ raise UserInputError("page_size must be a positive integer.")
590
+ return min(page_size, PAGE_LIMIT)
@@ -138,6 +138,27 @@ _ACCEPTED_STATUS_MAP: dict[str, str] = {
138
138
  "queued": "delayed",
139
139
  }
140
140
 
141
+ # Coarse backend rejection statuses with no finer py-sdk error code. ``/v1/orders``
142
+ # (and the per-row ``/v1/orders/batch`` entries) can return HTTP 200 with a row
143
+ # whose status is ``REJECTED`` / ``ERROR`` / ``FAILED`` / ``CANCELLED``. These map
144
+ # to a RejectedOrder with the "unknown" catch-all code (the more specific statuses
145
+ # in ``_STATUS_TO_ERROR_CODE`` keep their codes). Kept for naming clarity even
146
+ # though acceptance is now decided by the allowlist below, not this denylist —
147
+ # any status outside the accepted set is rejected regardless.
148
+ _REJECTED_STATUSES: frozenset[str] = frozenset(
149
+ {"rejected", "error", "failed", "cancelled", "canceled"}
150
+ )
151
+
152
+ # Acceptance ALLOWLIST — mirrors py-sdk's ``order_response._is_accepted``. The real
153
+ # py-sdk accepts a row ONLY when ``status in {live, matched, delayed}`` AND
154
+ # ``success`` is truthy AND ``order_id != ''`` AND ``error_msg == ''``. We project
155
+ # the paper backend's wider status vocabulary onto those three post-statuses via
156
+ # ``_ACCEPTED_STATUS_MAP``, so the mirror's accepted set is exactly the keys of
157
+ # that map. An UNKNOWN / unexpected status is therefore NOT accepted (it falls
158
+ # through to RejectedOrder), matching py-sdk's strict allowlist rather than the
159
+ # previous denylist (which mistook an unrecognised status for ``matched``).
160
+ _ACCEPTED_STATUSES: frozenset[str] = frozenset(_ACCEPTED_STATUS_MAP)
161
+
141
162
 
142
163
  # ── numeric / side validation ────────────────────────────────────────────────
143
164
  # ``coerce_positive_decimal`` + ``validate_side`` are NOT re-implemented here:
@@ -149,7 +170,14 @@ _ACCEPTED_STATUS_MAP: dict[str, str] = {
149
170
  # not import ``_trade``.
150
171
 
151
172
 
152
- def _validate_token_id(token_id: object) -> str:
173
+ def validate_token_id(token_id: object) -> str:
174
+ """Reject a non-string / empty ``token_id`` like py-sdk's token guard.
175
+
176
+ A non-``str`` raises ``"token_id must be a string, got <type>."`` and an
177
+ empty string raises ``"token_id is required"``. Public so the secure client
178
+ can validate a token UP FRONT — before any reverse-resolution network call —
179
+ rather than only inside ``build_*_order`` after resolution.
180
+ """
153
181
  if not isinstance(token_id, str):
154
182
  raise UserInputError(f"token_id must be a string, got {type(token_id).__name__}.")
155
183
  if not token_id:
@@ -210,7 +238,7 @@ def build_limit_order(
210
238
  Returns a ``SignedOrder`` carrying the trading-semantic fields + the unsigned
211
239
  paper body; signing fields stay empty placeholders.
212
240
  """
213
- _validate_token_id(token_id)
241
+ validate_token_id(token_id)
214
242
  validated_price = coerce_positive_decimal("price", price)
215
243
  validated_size = coerce_positive_decimal("size", size)
216
244
  validate_side(side)
@@ -232,14 +260,18 @@ def build_limit_order(
232
260
 
233
261
  gtd_expiration = expiration if expiration is not None and expiration > 0 else 0
234
262
  order_type = "GTD" if gtd_expiration > 0 else "GTC"
263
+ # Serialize the monetary/size fields as decimal STRINGS, not floats: the
264
+ # backend's order body is a string-decimal contract, and a float would let
265
+ # binary-float drift (e.g. 0.1) ride onto the wire. ``str(Decimal(...))``
266
+ # carries the exact decimal the caller gave.
235
267
  body: dict[str, Any] = {
236
268
  "market_id": market_id,
237
269
  "outcome": outcome,
238
270
  "side": side,
239
- "price": float(validated_price),
271
+ "price": str(validated_price),
240
272
  "order_type": "limit",
241
273
  "time_in_force": order_type,
242
- "quantity": float(validated_size),
274
+ "quantity": str(validated_size),
243
275
  }
244
276
  if post_only:
245
277
  body["post_only"] = True
@@ -288,7 +320,7 @@ def build_market_order(
288
320
  when given, else the :data:`DEFAULT_BUY_WORST_PRICE` /
289
321
  :data:`DEFAULT_SELL_WORST_PRICE` default — so the FOK/FAK is never uncapped.
290
322
  """
291
- _validate_token_id(token_id)
323
+ validate_token_id(token_id)
292
324
  validate_side(side)
293
325
  if order_type not in _VALID_MARKET_ORDER_TYPES:
294
326
  raise UserInputError(f"order_type must be 'FAK' or 'FOK', got {order_type!r}.")
@@ -319,9 +351,10 @@ def build_market_order(
319
351
  if max_price is not None
320
352
  else DEFAULT_BUY_WORST_PRICE
321
353
  )
322
- # USD notional for a market BUY -> sent as ``amount`` (server derives shares).
323
- body["amount"] = float(validated_amount)
324
- body["price"] = float(cap)
354
+ # USD notional for a market BUY -> sent as ``amount`` (server derives
355
+ # shares). Serialized as a decimal string (string-decimal contract).
356
+ body["amount"] = str(validated_amount)
357
+ body["price"] = str(cap)
325
358
  else: # SELL
326
359
  if shares is None:
327
360
  raise UserInputError("shares is required for SELL market orders.")
@@ -337,9 +370,9 @@ def build_market_order(
337
370
  if min_price is not None
338
371
  else DEFAULT_SELL_WORST_PRICE
339
372
  )
340
- # Share count for a market SELL -> sent as ``quantity``.
341
- body["quantity"] = float(validated_shares)
342
- body["price"] = float(cap)
373
+ # Share count for a market SELL -> sent as ``quantity`` (decimal string).
374
+ body["quantity"] = str(validated_shares)
375
+ body["price"] = str(cap)
343
376
 
344
377
  return _make_signed_order(
345
378
  token_id=token_id,
@@ -414,11 +447,16 @@ def adapt_order_response(raw: Any) -> OrderResponse:
414
447
  """Adapt a PolySim ``POST /v1/orders`` reply onto py-sdk's ``OrderResponse``.
415
448
 
416
449
  A malformed reply (not a dict) raises ``UnexpectedResponseError``. Otherwise
417
- we read the order id + status and decide accepted vs rejected: an explicit
418
- ``success: false`` (or a recognised error status) yields a
419
- :class:`RejectedOrder` with a mapped :data:`OrderResponseErrorCode`; anything
420
- else is an :class:`AcceptedOrder`. Field names track py-sdk so a ported bot's
421
- ``resp.ok`` / ``resp.order_id`` / ``resp.status`` read identically.
450
+ we decide accepted vs rejected with py-sdk's strict ALLOWLIST
451
+ (``order_response._is_accepted``): a row is an :class:`AcceptedOrder` ONLY when
452
+ its status is a recognised accepted post-status AND ``success`` is not
453
+ explicitly false AND it carries a non-empty ``order_id`` AND no error message —
454
+ exactly py-sdk's ``status in {live, matched, delayed} AND success AND
455
+ order_id != '' AND error_msg == ''``. ANYTHING else (including an UNKNOWN /
456
+ unexpected status the backend never documented) is a :class:`RejectedOrder`
457
+ with a mapped :data:`OrderResponseErrorCode` (specific codes for the known
458
+ rejection statuses, ``"unknown"`` otherwise). Field names track py-sdk so a
459
+ ported bot's ``resp.ok`` / ``resp.order_id`` / ``resp.status`` read identically.
422
460
  """
423
461
  if not isinstance(raw, dict):
424
462
  raise UnexpectedResponseError("order response did not match expected shape")
@@ -427,13 +465,23 @@ def adapt_order_response(raw: Any) -> OrderResponse:
427
465
  status_key = status_raw.lower()
428
466
  success = raw.get("success")
429
467
  order_id = str(raw.get("order_id", "") or raw.get("id", "") or "")
468
+ error_message = str(
469
+ raw.get("error") or raw.get("message") or raw.get("detail") or ""
470
+ )
430
471
 
431
- rejected = success is False or status_key in _STATUS_TO_ERROR_CODE
432
- if rejected:
472
+ # py-sdk allowlist: accepted requires ALL four a recognised accepted status,
473
+ # success not explicitly false, a non-empty order id, and no error message.
474
+ # (The paper backend omits ``success`` on accepted rows, so absence is truthy;
475
+ # only an explicit ``success: false`` rejects on that axis.)
476
+ accepted = (
477
+ status_key in _ACCEPTED_STATUSES
478
+ and success is not False
479
+ and order_id != ""
480
+ and error_message == ""
481
+ )
482
+ if not accepted:
433
483
  code = _STATUS_TO_ERROR_CODE.get(status_key, "unknown")
434
- message = str(
435
- raw.get("error") or raw.get("message") or raw.get("detail") or status_raw or "rejected"
436
- )
484
+ message = error_message or status_raw or "rejected"
437
485
  return RejectedOrder(code=code, message=message)
438
486
 
439
487
  return AcceptedOrder(
@@ -517,4 +565,5 @@ __all__ = [
517
565
  "paper_order_kwargs",
518
566
  "split_token_local",
519
567
  "validate_cancel_order_ids",
568
+ "validate_token_id",
520
569
  ]
@@ -433,18 +433,15 @@ class AsyncPublicClient:
433
433
  parity and ignored.
434
434
  """
435
435
  forward = _common.list_markets_forward(closed=closed, order=order, ascending=ascending)
436
+ limit = _common.resolve_page_size(page_size)
436
437
 
437
438
  async def fetch(cursor: str | None) -> Page[Market]:
438
439
  offset = _decode_cursor(cursor)
439
440
  if offset < 0:
440
441
  return Page(items=(), has_more=False)
441
- rows = await self._client.list_markets(
442
- limit=_common.PAGE_LIMIT, offset=offset, **forward
443
- )
442
+ rows = await self._client.list_markets(limit=limit, offset=offset, **forward)
444
443
  items = tuple(_common.adapt_market(row) for row in rows)
445
- next_cur = _next_cursor(offset, len(rows), _common.PAGE_LIMIT)
446
- return Page(
447
- items=items, has_more=len(rows) >= _common.PAGE_LIMIT, next_cursor=next_cur
448
- )
444
+ next_cur = _next_cursor(offset, len(rows), limit)
445
+ return Page(items=items, has_more=len(rows) >= limit, next_cursor=next_cur)
449
446
 
450
447
  return AsyncPaginator(fetch=fetch)
@@ -57,7 +57,7 @@ import logging
57
57
  from collections.abc import Sequence
58
58
  from decimal import Decimal
59
59
  from types import TracebackType
60
- from typing import TYPE_CHECKING, Any, Literal, overload
60
+ from typing import Any, Literal, overload
61
61
 
62
62
  from polysim_polymarket.clients import _account, _onchain, _trade
63
63
  from polysim_polymarket.clients.async_public import AsyncPublicClient
@@ -106,13 +106,14 @@ from polysim_polymarket.streams import (
106
106
  )
107
107
  from polysim_polymarket.streams._stream_open import open_secure_stream as _open_secure_stream
108
108
 
109
- if TYPE_CHECKING:
110
- # ``TransactionHandle`` is the py-sdk name for the ASYNC on-chain return type
111
- # (an alias of the async paper handle, defined in ``_onchain``). Imported under
112
- # TYPE_CHECKING so the on-chain methods can annotate ``-> TransactionHandle``
113
- # with the bare py-sdk name (annotation-string parity) without a runtime
114
- # import; the returned object is the same ``_onchain`` async paper handle.
115
- from polysim_polymarket.clients._onchain import TransactionHandle
109
+ # ``TransactionHandle`` is the py-sdk name for the ASYNC on-chain return type (an
110
+ # alias of the async paper handle, defined in ``_onchain``). The on-chain methods
111
+ # annotate ``-> TransactionHandle`` with the bare py-sdk name (annotation parity);
112
+ # ``_onchain`` is already imported above, so bind the name into this module's
113
+ # globals at RUNTIME (not only under TYPE_CHECKING) otherwise
114
+ # ``typing.get_type_hints(AsyncSecureClient.<onchain method>)`` raises
115
+ # ``NameError``. The returned object is the same ``_onchain`` async paper handle.
116
+ TransactionHandle = _onchain.TransactionHandle
116
117
 
117
118
 
118
119
  class AsyncSecureClient:
@@ -489,11 +490,20 @@ class AsyncSecureClient:
489
490
 
490
491
  Async twin of ``SecureClient.get_balance_allowance``: validates
491
492
  ``asset_type`` (case-SENSITIVE) via the shared
492
- :func:`_account.validate_asset_type`, reads ``GET /v1/account/balance``
493
- (awaited), and adapts the paper-cash figure onto py-sdk's base-unit
494
- :class:`BalanceAllowance` via :func:`_account.adapt_balance_allowance`.
493
+ :func:`_account.validate_asset_type`. ``COLLATERAL`` awaits
494
+ ``GET /v1/account/balance`` and adapts the paper-cash figure;
495
+ ``CONDITIONAL`` (token_id required) resolves the token, awaits
496
+ ``GET /v1/account/positions`` and reports the open position's share count
497
+ as the conditional-token balance (matching real py-sdk's CONDITIONAL
498
+ semantics — the held conditional token, not collateral; flat = 0).
495
499
  """
496
500
  _account.validate_asset_type(asset_type)
501
+ if asset_type == "CONDITIONAL":
502
+ if not token_id:
503
+ raise UserInputError("token_id is required for a CONDITIONAL balance.")
504
+ market_id, outcome = await self._resolve_coordinates(token_id)
505
+ positions = await self._client.positions()
506
+ return _account.adapt_conditional_balance(positions, market_id, outcome)
497
507
  payload = await self._client.balance()
498
508
  return _account.adapt_balance_allowance(payload)
499
509
 
@@ -598,7 +608,12 @@ class AsyncSecureClient:
598
608
  reverse-resolved by awaiting ``GET /v1/markets-by-token/{id}`` (cached per
599
609
  token). All routing logic is the transport-free ``_trade`` seam; only the
600
610
  awaited fetch lives here.
611
+
612
+ ``token_id`` is validated (non-empty string) UP FRONT — before the
613
+ reverse-resolution network call — so a bad token raises ``UserInputError``
614
+ rather than hitting the network.
601
615
  """
616
+ _trade.validate_token_id(token_id)
602
617
  if not _trade.needs_token_reverse_resolution(token_id):
603
618
  return _trade.split_token_local(token_id)
604
619
  cached = self._token_coordinates.get(token_id)
@@ -428,16 +428,15 @@ class PublicClient:
428
428
  shared cursor<->offset helpers.
429
429
  """
430
430
  forward = _common.list_markets_forward(closed=closed, order=order, ascending=ascending)
431
+ limit = _common.resolve_page_size(page_size)
431
432
 
432
433
  def fetch(cursor: str | None) -> Page[Market]:
433
434
  offset = _decode_cursor(cursor)
434
435
  if offset < 0:
435
436
  return Page(items=(), has_more=False)
436
- rows = self._client.list_markets(limit=_common.PAGE_LIMIT, offset=offset, **forward)
437
+ rows = self._client.list_markets(limit=limit, offset=offset, **forward)
437
438
  items = tuple(_common.adapt_market(row) for row in rows)
438
- next_cur = _next_cursor(offset, len(rows), _common.PAGE_LIMIT)
439
- return Page(
440
- items=items, has_more=len(rows) >= _common.PAGE_LIMIT, next_cursor=next_cur
441
- )
439
+ next_cur = _next_cursor(offset, len(rows), limit)
440
+ return Page(items=items, has_more=len(rows) >= limit, next_cursor=next_cur)
442
441
 
443
442
  return Paginator(fetch=fetch)
@@ -67,7 +67,7 @@ import logging
67
67
  from collections.abc import Sequence
68
68
  from decimal import Decimal
69
69
  from types import TracebackType
70
- from typing import TYPE_CHECKING, Any, Literal
70
+ from typing import Any, Literal
71
71
 
72
72
  from polysim_polymarket.clients import _account, _onchain, _trade
73
73
  from polysim_polymarket.clients.public import PublicClient
@@ -106,13 +106,15 @@ from polysim_polymarket.models import (
106
106
  )
107
107
  from polysim_polymarket.pagination import Page, Paginator, _EmptyPaginator
108
108
 
109
- if TYPE_CHECKING:
110
- # ``SyncTransactionHandle`` is the py-sdk name for the on-chain return type (an
111
- # alias of the paper handle, defined in ``_onchain``). Imported under
112
- # TYPE_CHECKING so the on-chain methods can annotate ``-> SyncTransactionHandle``
113
- # with the bare py-sdk name (annotation-string parity) without a runtime import;
114
- # the returned object is the same ``_onchain`` paper handle either way.
115
- from polysim_polymarket.clients._onchain import SyncTransactionHandle
109
+ # ``SyncTransactionHandle`` is the py-sdk name for the on-chain return type (an
110
+ # alias of the paper handle, defined in ``_onchain``). The on-chain methods
111
+ # annotate ``-> SyncTransactionHandle`` with the bare py-sdk name (annotation
112
+ # parity); ``_onchain`` is already imported above, so bind the name into this
113
+ # module's globals at RUNTIME (not only under TYPE_CHECKING) otherwise
114
+ # ``typing.get_type_hints(SecureClient.<onchain method>)`` raises ``NameError``,
115
+ # breaking any tool / doc generator that introspects the client. The returned
116
+ # object is the same ``_onchain`` paper handle either way.
117
+ SyncTransactionHandle = _onchain.SyncTransactionHandle
116
118
 
117
119
 
118
120
  class SecureClient:
@@ -450,13 +452,25 @@ class SecureClient:
450
452
  case-SENSITIVE (``"COLLATERAL"`` / ``"CONDITIONAL"`` only — py-sdk's
451
453
  contract); anything else raises ``UserInputError`` before any read.
452
454
 
453
- Maps to PolySimulator paper cash: reads ``GET /v1/account/balance`` and
454
- adapts the (USD, float) paper-cash figure onto py-sdk's base-unit
455
- :class:`BalanceAllowance` shape (USDC has 6 decimals, so 1 USD = 1_000_000
456
- base units). Paper trading has no on-chain allowance, so ``allowances`` is
457
- empty matching the v1 mirror's "no allowance on paper" semantics.
455
+ * ``COLLATERAL`` reads ``GET /v1/account/balance`` and adapts the (USD)
456
+ paper-cash figure onto py-sdk's base-unit shape (USDC has 6 decimals,
457
+ so 1 USD = 1_000_000 base units).
458
+ * ``CONDITIONAL`` reports the conditional TOKEN balance matching the
459
+ real py-sdk, where a CONDITIONAL read returns the held conditional-token
460
+ balance for ``token_id`` (NOT collateral). ``token_id`` is required; it
461
+ is resolved to ``(market_id, outcome)`` and the open position's share
462
+ count is reported in base units (a flat position is a genuine 0).
463
+
464
+ Paper trading has no on-chain allowance, so ``allowances`` is empty —
465
+ matching the v1 mirror's "no allowance on paper" semantics.
458
466
  """
459
467
  _account.validate_asset_type(asset_type)
468
+ if asset_type == "CONDITIONAL":
469
+ if not token_id:
470
+ raise UserInputError("token_id is required for a CONDITIONAL balance.")
471
+ market_id, outcome = self._resolve_coordinates(token_id)
472
+ positions = self._client.positions()
473
+ return _account.adapt_conditional_balance(positions, market_id, outcome)
460
474
  payload = self._client.balance()
461
475
  return _account.adapt_balance_allowance(payload)
462
476
 
@@ -572,7 +586,12 @@ class SecureClient:
572
586
  market + outcome. All the routing logic is the transport-free
573
587
  :mod:`~polysim_polymarket.clients._trade` seam; only the network fetch
574
588
  lives here.
589
+
590
+ ``token_id`` is validated (non-empty string) UP FRONT — before the
591
+ reverse-resolution ``GET /v1/markets-by-token`` network call — so a bad
592
+ token raises ``UserInputError`` rather than hitting the network.
575
593
  """
594
+ _trade.validate_token_id(token_id)
576
595
  if not _trade.needs_token_reverse_resolution(token_id):
577
596
  return _trade.split_token_local(token_id)
578
597
  cached = self._token_coordinates.get(token_id)
@@ -21,7 +21,7 @@ exactly so the scalar types match. Timestamps mirror py-sdk's epoch-ms parsing.
21
21
  from __future__ import annotations
22
22
 
23
23
  from dataclasses import dataclass
24
- from datetime import UTC, datetime
24
+ from datetime import datetime, timezone
25
25
  from decimal import Decimal
26
26
  from typing import Annotated, Literal, NamedTuple, TypeAlias
27
27
 
@@ -80,7 +80,7 @@ def _parse_epoch_ms_timestamp(value: object) -> object:
80
80
  raise ValueError(f"invalid epoch-ms timestamp: {value!r}")
81
81
  ms = int(value)
82
82
  try:
83
- return datetime.fromtimestamp(ms / 1000, tz=UTC)
83
+ return datetime.fromtimestamp(ms / 1000, tz=timezone.utc)
84
84
  except (OverflowError, OSError, ValueError) as error:
85
85
  raise ValueError(f"invalid epoch-ms timestamp: {value!r}") from error
86
86
 
@@ -24,13 +24,29 @@ from __future__ import annotations
24
24
  from collections.abc import AsyncIterator, Generator
25
25
  from dataclasses import dataclass, field
26
26
  from decimal import Decimal
27
- from enum import StrEnum
27
+ from enum import Enum
28
28
  from types import TracebackType
29
29
  from typing import Any, Literal, Protocol, TypeAlias, runtime_checkable
30
30
 
31
31
  from polysim_polymarket.clients._onchain import RFQ_NOT_SIMULATED
32
32
  from polysim_polymarket.errors import PolyException
33
33
 
34
+
35
+ class StrEnum(str, Enum):
36
+ """3.10-safe stand-in for ``enum.StrEnum`` (3.11+).
37
+
38
+ ``enum.StrEnum`` only exists from 3.11; the package floor is 3.10, so we
39
+ mirror its observable behaviour with the classic ``(str, Enum)`` mixin —
40
+ members compare/hash equal to their string value and ``str(member)`` is the
41
+ value. The one cosmetic difference (``StrEnum``'s ``__str__`` returns the
42
+ value directly) is replicated below so a ported bot's ``str(RfqSide.YES)``
43
+ yields ``"YES"`` on both Python versions.
44
+ """
45
+
46
+ def __str__(self) -> str:
47
+ return str(self.value)
48
+
49
+
34
50
  # Id aliases — bare ``str`` NewType-equivalents (py-sdk uses ``TypeAlias = str``).
35
51
  RfqId: TypeAlias = str
36
52
  RfqQuoteId: TypeAlias = str
@@ -67,6 +67,7 @@ SEAMS — documented, not fabricated:
67
67
 
68
68
  from __future__ import annotations
69
69
 
70
+ from datetime import datetime, timezone
70
71
  from typing import Any
71
72
 
72
73
  from polysim_polymarket.streams._crypto_events import (
@@ -151,6 +152,34 @@ def _split_condition_id(token_id: str) -> str:
151
152
  return tid
152
153
 
153
154
 
155
+ def _parse_fill_timestamp(value: Any) -> datetime | None:
156
+ """Parse a fill frame's ``filled_at`` into an aware UTC datetime, or ``None``.
157
+
158
+ The backend stamps ``filled_at`` as an ISO-8601 string (``Order.filled_at``'s
159
+ ``.isoformat()``), but a frame may also carry an epoch-seconds / epoch-ms
160
+ value. The user-event payloads' ``timestamp`` is an
161
+ ``EpochSecondsOrMsTimestamp`` whose validator accepts a ``datetime`` as-is but
162
+ NOT an ISO string, so we resolve ISO here (normalising a trailing ``Z`` for
163
+ 3.10's ``fromisoformat``) and let an already-numeric value flow through the
164
+ validator. A bad/None value yields ``None`` (the timestamp stays unset)
165
+ rather than sinking an otherwise-valid event.
166
+ """
167
+ if value is None or value == "":
168
+ return None
169
+ if isinstance(value, datetime):
170
+ return value
171
+ if isinstance(value, str) and not value.isdecimal():
172
+ text = value
173
+ if text.endswith(("Z", "z")):
174
+ text = text[:-1] + "+00:00"
175
+ try:
176
+ dt = datetime.fromisoformat(text)
177
+ except ValueError:
178
+ return None
179
+ return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt
180
+ return None
181
+
182
+
154
183
  def _coerce_epoch_ms_str(value: Any) -> str | None:
155
184
  """Render an epoch-ms timestamp as the digit-string the strict market
156
185
  ``EpochMsTimestamp`` validator wants. Non-integer / non-digit inputs become
@@ -306,41 +335,66 @@ def adapt_execution_frame(frame: dict[str, Any], spec: UserSpec) -> list[UserEve
306
335
  if token_id is None:
307
336
  return []
308
337
 
309
- trade = UserTradeEvent.model_validate(
310
- {
311
- "type": "trade",
312
- "payload": {
313
- "id": order_id,
314
- "taker_order_id": order_id,
315
- "market": market,
316
- "asset_id": token_id,
317
- "side": side,
318
- "size": quantity,
319
- "price": price,
320
- "status": "MATCHED",
321
- "owner": "",
322
- "outcome": outcome,
323
- },
324
- }
325
- )
326
- order = UserOrderEvent.model_validate(
327
- {
328
- "type": "order",
329
- "payload": {
330
- "id": order_id,
331
- "owner": "",
332
- "market": market,
333
- "asset_id": token_id,
334
- "side": side,
335
- "original_size": quantity,
336
- "size_matched": quantity,
337
- "price": price,
338
- "type": "UPDATE",
339
- "status": "MATCHED",
340
- "outcome": outcome,
341
- },
342
- }
343
- )
338
+ # The fill's ``filled_at`` is the trade/order timestamp. Carry it onto both
339
+ # payloads' ``timestamp`` (an ``EpochSecondsOrMsTimestamp``). That validator
340
+ # accepts an epoch number / digit-string and a ``datetime`` as-is, but NOT an
341
+ # ISO string — and the backend stamps ``filled_at`` as ISO — so resolve an
342
+ # ISO value to a ``datetime`` here and pass an epoch value through unchanged.
343
+ # A ``None`` / unparseable value leaves ``timestamp`` at its default ``None``.
344
+ raw_filled_at = frame.get("filled_at")
345
+ filled_at: Any
346
+ if isinstance(raw_filled_at, bool):
347
+ # ``bool`` is an ``int`` subclass — never a valid epoch; don't let
348
+ # ``True`` slip through as the epoch ``1`` (1970) timestamp.
349
+ filled_at = None
350
+ elif isinstance(raw_filled_at, (int, float, datetime)):
351
+ # epoch seconds/ms or an already-resolved datetime — the validator
352
+ # accepts these as-is.
353
+ filled_at = raw_filled_at
354
+ elif isinstance(raw_filled_at, str):
355
+ # a digit-string epoch passes straight through; an ISO (or other)
356
+ # string is resolved to a datetime here (or ``None`` if unparseable).
357
+ filled_at = (
358
+ raw_filled_at
359
+ if raw_filled_at.isdecimal()
360
+ else _parse_fill_timestamp(raw_filled_at)
361
+ )
362
+ else:
363
+ # dict / list / unexpected type — never forward it into ``timestamp``,
364
+ # which would make ``model_validate`` raise and sink the whole fill.
365
+ filled_at = None
366
+
367
+ trade_payload: dict[str, Any] = {
368
+ "id": order_id,
369
+ "taker_order_id": order_id,
370
+ "market": market,
371
+ "asset_id": token_id,
372
+ "side": side,
373
+ "size": quantity,
374
+ "price": price,
375
+ "status": "MATCHED",
376
+ "owner": "",
377
+ "outcome": outcome,
378
+ }
379
+ order_payload: dict[str, Any] = {
380
+ "id": order_id,
381
+ "owner": "",
382
+ "market": market,
383
+ "asset_id": token_id,
384
+ "side": side,
385
+ "original_size": quantity,
386
+ "size_matched": quantity,
387
+ "price": price,
388
+ "type": "UPDATE",
389
+ "status": "MATCHED",
390
+ "outcome": outcome,
391
+ }
392
+ if filled_at is not None and filled_at != "":
393
+ trade_payload["timestamp"] = filled_at
394
+ order_payload["timestamp"] = filled_at
395
+
396
+ trade = UserTradeEvent.model_validate({"type": "trade", "payload": trade_payload})
397
+ order = UserOrderEvent.model_validate({"type": "order", "payload": order_payload})
344
398
  return [trade, order]
345
399
 
346
400
 
@@ -19,7 +19,11 @@ import asyncio
19
19
  import contextlib
20
20
  from collections.abc import AsyncIterator, Awaitable, Callable
21
21
  from types import TracebackType
22
- from typing import Generic, Protocol, Self, TypeVar, runtime_checkable
22
+ from typing import Generic, Protocol, TypeVar, runtime_checkable
23
+
24
+ # ``typing.Self`` is 3.11-only; the package floor is 3.10, so source it from
25
+ # ``typing_extensions`` (a declared dependency) for a single 3.10-safe symbol.
26
+ from typing_extensions import Self
23
27
 
24
28
  T = TypeVar("T")
25
29
  T_co = TypeVar("T_co", covariant=True)
@@ -62,6 +66,10 @@ class AsyncSubscriptionHandle(Generic[T]):
62
66
  self._ended = False
63
67
  self._dropped = 0
64
68
  self._end_error: BaseException | None = None
69
+ # Set once the end sentinel has been consumed by ``__anext__``. After
70
+ # that the handle is permanently terminal: a further ``__anext__`` must
71
+ # re-raise the terminal outcome WITHOUT blocking on the (now-empty) queue.
72
+ self._terminated = False
65
73
  self._closing: asyncio.Task[None] | None = None
66
74
  self._on_close: Callable[[AsyncSubscriptionHandle[T]], Awaitable[None]] | None = None
67
75
 
@@ -116,8 +124,16 @@ class AsyncSubscriptionHandle(Generic[T]):
116
124
  return self
117
125
 
118
126
  async def __anext__(self) -> T:
127
+ # Once the end sentinel has been consumed the handle is terminal: re-raise
128
+ # the terminal outcome immediately instead of blocking on the empty queue
129
+ # (the sentinel is single-use — a second ``get()`` would hang forever).
130
+ if self._terminated:
131
+ if self._end_error is not None:
132
+ raise self._end_error
133
+ raise StopAsyncIteration
119
134
  item = await self._queue.get()
120
135
  if isinstance(item, _EndSentinel):
136
+ self._terminated = True
121
137
  if self._end_error is not None:
122
138
  raise self._end_error
123
139
  raise StopAsyncIteration
@@ -10,7 +10,7 @@ permissive parsers field-for-field so the parsed scalar types match.
10
10
 
11
11
  from __future__ import annotations
12
12
 
13
- from datetime import UTC, datetime
13
+ from datetime import datetime, timezone
14
14
  from decimal import Decimal
15
15
  from typing import TYPE_CHECKING, Annotated
16
16
 
@@ -44,7 +44,7 @@ def _parse_epoch_ms_timestamp(value: object) -> object:
44
44
  raise ValueError(f"invalid epoch-ms timestamp: {value!r}")
45
45
  ms = int(value)
46
46
  try:
47
- return datetime.fromtimestamp(ms / 1000, tz=UTC)
47
+ return datetime.fromtimestamp(ms / 1000, tz=timezone.utc)
48
48
  except (OverflowError, OSError, ValueError) as error:
49
49
  raise ValueError(f"invalid epoch-ms timestamp: {value!r}") from error
50
50
 
@@ -58,18 +58,23 @@ def _parse_epoch_ms_or_iso_timestamp(value: object) -> object:
58
58
  raise ValueError(f"expected timestamp, got bool {value!r}")
59
59
  if isinstance(value, int):
60
60
  try:
61
- return datetime.fromtimestamp(value / 1000, tz=UTC)
61
+ return datetime.fromtimestamp(value / 1000, tz=timezone.utc)
62
62
  except (OverflowError, OSError, ValueError) as error:
63
63
  raise ValueError(f"invalid epoch-ms timestamp: {value!r}") from error
64
64
  if isinstance(value, str):
65
65
  if value.isdecimal():
66
66
  ms = int(value)
67
67
  try:
68
- return datetime.fromtimestamp(ms / 1000, tz=UTC)
68
+ return datetime.fromtimestamp(ms / 1000, tz=timezone.utc)
69
69
  except (OverflowError, OSError, ValueError) as error:
70
70
  raise ValueError(f"invalid epoch-ms timestamp: {value!r}") from error
71
+ # 3.10's ``datetime.fromisoformat`` does NOT accept a trailing ``Z``
72
+ # (Zulu/UTC) — that only landed in 3.11. The package floor is 3.10, so
73
+ # normalise a trailing ``Z`` to ``+00:00`` before parsing, matching how
74
+ # the rest of the SDK (``polysim_sdk.updown._parse_iso``) handles it.
75
+ iso = value[:-1] + "+00:00" if value.endswith(("Z", "z")) else value
71
76
  try:
72
- return datetime.fromisoformat(value)
77
+ return datetime.fromisoformat(iso)
73
78
  except ValueError as error:
74
79
  raise ValueError(f"invalid timestamp: {value!r}") from error
75
80
  raise ValueError(f"expected epoch-ms or ISO timestamp, got {type(value).__name__}")
@@ -92,7 +97,7 @@ def _parse_epoch_seconds_or_ms_timestamp(value: object) -> object:
92
97
  raise ValueError(f"expected epoch timestamp, got {type(value).__name__}")
93
98
  seconds = magnitude / 1000 if magnitude >= _EPOCH_MS_THRESHOLD else magnitude
94
99
  try:
95
- return datetime.fromtimestamp(seconds, tz=UTC)
100
+ return datetime.fromtimestamp(seconds, tz=timezone.utc)
96
101
  except (OverflowError, OSError, ValueError) as error:
97
102
  raise ValueError(f"invalid epoch timestamp: {value!r}") from error
98
103
 
@@ -113,7 +118,7 @@ def _parse_epoch_seconds_timestamp(value: object) -> object:
113
118
  else:
114
119
  raise ValueError(f"expected epoch-seconds timestamp, got {type(value).__name__}")
115
120
  try:
116
- return datetime.fromtimestamp(seconds, tz=UTC)
121
+ return datetime.fromtimestamp(seconds, tz=timezone.utc)
117
122
  except (OverflowError, OSError, ValueError) as error:
118
123
  raise ValueError(f"invalid epoch-seconds timestamp: {value!r}") from error
119
124
 
@@ -26,6 +26,7 @@ Example:
26
26
  print(fill["fee"], fill["slippage_bps"])
27
27
  """
28
28
 
29
+ from polysim_sdk._version import __version__
29
30
  from polysim_sdk.aio import AsyncPolySimClient
30
31
  from polysim_sdk.client import PolySimClient
31
32
  from polysim_sdk.constants import OrderType, Side
@@ -49,6 +50,5 @@ __all__ = [
49
50
  "RateLimitError",
50
51
  "ValidationError",
51
52
  "retry_on_market_warmup",
53
+ "__version__",
52
54
  ]
53
-
54
- __version__ = "0.2.2"
@@ -30,6 +30,7 @@ from urllib.parse import urljoin
30
30
 
31
31
  import httpx
32
32
 
33
+ from polysim_sdk._version import DEFAULT_USER_AGENT
33
34
  from polysim_sdk.exceptions import (
34
35
  ApiError,
35
36
  EdgeBlockedError,
@@ -206,7 +207,7 @@ class SyncTransport:
206
207
  timeout: float = DEFAULT_TIMEOUT_SECONDS,
207
208
  max_retries: int = DEFAULT_MAX_RETRIES,
208
209
  floor_interval: float = DEFAULT_FLOOR_INTERVAL_SECONDS,
209
- user_agent: str = "polysim-sdk/0.2.2",
210
+ user_agent: str = DEFAULT_USER_AGENT,
210
211
  ) -> None:
211
212
  self.base_url = base_url.rstrip("/")
212
213
  self._max_retries = max_retries
@@ -317,7 +318,7 @@ class AsyncTransport:
317
318
  timeout: float = DEFAULT_TIMEOUT_SECONDS,
318
319
  max_retries: int = DEFAULT_MAX_RETRIES,
319
320
  floor_interval: float = DEFAULT_FLOOR_INTERVAL_SECONDS,
320
- user_agent: str = "polysim-sdk/0.2.2",
321
+ user_agent: str = DEFAULT_USER_AGENT,
321
322
  ) -> None:
322
323
  self.base_url = base_url.rstrip("/")
323
324
  self._max_retries = max_retries
@@ -31,14 +31,23 @@ END_CURSOR = "LTE="
31
31
 
32
32
 
33
33
  def _decode_cursor(cursor: str | None) -> int:
34
- """base64 cursor -> integer offset. START/empty -> 0, END -> -1."""
34
+ """base64 cursor -> integer offset. START/empty -> 0, END -> -1.
35
+
36
+ ANY decode failure (bad base64, non-ASCII / non-numeric payload, a wrong
37
+ input type) falls back to ``0`` rather than propagating an unexpected error
38
+ type — a malformed cursor must never crash a paginator. The catch is broad
39
+ (``Exception``) on purpose: ``base64.b64decode`` / ``bytes.decode`` /
40
+ ``int()`` can raise ``binascii.Error`` / ``UnicodeDecodeError`` / ``ValueError``
41
+ / ``TypeError`` (and a future codec could surface another), all of which mean
42
+ the same thing here — "unparseable cursor, start from 0".
43
+ """
35
44
  if not cursor or cursor == START_CURSOR:
36
45
  return 0
37
46
  if cursor == END_CURSOR:
38
47
  return -1
39
48
  try:
40
49
  return int(base64.b64decode(cursor).decode())
41
- except (ValueError, TypeError):
50
+ except Exception:
42
51
  return 0
43
52
 
44
53
 
@@ -0,0 +1,28 @@
1
+ """Single source of truth for the SDK version + default User-Agent.
2
+
3
+ The version is read once from the installed package metadata
4
+ (``importlib.metadata.version("polysimulator")``) so ``polysim_sdk.__version__``
5
+ and the outbound ``User-Agent`` can never drift from ``[project].version`` in
6
+ ``pyproject.toml``. A literal fallback covers an editable / not-yet-installed
7
+ checkout where the distribution metadata is absent.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from importlib.metadata import PackageNotFoundError, version
13
+
14
+ # Fallback for a checkout with no installed distribution metadata (e.g. running
15
+ # straight from source without ``pip install -e``). Kept in sync with
16
+ # ``[project].version`` as a last resort only — the metadata read is canonical.
17
+ _FALLBACK_VERSION = "0.4.2"
18
+
19
+ try:
20
+ __version__ = version("polysimulator")
21
+ except PackageNotFoundError: # pragma: no cover - exercised only in bare checkouts
22
+ __version__ = _FALLBACK_VERSION
23
+
24
+ # The default outbound User-Agent. Built once from the resolved version so every
25
+ # transport (sync/async HTTP + SSE + WS) sends a UA that matches the package.
26
+ DEFAULT_USER_AGENT = f"polysim-sdk/{__version__}"
27
+
28
+ __all__ = ["DEFAULT_USER_AGENT", "__version__"]
@@ -32,6 +32,7 @@ from polysim_sdk._http import (
32
32
  AsyncTransport,
33
33
  unwrap_list,
34
34
  )
35
+ from polysim_sdk._version import DEFAULT_USER_AGENT
35
36
 
36
37
 
37
38
  class AsyncPolySimClient:
@@ -49,7 +50,7 @@ class AsyncPolySimClient:
49
50
  timeout: float = DEFAULT_TIMEOUT_SECONDS,
50
51
  max_retries: int = DEFAULT_MAX_RETRIES,
51
52
  floor_interval: float = DEFAULT_FLOOR_INTERVAL_SECONDS,
52
- user_agent: str = "polysim-sdk/0.2.2",
53
+ user_agent: str = DEFAULT_USER_AGENT,
53
54
  ) -> None:
54
55
  resolved_key = api_key or os.environ.get("POLYSIM_API_KEY")
55
56
  if not resolved_key:
@@ -607,7 +608,7 @@ class AsyncPolySimClient:
607
608
  timeout: float = DEFAULT_TIMEOUT_SECONDS,
608
609
  max_retries: int = DEFAULT_MAX_RETRIES,
609
610
  floor_interval: float = DEFAULT_FLOOR_INTERVAL_SECONDS,
610
- user_agent: str = "polysim-sdk/0.2.2",
611
+ user_agent: str = DEFAULT_USER_AGENT,
611
612
  ) -> dict[str, Any]:
612
613
  """``POST /v1/keys/bootstrap`` — JWT-authed first-key mint, no key needed.
613
614
 
@@ -25,6 +25,7 @@ from polysim_sdk._http import (
25
25
  SyncTransport,
26
26
  unwrap_list,
27
27
  )
28
+ from polysim_sdk._version import DEFAULT_USER_AGENT
28
29
 
29
30
 
30
31
  class PolySimClient:
@@ -43,7 +44,7 @@ class PolySimClient:
43
44
  timeout: float = DEFAULT_TIMEOUT_SECONDS,
44
45
  max_retries: int = DEFAULT_MAX_RETRIES,
45
46
  floor_interval: float = DEFAULT_FLOOR_INTERVAL_SECONDS,
46
- user_agent: str = "polysim-sdk/0.2.2",
47
+ user_agent: str = DEFAULT_USER_AGENT,
47
48
  ) -> None:
48
49
  resolved_key = api_key or os.environ.get("POLYSIM_API_KEY")
49
50
  if not resolved_key:
@@ -713,7 +714,7 @@ class PolySimClient:
713
714
  timeout: float = DEFAULT_TIMEOUT_SECONDS,
714
715
  max_retries: int = DEFAULT_MAX_RETRIES,
715
716
  floor_interval: float = DEFAULT_FLOOR_INTERVAL_SECONDS,
716
- user_agent: str = "polysim-sdk/0.2.2",
717
+ user_agent: str = DEFAULT_USER_AGENT,
717
718
  ) -> dict[str, Any]:
718
719
  """``POST /v1/keys/bootstrap`` — mint the FIRST key from a Supabase JWT.
719
720
 
@@ -32,6 +32,7 @@ from typing import TYPE_CHECKING, Any
32
32
  import httpx
33
33
 
34
34
  from polysim_sdk._http import raise_for_status, resolve_url
35
+ from polysim_sdk._version import DEFAULT_USER_AGENT
35
36
 
36
37
  if TYPE_CHECKING:
37
38
  from polysim_sdk.aio import AsyncPolySimClient
@@ -170,7 +171,7 @@ def spot_stream(
170
171
  backoff = reconnect_min_interval
171
172
  attempts = 0
172
173
  url = resolve_url(client.base_url, "/prices/stream")
173
- headers = _sse_headers(client._api_key, "polysim-sdk/0.2.2")
174
+ headers = _sse_headers(client._api_key, DEFAULT_USER_AGENT)
174
175
  with httpx.Client(timeout=_stream_timeout()) as http:
175
176
  while True:
176
177
  try:
@@ -228,7 +229,7 @@ async def aspot_stream(
228
229
  backoff = reconnect_min_interval
229
230
  attempts = 0
230
231
  url = resolve_url(client.base_url, "/prices/stream")
231
- headers = _sse_headers(client._api_key, "polysim-sdk/0.2.2")
232
+ headers = _sse_headers(client._api_key, DEFAULT_USER_AGENT)
232
233
  async with httpx.AsyncClient(timeout=_stream_timeout()) as http:
233
234
  while True:
234
235
  try:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "polysimulator"
7
- version = "0.4.1"
7
+ version = "0.4.2"
8
8
  description = "Python SDK for the PolySimulator paper-trading API, with py-clob-client v1 drop-in parity (not py-clob-client-v2) and a polysim_polymarket paper-mode mirror of Polymarket's unified py-sdk (polymarket-client 0.1.0b8)."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -27,6 +27,10 @@ dependencies = [
27
27
  # polysim_polymarket (py-sdk mirror) parses read models with pydantic v2,
28
28
  # matching the real polymarket-client's modelling layer.
29
29
  "pydantic>=2,<3",
30
+ # ``typing_extensions.Self`` backfills ``typing.Self`` (3.11+) for the 3.10
31
+ # floor the package declares. (pydantic already pulls it in transitively;
32
+ # declared explicitly so the import is a first-class dependency.)
33
+ "typing_extensions>=4",
30
34
  ]
31
35
 
32
36
  [project.optional-dependencies]
File without changes
File without changes
File without changes