polysimulator 0.4.0__tar.gz → 0.4.1__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 (50) hide show
  1. {polysimulator-0.4.0 → polysimulator-0.4.1}/PKG-INFO +101 -4
  2. {polysimulator-0.4.0 → polysimulator-0.4.1}/README.md +100 -3
  3. {polysimulator-0.4.0 → polysimulator-0.4.1}/polysim_clob_client/client.py +12 -8
  4. {polysimulator-0.4.0 → polysimulator-0.4.1}/polysim_polymarket/clients/async_public.py +5 -3
  5. {polysimulator-0.4.0 → polysimulator-0.4.1}/polysim_polymarket/clients/public.py +5 -4
  6. {polysimulator-0.4.0 → polysimulator-0.4.1}/polysim_sdk/__init__.py +2 -0
  7. {polysimulator-0.4.0 → polysimulator-0.4.1}/polysim_sdk/_http.py +10 -0
  8. {polysimulator-0.4.0 → polysimulator-0.4.1}/polysim_sdk/_shared.py +11 -4
  9. polysimulator-0.4.1/polysim_sdk/retry.py +89 -0
  10. {polysimulator-0.4.0 → polysimulator-0.4.1}/pyproject.toml +1 -1
  11. {polysimulator-0.4.0 → polysimulator-0.4.1}/.gitignore +0 -0
  12. {polysimulator-0.4.0 → polysimulator-0.4.1}/LICENSE +0 -0
  13. {polysimulator-0.4.0 → polysimulator-0.4.1}/NOTICE +0 -0
  14. {polysimulator-0.4.0 → polysimulator-0.4.1}/polysim_clob_client/__init__.py +0 -0
  15. {polysimulator-0.4.0 → polysimulator-0.4.1}/polysim_clob_client/clob_types.py +0 -0
  16. {polysimulator-0.4.0 → polysimulator-0.4.1}/polysim_clob_client/constants.py +0 -0
  17. {polysimulator-0.4.0 → polysimulator-0.4.1}/polysim_clob_client/exceptions.py +0 -0
  18. {polysimulator-0.4.0 → polysimulator-0.4.1}/polysim_clob_client/order_builder/__init__.py +0 -0
  19. {polysimulator-0.4.0 → polysimulator-0.4.1}/polysim_clob_client/order_builder/constants.py +0 -0
  20. {polysimulator-0.4.0 → polysimulator-0.4.1}/polysim_polymarket/__init__.py +0 -0
  21. {polysimulator-0.4.0 → polysimulator-0.4.1}/polysim_polymarket/clients/__init__.py +0 -0
  22. {polysimulator-0.4.0 → polysimulator-0.4.1}/polysim_polymarket/clients/_account.py +0 -0
  23. {polysimulator-0.4.0 → polysimulator-0.4.1}/polysim_polymarket/clients/_common.py +0 -0
  24. {polysimulator-0.4.0 → polysimulator-0.4.1}/polysim_polymarket/clients/_onchain.py +0 -0
  25. {polysimulator-0.4.0 → polysimulator-0.4.1}/polysim_polymarket/clients/_trade.py +0 -0
  26. {polysimulator-0.4.0 → polysimulator-0.4.1}/polysim_polymarket/clients/async_secure.py +0 -0
  27. {polysimulator-0.4.0 → polysimulator-0.4.1}/polysim_polymarket/clients/secure.py +0 -0
  28. {polysimulator-0.4.0 → polysimulator-0.4.1}/polysim_polymarket/environments.py +0 -0
  29. {polysimulator-0.4.0 → polysimulator-0.4.1}/polysim_polymarket/errors.py +0 -0
  30. {polysimulator-0.4.0 → polysimulator-0.4.1}/polysim_polymarket/models.py +0 -0
  31. {polysimulator-0.4.0 → polysimulator-0.4.1}/polysim_polymarket/pagination.py +0 -0
  32. {polysimulator-0.4.0 → polysimulator-0.4.1}/polysim_polymarket/rfq.py +0 -0
  33. {polysimulator-0.4.0 → polysimulator-0.4.1}/polysim_polymarket/streams/__init__.py +0 -0
  34. {polysimulator-0.4.0 → polysimulator-0.4.1}/polysim_polymarket/streams/_adapt.py +0 -0
  35. {polysimulator-0.4.0 → polysimulator-0.4.1}/polysim_polymarket/streams/_crypto_events.py +0 -0
  36. {polysimulator-0.4.0 → polysimulator-0.4.1}/polysim_polymarket/streams/_handle.py +0 -0
  37. {polysimulator-0.4.0 → polysimulator-0.4.1}/polysim_polymarket/streams/_market_events.py +0 -0
  38. {polysimulator-0.4.0 → polysimulator-0.4.1}/polysim_polymarket/streams/_runner.py +0 -0
  39. {polysimulator-0.4.0 → polysimulator-0.4.1}/polysim_polymarket/streams/_specs.py +0 -0
  40. {polysimulator-0.4.0 → polysimulator-0.4.1}/polysim_polymarket/streams/_stream_open.py +0 -0
  41. {polysimulator-0.4.0 → polysimulator-0.4.1}/polysim_polymarket/streams/_user_events.py +0 -0
  42. {polysimulator-0.4.0 → polysimulator-0.4.1}/polysim_polymarket/streams/_validators.py +0 -0
  43. {polysimulator-0.4.0 → polysimulator-0.4.1}/polysim_sdk/aio.py +0 -0
  44. {polysimulator-0.4.0 → polysimulator-0.4.1}/polysim_sdk/client.py +0 -0
  45. {polysimulator-0.4.0 → polysimulator-0.4.1}/polysim_sdk/constants.py +0 -0
  46. {polysimulator-0.4.0 → polysimulator-0.4.1}/polysim_sdk/exceptions.py +0 -0
  47. {polysimulator-0.4.0 → polysimulator-0.4.1}/polysim_sdk/pagination.py +0 -0
  48. {polysimulator-0.4.0 → polysimulator-0.4.1}/polysim_sdk/sse.py +0 -0
  49. {polysimulator-0.4.0 → polysimulator-0.4.1}/polysim_sdk/updown.py +0 -0
  50. {polysimulator-0.4.0 → polysimulator-0.4.1}/polysim_sdk/ws.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: polysimulator
