synpath 0.1.0__py3-none-any.whl

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 (77) hide show
  1. synpath/__init__.py +183 -0
  2. synpath/__main__.py +66 -0
  3. synpath/base.py +723 -0
  4. synpath/bucket.py +154 -0
  5. synpath/client.py +356 -0
  6. synpath/engine/__init__.py +37 -0
  7. synpath/engine/__main__.py +354 -0
  8. synpath/engine/alerts.py +170 -0
  9. synpath/engine/engine.py +888 -0
  10. synpath/engine/eod.py +154 -0
  11. synpath/engine/events.py +140 -0
  12. synpath/engine/fair_values.py +117 -0
  13. synpath/engine/feeds.py +220 -0
  14. synpath/engine/journal.py +907 -0
  15. synpath/engine/ledger.py +353 -0
  16. synpath/engine/orders/__init__.py +42 -0
  17. synpath/engine/orders/base.py +441 -0
  18. synpath/engine/orders/day.py +72 -0
  19. synpath/engine/orders/iceberg.py +121 -0
  20. synpath/engine/orders/manager.py +223 -0
  21. synpath/engine/orders/oco.py +255 -0
  22. synpath/engine/orders/peg.py +168 -0
  23. synpath/engine/orders/routed.py +496 -0
  24. synpath/engine/orders/stop.py +240 -0
  25. synpath/engine/orders/taker.py +187 -0
  26. synpath/engine/orders/twap.py +190 -0
  27. synpath/engine/paper.py +532 -0
  28. synpath/engine/reconcile.py +279 -0
  29. synpath/engine/risk.py +403 -0
  30. synpath/engine/router.py +261 -0
  31. synpath/errors.py +98 -0
  32. synpath/history.py +71 -0
  33. synpath/hosted.py +86 -0
  34. synpath/hosted_auth.py +201 -0
  35. synpath/ids.py +61 -0
  36. synpath/kalshi.py +1378 -0
  37. synpath/matching.py +86 -0
  38. synpath/polymarket.py +1004 -0
  39. synpath/polymarket_us.py +989 -0
  40. synpath/remote.py +195 -0
  41. synpath/server/__init__.py +98 -0
  42. synpath/server/__main__.py +118 -0
  43. synpath/server/api.py +439 -0
  44. synpath/server/errors.py +87 -0
  45. synpath/server/local.py +96 -0
  46. synpath/server/models.py +75 -0
  47. synpath/server/serve.py +236 -0
  48. synpath/server/store.py +363 -0
  49. synpath/server/trading.py +764 -0
  50. synpath/trading/__init__.py +79 -0
  51. synpath/trading/__main__.py +69 -0
  52. synpath/trading/base.py +126 -0
  53. synpath/trading/credentials.py +400 -0
  54. synpath/trading/errors.py +94 -0
  55. synpath/trading/init.py +233 -0
  56. synpath/trading/instruments.py +162 -0
  57. synpath/trading/kalshi.py +957 -0
  58. synpath/trading/limiter.py +177 -0
  59. synpath/trading/money.py +172 -0
  60. synpath/trading/polymarket.py +1362 -0
  61. synpath/trading/polymarket_signing.py +478 -0
  62. synpath/trading/polymarket_us.py +705 -0
  63. synpath/trading/polymarket_us_exchange.py +825 -0
  64. synpath/trading/types.py +414 -0
  65. synpath/types.py +608 -0
  66. synpath/ws/__init__.py +55 -0
  67. synpath/ws/base.py +544 -0
  68. synpath/ws/grpc.py +578 -0
  69. synpath/ws/kalshi.py +418 -0
  70. synpath/ws/polymarket.py +430 -0
  71. synpath/ws/polymarket_us.py +299 -0
  72. synpath/ws/polymarket_us_exchange.py +754 -0
  73. synpath-0.1.0.dist-info/METADATA +224 -0
  74. synpath-0.1.0.dist-info/RECORD +77 -0
  75. synpath-0.1.0.dist-info/WHEEL +4 -0
  76. synpath-0.1.0.dist-info/entry_points.txt +2 -0
  77. synpath-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,825 @@
