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,754 @@
1
+ """Polymarket US exchange API streams, over gRPC.
2
+
3
+ `grpc-api.{preprod,prod}.polymarketexchange.com:443`, authenticated with the
4
+ same Auth0 access token as the exchange REST API (sent as `authorization:
5
+ Bearer` metadata, with `x-participant-id` on account data). Each stream
6
+ here is one server-streaming RPC:
7
+
8
+ | Stream | RPC | Carries |
9
+ |---|---|---|
10
+ | `PolymarketUSExchangeOrderStream` | `OrderEntryAPI.CreateOrderSubscription` | open orders as a snapshot, then every execution |
11
+ | `PolymarketUSExchangeDropCopyStream` | `DropCopyAPI.CreateDropCopySubscription` | every execution in the firm, resumable |
12
+ | `PolymarketUSExchangeTradeCaptureStream` | `DropCopyAPI.CreateTradeCaptureReportSubscription` | trades through clearing, busts included, resumable |
13
+ | `PolymarketUSExchangePositionChangeStream` | `DropCopyAPI.CreatePositionChangeSubscription` | position changes, resumable |
14
+ | `PolymarketUSExchangeInstrumentStream` | `DropCopyAPI.CreateInstrumentStateChangeSubscription` | instrument state (open, halted, expired ...), resumable |
15
+ | `PolymarketUSExchangePositionStream` | `PositionAPI.CreatePositionSubscription` | positions as a snapshot, then changes |
16
+ | `PolymarketUSExchangeMarketDataStream` | `MarketDataSubscriptionAPI.CreateMarketDataSubscription` | books to a depth, statistics |
17
+ | `PolymarketUSExchangeBalanceLedgerStream` | `FundingAPI.CreateBalanceLedgerSubscription` | balance ledger entries, replayable by time |
18
+
19
+ What shapes them:
20
+
21
+ **The venue's protos are needed and not shipped.** See `synpath.ws.grpc`.
22
+
23
+ **Delivery is at least once.** Executions are deduplicated by id; trade
24
+ reports by id *and state*, because a trade is sent again under the same id
25
+ when it clears or is busted.
26
+
27
+ **Drop copy resumes.** Every drop-copy response carries a resume token. A
28
+ reconnect sends the last one, so nothing is missed and no reconciliation is
29
+ asked for; a token the venue refuses falls back to resuming from the time of
30
+ the last event. After each batch that moves the token a
31
+ `VenueEvent(name="checkpoint")` carries it: persist it once the events
32
+ before it are handled, and pass it back as `resume_token=` after a restart.
33
+
34
+ **A busted trade is a failed fill.** Trade capture reports become `FillEvent`s
35
+ whose `settlement` is `matched` while the trade is on its way through
36
+ clearing, `confirmed` once cleared, and `failed` when it is busted or the
37
+ clearing house rejects it -- the same fill id each time, as on Polymarket.
38
+
39
+ **Integers everywhere.** Orders carry the scales they were entered with, so
40
+ order and execution streams decode themselves. Positions and books carry
41
+ none; their scales come from reference data (six requests a minute, firm
42
+ wide), read once per symbol and cached on the trading adapter.
43
+
44
+ **Twenty streams a firm.** Every stream here is one of them.
45
+
46
+ Built from the published protos and documentation. A market-data update is
47
+ read as the whole book to the requested depth, since the message carries no
48
+ way to mark a level removed, and heartbeat intervals are not published, so
49
+ heartbeat streams reconnect after two minutes of silence.
50
+ """
51
+ from __future__ import annotations
52
+
53
+ import base64
54
+ from decimal import Decimal
55
+ from typing import Any, Callable
56
+
57
+ from .. import ids
58
+ from ..base import Capability
59
+ from ..errors import ExchangeError
60
+ from ..polymarket_us import parse_ts
61
+ from ..trading.credentials import PolymarketUSExchangeCredentials
62
+ from ..trading.polymarket_us_exchange import (
63
+ InstrumentScale, PolymarketUSExchangeTrading, fill_of, order_of, position_of, rfc3339, scale_from_order,
64
+ )
65
+ from ..trading.types import Account, Fill, Position, PositionSide, SettlementState
66
+ from .base import (
67
+ BookEvent, Event, FillEvent, LocalBook, MarketStatusEvent, OrderEvent, PositionEvent, QuoteEvent, VenueEvent, now_ms,
68
+ )
69
+ from .grpc import GrpcStream, Outcome, ProtoBundle, RecentIds
70
+
71
+ VENUE = "polymarket_us"
72
+ TARGETS = {
73
+ "preprod": "grpc-api.preprod.polymarketexchange.com:443",
74
+ "prod": "grpc-api.prod.polymarketexchange.com:443",
75
+ }
76
+ ANCHOR = "polymarket/v1/trading.proto"
77
+ PROTO_FILES = (
78
+ "polymarket/v1/trading.proto",
79
+ "polymarket/v1/dropcopy.proto",
80
+ "polymarket/v1/positions.proto",
81
+ "polymarket/v1/marketdatasubscription.proto",
82
+ "polymarket/v1/funding.proto",
83
+ )
84
+ MAX_SYMBOLS = 1000
85
+ HEARTBEAT_IDLE_S = 120.0
86
+
87
+ FILL_TYPES = {"EXECUTION_TYPE_FILL", "EXECUTION_TYPE_PARTIAL_FILL"}
88
+
89
+ INSTRUMENT_STATE = {
90
+ "INSTRUMENT_STATE_PENDING": "created",
91
+ "INSTRUMENT_STATE_OPEN": "open",
92
+ "INSTRUMENT_STATE_PREOPEN": "paused",
93
+ "INSTRUMENT_STATE_SUSPENDED": "paused",
94
+ "INSTRUMENT_STATE_HALTED": "paused",
95
+ "INSTRUMENT_STATE_MATCH_AND_CLOSE_AUCTION": "closed",
96
+ "INSTRUMENT_STATE_CLOSED": "closed",
97
+ "INSTRUMENT_STATE_EXPIRED": "closed",
98
+ "INSTRUMENT_STATE_TERMINATED": "settled",
99
+ }
100
+
101
+ TRADE_SETTLEMENT = {
102
+ "TRADE_STATE_CLEARED": SettlementState.CONFIRMED,
103
+ "TRADE_STATE_BUSTED": SettlementState.FAILED,
104
+ "TRADE_STATE_REJECTED": SettlementState.FAILED,
105
+ }
106
+ """Every other state (new, in flight, pending, acknowledged, retrying, and any
107
+ state added later) is a trade still on its way: `matched`."""
108
+
109
+
110
+ def load_protos(path: Any = None, **kwargs: Any) -> ProtoBundle:
111
+ """The exchange API's protos from `path` (a directory or the zip), or from
112
+ `SYNPATH_POLYMARKET_US_PROTOS`."""
113
+ return ProtoBundle.load(path, anchor=ANCHOR, files=PROTO_FILES, **kwargs)
114
+
115
+
116
+ # ---------------------------------------------------------------------------
117
+ # Pure normalizers, on messages as dicts
118
+ # ---------------------------------------------------------------------------
119
+
120
+ def _enum_word(value: Any, prefix: str) -> str:
121
+ text = str(value or "")
122
+ return text[len(prefix):].lower() if text.startswith(prefix) else text.lower()
123
+
124
+
125
+ def _carries_scale(order: dict[str, Any]) -> bool:
126
+ """Whether an order carries its scales; unset int64s arrive as "0"."""
127
+ return int(order.get("priceScale") or 0) != 0
128
+
129
+
130
+ def _require(scale: InstrumentScale | None, symbol: str) -> InstrumentScale:
131
+ if scale is None:
132
+ raise LookupError(f"no reference data for {symbol!r}, so its integers cannot be read")
133
+ return scale
134
+
135
+
136
+ def execution_events(execution: dict[str, Any], scale: InstrumentScale | None, *, account: Account | None) -> list[Event]:
137
+ """An execution report as the order it leaves behind and, for a fill, the fill."""
138
+ raw_order = execution.get("order") or {}
139
+ symbol = str(raw_order.get("symbol") or "")
140
+ scale = _require(scale_from_order(raw_order, scale), symbol)
141
+ kind = str(execution.get("type") or "EXECUTION_TYPE_NEW")
142
+ order = order_of(raw_order, scale, account=account)
143
+ detail = {k: execution[k] for k in ("id", "type", "text", "transactTime") if execution.get(k) not in (None, "")}
144
+ # Both reasons default to a real-looking value; they mean something only
145
+ # on a reject or an unsolicited cancel.
146
+ if kind == "EXECUTION_TYPE_REJECTED" and execution.get("orderRejectReason"):
147
+ detail["orderRejectReason"] = execution["orderRejectReason"]
148
+ if str(execution.get("unsolicitedCancelReason") or "").removesuffix("_UNDEFINED") not in ("", "UNSOLICITED_CXL_REASON"):
149
+ detail["unsolicitedCancelReason"] = execution["unsolicitedCancelReason"]
150
+ order = order.model_copy(update={"info": {**order.info, "execution": detail}})
151
+ events: list[Event] = [OrderEvent(venue=VENUE, order=order, native=_enum_word(kind, "EXECUTION_TYPE_"))]
152
+ if kind in FILL_TYPES and scale.qty_from_wire(execution.get("lastShares")) != 0:
153
+ events.append(FillEvent(venue=VENUE, fill=fill_of(execution, scale, account=account)))
154
+ return events
155
+
156
+
157
+ def trade_fills(
158
+ trade: dict[str, Any],
159
+ scale_for: Callable[[str], InstrumentScale | None],
160
+ *,
161
+ account_for: Callable[[str], Account | None],
162
+ ) -> list[Fill]:
163
+ """A trade capture report as this firm's fills, settled as far as clearing
164
+ has got. `reportingCounterparty` names the firm's side; where it is not
165
+ given, every execution that names an account is the firm's."""
166
+ state = str(trade.get("state") or "TRADE_STATE_UNDEFINED")
167
+ settlement = TRADE_SETTLEMENT.get(state, SettlementState.MATCHED)
168
+ reporting = str(trade.get("reportingCounterparty") or "")
169
+ fills: list[Fill] = []
170
+ for role in ("aggressor", "passive"):
171
+ execution = trade.get(role)
172
+ if not execution:
173
+ continue
174
+ raw_order = execution.get("order") or {}
175
+ if reporting in ("SIDE_BUY", "SIDE_SELL"):
176
+ if raw_order.get("side") != reporting:
177
+ continue
178
+ elif not raw_order.get("account"):
179
+ continue
180
+ symbol = str(raw_order.get("symbol") or "")
181
+ scale = _require(scale_from_order(raw_order, scale_for(symbol)), symbol)
182
+ fill = fill_of({**execution, "tradeId": execution.get("tradeId") or trade.get("id")}, scale, account=account_for(str(raw_order.get("account") or "")))
183
+ fills.append(fill.model_copy(update={
184
+ "settlement": settlement,
185
+ "info": {**fill.info, "trade": {k: v for k, v in trade.items() if k not in ("aggressor", "passive")}, "role": role},
186
+ }))
187
+ return fills
188
+
189
+
190
+ def market_status_of(instrument: dict[str, Any]) -> MarketStatusEvent:
191
+ native = str(instrument.get("state") or "INSTRUMENT_STATE_CLOSED")
192
+ return MarketStatusEvent(
193
+ venue=VENUE, market_id=ids.qualify(VENUE, str(instrument.get("symbol") or "")), state=INSTRUMENT_STATE.get(native, "updated"), # type: ignore[arg-type]
194
+ native=native, timestamp=parse_ts(instrument.get("updateTime")),
195
+ info={k: instrument[k] for k in ("priceScale", "fractionalQtyScale", "nonTradable", "description") if k in instrument},
196
+ )
197
+
198
+
199
+ def flat_position(account: Account | None, symbol: str) -> Position:
200
+ return Position(venue=VENUE, account=account, market_id=ids.qualify(VENUE, symbol), side=PositionSide.FLAT)
201
+
202
+
203
+ def ledger_event(entry: dict[str, Any]) -> VenueEvent:
204
+ """A balance ledger entry. Balances are decimal strings on this message."""
205
+ before = Decimal(str(entry.get("beforeBalance") or "0"))
206
+ after = Decimal(str(entry.get("afterBalance") or "0"))
207
+ kind = str(entry.get("entryType") or "")
208
+ word = _enum_word(kind, "LEDGER_ENTRY_TYPE_BALANCE_") if kind.startswith("LEDGER_ENTRY_TYPE_BALANCE_") else _enum_word(kind, "LEDGER_ENTRY_TYPE_")
209
+ stamp = parse_ts(entry.get("updateTime"))
210
+ return VenueEvent(venue=VENUE, name="balance_ledger", timestamp=stamp, payload={
211
+ "id": str(entry.get("id") or ""), "account": str(entry.get("account") or ""),
212
+ "currency": str(entry.get("currency") or ""), "before": before, "after": after, "change": after - before,
213
+ "entry_type": word, "symbol": entry.get("symbol") or None, "description": entry.get("description") or "",
214
+ "business_date": entry.get("updateBusinessDate") or None, "timestamp": stamp, "info": entry,
215
+ })
216
+
217
+
218
+ def book_sides(update: dict[str, Any], scale: InstrumentScale) -> tuple[list[tuple[Decimal, Decimal]], list[tuple[Decimal, Decimal]]]:
219
+ def side(rows: Any) -> list[tuple[Decimal, Decimal]]:
220
+ out = []
221
+ for row in rows or []:
222
+ price = scale.price_from_wire(row.get("px"))
223
+ if price is not None:
224
+ out.append((price, scale.qty_from_wire(row.get("qty"))))
225
+ return out
226
+ return side(update.get("bids")), side(update.get("offers"))
227
+
228
+
229
+ # ---------------------------------------------------------------------------
230
+ # Streams
231
+ # ---------------------------------------------------------------------------
232
+
233
+ class _ExchangeStream(GrpcStream):
234
+ venue = VENUE
235
+ account_scoped = True
236
+ """Sends `x-participant-id`; reference and market data need none."""
237
+
238
+ def __init__(
239
+ self,
240
+ source: PolymarketUSExchangeCredentials | PolymarketUSExchangeTrading,
241
+ *,
242
+ protos: ProtoBundle | Any = None,
243
+ target: str | None = None,
244
+ **kwargs: Any,
245
+ ):
246
+ if isinstance(source, PolymarketUSExchangeTrading):
247
+ self.trading, self._owns_trading = source, False
248
+ else:
249
+ self.trading, self._owns_trading = PolymarketUSExchangeTrading(source), True
250
+ if isinstance(protos, ProtoBundle):
251
+ bundle: ProtoBundle | None = protos
252
+ elif protos is None and kwargs.get("call") is not None:
253
+ bundle = None
254
+ else:
255
+ bundle = load_protos(protos)
256
+ if self.data_heartbeat:
257
+ kwargs.setdefault("idle_timeout", HEARTBEAT_IDLE_S)
258
+ super().__init__(target or TARGETS[self.trading.credentials.env], protos=bundle, **kwargs)
259
+ self.seen = RecentIds()
260
+
261
+ async def metadata(self) -> list[tuple[str, str]]:
262
+ metadata = [("authorization", f"Bearer {await self.trading.tokens.token()}")]
263
+ if self.account_scoped:
264
+ metadata.append(("x-participant-id", self.trading.credentials.participant_id))
265
+ return metadata
266
+
267
+ async def on_reauthenticate(self) -> None:
268
+ self.trading.tokens.invalidate()
269
+
270
+ async def close(self) -> None:
271
+ await super().close()
272
+ if self._owns_trading:
273
+ await self.trading.close()
274
+
275
+ def account_for(self, name: str) -> Account | None:
276
+ if not name or name == self.trading.trading_account:
277
+ return self.trading.account
278
+ return Account(venue=VENUE, name=name)
279
+
280
+ # -- reference data -------------------------------------------------------
281
+
282
+ def symbols_needing_scales(self, message: dict[str, Any]) -> list[str]:
283
+ return []
284
+
285
+ async def prepare(self, message: dict[str, Any]) -> None:
286
+ missing = [s for s in dict.fromkeys(self.symbols_needing_scales(message)) if s and self.trading.cached_scale(s) is None]
287
+ if missing:
288
+ try:
289
+ await self.trading.load_instruments(missing, strict=False)
290
+ except ExchangeError as exc:
291
+ self.status("error", f"reference data for {missing[:3]} failed: {exc}")
292
+
293
+ def _each(self, items: Any, read: Callable[[dict[str, Any]], list[Event]]) -> list[Event]:
294
+ """Read items one at a time, so one unreadable item costs only itself."""
295
+ events: list[Event] = []
296
+ for item in items or []:
297
+ try:
298
+ events.extend(read(item))
299
+ except Exception as exc:
300
+ self.status("error", f"unreadable item: {type(exc).__name__}: {exc}")
301
+ return events
302
+
303
+
304
+ class _ResumableStream(_ExchangeStream):
305
+ """A drop-copy RPC: every response carries a token to resume after it."""
306
+
307
+ items_field = ""
308
+ refused_token_codes = {"INVALID_ARGUMENT", "FAILED_PRECONDITION", "OUT_OF_RANGE", "NOT_FOUND"}
309
+
310
+ def __init__(
311
+ self,
312
+ source: PolymarketUSExchangeCredentials | PolymarketUSExchangeTrading,
313
+ *,
314
+ symbols: list[str] | None = None,
315
+ firms: list[str] | None = None,
316
+ resume_token: bytes | str | None = None,
317
+ resume_time: int | None = None,
318
+ **kwargs: Any,
319
+ ):
320
+ super().__init__(source, **kwargs)
321
+ self.symbols = list(symbols or [])
322
+ self.firms = list(firms or [])
323
+ if isinstance(resume_token, bytes):
324
+ resume_token = base64.b64encode(resume_token).decode()
325
+ self.resume_token: str | None = resume_token or None
326
+ """The last token, base64 as the venue's JSON mapping writes bytes."""
327
+ self.resume_time: int | None = resume_time
328
+ """Where to resume from without a token, in milliseconds."""
329
+ self.last_event_time: int | None = None
330
+
331
+ request_fields: tuple[str, ...] = ("symbols", "firms")
332
+
333
+ def request(self) -> dict[str, Any] | None:
334
+ request: dict[str, Any] = {"symbols": self.symbols}
335
+ if "firms" in self.request_fields:
336
+ request["firms"] = self.firms
337
+ if self.resume_token:
338
+ request["resumeToken"] = self.resume_token
339
+ elif self.resume_time is not None:
340
+ request["resumeTime"] = rfc3339(self.resume_time)
341
+ if self.resume_time is None and not self.resume_token:
342
+ # Nothing to resume from yet: a reconnect before the first token
343
+ # replays from here.
344
+ self.resume_time = now_ms()
345
+ return request
346
+
347
+ def reconcile_on_reconnect(self) -> bool:
348
+ return False
349
+
350
+ def classify(self, code: str | None) -> Outcome:
351
+ if code in self.refused_token_codes and self.resume_token:
352
+ since = self.last_event_time if self.last_event_time is not None else self.resume_time
353
+ self.resume_token = None
354
+ self.resume_time = since
355
+ self.status("error", f"the venue refused the resume token ({code}); resuming from {rfc3339(since) if since else 'now'}")
356
+ return "retry"
357
+ return super().classify(code)
358
+
359
+ def event_time(self, item: dict[str, Any]) -> int | None:
360
+ return None
361
+
362
+ def read(self, item: dict[str, Any]) -> list[Event]:
363
+ return []
364
+
365
+ def handle(self, message: dict[str, Any]) -> list[Event]:
366
+ def one(item: dict[str, Any]) -> list[Event]:
367
+ stamp = self.event_time(item)
368
+ if stamp is not None and (self.last_event_time is None or stamp > self.last_event_time):
369
+ self.last_event_time = stamp
370
+ return self.read(item)
371
+
372
+ events = self._each(message.get(self.items_field), one)
373
+ token = message.get("resumeToken") or None
374
+ if token and token != self.resume_token:
375
+ self.resume_token = token
376
+ events.append(VenueEvent(venue=VENUE, name="checkpoint", timestamp=self.last_event_time, payload={
377
+ "stream": self.name, "resume_token": token,
378
+ }))
379
+ return events
380
+
381
+
382
+ class PolymarketUSExchangeDropCopyStream(_ResumableStream):
383
+ """Every execution in the firm, from the post-trade copy: orders and fills
384
+ for all accounts, resumable across reconnects and restarts."""
385
+
386
+ name = "drop_copy"
387
+ method = "polymarket.v1.DropCopyAPI/CreateDropCopySubscription"
388
+ items_field = "executions"
389
+ private = True
390
+ has: dict[str, Capability] = {"watch_orders": True, "watch_my_trades": True}
391
+
392
+ def symbols_needing_scales(self, message: dict[str, Any]) -> list[str]:
393
+ return [str((e.get("order") or {}).get("symbol") or "") for e in message.get("executions") or [] if not _carries_scale(e.get("order") or {})]
394
+
395
+ def event_time(self, item: dict[str, Any]) -> int | None:
396
+ return parse_ts(item.get("transactTime"))
397
+
398
+ def read(self, item: dict[str, Any]) -> list[Event]:
399
+ key = item.get("id")
400
+ if key and self.seen.seen(key):
401
+ return []
402
+ raw_order = item.get("order") or {}
403
+ symbol = str(raw_order.get("symbol") or "")
404
+ return execution_events(item, self.trading.cached_scale(symbol), account=self.account_for(str(raw_order.get("account") or "")))
405
+
406
+
407
+ class PolymarketUSExchangeTradeCaptureStream(_ResumableStream):
408
+ """Trades as they move through clearing. Each report is the firm's fills
409
+ with `settlement` updated; a bust arrives as the same fill `failed`."""
410
+
411
+ name = "trade_capture"
412
+ method = "polymarket.v1.DropCopyAPI/CreateTradeCaptureReportSubscription"
413
+ items_field = "tradeCaptureReports"
414
+ private = True
415
+ has: dict[str, Capability] = {"watch_my_trades": True}
416
+
417
+ def symbols_needing_scales(self, message: dict[str, Any]) -> list[str]:
418
+ out = []
419
+ for trade in message.get("tradeCaptureReports") or []:
420
+ for role in ("aggressor", "passive"):
421
+ order = (trade.get(role) or {}).get("order") or {}
422
+ if order and not _carries_scale(order):
423
+ out.append(str(order.get("symbol") or ""))
424
+ return out
425
+
426
+ def event_time(self, item: dict[str, Any]) -> int | None:
427
+ stamps = [parse_ts((item.get(role) or {}).get("transactTime")) for role in ("aggressor", "passive")]
428
+ return max((s for s in stamps if s is not None), default=None)
429
+
430
+ def read(self, item: dict[str, Any]) -> list[Event]:
431
+ key = (item.get("id"), item.get("state"), item.get("reportingCounterparty"))
432
+ if item.get("id") and self.seen.seen(key):
433
+ return []
434
+ return [FillEvent(venue=VENUE, fill=f) for f in trade_fills(item, self.trading.cached_scale, account_for=self.account_for)]
435
+
436
+
437
+ class PolymarketUSExchangePositionChangeStream(_ResumableStream):
438
+ """Position changes across the firm, resumable."""
439
+
440
+ name = "position_changes"
441
+ method = "polymarket.v1.DropCopyAPI/CreatePositionChangeSubscription"
442
+ items_field = "positionChanges"
443
+ private = True
444
+ has: dict[str, Capability] = {"watch_positions": True}
445
+
446
+ def symbols_needing_scales(self, message: dict[str, Any]) -> list[str]:
447
+ return [str((c.get("position") or {}).get("symbol") or "") for c in message.get("positionChanges") or []]
448
+
449
+ def event_time(self, item: dict[str, Any]) -> int | None:
450
+ return parse_ts(item.get("changeTime"))
451
+
452
+ def read(self, item: dict[str, Any]) -> list[Event]:
453
+ raw = item.get("position") or {}
454
+ symbol = str(raw.get("symbol") or "")
455
+ scale = _require(self.trading.cached_scale(symbol), symbol)
456
+ return [PositionEvent(venue=VENUE, position=position_of(raw, scale, account=self.account_for(str(raw.get("account") or ""))))]
457
+
458
+
459
+ class PolymarketUSExchangeInstrumentStream(_ResumableStream):
460
+ """Instrument state changes: pending, open, halted, closed, expired,
461
+ terminated. Needs no participant id."""
462
+
463
+ name = "instruments"
464
+ method = "polymarket.v1.DropCopyAPI/CreateInstrumentStateChangeSubscription"
465
+ items_field = "instruments"
466
+ account_scoped = False
467
+ request_fields = ("symbols",)
468
+ has: dict[str, Capability] = {"watch_market_status": True}
469
+
470
+ def event_time(self, item: dict[str, Any]) -> int | None:
471
+ return parse_ts(item.get("updateTime"))
472
+
473
+ def read(self, item: dict[str, Any]) -> list[Event]:
474
+ event = market_status_of(item)
475
+ if self.seen.seen((event.market_id, event.native, item.get("updateTime"))):
476
+ return []
477
+ if _carries_scale(item) and item.get("symbol"):
478
+ # Reference data for free: remember the scales it carries.
479
+ self.trading.remember_instrument(InstrumentScale.from_instrument(item))
480
+ return [event]
481
+
482
+
483
+ class PolymarketUSExchangeOrderStream(_ExchangeStream):
484
+ """This participant's orders: a snapshot of open orders on every connect,
485
+ then executions -- acceptance, fills, cancels, replaces, rejects -- with
486
+ fills also as `FillEvent`s, and refused cancels as
487
+ `VenueEvent(name="cancel_rejected")`. Not replayed after a reconnect."""
488
+
489
+ name = "orders"
490
+ method = "polymarket.v1.OrderEntryAPI/CreateOrderSubscription"
491
+ private = True
492
+ data_heartbeat = True
493
+ has: dict[str, Capability] = {"watch_orders": True, "watch_my_trades": True}
494
+
495
+ def __init__(self, source: Any, *, symbols: list[str] | None = None, accounts: list[str] | None = None, **kwargs: Any):
496
+ super().__init__(source, **kwargs)
497
+ self.symbols = list(symbols or [])
498
+ self.accounts = list(accounts or [])
499
+ self.session_id: str | None = None
500
+
501
+ async def watch_orders(self, market_ids: list[str] | None = None, accounts: list[str] | None = None) -> None:
502
+ symbols = None if market_ids is None else [ids.native(VENUE, m) for m in market_ids]
503
+ """Narrow (or widen, with `None`) what the stream carries; empty means everything."""
504
+ symbols, accounts = list(symbols or []), list(accounts or [])
505
+ if (symbols, accounts) != (self.symbols, self.accounts):
506
+ self.symbols, self.accounts = symbols, accounts
507
+ self.resubscribe()
508
+ self.start()
509
+
510
+ watch_my_trades = watch_orders
511
+
512
+ def request(self) -> dict[str, Any] | None:
513
+ return {"symbols": self.symbols, "accounts": self.accounts, "snapshotOnly": False}
514
+
515
+ def symbols_needing_scales(self, message: dict[str, Any]) -> list[str]:
516
+ orders = list((message.get("snapshot") or {}).get("orders") or [])
517
+ orders += [e.get("order") or {} for e in (message.get("update") or {}).get("executions") or []]
518
+ return [str(o.get("symbol") or "") for o in orders if not _carries_scale(o)]
519
+
520
+ def _order(self, raw: dict[str, Any]) -> list[Event]:
521
+ symbol = str(raw.get("symbol") or "")
522
+ scale = _require(scale_from_order(raw, self.trading.cached_scale(symbol)), symbol)
523
+ return [OrderEvent(venue=VENUE, order=order_of(raw, scale, account=self.account_for(str(raw.get("account") or ""))), native="snapshot")]
524
+
525
+ def _execution(self, item: dict[str, Any]) -> list[Event]:
526
+ if item.get("id") and self.seen.seen(item["id"]):
527
+ return []
528
+ raw_order = item.get("order") or {}
529
+ return execution_events(item, self.trading.cached_scale(str(raw_order.get("symbol") or "")), account=self.account_for(str(raw_order.get("account") or "")))
530
+
531
+ def handle(self, message: dict[str, Any]) -> list[Event]:
532
+ self.session_id = message.get("sessionId") or self.session_id
533
+ if "snapshot" in message:
534
+ return self._each((message["snapshot"] or {}).get("orders"), self._order)
535
+ update = message.get("update")
536
+ if update is None:
537
+ return []
538
+ events = self._each(update.get("executions"), self._execution)
539
+ reject = update.get("cancelReject")
540
+ if reject:
541
+ events.append(VenueEvent(venue=VENUE, name="cancel_rejected", timestamp=parse_ts(reject.get("transactTime")), payload={
542
+ "order_id": reject.get("orderId") or None, "client_order_id": reject.get("clordId") or None,
543
+ "reason": reject.get("rejectReason"), "text": reject.get("text") or "", "is_replace": bool(reject.get("isReplace")),
544
+ }))
545
+ return events
546
+
547
+
548
+ class PolymarketUSExchangePositionStream(_ExchangeStream):
549
+ """Positions: a snapshot on every connect, then changes. The snapshot
550
+ restates everything, so a reconnect needs no reconciliation; a position
551
+ held before and missing from a new snapshot is reported flat."""
552
+
553
+ name = "positions"
554
+ method = "polymarket.v1.PositionAPI/CreatePositionSubscription"
555
+ private = True
556
+ data_heartbeat = True
557
+ has: dict[str, Capability] = {"watch_positions": True}
558
+
559
+ def __init__(self, source: Any, *, accounts: list[str] | None = None, **kwargs: Any):
560
+ super().__init__(source, **kwargs)
561
+ self.accounts = list(accounts or [])
562
+ self._held: dict[tuple[str, str], Account | None] = {}
563
+
564
+ def request(self) -> dict[str, Any] | None:
565
+ return {"accounts": self.accounts}
566
+
567
+ def reconcile_on_reconnect(self) -> bool:
568
+ return False
569
+
570
+ def symbols_needing_scales(self, message: dict[str, Any]) -> list[str]:
571
+ body = message.get("snapshot") or message.get("update") or {}
572
+ return [str(p.get("symbol") or "") for p in body.get("positions") or []]
573
+
574
+ def _position(self, raw: dict[str, Any]) -> list[Event]:
575
+ symbol = str(raw.get("symbol") or "")
576
+ scale = _require(self.trading.cached_scale(symbol), symbol)
577
+ name = str(raw.get("account") or "")
578
+ position = position_of(raw, scale, account=self.account_for(name))
579
+ key = (name, symbol)
580
+ if position.side == PositionSide.FLAT:
581
+ self._held.pop(key, None)
582
+ else:
583
+ self._held[key] = position.account
584
+ return [PositionEvent(venue=VENUE, position=position)]
585
+
586
+ def handle(self, message: dict[str, Any]) -> list[Event]:
587
+ if "snapshot" in message:
588
+ before = dict(self._held)
589
+ self._held.clear()
590
+ events = self._each((message["snapshot"] or {}).get("positions"), self._position)
591
+ for (name, symbol), account in before.items():
592
+ if (name, symbol) not in self._held:
593
+ events.append(PositionEvent(venue=VENUE, position=flat_position(account, symbol)))
594
+ return events
595
+ update = message.get("update")
596
+ return self._each((update or {}).get("positions"), self._position)
597
+
598
+
599
+ class PolymarketUSExchangeMarketDataStream(_ExchangeStream):
600
+ """Books to a depth and market statistics for up to a thousand symbols.
601
+
602
+ Each update is taken as the whole book to the subscribed depth and
603
+ replaces the local one, kept on the YES leg: `book("<symbol>:no")` is the
604
+ mirrored view. Adding symbols reopens the call with the full set."""
605
+
606
+ name = "market_data"
607
+ method = "polymarket.v1.MarketDataSubscriptionAPI/CreateMarketDataSubscription"
608
+ account_scoped = False
609
+ data_heartbeat = True
610
+ has: dict[str, Capability] = {"watch_order_book": True, "watch_ticker": True, "watch_trades": False, "watch_market_status": False}
611
+
612
+ def __init__(self, source: Any, *, depth: int = 10, **kwargs: Any):
613
+ super().__init__(source, **kwargs)
614
+ self.depth = depth
615
+ self.symbols: list[str] = []
616
+ self.books: dict[str, LocalBook] = {}
617
+
618
+ async def watch_order_book(self, market_ids: list[str]) -> None:
619
+ symbols = [ids.native(VENUE, m) for m in market_ids]
620
+ added = [s for s in dict.fromkeys(symbols) if s not in self.symbols]
621
+ if len(self.symbols) + len(added) > MAX_SYMBOLS:
622
+ raise ValueError(f"polymarket_us: one market-data stream carries at most {MAX_SYMBOLS} symbols; open another")
623
+ if added:
624
+ self.symbols.extend(added)
625
+ self.resubscribe()
626
+ self.start()
627
+
628
+ watch_ticker = watch_order_book
629
+
630
+ def book(self, market_id: str, side: str = "yes") -> LocalBook | None:
631
+ book = self.books.get(ids.native(VENUE, market_id))
632
+ if book is None or side != "no":
633
+ return book
634
+ return book.mirrored()
635
+
636
+ def request(self) -> dict[str, Any] | None:
637
+ if not self.symbols:
638
+ return None
639
+ return {"symbols": list(self.symbols), "depth": self.depth, "unaggregated": False, "snapshotOnly": False}
640
+
641
+ async def before_call(self, request: dict[str, Any]) -> dict[str, Any] | None:
642
+ missing = [s for s in request["symbols"] if self.trading.cached_scale(s) is None]
643
+ if missing:
644
+ await self.trading.load_instruments(missing, strict=False)
645
+ unknown = [s for s in request["symbols"] if self.trading.cached_scale(s) is None]
646
+ if unknown:
647
+ self.status("error", f"no reference data for {unknown[:5]}; not subscribed")
648
+ self.symbols = [s for s in self.symbols if s not in unknown]
649
+ request = {**request, "symbols": [s for s in request["symbols"] if s not in unknown]}
650
+ return request if request["symbols"] else None
651
+
652
+ def on_disconnect(self) -> None:
653
+ for book in self.books.values():
654
+ book.invalidate()
655
+
656
+ def handle(self, message: dict[str, Any]) -> list[Event]:
657
+ update = message.get("update")
658
+ if not update:
659
+ return []
660
+ symbol = str(update.get("symbol") or "")
661
+ scale = _require(self.trading.cached_scale(symbol), symbol)
662
+ stamp = parse_ts(update.get("transactTime"))
663
+ book = self.books.setdefault(symbol, LocalBook())
664
+ bids, asks = book_sides(update, scale)
665
+ hidden = bool(update.get("bookHidden"))
666
+ book.replace(bids, asks)
667
+ book.timestamp = stamp
668
+ if hidden:
669
+ book.invalidate()
670
+ levels_bids, levels_asks = book.levels()
671
+ events: list[Event] = [BookEvent(
672
+ venue=VENUE, market_id=ids.qualify(VENUE, symbol), kind="snapshot", bids=levels_bids,
673
+ asks=levels_asks, best_bid=book.best_bid, best_ask=book.best_ask, timestamp=stamp,
674
+ info={"book_hidden": True} if hidden else {},
675
+ )]
676
+ stats = update.get("stats")
677
+ if stats:
678
+ events.append(QuoteEvent(
679
+ venue=VENUE, market_id=ids.qualify(VENUE, symbol),
680
+ bid=book.best_bid, ask=book.best_ask,
681
+ bid_size=book.bids.get(book.best_bid) if book.best_bid is not None else None,
682
+ ask_size=book.asks.get(book.best_ask) if book.best_ask is not None else None,
683
+ last=scale.price_from_wire(stats.get("lastTradePx")),
684
+ volume=scale.qty_from_wire(stats["sharesTraded"]) if "sharesTraded" in stats else None,
685
+ open_interest=scale.qty_from_wire(stats["openInterest"]) if "openInterest" in stats else None,
686
+ timestamp=stamp, info=stats,
687
+ ))
688
+ return events
689
+
690
+
691
+ class PolymarketUSExchangeBalanceLedgerStream(_ExchangeStream):
692
+ """Every change to one account's balance -- deposits, withdrawals,
693
+ executions, commissions, resolutions -- as
694
+ `VenueEvent(name="balance_ledger")` with `before`, `after` and `change`.
695
+
696
+ Replayable by time: a reconnect asks again from the last entry's time and
697
+ duplicates are dropped by id, so nothing is missed. Pass `resume_time=`
698
+ (milliseconds) to replay after a restart; the venue keeps entries from
699
+ 2026-05-01."""
700
+
701
+ name = "balance_ledger"
702
+ method = "polymarket.v1.FundingAPI/CreateBalanceLedgerSubscription"
703
+ private = True
704
+ has: dict[str, Capability] = {
705
+ # Ledger entries carry the cash balance, not what is available or
706
+ # locked: `fetch_balance` remains the source for those.
707
+ "watch_balance": "partial",
708
+ }
709
+
710
+ def __init__(
711
+ self,
712
+ source: Any,
713
+ *,
714
+ account: str | None = None,
715
+ currency: str = "",
716
+ entry_types: list[str] | None = None,
717
+ resume_time: int | None = None,
718
+ **kwargs: Any,
719
+ ):
720
+ super().__init__(source, **kwargs)
721
+ self.ledger_account = account
722
+ self.currency = currency
723
+ self.entry_types = list(entry_types or [])
724
+ self.resume_time = resume_time
725
+ self._started_at: int | None = None
726
+
727
+ def request(self) -> dict[str, Any] | None:
728
+ request: dict[str, Any] = {"account": self.ledger_account or "", "currency": self.currency, "entryTypes": self.entry_types}
729
+ since = self.resume_time if self.resume_time is not None else (self._started_at if self.stats.connects else None)
730
+ if since is not None:
731
+ request["resumeTime"] = rfc3339(since)
732
+ if self._started_at is None:
733
+ self._started_at = now_ms()
734
+ return request
735
+
736
+ async def before_call(self, request: dict[str, Any]) -> dict[str, Any] | None:
737
+ if not request["account"]:
738
+ self.ledger_account = await self.trading._account()
739
+ request = {**request, "account": self.ledger_account}
740
+ return request
741
+
742
+ def reconcile_on_reconnect(self) -> bool:
743
+ return False
744
+
745
+ def _entry(self, entry: dict[str, Any]) -> list[Event]:
746
+ stamp = parse_ts(entry.get("updateTime"))
747
+ if stamp is not None and (self.resume_time is None or stamp > self.resume_time):
748
+ self.resume_time = stamp
749
+ if entry.get("id") and self.seen.seen(entry["id"]):
750
+ return []
751
+ return [ledger_event(entry)]
752
+
753
+ def handle(self, message: dict[str, Any]) -> list[Event]:
754
+ return self._each(message.get("entries"), self._entry)