3
- Version: 0.4.0
3
+ Version: 0.4.1
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
@@ -236,6 +236,39 @@ network, tolerant of missing fields): `seconds_to_expiry`, `is_window_open`,
236
236
  `INTERVALS` vocab tuples. For a **streaming** tap on the underlying, see
237
237
  [SSE: live underlying spot](#sse-live-underlying-spot) below.
238
238
 
239
+ ### New-market warm-up: retry order placement on a 404
240
+
241
+ A **brand-new** market — especially the 5m Up/Down windows that roll every few
242
+ minutes — has a short window after creation before it enters the
243
+ order-validation catalog. An order placed in that window transiently fails with
244
+ a `404` (`code="MARKET_NOT_FOUND"`). The window is short — on the order of ~30s,
245
+ the Up/Down roll cadence — but it is a cadence, **not** a guarantee.
246
+
247
+ The SDK does **not** auto-retry 404s (by design — a 404 is normally permanent),
248
+ so a bot trading freshly-rolled markets should catch and retry that one signal
249
+ with backoff. The native client ships a small opt-in helper that retries **only**
250
+ the warm-up 404 and re-raises everything else (and the 404 itself once the budget
251
+ is spent):
252
+
253
+ ```python
254
+ from polysim_sdk import PolySimClient, retry_on_market_warmup
255
+
256
+ with PolySimClient() as client:
257
+ fill = retry_on_market_warmup(
258
+ lambda: client.place_order(
259
+ market_id=cid, side="BUY", outcome="Up", amount="10", price="0.99",
260
+ ),
261
+ attempts=6, base_delay=2.0, # ~2,4,8,16,30s capped backoff between tries
262
+ )
263
+ ```
264
+
265
+ It wraps any zero-argument callable, so the same helper works with a
266
+ `polysim_clob_client` drop-in call (e.g.
267
+ `retry_on_market_warmup(lambda: clob.create_and_post_order(args))`); the drop-in
268
+ surfaces the same condition as a `PolyApiException` with `status_code == 404`.
269
+ See [Errors](#errors) for the standalone retry-on-404 pattern if you'd rather
270
+ roll your own.
271
+
239
272
  ---
240
273
 
241
274
  ## Polymarket compatibility
@@ -802,11 +835,14 @@ Every method maps by one of three strategies:
802
835
  `py-clob-client` addresses a single outcome token by `token_id`; PolySimulator
803
836
  addresses a **market plus an outcome**. The seam: a bare `token_id` is treated as
804
837
  the market id with outcome `YES`. Append `":NO"` or `":YES"` to target the other
805
- side explicitly.
838
+ side explicitly. **Up/Down markets** carry `Up`/`Down` outcomes, so the same
839
+ colon form also accepts `":UP"` / `":DOWN"` (case-insensitive); the backend
840
+ matches the order outcome case-insensitively, so either case is accepted.
806
841
 
807
842
  ```python
808
843
  client.create_order(OrderArgs(token_id="0xMARKET", ...)) # → market 0xMARKET, YES
809
844
  client.create_order(OrderArgs(token_id="0xMARKET:NO", ...)) # → market 0xMARKET, NO
845
+ client.create_order(OrderArgs(token_id="0xUPDOWN:UP", ...)) # → Up/Down market, UP
810
846
  ```
811
847
 
812
848
  **Reads are true-token-parity; writes use the market+outcome model.** This is an
@@ -829,8 +865,9 @@ intentional asymmetry:
829
865
  order loudly — it never silently places a different one.
830
866
 
831
867
  If you only ever pass real outcome-token ids (the `py-clob-client` norm), reads
832
- "just work" with full parity. The `:YES`/`:NO` suffix exists for callers who
833
- prefer to address PolySimulator markets by condition id directly.
868
+ "just work" with full parity. The `:YES`/`:NO` suffix (and `:UP`/`:DOWN` for
869
+ Up/Down markets) exists for callers who prefer to address PolySimulator markets
870
+ by condition id directly.
834
871
 
835
872
  ### Orders are never signed
836
873
 
@@ -947,6 +984,66 @@ The client paces itself (a 50 ms floor between requests) and backs off on
947
984
  `429`/`425`/`5xx` using `Retry-After`. Opt out with `floor_interval=0.0` and
948
985
  `max_retries=0` to handle pacing yourself.
949
986
 
987
+ ### New-market warm-up `404` (retry-on-404)
988
+
989
+ The SDK auto-retries `429`/`5xx` but **never** a `404` — a 404 is normally
990
+ permanent. The one benign exception is a **freshly-created market** (most often
991
+ a 5m Up/Down window): for a short window after creation — on the order of ~30s,
992
+ the Up/Down roll cadence, though not a guarantee — it is not yet in the
993
+ order-validation catalog and order placement transiently fails with
994
+ `ApiError(status_code=404, code="MARKET_NOT_FOUND")`. Retry **only** that signal,
995
+ with backoff; re-raise any other error (including a 404 with a different `code`,
996
+ which is a real "not found").
997
+
998
+ Use the shipped helper (recommended) …
999
+
1000
+ ```python
1001
+ from polysim_sdk import PolySimClient, retry_on_market_warmup
1002
+
1003
+ with PolySimClient() as client:
1004
+ fill = retry_on_market_warmup(
1005
+ lambda: client.place_order(
1006
+ market_id=cid, side="BUY", outcome="Up", amount="10", price="0.99",
1007
+ ),
1008
+ attempts=6, base_delay=2.0,
1009
+ )
1010
+ ```
1011
+
1012
+ … or roll your own on the native `ApiError`:
1013
+
1014
+ ```python
1015
+ import time
1016
+ from polysim_sdk import ApiError
1017
+
1018
+ for i in range(6):
1019
+ try:
1020
+ fill = client.place_order(market_id=cid, side="BUY", outcome="Up",
1021
+ amount="10", price="0.99")
1022
+ break
1023
+ except ApiError as exc:
1024
+ if exc.status_code == 404 and exc.code == "MARKET_NOT_FOUND" and i < 5:
1025
+ time.sleep(min(2.0 * 2**i, 30.0)) # capped backoff, then retry
1026
+ continue
1027
+ raise # any other error — and the final 404 — propagates
1028
+ ```
1029
+
1030
+ With the **`polysim_clob_client` drop-in**, the same condition surfaces as a
1031
+ `PolyApiException` with `status_code == 404`; catch it and retry the same way (or
1032
+ pass your drop-in call to `retry_on_market_warmup(lambda: clob.create_and_post_order(...))`,
1033
+ which works with any callable):
1034
+
1035
+ ```python
1036
+ from polysim_clob_client.exceptions import PolyApiException
1037
+
1038
+ try:
1039
+ resp = clob.create_and_post_order(order_args)
1040
+ except PolyApiException as exc:
1041
+ if exc.status_code == 404 and getattr(exc, "code", None) == "MARKET_NOT_FOUND":
1042
+ ... # warm-up — retry with backoff
1043
+ else:
1044
+ raise # any other error — including a 404 with a different code — propagates
1045
+ ```
1046
+
950
1047
  ### Troubleshooting: `error code: 1010` / blocked User-Agent
951
1048
 
952
1049
  If a raw HTTP call returns **HTTP 403** with the body `error code: 1010`, the
@@ -201,6 +201,39 @@ network, tolerant of missing fields): `seconds_to_expiry`, `is_window_open`,
201
201
  `INTERVALS` vocab tuples. For a **streaming** tap on the underlying, see
202
202
  [SSE: live underlying spot](#sse-live-underlying-spot) below.
203
203
 
204
+ ### New-market warm-up: retry order placement on a 404
205
+
206
+ A **brand-new** market — especially the 5m Up/Down windows that roll every few
207
+ minutes — has a short window after creation before it enters the
208
+ order-validation catalog. An order placed in that window transiently fails with
209
+ a `404` (`code="MARKET_NOT_FOUND"`). The window is short — on the order of ~30s,
210
+ the Up/Down roll cadence — but it is a cadence, **not** a guarantee.
211
+
212
+ The SDK does **not** auto-retry 404s (by design — a 404 is normally permanent),
213
+ so a bot trading freshly-rolled markets should catch and retry that one signal
214
+ with backoff. The native client ships a small opt-in helper that retries **only**
215
+ the warm-up 404 and re-raises everything else (and the 404 itself once the budget
216
+ is spent):
217
+
218
+ ```python
219
+ from polysim_sdk import PolySimClient, retry_on_market_warmup
220
+
221
+ with PolySimClient() as client:
222
+ fill = retry_on_market_warmup(
223
+ lambda: client.place_order(
224
+ market_id=cid, side="BUY", outcome="Up", amount="10", price="0.99",
225
+ ),
226
+ attempts=6, base_delay=2.0, # ~2,4,8,16,30s capped backoff between tries
227
+ )
228
+ ```
229
+
230
+ It wraps any zero-argument callable, so the same helper works with a
231
+ `polysim_clob_client` drop-in call (e.g.
232
+ `retry_on_market_warmup(lambda: clob.create_and_post_order(args))`); the drop-in
233
+ surfaces the same condition as a `PolyApiException` with `status_code == 404`.
234
+ See [Errors](#errors) for the standalone retry-on-404 pattern if you'd rather
235
+ roll your own.
236
+
204
237
  ---
205
238
 
206
239
  ## Polymarket compatibility
@@ -767,11 +800,14 @@ Every method maps by one of three strategies:
767
800
  `py-clob-client` addresses a single outcome token by `token_id`; PolySimulator
768
801
  addresses a **market plus an outcome**. The seam: a bare `token_id` is treated as
769
802
  the market id with outcome `YES`. Append `":NO"` or `":YES"` to target the other
770
- side explicitly.
803
+ side explicitly. **Up/Down markets** carry `Up`/`Down` outcomes, so the same
804
+ colon form also accepts `":UP"` / `":DOWN"` (case-insensitive); the backend
805
+ matches the order outcome case-insensitively, so either case is accepted.
771
806
 
772
807
  ```python
773
808
  client.create_order(OrderArgs(token_id="0xMARKET", ...)) # → market 0xMARKET, YES
774
809
  client.create_order(OrderArgs(token_id="0xMARKET:NO", ...)) # → market 0xMARKET, NO
810
+ client.create_order(OrderArgs(token_id="0xUPDOWN:UP", ...)) # → Up/Down market, UP
775
811
  ```
776
812
 
777
813
  **Reads are true-token-parity; writes use the market+outcome model.** This is an
@@ -794,8 +830,9 @@ intentional asymmetry:
794
830
  order loudly — it never silently places a different one.
795
831
 
796
832
  If you only ever pass real outcome-token ids (the `py-clob-client` norm), reads
797
- "just work" with full parity. The `:YES`/`:NO` suffix exists for callers who
798
- prefer to address PolySimulator markets by condition id directly.
833
+ "just work" with full parity. The `:YES`/`:NO` suffix (and `:UP`/`:DOWN` for
834
+ Up/Down markets) exists for callers who prefer to address PolySimulator markets
835
+ by condition id directly.
799
836
 
800
837
  ### Orders are never signed
801
838
 
@@ -912,6 +949,66 @@ The client paces itself (a 50 ms floor between requests) and backs off on
912
949
  `429`/`425`/`5xx` using `Retry-After`. Opt out with `floor_interval=0.0` and
913
950
  `max_retries=0` to handle pacing yourself.
914
951
 
952
+ ### New-market warm-up `404` (retry-on-404)
953
+
954
+ The SDK auto-retries `429`/`5xx` but **never** a `404` — a 404 is normally
955
+ permanent. The one benign exception is a **freshly-created market** (most often
956
+ a 5m Up/Down window): for a short window after creation — on the order of ~30s,
957
+ the Up/Down roll cadence, though not a guarantee — it is not yet in the
958
+ order-validation catalog and order placement transiently fails with
959
+ `ApiError(status_code=404, code="MARKET_NOT_FOUND")`. Retry **only** that signal,
960
+ with backoff; re-raise any other error (including a 404 with a different `code`,
961
+ which is a real "not found").
962
+
963
+ Use the shipped helper (recommended) …
964
+
965
+ ```python
966
+ from polysim_sdk import PolySimClient, retry_on_market_warmup
967
+
968
+ with PolySimClient() as client:
969
+ fill = retry_on_market_warmup(
970
+ lambda: client.place_order(
971
+ market_id=cid, side="BUY", outcome="Up", amount="10", price="0.99",
972
+ ),
973
+ attempts=6, base_delay=2.0,
974
+ )
975
+ ```
976
+
977
+ … or roll your own on the native `ApiError`:
978
+
979
+ ```python
980
+ import time
981
+ from polysim_sdk import ApiError
982
+
983
+ for i in range(6):
984
+ try:
985
+ fill = client.place_order(market_id=cid, side="BUY", outcome="Up",
986
+ amount="10", price="0.99")
987
+ break
988
+ except ApiError as exc:
989
+ if exc.status_code == 404 and exc.code == "MARKET_NOT_FOUND" and i < 5:
990
+ time.sleep(min(2.0 * 2**i, 30.0)) # capped backoff, then retry
991
+ continue
992
+ raise # any other error — and the final 404 — propagates
993
+ ```
994
+
995
+ With the **`polysim_clob_client` drop-in**, the same condition surfaces as a
996
+ `PolyApiException` with `status_code == 404`; catch it and retry the same way (or
997
+ pass your drop-in call to `retry_on_market_warmup(lambda: clob.create_and_post_order(...))`,
998
+ which works with any callable):
999
+
1000
+ ```python
1001
+ from polysim_clob_client.exceptions import PolyApiException
1002
+
1003
+ try:
1004
+ resp = clob.create_and_post_order(order_args)
1005
+ except PolyApiException as exc:
1006
+ if exc.status_code == 404 and getattr(exc, "code", None) == "MARKET_NOT_FOUND":
1007
+ ... # warm-up — retry with backoff
1008
+ else:
1009
+ raise # any other error — including a 404 with a different code — propagates
1010
+ ```
1011
+
915
1012
  ### Troubleshooting: `error code: 1010` / blocked User-Agent
916
1013
 
917
1014
  If a raw HTTP call returns **HTTP 403** with the body `error code: 1010`, the
@@ -149,7 +149,9 @@ class ClobClient:
149
149
 
150
150
  Thin wrapper over :func:`polysim_sdk._shared._split_token` (the shared
151
151
  parity seam): a bare ``token_id`` is the market id with outcome
152
- ``YES``; ``":NO"`` / ``":YES"`` targets the other outcome explicitly.
152
+ ``YES``; ``":NO"`` / ``":YES"`` targets the other outcome explicitly,
153
+ and ``":UP"`` / ``":DOWN"`` targets an UpDown market's outcomes (the
154
+ backend matches the outcome case-insensitively).
153
155
  """
154
156
  return _split_token_shared(token_id)
155
157
 
@@ -161,9 +163,10 @@ class ClobClient:
161
163
  condition ids. For those it reverse-resolves via
162
164
  ``GET /v1/markets-by-token/{token_id}`` (result cached per token), so an
163
165
  order placed with a genuine Polymarket token id lands on the right
164
- market + outcome. The ``condition_id:YES``/``:NO`` colon form and short
165
- / non-numeric ids resolve **locally with no network call** via
166
- :meth:`_split_token` (preserving the parity seam).
166
+ market + outcome. The ``condition_id:YES``/``:NO`` colon form (plus
167
+ ``:UP``/``:DOWN`` for UpDown markets) and short / non-numeric ids
168
+ resolve **locally with no network call** via :meth:`_split_token`
169
+ (preserving the parity seam).
167
170
  """
168
171
  tid = str(token_id)
169
172
  # Colon form is our own convenience extension — always resolve locally.
@@ -191,15 +194,16 @@ class ClobClient:
191
194
  ``GET /v1/book?token_id=...`` endpoint — this is what gives genuine
192
195
  parity with Polymarket's CLOB book reads, where py-clob-client always
193
196
  passes a real outcome-token id. The ``condition_id:YES`` / ``:NO``
194
- **colon form** is our own convenience extension; it keeps condition-id
195
- routing and threads the outcome through as a query param so ``:NO``
196
- actually reads the NO book.
197
+ **colon form** (plus ``:UP`` / ``:DOWN`` for UpDown markets) is our own
198
+ convenience extension; it keeps condition-id routing and threads the
199
+ outcome through as a query param so e.g. ``:NO`` reads the NO book and
200
+ ``:UP`` reads the UP book.
197
201
  """
198
202
  tid = str(token_id)
199
203
  if ":" in tid:
200
204
  market_id, _, outcome = tid.rpartition(":")
201
205
  outcome = outcome.upper()
202
- if outcome in ("YES", "NO") and market_id:
206
+ if outcome in ("YES", "NO", "UP", "DOWN") and market_id:
203
207
  return self._client.get_book(market_id, outcome=outcome)
204
208
  return self._client.get_book_by_token(tid)
205
209
 
@@ -165,14 +165,16 @@ class AsyncPublicClient:
165
165
 
166
166
  Same routing as the sync client's ``_book_for_token``: a bare token id
167
167
  routes to the token-native ``GET /v1/book?token_id=`` endpoint; the
168
- ``condition_id:YES`` / ``:NO`` colon form keeps condition-id routing and
169
- threads the outcome through as a query param.
168
+ ``condition_id:YES`` / ``:NO`` colon form (plus ``:UP`` / ``:DOWN`` for
169
+ UpDown markets) keeps condition-id routing and threads the outcome
170
+ through as a query param so ``:NO`` reads the NO book and ``:UP`` reads
171
+ the UP book.
170
172
  """
171
173
  tid = str(token_id)
172
174
  if ":" in tid:
173
175
  market_id, _, outcome = tid.rpartition(":")
174
176
  outcome = outcome.upper()
175
- if outcome in ("YES", "NO") and market_id:
177
+ if outcome in ("YES", "NO", "UP", "DOWN") and market_id:
176
178
  return await self._client.get_book(market_id, outcome=outcome)
177
179
  return await self._client.get_book_by_token(tid)
178
180
 
@@ -113,15 +113,16 @@ class PublicClient:
113
113
 
114
114
  A **bare** token id routes to the token-native ``GET /v1/book?token_id=``
115
115
  endpoint — the parity path, since py-sdk always passes a real
116
- outcome-token id. The ``condition_id:YES`` / ``:NO`` **colon form** is
117
- our convenience extension: it keeps condition-id routing and threads the
118
- outcome through as a query param so ``:NO`` reads the NO book.
116
+ outcome-token id. The ``condition_id:YES`` / ``:NO`` **colon form** (plus
117
+ ``:UP`` / ``:DOWN`` for UpDown markets) is our convenience extension: it
118
+ keeps condition-id routing and threads the outcome through as a query
119
+ param so ``:NO`` reads the NO book and ``:UP`` reads the UP book.
119
120
  """
120
121
  tid = str(token_id)
121
122
  if ":" in tid:
122
123
  market_id, _, outcome = tid.rpartition(":")
123
124
  outcome = outcome.upper()
124
- if outcome in ("YES", "NO") and market_id:
125
+ if outcome in ("YES", "NO", "UP", "DOWN") and market_id:
125
126
  return self._client.get_book(market_id, outcome=outcome)
126
127
  return self._client.get_book_by_token(tid)
127
128
 
@@ -36,6 +36,7 @@ from polysim_sdk.exceptions import (
36
36
  RateLimitError,
37
37
  ValidationError,
38
38
  )
39
+ from polysim_sdk.retry import retry_on_market_warmup
39
40
 
40
41
  __all__ = [
41
42
  "PolySimClient",
@@ -47,6 +48,7 @@ __all__ = [
47
48
  "PolySimError",
48
49
  "RateLimitError",
49
50
  "ValidationError",
51
+ "retry_on_market_warmup",
50
52
  ]
51
53
 
52
54
  __version__ = "0.2.2"
@@ -159,6 +159,16 @@ def raise_for_status(resp: httpx.Response) -> None:
159
159
  code = payload.get("code") or payload.get("error")
160
160
  if isinstance(code, dict):
161
161
  code = code.get("code") or code.get("error")
162
+ # The authoritative, docs-promised machine code rides in the
163
+ # ``X-Polysim-Code`` response header (always exposed). The body's ``error``
164
+ # field is the PM single-field shape — a human sentence, not a stable code
165
+ # — so the header wins when present. We only fall back to the body-derived
166
+ # value (the back-compat ``{error: <CODE>, message: <text>}`` inline shape)
167
+ # when the header is absent. Header lookup is case-insensitive (httpx
168
+ # ``Headers``); strip stray whitespace and ignore an empty value.
169
+ header_code = (resp.headers.get("X-Polysim-Code") or "").strip()
170
+ if header_code:
171
+ code = header_code
162
172
  request_id = resp.headers.get("x-request-id")
163
173
 
164
174
  if resp.status_code in (400, 422):
@@ -103,15 +103,22 @@ def _split_token(token_id: str) -> tuple[str, str]:
103
103
  """Map a py-clob ``token_id`` onto PolySim ``(market_id, outcome)``.
104
104
 
105
105
  py-clob-client addresses a single outcome token; PolySim addresses a
106
- market plus an outcome (YES/NO). The parity seam: a bare ``token_id``
107
- is treated as the market id with outcome ``YES``; append ``":NO"`` /
108
- ``":YES"`` to target the other outcome explicitly.
106
+ market plus an outcome. The parity seam: a bare ``token_id`` is treated
107
+ as the market id with outcome ``YES``; append ``":NO"`` / ``":YES"`` to
108
+ target the other outcome explicitly. UpDown markets carry ``Up`` / ``Down``
109
+ outcomes, so the same colon form also accepts ``":UP"`` / ``":DOWN"``
110
+ (case-insensitive, returned uppercase). The backend matches the outcome
111
+ case-insensitively, so ``"UP"`` / ``"DOWN"`` are accepted as written.
112
+
113
+ The colon form is the drop-in's own convenience extension (it is **not**
114
+ py-clob-client parity, where a real outcome-token id is always passed), so
115
+ accepting the extra UpDown outcomes leaves the v1 parity contract intact.
109
116
  """
110
117
  tid = str(token_id)
111
118
  if ":" in tid:
112
119
  market_id, _, outcome = tid.rpartition(":")
113
120
  outcome = outcome.upper()
114
- if outcome in ("YES", "NO") and market_id:
121
+ if outcome in ("YES", "NO", "UP", "DOWN") and market_id:
115
122
  return market_id, outcome
116
123
  return tid, "YES"
117
124
 
@@ -0,0 +1,89 @@
1
+ """Opt-in retry helpers for transient, well-understood failure windows.
2
+
3
+ The SDK deliberately does **not** auto-retry ``404`` responses — a 404 is
4
+ normally permanent (unknown market / order id), and silently retrying it would
5
+ hide real bugs behind a long backoff. There is, however, one benign 404: a
6
+ brand-new market (especially the BTC/ETH UpDown 5m windows that roll every few
7
+ minutes) has a short warm-up window after creation before it enters the
8
+ order-validation catalog, during which order placement transiently 404s with
9
+ ``code="MARKET_NOT_FOUND"``.
10
+
11
+ :func:`retry_on_market_warmup` is a small, explicit opt-in wrapper for exactly
12
+ that case: it retries a callable **only** on a ``MARKET_NOT_FOUND`` 404 with
13
+ capped exponential backoff, re-raising any other error immediately and the 404
14
+ itself once the attempt budget is spent. It wraps any zero-argument callable, so
15
+ it works with both the native ``polysim_sdk`` client and the ``polysim_clob_client``
16
+ drop-in.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import time
22
+ from collections.abc import Callable
23
+ from typing import TypeVar
24
+
25
+ from polysim_sdk.exceptions import ApiError
26
+
27
+ T = TypeVar("T")
28
+
29
+ # The server's ``error.code`` for an order placed against a market that is not
30
+ # (yet) in the validation catalog. The drop-in's PolyApiException carries the
31
+ # same status code; callers using it can wrap on status_code == 404 themselves.
32
+ MARKET_WARMUP_CODE = "MARKET_NOT_FOUND"
33
+
34
+
35
+ def _is_market_warmup_404(exc: BaseException) -> bool:
36
+ """True iff ``exc`` is the transient "market not in catalog yet" 404."""
37
+ return (
38
+ isinstance(exc, ApiError)
39
+ and exc.status_code == 404
40
+ and exc.code == MARKET_WARMUP_CODE
41
+ )
42
+
43
+
44
+ def retry_on_market_warmup(
45
+ fn: Callable[[], T],
46
+ *,
47
+ attempts: int = 6,
48
+ base_delay: float = 2.0,
49
+ max_delay: float = 30.0,
50
+ sleep: Callable[[float], None] = time.sleep,
51
+ ) -> T:
52
+ """Call ``fn`` and retry it through a market's warm-up 404.
53
+
54
+ Retries **only** when ``fn`` raises an :class:`~polysim_sdk.exceptions.ApiError`
55
+ with ``status_code == 404`` and ``code == "MARKET_NOT_FOUND"`` — the transient
56
+ signal that a freshly-created market has not yet entered the order-validation
57
+ catalog (typically clears within ~30s, the UpDown roll cadence; this is a
58
+ cadence, not a guarantee). Any other exception — including a 404 with a
59
+ different ``code`` — is re-raised immediately, and the warm-up 404 itself is
60
+ re-raised once ``attempts`` is exhausted.
61
+
62
+ Backoff is exponential (``base_delay * 2**i``) capped at ``max_delay``; the
63
+ sleep happens **between** attempts only (never after the final one). ``fn``
64
+ is a zero-argument callable, so wrap your call site in a ``lambda`` /
65
+ ``functools.partial`` — e.g.
66
+ ``retry_on_market_warmup(lambda: client.place_order(...))``. It works with
67
+ both the native client and the ``polysim_clob_client`` drop-in.
68
+
69
+ :param attempts: total number of calls to ``fn`` (must be >= 1).
70
+ :param base_delay: first backoff, in seconds.
71
+ :param max_delay: ceiling for the per-attempt backoff, in seconds.
72
+ :param sleep: injection seam for the backoff sleep (defaults to
73
+ :func:`time.sleep`); override in tests to avoid real waits.
74
+ """
75
+ if attempts < 1:
76
+ raise ValueError("attempts must be >= 1")
77
+ for i in range(attempts):
78
+ try:
79
+ return fn()
80
+ except ApiError as exc:
81
+ if not _is_market_warmup_404(exc) or i == attempts - 1:
82
+ raise
83
+ sleep(min(base_delay * (2**i), max_delay))
84
+ # Unreachable: the final iteration either returns or re-raises above. Present
85
+ # only so static checkers see every path returns a value or raises.
86
+ raise AssertionError("unreachable") # pragma: no cover
87
+
88
+
89
+ __all__ = ["retry_on_market_warmup", "MARKET_WARMUP_CODE"]
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "polysimulator"
7
- version = "0.4.0"
7
+ version = "0.4.1"
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"
File without changes
File without changes
File without changes