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,430 @@
1
+ """Polymarket's CLOB WebSockets: the public market channel and the user channel.
2
+
3
+ **Market channel.** Subscribing to token ids brings a `book` snapshot for
4
+ each, then `price_change` messages that set a level's absolute size. There
5
+ are no sequence numbers. What there is: every change carries the venue's
6
+ best bid and ask for that token. Those figures describe the book after
7
+ *every* change with the same timestamp -- one trade can arrive as two
8
+ messages stamped alike, and the first message's top of book already
9
+ reflects the second -- so the stream applies a timestamp's changes and
10
+ checks its own top of book against the venue's only once a later timestamp
11
+ arrives for that token (or half a second passes on the feed). A
12
+ disagreement means a message was missed: the stream reports a gap and
13
+ resubscribes the token, which brings a fresh snapshot. Heartbeat is the
14
+ text frame `PING` every ten seconds.
15
+
16
+ **User channel.** Authenticated with the CLOB API credentials in the
17
+ subscription frame. Order events (placement, update, cancellation) and trade
18
+ events that repeat as a trade's settlement moves from `MATCHED` through
19
+ `MINED` to `CONFIRMED` or `FAILED`. Nothing is replayed after a reconnect.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ from decimal import Decimal
24
+ from typing import Any
25
+
26
+ import httpx
27
+
28
+ from .. import ids
29
+ from ..base import Capability
30
+ from ..trading.polymarket import GAMMA_URL, MarketTokens, fills_of, order_of
31
+ from ..trading.types import Account, OrderStatus, Side
32
+ from .base import (
33
+ ONE,
34
+ BookEvent, BookLevel, D, Event, FillEvent, LocalBook, MarketStatusEvent, OrderEvent, QuoteEvent, Stream,
35
+ TradeEvent, VenueEvent, maybe_D,
36
+ )
37
+
38
+ VENUE = "polymarket"
39
+ MARKET_URL = "wss://ws-subscriptions-clob.polymarket.com/ws/market"
40
+ USER_URL = "wss://ws-subscriptions-clob.polymarket.com/ws/user"
41
+
42
+
43
+ class PolymarketMarketStream(Stream):
44
+ """Books, trades and market lifecycle for a set of tokens. No credentials."""
45
+
46
+ venue = VENUE
47
+ name = "market"
48
+ app_ping = "PING"
49
+ data_heartbeat = True
50
+ has: dict[str, Capability] = {
51
+ "watch_order_book": True,
52
+ "watch_ticker": True,
53
+ "watch_trades": True,
54
+ # New markets and resolutions, for the tokens subscribed; the venue
55
+ # publishes no pause or halt on this channel.
56
+ "watch_market_status": "partial",
57
+ }
58
+
59
+ def __init__(self, *, url: str = MARKET_URL, gamma_url: str = GAMMA_URL, **kwargs: Any):
60
+ super().__init__(url, **kwargs)
61
+ self.tokens: set[str] = set()
62
+ self.books: dict[str, LocalBook] = {}
63
+ self.markets: dict[str, str] = {}
64
+ """Token id -> condition id, learned from the stream."""
65
+ self.catalog = TokenCatalog(gamma_url)
66
+ """Which token is which side of which market, from the Gamma catalog."""
67
+ self._unverified: dict[str, tuple[int | None, Any, Any]] = {}
68
+ """Token -> (timestamp, venue best bid, venue best ask) not yet checked."""
69
+
70
+ async def watch_order_book(self, market_ids: list[str]) -> None:
71
+ """Books, trades, top of book and lifecycle for these markets, both
72
+ sides: the market channel sends all of them for whatever token it is
73
+ subscribed to. Each market not yet known costs one catalog read."""
74
+ token_ids = []
75
+ for market_id in market_ids:
76
+ tokens = await self.catalog.resolve(market_id)
77
+ token_ids += [tokens.yes_token, tokens.no_token]
78
+ await self.watch_tokens(token_ids)
79
+
80
+ async def watch_tokens(self, token_ids: list[str]) -> None:
81
+ """Subscribe by CLOB token id, for a caller that already holds them."""
82
+ new = [t for t in token_ids if t not in self.tokens]
83
+ if not new:
84
+ return
85
+ self.tokens.update(new)
86
+ for token in new:
87
+ self.books.setdefault(token, LocalBook())
88
+ await self.send({"assets_ids": new, "operation": "subscribe"})
89
+
90
+ watch_trades = watch_order_book
91
+ watch_ticker = watch_order_book
92
+
93
+ async def unwatch(self, market_ids: list[str]) -> None:
94
+ token_ids = []
95
+ for market_id in market_ids:
96
+ tokens = await self.catalog.resolve(market_id)
97
+ token_ids += [tokens.yes_token, tokens.no_token]
98
+ gone = [t for t in token_ids if t in self.tokens]
99
+ self.tokens.difference_update(gone)
100
+ for token in gone:
101
+ self.books.pop(token, None)
102
+ if gone:
103
+ await self.send({"assets_ids": gone, "operation": "unsubscribe"})
104
+
105
+ async def on_connect(self) -> None:
106
+ if self.tokens:
107
+ await self.send({"assets_ids": sorted(self.tokens), "type": "market", "custom_feature_enabled": True})
108
+
109
+ def on_disconnect(self) -> None:
110
+ self._unverified.clear()
111
+ for book in self.books.values():
112
+ book.invalidate()
113
+
114
+ def book(self, market_id: str, side: str = "yes") -> LocalBook | None:
115
+ """The local book for a market on either side; `None` if not watched.
116
+ Both sides are real books here, not mirrors."""
117
+ tokens = self.catalog.cached(market_id)
118
+ if tokens is None:
119
+ return None
120
+ return self.books.get(tokens.token(side))
121
+
122
+ def _where(self, token: str, condition: str) -> tuple[str, str]:
123
+ """`(market_id, side)` for a token the stream reported."""
124
+ tokens = self.catalog.cached(token)
125
+ if tokens is not None:
126
+ return tokens.market_id, tokens.outcome_of(token)
127
+ return ids.qualify(VENUE, condition), "yes"
128
+
129
+ # -- reading --------------------------------------------------------------
130
+
131
+ def handle(self, message: Any) -> list[Event]:
132
+ if isinstance(message, list):
133
+ events: list[Event] = []
134
+ for item in message:
135
+ events.extend(self.handle(item))
136
+ return events
137
+ if not isinstance(message, dict):
138
+ return [] # PONG
139
+ kind = message.get("event_type")
140
+ if kind == "book":
141
+ return self._on_book(message)
142
+ if kind == "price_change":
143
+ return self._on_price_change(message)
144
+ if kind == "last_trade_price":
145
+ token = str(message.get("asset_id") or "")
146
+ market_id, side = self._where(token, str(message.get("market") or ""))
147
+ price = D(message["price"])
148
+ taker = str(message.get("side") or "").upper()
149
+ taker_side = Side.BUY if taker == "BUY" else Side.SELL if taker == "SELL" else None
150
+ if side == "no":
151
+ price = ONE - price
152
+ taker_side = {Side.BUY: Side.SELL, Side.SELL: Side.BUY}.get(taker_side) # type: ignore[arg-type]
153
+ return [TradeEvent(
154
+ venue=VENUE, market_id=market_id,
155
+ id=message.get("transaction_hash") or None, price=price, amount=D(message["size"]),
156
+ taker_side=taker_side, timestamp=_ms(message.get("timestamp")), info=message,
157
+ )]
158
+ if kind == "best_bid_ask":
159
+ token = str(message.get("asset_id") or "")
160
+ market_id, side = self._where(token, str(message.get("market") or ""))
161
+ return [QuoteEvent(
162
+ venue=VENUE, market_id=market_id, side=side, # type: ignore[arg-type]
163
+ bid=maybe_D(message.get("best_bid")), ask=maybe_D(message.get("best_ask")),
164
+ timestamp=_ms(message.get("timestamp")), info=message,
165
+ )]
166
+ if kind == "tick_size_change":
167
+ return [VenueEvent(venue=VENUE, name="tick_size_change", payload=message, timestamp=_ms(message.get("timestamp")))]
168
+ if kind == "new_market":
169
+ return [MarketStatusEvent(
170
+ venue=VENUE, market_id=self._market_of(str(message.get("market") or "")), state="created", native=kind,
171
+ timestamp=_ms(message.get("timestamp")), info=message,
172
+ )]
173
+ if kind == "market_resolved":
174
+ return [MarketStatusEvent(
175
+ venue=VENUE, market_id=self._market_of(str(message.get("market") or "")), state="determined", native=kind,
176
+ result=message.get("winning_asset_id") or None, timestamp=_ms(message.get("timestamp")), info=message,
177
+ )]
178
+ return []
179
+
180
+ def _on_book(self, message: dict[str, Any]) -> list[Event]:
181
+ token = str(message.get("asset_id") or "")
182
+ market = str(message.get("market") or "")
183
+ self.markets[token] = market
184
+ book = self.books.setdefault(token, LocalBook())
185
+ recovering = not book.ready and book.timestamp is not None
186
+ book.replace(
187
+ [(D(level["price"]), D(level["size"])) for level in message.get("bids") or []],
188
+ [(D(level["price"]), D(level["size"])) for level in message.get("asks") or []],
189
+ )
190
+ book.timestamp = _ms(message.get("timestamp"))
191
+ self._unverified.pop(token, None)
192
+ if recovering:
193
+ self.status("resynced", f"book {token}", key=token)
194
+ bids, asks = book.levels()
195
+ market_id, side = self._where(token, market)
196
+ return [BookEvent(
197
+ venue=VENUE, market_id=market_id, side=side, kind="snapshot", bids=bids, asks=asks, # type: ignore[arg-type]
198
+ best_bid=book.best_bid, best_ask=book.best_ask, timestamp=book.timestamp,
199
+ info={"hash": message.get("hash"), "tick_size": message.get("tick_size"), "last_trade_price": message.get("last_trade_price")},
200
+ )]
201
+
202
+ def _on_price_change(self, message: dict[str, Any]) -> list[Event]:
203
+ market = str(message.get("market") or "")
204
+ stamp = _ms(message.get("timestamp"))
205
+ self._verify_older_than(stamp)
206
+ changed: dict[str, tuple[list[BookLevel], list[BookLevel]]] = {}
207
+ for change in message.get("price_changes") or []:
208
+ token = str(change.get("asset_id") or "")
209
+ book = self.books.get(token)
210
+ if book is None or not book.ready:
211
+ continue
212
+ pending = self._unverified.get(token)
213
+ if pending is not None and pending[0] != stamp and not self._verify(token):
214
+ continue
215
+ price, size = D(change["price"]), D(change["size"])
216
+ side = "bid" if str(change.get("side")).upper() == "BUY" else "ask"
217
+ new = book.set(side, price, size)
218
+ bids, asks = changed.setdefault(token, ([], []))
219
+ (bids if side == "bid" else asks).append(BookLevel(price, new))
220
+ self._unverified[token] = (stamp, change.get("best_bid"), change.get("best_ask"))
221
+ book.timestamp = stamp
222
+ events: list[Event] = []
223
+ for token, (bids, asks) in changed.items():
224
+ market_id, side = self._where(token, market)
225
+ events.append(BookEvent(
226
+ venue=VENUE, market_id=market_id, side=side, kind="delta", bids=tuple(bids), asks=tuple(asks), # type: ignore[arg-type]
227
+ best_bid=self.books[token].best_bid, best_ask=self.books[token].best_ask, timestamp=stamp,
228
+ ))
229
+ return events
230
+
231
+ def _market_of(self, condition: str) -> str:
232
+ tokens = self.catalog.cached(condition)
233
+ return tokens.market_id if tokens is not None else ids.qualify(VENUE, condition)
234
+
235
+ def _verify_older_than(self, stamp: int | None, window_ms: int = 500) -> None:
236
+ if stamp is None:
237
+ return
238
+ for token, (pending_stamp, _, _) in list(self._unverified.items()):
239
+ if pending_stamp is not None and stamp - pending_stamp > window_ms:
240
+ self._verify(token)
241
+
242
+ def _verify(self, token: str) -> bool:
243
+ """Check the token's book against the venue's last top of book. On a
244
+ mismatch, report the gap, mark the book not ready and resubscribe."""
245
+ pending = self._unverified.pop(token, None)
246
+ book = self.books.get(token)
247
+ if pending is None or book is None or not book.ready:
248
+ return True
249
+ _, best_bid, best_ask = pending
250
+ if _agrees(book.best_bid, best_bid) and _agrees(book.best_ask, best_ask):
251
+ return True
252
+ self.status(
253
+ "gap", f"book {token}: local top {book.best_bid}/{book.best_ask}, venue {best_bid}/{best_ask}", key=token,
254
+ )
255
+ book.invalidate()
256
+ self._later(self._resubscribe(token))
257
+ return False
258
+
259
+ async def _resubscribe(self, token: str) -> None:
260
+ await self.send({"assets_ids": [token], "operation": "unsubscribe"})
261
+ await self.send({"assets_ids": [token], "operation": "subscribe"})
262
+
263
+ def _later(self, coroutine: Any) -> None:
264
+ import asyncio
265
+
266
+ asyncio.get_running_loop().create_task(coroutine)
267
+
268
+
269
+ class PolymarketUserStream(Stream):
270
+ """This account's orders and trades, with settlement state.
271
+
272
+ Takes the CLOB API credentials directly, or a `PolymarketTrading`
273
+ adapter to derive them from.
274
+ """
275
+
276
+ venue = VENUE
277
+ name = "user"
278
+ app_ping = "PING"
279
+ data_heartbeat = True
280
+ private = True
281
+ has: dict[str, Capability] = {
282
+ "watch_orders": True,
283
+ "watch_my_trades": True,
284
+ "watch_positions": False,
285
+ "watch_balance": False,
286
+ }
287
+
288
+ def __init__(
289
+ self, *, api_key: str | None = None, api_secret: str | None = None, api_passphrase: str | None = None,
290
+ trading: Any = None, wallets: set[str] | None = None, url: str = USER_URL, account_name: str = "default",
291
+ **kwargs: Any,
292
+ ):
293
+ super().__init__(url, **kwargs)
294
+ self.trading = trading
295
+ self.api_key, self.api_secret, self.api_passphrase = api_key, api_secret, api_passphrase
296
+ self.wallets = set(wallets or ())
297
+ if trading is not None:
298
+ self.wallets |= {trading.wallet, trading.signer.address}
299
+ self.markets: set[str] | None = None
300
+ self.account = Account(venue=VENUE, name=account_name)
301
+ self.catalog = TokenCatalog(kwargs.get("gamma_url") or GAMMA_URL)
302
+
303
+ async def watch_orders(self, market_ids: list[str] | None = None) -> None:
304
+ """Orders and trades for these markets, or for every market."""
305
+ if market_ids is None:
306
+ self.markets = None
307
+ else:
308
+ markets = [(await self.catalog.resolve(m)).condition_id for m in market_ids]
309
+ new = set(markets) - (self.markets or set())
310
+ self.markets = (self.markets or set()) | set(markets)
311
+ if new:
312
+ await self.send({"operation": "subscribe", "markets": sorted(new)})
313
+ return
314
+ if self._ws is not None:
315
+ await self.on_connect()
316
+
317
+ watch_my_trades = watch_orders
318
+
319
+ async def on_connect(self) -> None:
320
+ if self.trading is not None and not self.api_key:
321
+ await self.trading.ensure_api_credentials()
322
+ self.api_key, self.api_secret, self.api_passphrase = (
323
+ self.trading._api_key, self.trading._api_secret, self.trading._api_passphrase,
324
+ )
325
+ frame: dict[str, Any] = {
326
+ "auth": {"apiKey": self.api_key, "secret": self.api_secret, "passphrase": self.api_passphrase},
327
+ "type": "user",
328
+ }
329
+ if self.markets:
330
+ frame["markets"] = sorted(self.markets)
331
+ await self.send(frame)
332
+
333
+ def handle(self, message: Any) -> list[Event]:
334
+ if isinstance(message, list):
335
+ events: list[Event] = []
336
+ for item in message:
337
+ events.extend(self.handle(item))
338
+ return events
339
+ if not isinstance(message, dict):
340
+ return []
341
+ kind = message.get("event_type")
342
+ if kind == "order":
343
+ native = str(message.get("type") or "")
344
+ market_id, side = self._where(message)
345
+ order = order_of(message, market_id=market_id, outcome=side, account=self.account)
346
+ if native == "CANCELLATION" and not order.is_terminal:
347
+ order = order.model_copy(update={"status": OrderStatus.CANCELED, "remaining": Decimal("0")})
348
+ return [OrderEvent(venue=VENUE, order=order, native=native)]
349
+ if kind == "trade":
350
+ market_id, _ = self._where(message)
351
+ return [FillEvent(venue=VENUE, fill=fill) for fill in fills_of(
352
+ message, api_key=self.api_key or "", wallets=self.wallets, market_id=market_id,
353
+ outcome_of=self._outcome_of, account=self.account,
354
+ )]
355
+ return []
356
+
357
+ def _where(self, message: dict[str, Any]) -> tuple[str, str]:
358
+ """`(market_id, side)` for an order or trade message. The user channel
359
+ names the outcome in words, so a market never seen by the catalog is
360
+ still placed on the right side."""
361
+ token = str(message.get("asset_id") or "")
362
+ tokens = self.catalog.cached(token) or self.catalog.cached(str(message.get("market") or ""))
363
+ if tokens is not None:
364
+ return tokens.market_id, tokens.outcome_of(token) if token in (tokens.yes_token, tokens.no_token) else _outcome_word(message)
365
+ return ids.qualify(VENUE, str(message.get("market") or "")), _outcome_word(message)
366
+
367
+ def _outcome_of(self, token: str) -> str:
368
+ tokens = self.catalog.cached(token)
369
+ return tokens.outcome_of(token) if tokens is not None else "yes"
370
+
371
+
372
+ def _outcome_word(message: dict[str, Any]) -> str:
373
+ """The venue's `outcome` label, when it is a plain Yes/No; the first
374
+ outcome otherwise."""
375
+ return "no" if str(message.get("outcome") or "").strip().lower() == "no" else "yes"
376
+
377
+
378
+ class TokenCatalog:
379
+ """Which token is which side of which market, read from Gamma once per
380
+ market and kept by Gamma id, condition id and token id."""
381
+
382
+ def __init__(self, gamma_url: str = GAMMA_URL):
383
+ self.gamma_url = gamma_url.rstrip("/")
384
+ self._by_key: dict[str, MarketTokens] = {}
385
+
386
+ def remember(self, tokens: MarketTokens) -> MarketTokens:
387
+ for key in (tokens.gamma_id, tokens.condition_id, tokens.yes_token, tokens.no_token):
388
+ if key:
389
+ self._by_key[key] = tokens
390
+ return tokens
391
+
392
+ def cached(self, key: str) -> MarketTokens | None:
393
+ """By Synpath id, Gamma id, condition id or token id, if seen."""
394
+ _, native = ids.split(key)
395
+ return self._by_key.get(native)
396
+
397
+ async def resolve(self, market_id: str) -> MarketTokens:
398
+ native = ids.native(VENUE, market_id)
399
+ cached = self._by_key.get(native)
400
+ if cached is not None:
401
+ return cached
402
+ async with httpx.AsyncClient(base_url=self.gamma_url, timeout=15) as client:
403
+ if native.startswith("0x"):
404
+ response = await client.get("/markets", params={"condition_ids": native})
405
+ rows = response.json() if response.status_code == 200 else []
406
+ raw = rows[0] if isinstance(rows, list) and rows else None
407
+ else:
408
+ response = await client.get(f"/markets/{native}")
409
+ raw = response.json() if response.status_code == 200 else None
410
+ if not isinstance(raw, dict) or not raw.get("id"):
411
+ from ..errors import MarketNotFound
412
+ raise MarketNotFound(f"polymarket: no market {native}")
413
+ return self.remember(MarketTokens.from_gamma(raw))
414
+
415
+
416
+ def _ms(value: Any) -> int | None:
417
+ if value in (None, ""):
418
+ return None
419
+ number = int(str(value))
420
+ return number if number > 1e11 else number * 1000
421
+
422
+
423
+ def _agrees(local: Decimal | None, venue: Any) -> bool:
424
+ """A venue top of book of `0`, `1` or empty means no level on that side."""
425
+ if venue in (None, ""):
426
+ return True
427
+ price = Decimal(str(venue))
428
+ if local is None:
429
+ return price in (Decimal("0"), Decimal("1"))
430
+ return local == price