1
+ """Polymarket US order entry, on the exchange API for onboarded firms.
2
+
3
+ `api.{preprod,prod}.polymarketexchange.com`, reached with an Auth0
4
+ private-key JWT exchanged for a three-minute access token and a participant
5
+ id issued at onboarding. The retail API most accounts use is
6
+ `polymarket_us`; this is the one a firm gets after onboarding, with a
7
+ preprod environment, firm-wide rate limits and native stop orders.
8
+
9
+ What differs from the retail API, and shapes this adapter:
10
+
11
+ **Integers on the wire.** Prices and quantities are int64s scaled per
12
+ instrument (`priceScale`, `fractionalQtyScale` from reference data). The
13
+ scales are read once per symbol and cached: reference data is limited to six
14
+ requests a minute firm-wide.
15
+
16
+ **The YES leg only.** Orders carry a side and a YES price, nothing more.
17
+ Buying NO at `q` is sent as selling YES at `1 - q` and reads back that way,
18
+ as on Kalshi.
19
+
20
+ **Stops are held by the exchange.** `ORDER_TYPE_STOP` and
21
+ `ORDER_TYPE_STOP_LIMIT` are in the published order schema, so a stop-market
22
+ or stop-limit request is sent as one; a NO stop is the YES stop at `1 - s`
23
+ on the other side, which triggers on the same event. Trailing stops and the
24
+ rest remain the engine's.
25
+
26
+ **Reads are rationed.** Open orders are cheap; searching orders or
27
+ executions is twelve requests a minute firm-wide, and each has its own
28
+ budget here so a reconciliation loop cannot starve order entry.
29
+
30
+ **Entry is asynchronous.** An insert returns an order id; acceptance,
31
+ fills and rejection follow. Cancels and replaces return nothing. Results are
32
+ `pending`, `pending_cancel` or `pending_replace` unless read back.
33
+ """
34
+ from __future__ import annotations
35
+
36
+ import asyncio
37
+ import time
38
+ import uuid
39
+ from dataclasses import dataclass, field
40
+ from datetime import datetime, timezone
41
+ from decimal import Decimal
42
+ from typing import Any
43
+
44
+ from ..base import AsyncHttpClient, Capability
45
+ from .. import ids
46
+ from ..errors import AuthenticationError, BadRequest, ExchangeError, MarketNotFound, NotSupported
47
+ from ..polymarket_us import fee_schedule_of, parse_ts
48
+ from ..types import Page
49
+ from .base import TradingExchange
50
+ from .credentials import PolymarketUSExchangeCredentials
51
+ from .errors import (
52
+ CredentialsMissing, DuplicateClientOrderId, InsufficientFunds, InvalidOrder, MarketHalted, OrderNotFound,
53
+ OrderRejected, PermissionDenied,
54
+ )
55
+ from .limiter import BudgetLimiter, Priority
56
+ from .money import D, validate_amount, validate_price
57
+ from .types import (
58
+ Account, Balance, EditRequest, FeeEstimate, Fill, Liquidity, Order, OrderRequest, OrderStatus, OrderType,
59
+ Position, PositionSide, Precision, Side, TimeInForce,
60
+ )
61
+
62
+ VENUE = "polymarket_us"
63
+
64
+ ENVIRONMENTS = {
65
+ "preprod": ("https://api.preprod.polymarketexchange.com", "pmx-preprod.us.auth0.com"),
66
+ "prod": ("https://api.prod.polymarketexchange.com", "pmx-prod.us.auth0.com"),
67
+ }
68
+ GATEWAY_URL = "https://gateway.polymarket.us"
69
+
70
+ BATCH = 20
71
+ TOKEN_MARGIN_S = 30
72
+ """Refresh the access token this long before the venue says it expires."""
73
+
74
+ NATIVE_ORDER_TYPES = frozenset({OrderType.LIMIT, OrderType.MARKET, OrderType.STOP_MARKET, OrderType.STOP_LIMIT})
75
+
76
+ TIF_TO_WIRE = {
77
+ TimeInForce.GTC: "TIME_IN_FORCE_GOOD_TILL_CANCEL",
78
+ TimeInForce.GTD: "TIME_IN_FORCE_GOOD_TILL_TIME",
79
+ TimeInForce.IOC: "TIME_IN_FORCE_IMMEDIATE_OR_CANCEL",
80
+ TimeInForce.FOK: "TIME_IN_FORCE_FILL_OR_KILL",
81
+ }
82
+ TIF_FROM_WIRE = {wire: tif for tif, wire in TIF_TO_WIRE.items()} | {"TIME_IN_FORCE_DAY": TimeInForce.DAY}
83
+
84
+ TYPE_TO_WIRE = {
85
+ OrderType.LIMIT: "ORDER_TYPE_LIMIT",
86
+ OrderType.MARKET: "ORDER_TYPE_LIMIT",
87
+ OrderType.STOP_MARKET: "ORDER_TYPE_STOP",
88
+ OrderType.STOP_LIMIT: "ORDER_TYPE_STOP_LIMIT",
89
+ }
90
+ TYPE_FROM_WIRE = {
91
+ "ORDER_TYPE_LIMIT": OrderType.LIMIT,
92
+ "ORDER_TYPE_MARKET_TO_LIMIT": OrderType.MARKET,
93
+ "ORDER_TYPE_STOP": OrderType.STOP_MARKET,
94
+ "ORDER_TYPE_STOP_LIMIT": OrderType.STOP_LIMIT,
95
+ }
96
+
97
+ STATE = {
98
+ "ORDER_STATE_PENDING_NEW": OrderStatus.PENDING,
99
+ "ORDER_STATE_PENDING_RISK": OrderStatus.PENDING,
100
+ "ORDER_STATE_NEW": OrderStatus.OPEN,
101
+ "ORDER_STATE_PARTIALLY_FILLED": OrderStatus.OPEN,
102
+ "ORDER_STATE_PENDING_REPLACE": OrderStatus.PENDING_REPLACE,
103
+ "ORDER_STATE_PENDING_CANCEL": OrderStatus.PENDING_CANCEL,
104
+ "ORDER_STATE_FILLED": OrderStatus.CLOSED,
105
+ "ORDER_STATE_CANCELED": OrderStatus.CANCELED,
106
+ "ORDER_STATE_REPLACED": OrderStatus.CANCELED,
107
+ "ORDER_STATE_REJECTED": OrderStatus.REJECTED,
108
+ "ORDER_STATE_EXPIRED": OrderStatus.EXPIRED,
109
+ }
110
+
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # Instruments and scaling
114
+ # ---------------------------------------------------------------------------
115
+
116
+ @dataclass
117
+ class InstrumentScale:
118
+ """How one symbol's integers map to prices and contracts."""
119
+
120
+ symbol: str
121
+ price_scale: int
122
+ qty_scale: int
123
+ tick: Decimal
124
+ min_quantity: Decimal
125
+ state: str = ""
126
+ raw: dict[str, Any] = field(default_factory=dict)
127
+
128
+ @property
129
+ def precision(self) -> Precision:
130
+ whole = self.min_quantity >= 1 and self.min_quantity % 1 == 0
131
+ return Precision(tick=self.tick, min_amount=self.min_quantity, amount_step=self.min_quantity, whole_contracts=whole)
132
+
133
+ @classmethod
134
+ def from_instrument(cls, raw: dict[str, Any]) -> "InstrumentScale":
135
+ price_scale = int(raw.get("priceScale") or 1)
136
+ qty_scale = int(raw.get("fractionalQtyScale") or 1) or 1
137
+ tick = D(str(raw.get("tickSize"))) if raw.get("tickSize") else Decimal(1) / price_scale
138
+ if tick >= 1:
139
+ # A dollar tick of 1 or more cannot exist on a $1 contract, so a
140
+ # tick that large is in scaled units.
141
+ tick = tick / price_scale
142
+ minimum = D(str(raw.get("minimumTradeQty") or qty_scale)) / qty_scale
143
+ return cls(
144
+ symbol=str(raw.get("symbol") or ""), price_scale=price_scale, qty_scale=qty_scale,
145
+ tick=tick, min_quantity=minimum or Decimal(1), state=str(raw.get("state") or ""), raw=raw,
146
+ )
147
+
148
+ def price_to_wire(self, price: Decimal) -> str:
149
+ scaled = price * self.price_scale
150
+ if scaled != scaled.to_integral_value():
151
+ raise InvalidOrder(f"polymarket_us: price {price} is finer than {self.symbol}'s price scale {self.price_scale}")
152
+ return str(int(scaled))
153
+
154
+ def qty_to_wire(self, quantity: Decimal) -> str:
155
+ scaled = quantity * self.qty_scale
156
+ if scaled != scaled.to_integral_value():
157
+ raise InvalidOrder(f"polymarket_us: quantity {quantity} is finer than {self.symbol}'s quantity scale {self.qty_scale}")
158
+ return str(int(scaled))
159
+
160
+ def price_from_wire(self, value: Any) -> Decimal | None:
161
+ if value in (None, "", "0", 0):
162
+ return None
163
+ return D(str(value)) / self.price_scale
164
+
165
+ def qty_from_wire(self, value: Any) -> Decimal:
166
+ return D(str(value or 0)) / self.qty_scale
167
+
168
+
169
+ # ---------------------------------------------------------------------------
170
+ # Pure translation
171
+ # ---------------------------------------------------------------------------
172
+
173
+ def symbol_of(market_id: str) -> str:
174
+ """The exchange symbol (the market slug) behind a Synpath market id or a
175
+ bare slug. Another venue's id is refused before anything is signed."""
176
+ try:
177
+ return ids.native(VENUE, market_id)
178
+ except BadRequest as exc:
179
+ raise InvalidOrder(str(exc)) from None
180
+
181
+
182
+ def rfc3339(ms: int) -> str:
183
+ return datetime.fromtimestamp(ms / 1000, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
184
+
185
+
186
+ def wire_side(side: Side) -> str:
187
+ """Orders are already on the YES leg, which is what the exchange trades."""
188
+ return "SIDE_BUY" if side == Side.BUY else "SIDE_SELL"
189
+
190
+
191
+ def translate_order(request: OrderRequest, scale: InstrumentScale, *, account: str) -> dict[str, Any]:
192
+ """An `OrderRequest` as an `InsertOrderRequest`, on the YES leg."""
193
+ if request.type not in NATIVE_ORDER_TYPES:
194
+ raise InvalidOrder(
195
+ f"polymarket_us: {request.type.value} is held by the execution engine; this venue holds "
196
+ f"limit, stop-market and stop-limit orders"
197
+ )
198
+ if request.time_in_force == TimeInForce.DAY:
199
+ raise InvalidOrder("polymarket_us: 'day' is rewritten to 'gtd' by the engine; the venue's DAY does not cancel at the roll")
200
+ if request.reduce_only:
201
+ raise InvalidOrder("polymarket_us: the exchange API has no reduce-only flag")
202
+ symbol = symbol_of(request.market_id)
203
+ precision = scale.precision
204
+ needs_price = request.type in (OrderType.LIMIT, OrderType.MARKET, OrderType.STOP_LIMIT)
205
+ if needs_price and request.price is None:
206
+ raise InvalidOrder(
207
+ "polymarket_us: a price is required -- a market order is sent as an immediate limit at the "
208
+ "protection price you give, and a stop-limit needs its limit"
209
+ )
210
+ if request.type in (OrderType.STOP_MARKET, OrderType.STOP_LIMIT) and request.stop_price is None:
211
+ raise InvalidOrder("polymarket_us: a stop order needs stop_price")
212
+ price = validate_price(D(request.price), precision) if needs_price else None
213
+ stop = validate_price(D(request.stop_price), precision) if request.stop_price is not None else None
214
+ quantity = validate_amount(D(request.amount), precision)
215
+ side, yes_price, yes_stop = wire_side(request.side), price, stop
216
+ tif = request.time_in_force
217
+ if request.type == OrderType.MARKET:
218
+ tif = TimeInForce.FOK if tif == TimeInForce.FOK else TimeInForce.IOC
219
+ if request.post_only and tif not in (TimeInForce.GTC, TimeInForce.GTD):
220
+ raise InvalidOrder("polymarket_us: post-only orders must be gtc or gtd")
221
+ body: dict[str, Any] = {
222
+ "type": TYPE_TO_WIRE[request.type],
223
+ "side": side,
224
+ "orderQty": scale.qty_to_wire(quantity),
225
+ "symbol": symbol,
226
+ "timeInForce": TIF_TO_WIRE[tif],
227
+ "clordId": request.client_order_id or str(uuid.uuid4()),
228
+ "account": account,
229
+ "manualOrderIndicator": "MANUAL_ORDER_INDICATOR_MANUAL" if request.params.get("manual") else "MANUAL_ORDER_INDICATOR_AUTOMATED",
230
+ }
231
+ if yes_price is not None:
232
+ body["price"] = scale.price_to_wire(yes_price)
233
+ if yes_stop is not None:
234
+ body["stopPrice"] = scale.price_to_wire(yes_stop)
235
+ if tif == TimeInForce.GTD:
236
+ if request.expires_at is None:
237
+ raise InvalidOrder("polymarket_us: a gtd order needs expires_at")
238
+ body["goodTillTime"] = rfc3339(request.expires_at)
239
+ if request.post_only:
240
+ body["participateDontInitiate"] = True
241
+ for key in ("selfMatchPreventionInstruction", "selfMatchPreventionId", "orderCapacity", "minQty", "allOrNone"):
242
+ if key in request.params:
243
+ body[key] = request.params[key]
244
+ return body
245
+
246
+
247
+ # ---------------------------------------------------------------------------
248
+ # Normalizers
249
+ # ---------------------------------------------------------------------------
250
+
251
+ def order_of(raw: dict[str, Any], scale: InstrumentScale, *, account: Account | None = None) -> Order:
252
+ """An exchange order, on the YES leg."""
253
+ symbol = str(raw.get("symbol") or scale.symbol)
254
+ amount = scale.qty_from_wire(raw.get("orderQty"))
255
+ filled = scale.qty_from_wire(raw.get("cumQty"))
256
+ leaves = raw.get("leavesQty")
257
+ return Order(
258
+ id=str(raw.get("id") or ""),
259
+ client_order_id=raw.get("clordId") or None,
260
+ venue=VENUE,
261
+ account=account,
262
+ market_id=ids.qualify(VENUE, symbol),
263
+ side=Side.BUY if raw.get("side") == "SIDE_BUY" else Side.SELL,
264
+ type=TYPE_FROM_WIRE.get(str(raw.get("type") or ""), OrderType.LIMIT),
265
+ time_in_force=TIF_FROM_WIRE.get(str(raw.get("timeInForce") or ""), TimeInForce.GTC),
266
+ status=STATE.get(str(raw.get("state") or ""), OrderStatus.PENDING),
267
+ price=scale.price_from_wire(raw.get("price")),
268
+ stop_price=scale.price_from_wire(raw.get("stopPrice")),
269
+ amount=amount,
270
+ filled=filled,
271
+ remaining=scale.qty_from_wire(leaves) if leaves is not None else None,
272
+ average_price=scale.price_from_wire(raw.get("avgPx")) if filled > 0 else None,
273
+ post_only=bool(raw.get("participateDontInitiate")),
274
+ expires_at=parse_ts(raw.get("goodTillTime")),
275
+ created_at=parse_ts(raw.get("createTime")),
276
+ updated_at=parse_ts(raw.get("lastTransactTime") or raw.get("insertTime")),
277
+ info=raw,
278
+ )
279
+
280
+
281
+ def scale_from_order(order: dict[str, Any], fallback: InstrumentScale | None = None) -> InstrumentScale | None:
282
+ """The scales an order carries (copied from its instrument when it was
283
+ entered), or `fallback` where it carries none."""
284
+ price_scale = int(order.get("priceScale") or 0)
285
+ if not price_scale:
286
+ return fallback
287
+ qty_scale = int(order.get("fractionalQuantityScale") or 0) or 1
288
+ if fallback is not None and (fallback.price_scale, fallback.qty_scale) == (price_scale, qty_scale):
289
+ return fallback
290
+ return InstrumentScale(
291
+ symbol=str(order.get("symbol") or (fallback.symbol if fallback else "")), price_scale=price_scale,
292
+ qty_scale=qty_scale, tick=fallback.tick if fallback else Decimal(1) / price_scale,
293
+ min_quantity=fallback.min_quantity if fallback else Decimal(1) / qty_scale,
294
+ )
295
+
296
+
297
+ def commission_of(value: Any, scale: InstrumentScale) -> Decimal | None:
298
+ """A commission field in dollars. Commissions are notional units: one
299
+ dollar is `price_scale * fractional_quantity_scale` of them. Negative is
300
+ a rebate."""
301
+ if value in (None, ""):
302
+ return None
303
+ return D(str(value)) / (scale.price_scale * scale.qty_scale)
304
+
305
+
306
+ def fill_of(raw: dict[str, Any], scale: InstrumentScale, *, account: Account | None = None) -> Fill:
307
+ order = raw.get("order") or {}
308
+ scale = scale_from_order(order, scale) or scale
309
+ symbol = str(order.get("symbol") or scale.symbol)
310
+ return Fill(
311
+ id=str(raw.get("tradeId") or raw.get("id") or ""),
312
+ order_id=str(order.get("id") or ""),
313
+ client_order_id=order.get("clordId") or None,
314
+ venue=VENUE,
315
+ account=account,
316
+ market_id=ids.qualify(VENUE, symbol),
317
+ side=Side.BUY if order.get("side") == "SIDE_BUY" else Side.SELL,
318
+ price=scale.price_from_wire(raw.get("lastPx")) or Decimal("0"),
319
+ amount=scale.qty_from_wire(raw.get("lastShares")),
320
+ fee=commission_of(raw.get("commissionNotionalCollected"), scale),
321
+ fee_currency="USD",
322
+ liquidity=Liquidity.TAKER if raw.get("aggressor") else Liquidity.MAKER,
323
+ timestamp=parse_ts(raw.get("transactTime")) or 0,
324
+ info=raw,
325
+ )
326
+
327
+
328
+ def position_of(raw: dict[str, Any], scale: InstrumentScale, *, account: Account | None = None) -> Position:
329
+ """A netted position. Cost and realized P&L stay in `info`: the schema
330
+ gives them as integers without saying which scale they use."""
331
+ symbol = str(raw.get("symbol") or scale.symbol)
332
+ net = scale.qty_from_wire(raw.get("netPosition"))
333
+ return Position(
334
+ venue=VENUE, account=account, market_id=ids.qualify(VENUE, symbol),
335
+ side=PositionSide.LONG if net > 0 else PositionSide.SHORT if net < 0 else PositionSide.FLAT,
336
+ contracts=abs(net), resolved=bool(raw.get("expired")),
337
+ timestamp=parse_ts(raw.get("updateTime")), info=raw,
338
+ )
339
+
340
+
341
+ GRPC_CODES = {3: "invalid_argument", 5: "not_found", 6: "already_exists", 7: "permission_denied", 8: "resource_exhausted", 9: "failed_precondition"}
342
+
343
+
344
+ def error_of(exc: ExchangeError) -> ExchangeError:
345
+ body = exc.body if isinstance(exc.body, dict) else {}
346
+ message = str(body.get("message") or exc)
347
+ code = body.get("code")
348
+ text = message.lower()
349
+ if "global rate limit exceeded" in text:
350
+ return OrderRejected(message, reason="latency_stopgap", info=body, body=exc.body, status=exc.status)
351
+ if code == 7 or exc.status == 403:
352
+ return PermissionDenied(message, body=exc.body, status=exc.status)
353
+ if isinstance(exc, AuthenticationError):
354
+ return exc
355
+ if code == 6 or (exc.status == 409 and "clord" in text):
356
+ return DuplicateClientOrderId(message)
357
+ if "buying power" in text or "insufficient" in text:
358
+ return InsufficientFunds(message, body=exc.body, status=exc.status)
359
+ if any(word in text for word in ("market closed", "exchange closed", "instrument closed", "halted", "suspended", "not open")):
360
+ return MarketHalted(message, body=exc.body, status=exc.status)
361
+ if code == 5 or isinstance(exc, MarketNotFound):
362
+ return OrderNotFound(message, body=exc.body, status=exc.status)
363
+ if isinstance(exc, BadRequest) or code in GRPC_CODES:
364
+ return OrderRejected(message, reason=GRPC_CODES.get(code) if isinstance(code, int) else None, info=body, body=exc.body, status=exc.status)
365
+ return exc
366
+
367
+
368
+ # ---------------------------------------------------------------------------
369
+ # Auth
370
+ # ---------------------------------------------------------------------------
371
+
372
+ class TokenSource:
373
+ """Signs the client assertion and keeps a valid access token.
374
+
375
+ One refresh at a time: concurrent callers wait for the same token rather
376
+ than each asking Auth0 for their own.
377
+ """
378
+
379
+ def __init__(self, credentials: PolymarketUSExchangeCredentials, http: AsyncHttpClient, *, audience: str, domain: str):
380
+ try:
381
+ import jwt # noqa: F401
382
+ from cryptography.hazmat.primitives import serialization
383
+ except ImportError as exc: # pragma: no cover - depends on the environment
384
+ raise ImportError("Polymarket US exchange auth needs PyJWT: pip install synpath") from exc
385
+ self.client_id = credentials.client_id
386
+ self._key = serialization.load_pem_private_key(credentials.private_key_pem, password=None)
387
+ self.http = http
388
+ self.audience = audience
389
+ self.domain = domain
390
+ self._token: str | None = None
391
+ self._expires = 0.0
392
+ self._lock = asyncio.Lock()
393
+
394
+ def assertion(self, *, now: int | None = None) -> str:
395
+ import jwt
396
+
397
+ stamp = int(now if now is not None else time.time())
398
+ claims = {
399
+ "iss": self.client_id, "sub": self.client_id, "aud": f"https://{self.domain}/oauth/token",
400
+ "iat": stamp, "exp": stamp + 300, "jti": str(uuid.uuid4()),
401
+ }
402
+ return jwt.encode(claims, self._key, algorithm="RS256")
403
+
404
+ async def token(self) -> str:
405
+ if self._token and time.monotonic() < self._expires:
406
+ return self._token
407
+ async with self._lock:
408
+ if self._token and time.monotonic() < self._expires:
409
+ return self._token
410
+ raw = await self.http.post(f"https://{self.domain}/oauth/token", json={
411
+ "client_id": self.client_id,
412
+ "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
413
+ "client_assertion": self.assertion(),
414
+ "audience": self.audience,
415
+ "grant_type": "client_credentials",
416
+ })
417
+ if not raw or not raw.get("access_token"):
418
+ raise CredentialsMissing("polymarket_us: the token endpoint returned no access token")
419
+ self._token = str(raw["access_token"])
420
+ self._expires = time.monotonic() + max(0, int(raw.get("expires_in") or 180) - TOKEN_MARGIN_S)
421
+ return self._token
422
+
423
+ def invalidate(self) -> None:
424
+ self._token, self._expires = None, 0.0
425
+
426
+
427
+ # ---------------------------------------------------------------------------
428
+ # Adapter
429
+ # ---------------------------------------------------------------------------
430
+
431
+ class PolymarketUSExchangeTrading(TradingExchange):
432
+ """Polymarket US order entry on the exchange API.
433
+
434
+ ```python
435
+ from synpath.trading.credentials import load_credentials, require
436
+ from synpath.trading.polymarket_us_exchange import PolymarketUSExchangeTrading
437
+
438
+ creds = require("polymarket_us_exchange", load_credentials())
439
+ async with PolymarketUSExchangeTrading(creds) as pmx:
440
+ print(await pmx.whoami())
441
+ print(await pmx.fetch_balance())
442
+ ```
443
+ """
444
+
445
+ id = VENUE
446
+ name = "Polymarket US (exchange API)"
447
+ native_order_types = NATIVE_ORDER_TYPES
448
+ """Order types the exchange holds itself, stops included."""
449
+ has: dict[str, Capability] = {
450
+ "create_order": True,
451
+ "create_orders": True,
452
+ "cancel_order": True,
453
+ "cancel_orders": True,
454
+ # No cancel-all endpoint: open orders are listed and cancelled
455
+ # twenty to a request.
456
+ "cancel_all_orders": True,
457
+ "edit_order": True,
458
+ "fetch_order": True,
459
+ "fetch_open_orders": True,
460
+ "fetch_orders": True,
461
+ "fetch_my_trades": True,
462
+ "fetch_positions": True,
463
+ "fetch_balance": True,
464
+ "fetch_settlements": False,
465
+ "fetch_queue_position": False,
466
+ "fetch_fee_estimate": True,
467
+ "rfq": False,
468
+ "split_merge": False,
469
+ "watch_orders": False,
470
+ "watch_my_trades": False,
471
+ "watch_positions": False,
472
+ "watch_balance": False,
473
+ }
474
+
475
+ def __init__(
476
+ self,
477
+ credentials: PolymarketUSExchangeCredentials,
478
+ *,
479
+ account_name: str | None = None,
480
+ base_url: str | None = None,
481
+ auth_domain: str | None = None,
482
+ gateway_url: str = GATEWAY_URL,
483
+ limiter: BudgetLimiter | None = None,
484
+ timeout: float = 30.0,
485
+ client: Any = None,
486
+ ):
487
+ import httpx
488
+
489
+ env_url, env_domain = ENVIRONMENTS[credentials.env]
490
+ self.credentials = credentials
491
+ self.base_url = (base_url or env_url).rstrip("/")
492
+ shared = client or httpx.AsyncClient(timeout=timeout, follow_redirects=True)
493
+ self._http = shared
494
+ self.api = AsyncHttpClient(self.base_url, limiter=None, client=shared, venue=VENUE)
495
+ self.gateway = AsyncHttpClient(gateway_url, limiter=None, client=shared, venue=VENUE)
496
+ self.tokens = TokenSource(credentials, self.api, audience=self.base_url, domain=auth_domain or env_domain)
497
+ self.trading_account = credentials.account
498
+ self.account = Account(venue=VENUE, name=account_name or credentials.account or "default")
499
+ # 100 requests a second per firm on a one-minute average; the query
500
+ # endpoints below also have their own, much smaller budgets.
501
+ self.limiter = limiter or BudgetLimiter(read_per_second=50, write_per_second=50)
502
+ self.search_orders_budget = BudgetLimiter(read_per_second=12 / 60, write_per_second=12 / 60, burst_seconds=60, max_wait_s=30)
503
+ self.search_executions_budget = BudgetLimiter(read_per_second=12 / 60, write_per_second=12 / 60, burst_seconds=60, max_wait_s=30)
504
+ self.refdata_budget = BudgetLimiter(read_per_second=6 / 60, write_per_second=6 / 60, burst_seconds=60, max_wait_s=30)
505
+ self._scales: dict[str, InstrumentScale] = {}
506
+ self._gateway_markets: dict[str, dict[str, Any]] = {}
507
+
508
+ # -- transport ------------------------------------------------------------
509
+
510
+ async def _call(
511
+ self, method: str, path: str, *, params: Any = None, json: Any = None, kind: str = "write",
512
+ priority: Priority = Priority.NORMAL, budget: BudgetLimiter | None = None, account_scoped: bool = True,
513
+ ) -> Any:
514
+ await self.limiter.acquire(cost=1, kind=kind, priority=priority) # type: ignore[arg-type]
515
+ if budget is not None:
516
+ await budget.acquire(cost=1, kind="read")
517
+ for attempt in range(2):
518
+ headers = {"Authorization": f"Bearer {await self.tokens.token()}"}
519
+ if account_scoped:
520
+ headers["x-participant-id"] = self.credentials.participant_id
521
+ try:
522
+ return await self.api.request(method, path, params=params or None, json=json, headers=headers)
523
+ except AuthenticationError as exc:
524
+ if exc.status == 401 and attempt == 0:
525
+ self.tokens.invalidate()
526
+ continue
527
+ raise error_of(exc) from None
528
+ except ExchangeError as exc:
529
+ raise error_of(exc) from None
530
+ raise AssertionError("unreachable")
531
+
532
+ async def _account(self) -> str:
533
+ if not self.trading_account:
534
+ accounts = await self.list_accounts()
535
+ if not accounts:
536
+ raise PermissionDenied(f"polymarket_us: participant {self.credentials.participant_id} has no trading account")
537
+ self.trading_account = accounts[0]
538
+ return self.trading_account
539
+
540
+ # -- identity -------------------------------------------------------------
541
+
542
+ async def whoami(self) -> dict[str, Any]:
543
+ return await self._call("GET", "/v1/whoami", kind="read")
544
+
545
+ async def list_accounts(self) -> list[str]:
546
+ raw = await self._call("GET", "/v1/accounts", kind="read")
547
+ return [str(a) for a in (raw or {}).get("accounts") or []]
548
+
549
+ # -- reference data -------------------------------------------------------
550
+
551
+ async def load_instruments(self, symbols: list[str], *, strict: bool = True) -> dict[str, InstrumentScale]:
552
+ """Scales for these symbols, fetched in one reference-data request and
553
+ cached for the life of the adapter. With `strict`, a symbol reference
554
+ data does not know is an `InvalidOrder`; without, it is left out."""
555
+ missing = [s for s in dict.fromkeys(symbols) if s not in self._scales]
556
+ if missing:
557
+ raw = await self._call(
558
+ "POST", "/v1/refdata/instruments", json={"symbols": missing, "pageSize": max(len(missing), 1)},
559
+ kind="read", budget=self.refdata_budget, account_scoped=False,
560
+ )
561
+ for row in (raw or {}).get("instruments") or []:
562
+ scale = InstrumentScale.from_instrument(row)
563
+ self._scales[scale.symbol] = scale
564
+ unknown = [s for s in symbols if s not in self._scales]
565
+ if unknown and strict:
566
+ raise InvalidOrder(f"polymarket_us: no instrument {unknown[0]!r} in reference data")
567
+ return {s: self._scales[s] for s in symbols if s in self._scales}
568
+
569
+ async def scale_of(self, symbol: str) -> InstrumentScale:
570
+ return (await self.load_instruments([symbol]))[symbol]
571
+
572
+ def cached_scale(self, symbol: str) -> InstrumentScale | None:
573
+ """A symbol's scales if already read, without asking reference data."""
574
+ return self._scales.get(symbol)
575
+
576
+ def remember_instrument(self, scale: InstrumentScale) -> None:
577
+ self._scales[scale.symbol] = scale
578
+
579
+ # -- orders ---------------------------------------------------------------
580
+
581
+ async def create_order(self, request: OrderRequest) -> Order:
582
+ """Insert one order. Returns it `pending` with the exchange's id; the
583
+ exchange accepts or rejects it asynchronously."""
584
+ symbol = symbol_of(request.market_id)
585
+ scale = await self.scale_of(symbol)
586
+ body = translate_order(request, scale, account=await self._account())
587
+ raw = await self._call("POST", "/v1/trading/orders", json=body)
588
+ return self._pending(str((raw or {}).get("orderId") or ""), request, body, scale)
589
+
590
+ def _pending(self, order_id: str, request: OrderRequest, body: dict[str, Any], scale: InstrumentScale) -> Order:
591
+ order = order_of({**body, "id": order_id, "state": "ORDER_STATE_PENDING_NEW"}, scale, account=request.account or self.account)
592
+ return order.model_copy(update={
593
+ "created_at": int(time.time() * 1000), "book": request.book, "trader": request.trader,
594
+ "tags": request.tags, "info": {"request": body},
595
+ })
596
+
597
+ async def create_orders(self, requests: list[OrderRequest]) -> list[Order | Exception]:
598
+ account = await self._account()
599
+ symbols = []
600
+ for request in requests:
601
+ try:
602
+ symbols.append(symbol_of(request.market_id))
603
+ except InvalidOrder:
604
+ pass
605
+ await self.load_instruments(symbols, strict=False)
606
+ prepared: list[tuple[dict[str, Any], InstrumentScale] | Exception] = []
607
+ for request in requests:
608
+ try:
609
+ symbol = symbol_of(request.market_id)
610
+ scale = self._scales[symbol]
611
+ prepared.append((translate_order(request, scale, account=account), scale))
612
+ except InvalidOrder as exc:
613
+ prepared.append(exc)
614
+ except KeyError:
615
+ prepared.append(InvalidOrder(f"polymarket_us: no instrument for {request.market_id!r} in reference data"))
616
+ results: list[Order | Exception] = [p if isinstance(p, Exception) else p for p in prepared] # type: ignore[misc]
617
+ sendable = [i for i, p in enumerate(prepared) if not isinstance(p, Exception)]
618
+ for start in range(0, len(sendable), BATCH):
619
+ chunk = sendable[start:start + BATCH]
620
+ raw = await self._call("POST", "/v1/trading/orders/list", json={"requests": [prepared[i][0] for i in chunk]}) # type: ignore[index]
621
+ answers = list((raw or {}).get("responses") or [])
622
+ for offset, index in enumerate(chunk):
623
+ body, scale = prepared[index] # type: ignore[misc]
624
+ order_id = (answers[offset] or {}).get("orderId") if offset < len(answers) else None
625
+ results[index] = (
626
+ self._pending(str(order_id), requests[index], body, scale) if order_id
627
+ else OrderRejected("polymarket_us: no order id returned for this entry", reason="no_id")
628
+ )
629
+ return results
630
+
631
+ async def cancel_order(self, order_id: str, *, market_id: str | None = None, current: Order | None = None) -> Order:
632
+ """Request a cancel. Returns the order `pending_cancel`; pass
633
+ `current` (or `market_id`) to spare the lookup of its symbol."""
634
+ current = current or await self._find_order(order_id, symbol=symbol_of(market_id) if market_id else None)
635
+ await self._call(
636
+ "POST", "/v1/trading/orders/cancel", json={"orderId": order_id, "symbol": symbol_of(current.market_id)},
637
+ priority=Priority.HIGH,
638
+ )
639
+ return current.model_copy(update={"status": OrderStatus.PENDING_CANCEL})
640
+
641
+ async def cancel_orders(self, order_ids: list[str], *, market_id: str | None = None) -> list[Order | Exception]:
642
+ open_orders = {o.id: o for o in await self.fetch_open_orders(market_id=market_id)}
643
+ results: dict[str, Order | Exception] = {}
644
+ known = [oid for oid in order_ids if oid in open_orders]
645
+ for oid in order_ids:
646
+ if oid not in open_orders:
647
+ results[oid] = OrderNotFound(f"polymarket_us: {oid} is not an open order")
648
+ for start in range(0, len(known), BATCH):
649
+ chunk = known[start:start + BATCH]
650
+ await self._call(
651
+ "POST", "/v1/trading/orders/cancel/list",
652
+ json={"requests": [{"orderId": oid, "symbol": symbol_of(open_orders[oid].market_id)} for oid in chunk]},
653
+ priority=Priority.HIGH,
654
+ )
655
+ for oid in chunk:
656
+ results[oid] = open_orders[oid].model_copy(update={"status": OrderStatus.PENDING_CANCEL})
657
+ return [results[oid] for oid in order_ids]
658
+
659
+ async def cancel_all_orders(self, *, market_id: str | None = None) -> int:
660
+ """Every open order, or every one on a symbol: listed, then cancelled
661
+ twenty to a request. Returns how many cancels were sent."""
662
+ orders = await self.fetch_open_orders(market_id=market_id)
663
+ results = await self.cancel_orders([o.id for o in orders], market_id=market_id)
664
+ return sum(1 for r in results if not isinstance(r, Exception))
665
+
666
+ async def edit_order(self, request: EditRequest, *, current: Order | None = None) -> Order:
667
+ """Cancel-replace price, quantity, time in force or expiry. Returns the
668
+ order `pending_replace`; whether queue priority survives is not
669
+ published, so `queue_priority_preserved` is `None`. Prices are YES
670
+ prices, as the order reads back."""
671
+ current = current or await self._find_order(request.order_id)
672
+ scale = await self.scale_of(symbol_of(current.market_id))
673
+ body: dict[str, Any] = {
674
+ "orderId": request.order_id, "symbol": symbol_of(current.market_id),
675
+ "clordId": request.client_order_id or str(uuid.uuid4()),
676
+ "manualOrderIndicator": "MANUAL_ORDER_INDICATOR_AUTOMATED",
677
+ }
678
+ price = D(request.price) if request.price is not None else current.price
679
+ if price is not None:
680
+ body["price"] = scale.price_to_wire(validate_price(price, scale.precision))
681
+ if current.stop_price is not None:
682
+ body["stopPrice"] = scale.price_to_wire(current.stop_price)
683
+ quantity = validate_amount(D(request.amount), scale.precision) if request.amount is not None else current.amount
684
+ body["orderQty"] = scale.qty_to_wire(quantity)
685
+ tif = request.time_in_force or current.time_in_force
686
+ if tif == TimeInForce.DAY:
687
+ raise InvalidOrder("polymarket_us: 'day' is rewritten to 'gtd' by the engine")
688
+ body["timeInForce"] = TIF_TO_WIRE[tif]
689
+ expires = request.expires_at or current.expires_at
690
+ if tif == TimeInForce.GTD:
691
+ if expires is None:
692
+ raise InvalidOrder("polymarket_us: a gtd order needs expires_at")
693
+ body["goodTillTime"] = rfc3339(expires)
694
+ await self._call("POST", "/v1/trading/orders/replace", json=body)
695
+ return current.model_copy(update={
696
+ "status": OrderStatus.PENDING_REPLACE, "queue_priority_preserved": None,
697
+ "info": {**current.info, "replace": body},
698
+ })
699
+
700
+ async def fetch_open_orders(self, *, market_id: str | None = None) -> list[Order]:
701
+ params: dict[str, Any] = {"accounts": await self._account()}
702
+ if market_id:
703
+ params["symbols"] = symbol_of(market_id)
704
+ raw = await self._call("GET", "/v1/trading/orders/open", params=params, kind="read")
705
+ rows = (raw or {}).get("orders") or []
706
+ scales = await self.load_instruments([str(r.get("symbol")) for r in rows]) if rows else {}
707
+ return [order_of(r, scales[str(r.get("symbol"))], account=self.account) for r in rows]
708
+
709
+ async def _find_order(self, order_id: str, *, symbol: str | None = None) -> Order:
710
+ for order in await self.fetch_open_orders(market_id=symbol):
711
+ if order.id == order_id:
712
+ return order
713
+ return await self.fetch_order(order_id)
714
+
715
+ async def fetch_order(self, order_id: str) -> Order:
716
+ """By id: from the open orders when it is still working, otherwise
717
+ from order search, which is rationed to twelve requests a minute."""
718
+ for order in await self.fetch_open_orders():
719
+ if order.id == order_id:
720
+ return order
721
+ page = await self.fetch_orders(order_id=order_id, limit=1)
722
+ if not page:
723
+ raise OrderNotFound(f"polymarket_us: no order {order_id}")
724
+ return page[0]
725
+
726
+ async def fetch_orders(
727
+ self, *, status: str | None = None, market_id: str | None = None, since: int | None = None,
728
+ limit: int | None = None, cursor: str | None = None, order_id: str | None = None,
729
+ ) -> Page[Order]:
730
+ """Order search. `status` is the venue's filter word without its
731
+ prefix: `open`, `closed`, `filled`, `canceled`, `rejected`, `expired`."""
732
+ body: dict[str, Any] = {"accounts": [await self._account()]}
733
+ if status:
734
+ body["orderStateFilter"] = f"ORDER_STATE_FILTER_{status.upper()}"
735
+ for key, value in (("symbol", symbol_of(market_id) if market_id else None), ("orderId", order_id), ("pageToken", cursor), ("pageSize", limit)):
736
+ if value:
737
+ body[key] = value
738
+ if since:
739
+ body["startTime"] = rfc3339(since)
740
+ raw = await self._call("POST", "/v1/report/orders/search", json=body, kind="read", budget=self.search_orders_budget)
741
+ rows = (raw or {}).get("order") or (raw or {}).get("orders") or []
742
+ scales = await self.load_instruments([str(r.get("symbol")) for r in rows]) if rows else {}
743
+ return Page(
744
+ [order_of(r, scales[str(r.get("symbol"))], account=self.account) for r in rows],
745
+ next_cursor=(raw or {}).get("nextPageToken") or None,
746
+ )
747
+
748
+ async def fetch_my_trades(
749
+ self, *, market_id: str | None = None, order_id: str | None = None, since: int | None = None,
750
+ limit: int | None = None, cursor: str | None = None,
751
+ ) -> Page[Fill]:
752
+ body: dict[str, Any] = {
753
+ "accounts": [await self._account()],
754
+ "types": ["EXECUTION_TYPE_FILL", "EXECUTION_TYPE_PARTIAL_FILL"],
755
+ "newestFirst": True,
756
+ }
757
+ for key, value in (("symbol", symbol_of(market_id) if market_id else None), ("orderId", order_id), ("pageToken", cursor), ("pageSize", limit)):
758
+ if value:
759
+ body[key] = value
760
+ if since:
761
+ body["startTime"] = rfc3339(since)
762
+ raw = await self._call("POST", "/v1/report/executions/search", json=body, kind="read", budget=self.search_executions_budget)
763
+ rows = (raw or {}).get("executions") or []
764
+ scales = await self.load_instruments([str((r.get("order") or {}).get("symbol")) for r in rows]) if rows else {}
765
+ return Page(
766
+ [fill_of(r, scales[str((r.get("order") or {}).get("symbol"))], account=self.account) for r in rows],
767
+ next_cursor=None if (raw or {}).get("eof") else (raw or {}).get("nextPageToken") or None,
768
+ )
769
+
770
+ async def preview_order(self, request: OrderRequest) -> Order:
771
+ symbol = symbol_of(request.market_id)
772
+ scale = await self.scale_of(symbol)
773
+ body = translate_order(request, scale, account=await self._account())
774
+ raw = await self._call("POST", "/v1/trading/orders/preview", json={"request": body}, kind="read")
775
+ return order_of((raw or {}).get("previewOrder") or {}, scale, account=self.account)
776
+
777
+ # -- account --------------------------------------------------------------
778
+
779
+ async def fetch_positions(self, *, market_id: str | None = None, event_id: str | None = None) -> list[Position]:
780
+ params: dict[str, Any] = {"name": await self._account()}
781
+ if market_id:
782
+ params["symbol"] = symbol_of(market_id)
783
+ raw = await self._call("GET", "/v1/positions", params=params, kind="read")
784
+ rows = (raw or {}).get("positions") or []
785
+ scales = await self.load_instruments([str(r.get("symbol")) for r in rows]) if rows else {}
786
+ positions = [position_of(r, scales[str(r.get("symbol"))], account=self.account) for r in rows]
787
+ return [p for p in positions if p.contracts > 0]
788
+
789
+ async def fetch_balance(self, *, account: Account | None = None) -> Balance:
790
+ raw = await self._call("POST", "/v1/positions/balance", json={"name": await self._account(), "currency": "USD"}, kind="read") or {}
791
+ total = D(raw.get("balance") or "0")
792
+ buying_power = D(raw["buyingPower"]) if raw.get("buyingPower") not in (None, "") else None
793
+ return Balance(
794
+ venue=VENUE, account=account or self.account, currency="USD", total=total,
795
+ available=buying_power if buying_power is not None else total,
796
+ locked=D(raw["openOrders"]) if raw.get("openOrders") not in (None, "") else None,
797
+ buying_power=buying_power, timestamp=parse_ts(raw.get("updateTime")), info=raw,
798
+ )
799
+
800
+ async def fetch_fee_estimate(self, market_id: str, side: Side, price: Decimal, amount: Decimal) -> FeeEstimate:
801
+ """From the public gateway's market (same symbols): the taker theta and
802
+ the venue's maker rebate, at the YES price."""
803
+ symbol = symbol_of(market_id)
804
+ market = self._gateway_markets.get(symbol)
805
+ if market is None:
806
+ try:
807
+ raw = await self.gateway.get(f"/v1/market/slug/{symbol}")
808
+ except MarketNotFound:
809
+ raise NotSupported(f"polymarket_us: {symbol} is not on the public gateway") from None
810
+ market = self._gateway_markets[symbol] = (raw or {}).get("market") or raw or {}
811
+ schedule = fee_schedule_of(market)
812
+ if schedule is None:
813
+ raise NotSupported(f"polymarket_us: market {symbol} publishes no fee coefficient")
814
+ wire = float(D(price))
815
+ taker = schedule.estimate(wire, float(amount), taker=True)
816
+ maker = schedule.estimate(wire, float(amount), taker=False)
817
+ return FeeEstimate(
818
+ venue=VENUE, market_id=ids.qualify(VENUE, symbol), side=side, price=D(price), amount=D(amount),
819
+ taker_fee=D(str(taker)) if taker is not None else None,
820
+ maker_fee=D(str(maker)) if maker is not None else None,
821
+ currency="USD", info={"schedule": schedule.model_dump()},
822
+ )
823
+
824
+ async def close(self) -> None:
825
+ await self._http.aclose()