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,705 @@
1
+ """Polymarket US order entry, on the retail API (`api.polymarket.us`).
2
+
3
+ Polymarket US is a CFTC-regulated exchange with two trading APIs. This is
4
+ the one any verified account can use: a key from polymarket.us/developer,
5
+ Ed25519-signed requests, twenty requests a second. The exchange API for
6
+ onboarded firms (private-key JWT, integer prices, a preprod environment)
7
+ is `polymarket_us_exchange`.
8
+
9
+ Facts that shape the adapter:
10
+
11
+ **One book per market, both outcomes addressable.** The market trades one
12
+ instrument, its YES side; the price on the wire is always the YES price.
13
+ But an order names its outcome and action (`OUTCOME_SIDE_NO` +
14
+ `ORDER_ACTION_BUY` is buying NO), so an order for `{slug}:no` is sent as
15
+ that, priced at `1 - q`, and reads back as the same NO order. Positions
16
+ net: long YES, or short YES (the venue's own word for holding NO), with
17
+ margin rather than a second inventory.
18
+
19
+ **Order entry is asynchronous.** Creating, cancelling or modifying returns
20
+ an id and nothing about the outcome; the matching engine decides after.
21
+ Results here are `pending`, `pending_cancel` or read back, never assumed.
22
+ Batched cancels and modifies echo the ids sent, which the venue says is not
23
+ a confirmation.
24
+
25
+ **No client order id, no reduce-only.** The venue assigns order ids; a
26
+ `client_order_id` is kept on the returned `Order` for the caller's own
27
+ records and is not sent. Reduce-only is refused: the venue offers
28
+ close-position instead.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import base64
33
+ import time
34
+ from dataclasses import dataclass, field
35
+ from datetime import datetime, timezone
36
+ from decimal import Decimal
37
+ from typing import Any
38
+
39
+ from ..base import AsyncHttpClient, Capability
40
+ from .. import ids
41
+ from ..errors import AuthenticationError, BadRequest, ExchangeError, MarketNotFound, NotSupported
42
+ from ..polymarket_us import fee_schedule_of, parse_ts
43
+ from ..types import Page
44
+ from .base import TradingExchange
45
+ from .credentials import PolymarketUSCredentials
46
+ from .errors import InsufficientFunds, InvalidOrder, MarketHalted, OrderNotFound, OrderRejected
47
+ from .limiter import BudgetLimiter, Priority
48
+ from .money import D, complement, validate_amount, validate_price
49
+ from .types import (
50
+ Account, Balance, EditRequest, FeeEstimate, Order, OrderRequest, OrderStatus, OrderType, Position,
51
+ PositionSide, Precision, Settlement, Side, TimeInForce, VENUE_ORDER_TYPES,
52
+ )
53
+
54
+ VENUE = "polymarket_us"
55
+ API_URL = "https://api.polymarket.us"
56
+ GATEWAY_URL = "https://gateway.polymarket.us"
57
+
58
+ BATCH = 20
59
+ PRICE_FLOOR = Decimal("0.01")
60
+ PRICE_CEILING = Decimal("0.99")
61
+ """The exchange's absolute price limits. A price outside them still gets an
62
+ order id and is then rejected, so it is refused here first."""
63
+
64
+ MARKET_TTL_S = 300.0
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Market rules
69
+ # ---------------------------------------------------------------------------
70
+
71
+ @dataclass
72
+ class MarketRules:
73
+ """What an order on one market must respect, from the public gateway."""
74
+
75
+ slug: str
76
+ tick: Decimal
77
+ min_quantity: Decimal
78
+ raw: dict[str, Any] = field(default_factory=dict)
79
+ read_at: float = field(default_factory=time.monotonic)
80
+
81
+ @property
82
+ def precision(self) -> Precision:
83
+ whole = self.min_quantity >= 1 and self.min_quantity % 1 == 0
84
+ return Precision(
85
+ tick=self.tick, min_amount=self.min_quantity, amount_step=self.min_quantity, whole_contracts=whole,
86
+ )
87
+
88
+ @classmethod
89
+ def from_market(cls, market: dict[str, Any]) -> "MarketRules":
90
+ return cls(
91
+ slug=str(market.get("slug") or ""),
92
+ tick=D(str(market.get("orderPriceMinTickSize") or "0.01")),
93
+ min_quantity=D(str(market.get("minimumTradeQty") or "1")),
94
+ raw=market,
95
+ )
96
+
97
+
98
+ # ---------------------------------------------------------------------------
99
+ # Pure translation
100
+ # ---------------------------------------------------------------------------
101
+
102
+ def slug_of(market_id: str) -> str:
103
+ """The slug behind a Synpath market id (`polymarket_us:<slug>`) or a bare
104
+ slug. Another venue's id is refused before anything is signed."""
105
+ try:
106
+ return ids.native(VENUE, market_id)
107
+ except BadRequest as exc:
108
+ raise InvalidOrder(str(exc)) from None
109
+
110
+
111
+ TIF_TO_WIRE = {
112
+ TimeInForce.GTC: "TIME_IN_FORCE_GOOD_TILL_CANCEL",
113
+ TimeInForce.GTD: "TIME_IN_FORCE_GOOD_TILL_DATE",
114
+ TimeInForce.IOC: "TIME_IN_FORCE_IMMEDIATE_OR_CANCEL",
115
+ TimeInForce.FOK: "TIME_IN_FORCE_FILL_OR_KILL",
116
+ }
117
+ TIF_FROM_WIRE = {wire: tif for tif, wire in TIF_TO_WIRE.items()} | {"TIME_IN_FORCE_DAY": TimeInForce.DAY}
118
+
119
+
120
+ def rfc3339(ms: int) -> str:
121
+ return datetime.fromtimestamp(ms / 1000, tz=timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
122
+
123
+
124
+ def number(value: Decimal) -> int | float:
125
+ """A quantity as the JSON number the venue wants: an integer when whole."""
126
+ return int(value) if value == value.to_integral_value() else float(value)
127
+
128
+
129
+ def usd(value: Decimal) -> dict[str, str]:
130
+ return {"value": str(value), "currency": "USD"}
131
+
132
+
133
+ def check_prices(price: Decimal, rules: MarketRules) -> Decimal:
134
+ """Validate a YES price; the wire carries it as given."""
135
+ validate_price(price, rules.precision)
136
+ wire = price
137
+ if not PRICE_FLOOR <= wire <= PRICE_CEILING:
138
+ raise InvalidOrder(
139
+ f"polymarket_us: the YES price would be {wire}, outside the exchange's [{PRICE_FLOOR}, {PRICE_CEILING}]"
140
+ )
141
+ return wire
142
+
143
+
144
+ def translate_order(request: OrderRequest, rules: MarketRules) -> dict[str, Any]:
145
+ """An `OrderRequest` as the `POST /v1/orders` body."""
146
+ if request.type not in VENUE_ORDER_TYPES:
147
+ raise InvalidOrder(
148
+ f"polymarket_us: {request.type.value} is held by the execution engine on this API; "
149
+ f"submit it through the engine"
150
+ )
151
+ if request.time_in_force == TimeInForce.DAY:
152
+ raise InvalidOrder(
153
+ "polymarket_us: 'day' is rewritten to 'gtd' by the engine; the venue's own DAY orders do not "
154
+ "cancel at the session roll"
155
+ )
156
+ if request.price is None:
157
+ raise InvalidOrder(
158
+ "polymarket_us: a price is required -- a market order is sent as an immediate limit at the "
159
+ "protection price you give"
160
+ )
161
+ if request.reduce_only:
162
+ raise InvalidOrder("polymarket_us: the venue has no reduce-only flag; use close_position")
163
+ slug = slug_of(request.market_id)
164
+ wire_price = check_prices(D(request.price), rules)
165
+ quantity = validate_amount(D(request.amount), rules.precision)
166
+ if request.type == OrderType.MARKET:
167
+ tif = TimeInForce.FOK if request.time_in_force == TimeInForce.FOK else TimeInForce.IOC
168
+ else:
169
+ tif = request.time_in_force
170
+ if request.post_only and tif not in (TimeInForce.GTC, TimeInForce.GTD):
171
+ raise InvalidOrder("polymarket_us: post-only orders must be gtc or gtd")
172
+ body: dict[str, Any] = {
173
+ "marketSlug": slug,
174
+ "type": "ORDER_TYPE_LIMIT",
175
+ "price": usd(wire_price),
176
+ "quantity": number(quantity),
177
+ "tif": TIF_TO_WIRE[tif],
178
+ # Always the YES leg, as every price in this library is: `sell` is a
179
+ # YES sell, which on this netting venue is the same order as a NO buy.
180
+ "outcomeSide": "OUTCOME_SIDE_YES",
181
+ "action": "ORDER_ACTION_BUY" if request.side == Side.BUY else "ORDER_ACTION_SELL",
182
+ "manualOrderIndicator": (
183
+ "MANUAL_ORDER_INDICATOR_MANUAL" if request.params.get("manual") else "MANUAL_ORDER_INDICATOR_AUTOMATIC"
184
+ ),
185
+ }
186
+ if tif == TimeInForce.GTD:
187
+ if request.expires_at is None:
188
+ raise InvalidOrder("polymarket_us: a gtd order needs expires_at")
189
+ body["goodTillTime"] = rfc3339(request.expires_at)
190
+ if request.post_only:
191
+ body["participateDontInitiate"] = True
192
+ return body
193
+
194
+
195
+ # ---------------------------------------------------------------------------
196
+ # Normalizers
197
+ # ---------------------------------------------------------------------------
198
+
199
+ STATE = {
200
+ "ORDER_STATE_PENDING_NEW": OrderStatus.PENDING,
201
+ "ORDER_STATE_PENDING_RISK": OrderStatus.PENDING,
202
+ "ORDER_STATE_NEW": OrderStatus.OPEN,
203
+ "ORDER_STATE_PARTIALLY_FILLED": OrderStatus.OPEN,
204
+ "ORDER_STATE_PENDING_REPLACE": OrderStatus.PENDING_REPLACE,
205
+ "ORDER_STATE_PENDING_CANCEL": OrderStatus.PENDING_CANCEL,
206
+ "ORDER_STATE_FILLED": OrderStatus.CLOSED,
207
+ "ORDER_STATE_CANCELED": OrderStatus.CANCELED,
208
+ "ORDER_STATE_REPLACED": OrderStatus.CANCELED,
209
+ "ORDER_STATE_REJECTED": OrderStatus.REJECTED,
210
+ "ORDER_STATE_EXPIRED": OrderStatus.EXPIRED,
211
+ }
212
+ """`REPLACED` is the superseded half of a cancel-replace; it will not trade
213
+ again, so it reads as cancelled with the native state kept in `info`."""
214
+
215
+ INTENT = {
216
+ "ORDER_INTENT_BUY_LONG": ("yes", Side.BUY),
217
+ "ORDER_INTENT_SELL_LONG": ("yes", Side.SELL),
218
+ "ORDER_INTENT_BUY_SHORT": ("no", Side.BUY),
219
+ "ORDER_INTENT_SELL_SHORT": ("no", Side.SELL),
220
+ }
221
+
222
+
223
+ def _amount(value: Any) -> Decimal | None:
224
+ if isinstance(value, dict):
225
+ value = value.get("value")
226
+ return D(value) if value not in (None, "") else None
227
+
228
+
229
+ def outcome_and_side(raw: dict[str, Any]) -> tuple[str, Side]:
230
+ """The outcome and action the venue holds the order in."""
231
+ side, action = raw.get("outcomeSide"), raw.get("action")
232
+ if side and action:
233
+ return ("yes" if side == "OUTCOME_SIDE_YES" else "no"), (Side.BUY if action == "ORDER_ACTION_BUY" else Side.SELL)
234
+ if raw.get("intent") in INTENT:
235
+ return INTENT[raw["intent"]]
236
+ return "yes", Side.BUY if raw.get("side") == "ORDER_SIDE_BUY" else Side.SELL
237
+
238
+
239
+ def yes_leg(outcome: str, side: Side) -> Side:
240
+ """An order the venue holds as NO, seen from the YES leg: buying NO is
241
+ selling YES and vice versa."""
242
+ if outcome == "yes":
243
+ return side
244
+ return Side.SELL if side == Side.BUY else Side.BUY
245
+
246
+
247
+ def yes_price(price: Decimal, outcome: str) -> Decimal:
248
+ return price if outcome == "yes" else complement(price)
249
+
250
+
251
+ def order_of(raw: dict[str, Any], *, account: Account | None = None) -> Order:
252
+ """A venue order on the YES leg: an order the venue holds as a NO buy at
253
+ 0.30 reads as a sell at 0.70."""
254
+ slug = str(raw.get("marketSlug") or (raw.get("marketMetadata") or {}).get("slug") or "")
255
+ outcome, venue_side = outcome_and_side(raw)
256
+ side = yes_leg(outcome, venue_side)
257
+ price = _amount(raw.get("price"))
258
+ average = _amount(raw.get("avgPx"))
259
+ amount = D(str(raw.get("quantity") or 0))
260
+ filled = D(str(raw.get("cumQuantity") or 0))
261
+ leaves = raw.get("leavesQuantity")
262
+ status = STATE.get(str(raw.get("state") or ""), OrderStatus.PENDING)
263
+ return Order(
264
+ id=str(raw.get("id") or ""),
265
+ venue=VENUE,
266
+ account=account,
267
+ market_id=ids.qualify(VENUE, slug),
268
+ side=side,
269
+ type=OrderType.MARKET if raw.get("type") == "ORDER_TYPE_MARKET" else OrderType.LIMIT,
270
+ time_in_force=TIF_FROM_WIRE.get(str(raw.get("tif") or ""), TimeInForce.GTC),
271
+ status=status,
272
+ price=price, # the venue already quotes the YES price
273
+ amount=amount,
274
+ filled=filled,
275
+ remaining=D(str(leaves)) if leaves is not None else None,
276
+ average_price=average if average is not None and filled > 0 else None,
277
+ fee=_amount(raw.get("commissionNotionalTotalCollected")),
278
+ fee_currency="USD",
279
+ expires_at=parse_ts(raw.get("goodTillTime")),
280
+ created_at=parse_ts(raw.get("createTime")),
281
+ updated_at=parse_ts(raw.get("insertTime")),
282
+ info=raw,
283
+ )
284
+
285
+
286
+ def pending_order(order_id: str, request: OrderRequest, body: dict[str, Any], account: Account) -> Order:
287
+ """The order just sent, before the engine has said anything about it."""
288
+ return Order(
289
+ id=order_id, client_order_id=request.client_order_id, venue=VENUE, account=account,
290
+ market_id=ids.qualify(VENUE, body["marketSlug"]), side=request.side,
291
+ type=request.type, time_in_force=TIF_FROM_WIRE[body["tif"]], status=OrderStatus.PENDING,
292
+ price=D(request.price) if request.price is not None else None, amount=D(request.amount),
293
+ post_only=bool(body.get("participateDontInitiate")), expires_at=request.expires_at,
294
+ created_at=int(time.time() * 1000), book=request.book, trader=request.trader, tags=request.tags,
295
+ info={"request": body},
296
+ )
297
+
298
+
299
+ def position_of(slug: str, raw: dict[str, Any], *, account: Account | None = None) -> Position:
300
+ """A netted position: long or short the market's YES side."""
301
+ net = D(raw.get("netPositionDecimal") or raw.get("netPosition") or "0")
302
+ cost = _amount(raw.get("cost"))
303
+ contracts = abs(net)
304
+ return Position(
305
+ venue=VENUE,
306
+ account=account,
307
+ market_id=ids.qualify(VENUE, slug),
308
+ side=PositionSide.LONG if net > 0 else PositionSide.SHORT if net < 0 else PositionSide.FLAT,
309
+ contracts=contracts,
310
+ entry_price=(abs(cost) / contracts).quantize(Decimal("0.0001")) if cost is not None and contracts > 0 else None,
311
+ unrealized_pnl=_amount(raw.get("cashValue")),
312
+ realized_pnl=_amount(raw.get("realized")),
313
+ resolved=bool(raw.get("expired")),
314
+ timestamp=parse_ts(raw.get("updateTime")),
315
+ info=raw,
316
+ )
317
+
318
+
319
+ RESOLUTION_RESULT = {"POSITION_RESOLUTION_SIDE_LONG": "yes", "POSITION_RESOLUTION_SIDE_SHORT": "no"}
320
+
321
+
322
+ def settlement_of(raw: dict[str, Any], *, account: Account | None = None) -> Settlement:
323
+ """A position resolution from the activity feed. Realized P&L is the change
324
+ across the resolution; `result` follows the venue's resolution side."""
325
+ before = raw.get("beforePosition") or {}
326
+ after = raw.get("afterPosition") or {}
327
+ net = D(before.get("netPositionDecimal") or before.get("netPosition") or "0")
328
+ result = RESOLUTION_RESULT.get(str(raw.get("side") or ""))
329
+ held = "yes" if net > 0 else "no" if net < 0 else None
330
+ realized_before, realized_after = _amount(before.get("realized")), _amount(after.get("realized"))
331
+ slug = str(raw.get("marketSlug") or "")
332
+ return Settlement(
333
+ venue=VENUE,
334
+ account=account,
335
+ market_id=ids.qualify(VENUE, slug),
336
+ held=PositionSide.LONG if held == "yes" else PositionSide.SHORT if held == "no" else None,
337
+ result=result,
338
+ won=(held == result) if held and result else None,
339
+ amount=abs(net) if held else None,
340
+ cost=_amount(before.get("cost")),
341
+ payout=None,
342
+ pnl=(realized_after - realized_before) if realized_after is not None and realized_before is not None else None,
343
+ timestamp=parse_ts(raw.get("updateTime")),
344
+ info=raw,
345
+ )
346
+
347
+
348
+ def balance_of(raw: dict[str, Any], *, account: Account) -> Balance:
349
+ rows = raw.get("balances") or []
350
+ row = next((r for r in rows if str(r.get("currency") or "USD").upper() == "USD"), rows[0] if rows else {})
351
+ total = D(str(row.get("currentBalance") or 0))
352
+ buying_power = D(str(row["buyingPower"])) if row.get("buyingPower") is not None else None
353
+ return Balance(
354
+ venue=VENUE, account=account, currency="USD", total=total,
355
+ available=buying_power if buying_power is not None else total,
356
+ locked=D(str(row["openOrders"])) if row.get("openOrders") is not None else None,
357
+ buying_power=buying_power, timestamp=parse_ts(row.get("lastUpdated")), info=row,
358
+ )
359
+
360
+
361
+ def error_of(exc: ExchangeError) -> ExchangeError:
362
+ body = exc.body if isinstance(exc.body, dict) else {}
363
+ message = str(body.get("message") or body.get("error") or exc)
364
+ text = message.lower()
365
+ if isinstance(exc, AuthenticationError):
366
+ return exc
367
+ if "global rate limit exceeded" in text:
368
+ # The venue's five-second latency stopgap, not a rate limit: safe to resend.
369
+ return OrderRejected(message, reason="latency_stopgap", info=body, body=exc.body, status=exc.status)
370
+ if "buying power" in text or "insufficient" in text:
371
+ return InsufficientFunds(message, body=exc.body, status=exc.status)
372
+ if ("closed" in text and "market" in text) or "halt" in text or "maintenance" in text:
373
+ return MarketHalted(message, body=exc.body, status=exc.status)
374
+ if isinstance(exc, MarketNotFound):
375
+ return OrderNotFound(message, body=exc.body, status=exc.status)
376
+ if isinstance(exc, BadRequest):
377
+ return OrderRejected(message, reason=str(body.get("code") or "") or None, info=body, body=exc.body, status=exc.status)
378
+ return exc
379
+
380
+
381
+ # ---------------------------------------------------------------------------
382
+ # Adapter
383
+ # ---------------------------------------------------------------------------
384
+
385
+ class PolymarketUSSigner:
386
+ """Holds the Ed25519 key and signs `timestamp + METHOD + path`, the path
387
+ without its query string, as the venue's SDK does."""
388
+
389
+ def __init__(self, key_id: str, secret_key: str):
390
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
391
+
392
+ raw = base64.b64decode(secret_key)
393
+ if len(raw) not in (32, 64):
394
+ raise InvalidOrder("polymarket_us: the secret key must be a base64 Ed25519 key (32 or 64 bytes)")
395
+ self.key_id = key_id
396
+ self._key = Ed25519PrivateKey.from_private_bytes(raw[:32])
397
+
398
+ def headers(self, method: str, path: str, *, timestamp_ms: int | None = None) -> dict[str, str]:
399
+ stamp = str(timestamp_ms if timestamp_ms is not None else int(time.time() * 1000))
400
+ signature = self._key.sign(f"{stamp}{method.upper()}{path}".encode())
401
+ return {
402
+ "X-PM-Access-Key": self.key_id,
403
+ "X-PM-Timestamp": stamp,
404
+ "X-PM-Signature": base64.b64encode(signature).decode(),
405
+ }
406
+
407
+
408
+ class PolymarketUSTrading(TradingExchange):
409
+ """Polymarket US order entry on the retail API.
410
+
411
+ ```python
412
+ from synpath.trading.credentials import load_credentials, require
413
+ from synpath.trading.polymarket_us import PolymarketUSTrading
414
+
415
+ creds = require("polymarket_us", load_credentials())
416
+ async with PolymarketUSTrading(creds) as us:
417
+ print(await us.fetch_balance())
418
+ ```
419
+ """
420
+
421
+ id = VENUE
422
+ name = "Polymarket US"
423
+ has: dict[str, Capability] = {
424
+ "create_order": True,
425
+ "create_orders": True,
426
+ "cancel_order": True,
427
+ "cancel_orders": True,
428
+ "cancel_all_orders": True,
429
+ "edit_order": True,
430
+ "fetch_order": True,
431
+ "fetch_open_orders": True,
432
+ "fetch_orders": False,
433
+ # The activity feed's trades carry no order id and no side, so they
434
+ # cannot be fills; `fetch_activities` returns them as the venue does.
435
+ "fetch_my_trades": False,
436
+ "fetch_positions": True,
437
+ "fetch_balance": True,
438
+ "fetch_settlements": True,
439
+ "fetch_queue_position": False,
440
+ "fetch_fee_estimate": True,
441
+ # RFQ on this venue quotes combos, which synpath does not model yet.
442
+ "rfq": False,
443
+ "split_merge": False,
444
+ "watch_orders": False,
445
+ "watch_my_trades": False,
446
+ "watch_positions": False,
447
+ "watch_balance": False,
448
+ }
449
+
450
+ def __init__(
451
+ self,
452
+ credentials: PolymarketUSCredentials,
453
+ *,
454
+ account_name: str = "default",
455
+ api_url: str = API_URL,
456
+ gateway_url: str = GATEWAY_URL,
457
+ limiter: BudgetLimiter | None = None,
458
+ timeout: float = 30.0,
459
+ client: Any = None,
460
+ ):
461
+ import httpx
462
+
463
+ self.credentials = credentials
464
+ self.signer = PolymarketUSSigner(credentials.key_id, credentials.secret_key)
465
+ self.account = Account(venue=VENUE, name=account_name)
466
+ # Twenty requests a second per key, shared by every endpoint: one lane.
467
+ self.limiter = limiter or BudgetLimiter(read_per_second=20, write_per_second=20, burst_seconds=1)
468
+ shared = client or httpx.AsyncClient(timeout=timeout, follow_redirects=True)
469
+ self.api = AsyncHttpClient(api_url, limiter=None, client=shared, venue=VENUE)
470
+ self.gateway = AsyncHttpClient(gateway_url, limiter=None, client=shared, venue=VENUE)
471
+ self._http = shared
472
+ self._markets: dict[str, MarketRules] = {}
473
+
474
+ async def _call(
475
+ self, method: str, path: str, *, params: Any = None, json: Any = None, priority: Priority = Priority.NORMAL,
476
+ ) -> Any:
477
+ await self.limiter.acquire(cost=1, kind="write", priority=priority)
478
+ headers = self.signer.headers(method, path)
479
+ try:
480
+ return await self.api.request(
481
+ method, path, params=params or None, json=json, headers=headers,
482
+ )
483
+ except ExchangeError as exc:
484
+ raise error_of(exc) from None
485
+
486
+ # -- market rules ---------------------------------------------------------
487
+
488
+ async def market_rules(self, slug: str, *, refresh: bool = False) -> MarketRules:
489
+ cached = self._markets.get(slug)
490
+ if cached and not refresh and time.monotonic() - cached.read_at < MARKET_TTL_S:
491
+ return cached
492
+ try:
493
+ raw = await self.gateway.get(f"/v1/market/slug/{slug}")
494
+ except MarketNotFound:
495
+ raise InvalidOrder(f"polymarket_us: no market {slug!r}") from None
496
+ rules = MarketRules.from_market((raw or {}).get("market") or raw or {})
497
+ self._markets[slug] = rules
498
+ return rules
499
+
500
+ def remember_market(self, rules: MarketRules) -> None:
501
+ self._markets[rules.slug] = rules
502
+
503
+ # -- orders ---------------------------------------------------------------
504
+
505
+ async def create_order(self, request: OrderRequest, *, rules: MarketRules | None = None) -> Order:
506
+ """Send one order. The result is `pending`: the venue answers with an
507
+ id, and whether the order rests, fills or is rejected comes after."""
508
+ slug = slug_of(request.market_id)
509
+ body = translate_order(request, rules or await self.market_rules(slug))
510
+ raw = await self._call("POST", "/v1/orders", json=body)
511
+ executions = (raw or {}).get("executions") or []
512
+ if executions and executions[-1].get("order"):
513
+ order = order_of(executions[-1]["order"], account=request.account or self.account)
514
+ return order.model_copy(update={"client_order_id": request.client_order_id})
515
+ return pending_order(str((raw or {}).get("id") or ""), request, body, request.account or self.account)
516
+
517
+ async def create_orders(self, requests: list[OrderRequest]) -> list[Order | Exception]:
518
+ """Twenty to a request. The gateway rejects a whole batch if any entry
519
+ is malformed, so every entry is validated here first and the refused
520
+ ones never join a batch."""
521
+ prepared: list[dict[str, Any] | Exception] = []
522
+ for request in requests:
523
+ try:
524
+ slug = slug_of(request.market_id)
525
+ prepared.append(translate_order(request, await self.market_rules(slug)))
526
+ except InvalidOrder as exc:
527
+ prepared.append(exc)
528
+ results: list[Order | Exception] = list(prepared) # type: ignore[arg-type]
529
+ sendable = [i for i, p in enumerate(prepared) if isinstance(p, dict)]
530
+ for start in range(0, len(sendable), BATCH):
531
+ chunk = sendable[start:start + BATCH]
532
+ raw = await self._call("POST", "/v1/orders/batched", json={"orders": [prepared[i] for i in chunk]})
533
+ ids = list((raw or {}).get("createdOrderIds") or [])
534
+ for offset, index in enumerate(chunk):
535
+ if offset < len(ids) and ids[offset]:
536
+ results[index] = pending_order(str(ids[offset]), requests[index], prepared[index], requests[index].account or self.account) # type: ignore[arg-type]
537
+ else:
538
+ results[index] = OrderRejected("polymarket_us: no order id returned for this entry", reason="no_id")
539
+ return results
540
+
541
+ async def cancel_order(self, order_id: str, *, market_id: str | None = None) -> Order:
542
+ """Ask for a cancel and read the order back; it is usually
543
+ `pending_cancel` or already `canceled` by then."""
544
+ slug = slug_of(market_id) if market_id else slug_of((await self.fetch_order(order_id)).market_id)
545
+ await self._call("POST", f"/v1/order/{order_id}/cancel", json={"marketSlug": slug}, priority=Priority.HIGH)
546
+ return await self.fetch_order(order_id)
547
+
548
+ async def cancel_orders(self, order_ids: list[str], *, market_id: str | None = None) -> list[Order | Exception]:
549
+ """Cancel by id, twenty to a request. The venue echoes the ids rather
550
+ than confirming them, so each comes back `pending_cancel`; an id not
551
+ among the open orders comes back `OrderNotFound` without being sent."""
552
+ open_orders = {o.id: o for o in await self.fetch_open_orders(market_id=market_id)}
553
+ results: dict[str, Order | Exception] = {}
554
+ entries = []
555
+ for oid in order_ids:
556
+ if oid in open_orders:
557
+ entries.append({"orderId": oid, "marketSlug": slug_of(open_orders[oid].market_id)})
558
+ else:
559
+ results[oid] = OrderNotFound(f"polymarket_us: {oid} is not an open order")
560
+ for start in range(0, len(entries), BATCH):
561
+ chunk = entries[start:start + BATCH]
562
+ await self._call("POST", "/v1/orders/batched/cancel", json={"orders": chunk}, priority=Priority.HIGH)
563
+ for entry in chunk:
564
+ results[entry["orderId"]] = open_orders[entry["orderId"]].model_copy(update={"status": OrderStatus.PENDING_CANCEL})
565
+ return [results[oid] for oid in order_ids]
566
+
567
+ async def cancel_all_orders(self, *, market_id: str | None = None) -> int:
568
+ body = {"slugs": [slug_of(market_id)]} if market_id else {}
569
+ raw = await self._call("POST", "/v1/orders/open/cancel", json=body, priority=Priority.HIGH)
570
+ return len((raw or {}).get("canceledOrderIds") or [])
571
+
572
+ async def edit_order(self, request: EditRequest, *, current: Order | None = None) -> Order:
573
+ """Modify price, quantity, time in force or expiry in place.
574
+
575
+ The venue forwards a modify to the exchange as a cancel-replace, and
576
+ does not say whether queue priority survives, so
577
+ `queue_priority_preserved` is `None`. Prices are in the order's own
578
+ outcome terms; the quantity is the order's new total.
579
+ """
580
+ current = current or await self.fetch_order(request.order_id)
581
+ slug = slug_of(current.market_id)
582
+ rules = await self.market_rules(slug)
583
+ price = D(request.price) if request.price is not None else current.price
584
+ if price is None:
585
+ raise InvalidOrder("polymarket_us: the order has no price to keep")
586
+ quantity = validate_amount(D(request.amount), rules.precision) if request.amount is not None else current.amount
587
+ tif = request.time_in_force or current.time_in_force
588
+ if tif == TimeInForce.DAY:
589
+ raise InvalidOrder("polymarket_us: 'day' is rewritten to 'gtd' by the engine")
590
+ body: dict[str, Any] = {
591
+ "marketSlug": slug,
592
+ "price": usd(check_prices(price, rules)),
593
+ "quantity": number(quantity),
594
+ "tif": TIF_TO_WIRE[tif],
595
+ }
596
+ expires = request.expires_at or current.expires_at
597
+ if tif == TimeInForce.GTD:
598
+ if expires is None:
599
+ raise InvalidOrder("polymarket_us: a gtd order needs expires_at")
600
+ body["goodTillTime"] = rfc3339(expires)
601
+ await self._call("POST", f"/v1/order/{request.order_id}/modify", json=body)
602
+ order = await self.fetch_order(request.order_id)
603
+ return order.model_copy(update={"queue_priority_preserved": None, "info": {**order.info, "modify": body}})
604
+
605
+ async def fetch_order(self, order_id: str) -> Order:
606
+ raw = await self._call("GET", f"/v1/order/{order_id}")
607
+ if not raw or not (raw.get("order") or {}).get("id"):
608
+ raise OrderNotFound(f"polymarket_us: no order {order_id}")
609
+ return order_of(raw["order"], account=self.account)
610
+
611
+ async def fetch_open_orders(self, *, market_id: str | None = None) -> list[Order]:
612
+ raw = await self._call("GET", "/v1/orders/open", params={"slugs": slug_of(market_id)} if market_id else None)
613
+ return [order_of(row, account=self.account) for row in (raw or {}).get("orders") or []]
614
+
615
+ async def preview_order(self, request: OrderRequest) -> Order:
616
+ """The venue's own validation and expected result, without placing anything."""
617
+ slug = slug_of(request.market_id)
618
+ body = translate_order(request, await self.market_rules(slug))
619
+ raw = await self._call("POST", "/v1/order/preview", json={"request": body})
620
+ return order_of((raw or {}).get("order") or {}, account=self.account)
621
+
622
+ async def close_position(
623
+ self, market_id: str, *, slippage_ticks: int | None = None, reference_price: Decimal | None = None,
624
+ ) -> Order:
625
+ """Sell everything held in a market at market, within an optional
626
+ slippage band of `slippage_ticks` from `reference_price` (a YES price)."""
627
+ slug = slug_of(market_id)
628
+ body: dict[str, Any] = {"marketSlug": slug, "manualOrderIndicator": "MANUAL_ORDER_INDICATOR_AUTOMATIC"}
629
+ if slippage_ticks is not None:
630
+ if reference_price is None:
631
+ raise InvalidOrder("polymarket_us: a slippage band needs the reference price it is measured from")
632
+ body["slippageTolerance"] = {"currentPrice": usd(D(reference_price)), "ticks": int(slippage_ticks)}
633
+ raw = await self._call("POST", "/v1/order/close-position", json=body)
634
+ executions = (raw or {}).get("executions") or []
635
+ if executions and executions[-1].get("order"):
636
+ return order_of(executions[-1]["order"], account=self.account)
637
+ return Order(
638
+ id=str((raw or {}).get("id") or ""), venue=VENUE, account=self.account,
639
+ market_id=ids.qualify(VENUE, slug), side=Side.SELL, type=OrderType.MARKET,
640
+ time_in_force=TimeInForce.IOC, status=OrderStatus.PENDING, amount=Decimal("0"),
641
+ info={"request": body, "close_position": True},
642
+ )
643
+
644
+ # -- account --------------------------------------------------------------
645
+
646
+ async def fetch_positions(self, *, market_id: str | None = None, event_id: str | None = None) -> list[Position]:
647
+ positions: list[Position] = []
648
+ cursor: str | None = None
649
+ while True:
650
+ raw = await self._call("GET", "/v1/portfolio/positions", params={
651
+ k: v for k, v in {"market": slug_of(market_id) if market_id else None, "limit": 100, "cursor": cursor}.items() if v is not None
652
+ })
653
+ for slug, row in ((raw or {}).get("positions") or {}).items():
654
+ positions.append(position_of(slug, row, account=self.account))
655
+ cursor = (raw or {}).get("nextCursor") or None
656
+ if (raw or {}).get("eof") or not cursor:
657
+ return [p for p in positions if p.contracts > 0]
658
+
659
+ async def fetch_balance(self, *, account: Account | None = None) -> Balance:
660
+ return balance_of(await self._call("GET", "/v1/account/balances") or {}, account=account or self.account)
661
+
662
+ async def fetch_activities(
663
+ self, *, types: list[str] | None = None, market_id: str | None = None, limit: int | None = None,
664
+ cursor: str | None = None, newest_first: bool = True,
665
+ ) -> Page:
666
+ """The venue's activity feed as it sends it: trades, resolutions,
667
+ deposits and withdrawals, rebates."""
668
+ params = {
669
+ "types": types, "marketSlug": slug_of(market_id) if market_id else None, "limit": limit, "cursor": cursor,
670
+ "sortOrder": "SORT_ORDER_DESCENDING" if newest_first else "SORT_ORDER_ASCENDING",
671
+ }
672
+ raw = await self._call("GET", "/v1/portfolio/activities", params={k: v for k, v in params.items() if v is not None})
673
+ return Page(list((raw or {}).get("activities") or []), next_cursor=None if (raw or {}).get("eof") else (raw or {}).get("nextCursor"))
674
+
675
+ async def fetch_settlements(
676
+ self, *, market_id: str | None = None, since: int | None = None, limit: int | None = None, cursor: str | None = None,
677
+ ) -> Page[Settlement]:
678
+ page = await self.fetch_activities(
679
+ types=["ACTIVITY_TYPE_POSITION_RESOLUTION"], market_id=market_id, limit=limit, cursor=cursor,
680
+ )
681
+ rows = [settlement_of(a.get("positionResolution") or {}, account=self.account) for a in page]
682
+ if since:
683
+ rows = [r for r in rows if (r.timestamp or 0) >= since]
684
+ return Page(rows, next_cursor=page.next_cursor)
685
+
686
+ async def fetch_fee_estimate(self, market_id: str, side: Side, price: Decimal, amount: Decimal) -> FeeEstimate:
687
+ """The market's published taker theta and the venue's maker rebate at
688
+ the YES `price` for `amount` contracts. A negative maker fee is a rebate."""
689
+ slug = slug_of(market_id)
690
+ rules = await self.market_rules(slug)
691
+ schedule = fee_schedule_of(rules.raw)
692
+ if schedule is None:
693
+ raise NotSupported(f"polymarket_us: market {slug} publishes no fee coefficient")
694
+ wire = float(D(price))
695
+ taker = schedule.estimate(wire, float(amount), taker=True)
696
+ maker = schedule.estimate(wire, float(amount), taker=False)
697
+ return FeeEstimate(
698
+ venue=VENUE, market_id=ids.qualify(VENUE, slug), side=side, price=D(price), amount=D(amount),
699
+ taker_fee=D(str(taker)) if taker is not None else None,
700
+ maker_fee=D(str(maker)) if maker is not None else None,
701
+ currency="USD", info={"schedule": schedule.model_dump()},
702
+ )
703
+
704
+ async def close(self) -> None:
705
+ await self._http.aclose()