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,299 @@
1
+ """Polymarket US's retail WebSockets: markets and private.
2
+
3
+ Both live at `wss://api.polymarket.us/v1/ws/...` and authenticate the
4
+ handshake with the retail API key, signing `timestamp + GET + path` as REST
5
+ does. Subscriptions take at most 100 markets each; the stream splits larger
6
+ sets. The server sends `{"heartbeat": {}}` periodically, so a silent
7
+ connection is a dead one.
8
+
9
+ **Markets.** Full market data arrives as the whole visible book each time,
10
+ not as deltas, so there is nothing to fall out of step with: every message
11
+ is a snapshot for `{slug}:yes`. A change of market state (open, suspended,
12
+ halted, expired) is reported when it is first seen. Trades carry the maker
13
+ and taker sides but no sequence number.
14
+
15
+ **Private.** Orders (a snapshot of open orders, then executions), positions
16
+ and balances. Nothing is replayed after a reconnect; the order subscription
17
+ sends a fresh snapshot of open orders when it is made again, but fills and
18
+ position changes missed in between are gone, hence `reconcile_required`.
19
+
20
+ The documentation spells the envelopes two ways (`orderSubscriptionSnapshot`
21
+ and `ordersSnapshot`, and so on) and the venue's own SDK accepts both; so
22
+ does this stream.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ from decimal import Decimal
27
+ from typing import Any
28
+
29
+ from .. import ids
30
+ from ..base import Capability
31
+ from ..polymarket_us import parse_ts
32
+ from ..trading.credentials import PolymarketUSCredentials
33
+ from ..trading.polymarket_us import (
34
+ PolymarketUSSigner, balance_of, order_of, position_of,
35
+ )
36
+ from ..trading.types import Account, Fill, Liquidity, Side
37
+ from .base import (
38
+ BookEvent, D, Event, FillEvent, LocalBook, MarketStatusEvent, OrderEvent, PositionEvent, QuoteEvent, Stream,
39
+ TradeEvent, BalanceEvent, maybe_D,
40
+ )
41
+
42
+ VENUE = "polymarket_us"
43
+ BASE_URL = "wss://api.polymarket.us"
44
+ MARKETS_PATH = "/v1/ws/markets"
45
+ PRIVATE_PATH = "/v1/ws/private"
46
+ MAX_SLUGS = 100
47
+
48
+ MARKET_STATE = {
49
+ "MARKET_STATE_OPEN": "open",
50
+ "MARKET_STATE_PREOPEN": "paused",
51
+ "MARKET_STATE_SUSPENDED": "paused",
52
+ "MARKET_STATE_HALTED": "paused",
53
+ "MARKET_STATE_MATCH_AND_CLOSE_AUCTION": "closed",
54
+ "MARKET_STATE_EXPIRED": "closed",
55
+ "MARKET_STATE_TERMINATED": "settled",
56
+ }
57
+
58
+
59
+ def _first(message: dict[str, Any], *names: str) -> Any:
60
+ for name in names:
61
+ if name in message:
62
+ return message[name]
63
+ return None
64
+
65
+
66
+ class _SignedStream(Stream):
67
+ path = ""
68
+ data_heartbeat = True
69
+
70
+ def __init__(self, credentials: PolymarketUSCredentials, *, base_url: str = BASE_URL, account_name: str = "default", **kwargs: Any):
71
+ super().__init__(base_url + self.path, **kwargs)
72
+ self.signer = PolymarketUSSigner(credentials.key_id, credentials.secret_key)
73
+ self.account = Account(venue=VENUE, name=account_name)
74
+ self._subscriptions: dict[str, dict[str, Any]] = {}
75
+ self._next = 1
76
+
77
+ def headers(self) -> dict[str, str]:
78
+ return self.signer.headers("GET", self.path)
79
+
80
+ async def _subscribe(self, kind: str, slugs: list[str] | None) -> None:
81
+ chunks = [slugs[i:i + MAX_SLUGS] for i in range(0, len(slugs), MAX_SLUGS)] if slugs else [None]
82
+ for chunk in chunks:
83
+ request_id = f"{kind.lower().removeprefix('subscription_type_')}-{self._next}"
84
+ self._next += 1
85
+ body: dict[str, Any] = {"requestId": request_id, "subscriptionType": kind}
86
+ if chunk:
87
+ body["marketSlugs"] = chunk
88
+ self._subscriptions[request_id] = body
89
+ await self.send({"subscribe": body})
90
+
91
+ async def on_connect(self) -> None:
92
+ for body in self._subscriptions.values():
93
+ await self.send({"subscribe": body})
94
+
95
+ def _common(self, message: dict[str, Any]) -> list[Event] | None:
96
+ if "heartbeat" in message:
97
+ return []
98
+ if "error" in message:
99
+ self.status("error", str(message.get("error")), key=message.get("requestId") or message.get("request_id"))
100
+ return []
101
+ return None
102
+
103
+
104
+ class PolymarketUSMarketStream(_SignedStream):
105
+ """Books, top of book and trades for Polymarket US markets."""
106
+
107
+ venue = VENUE
108
+ name = "markets"
109
+ path = MARKETS_PATH
110
+ has: dict[str, Capability] = {
111
+ "watch_order_book": True,
112
+ "watch_ticker": True,
113
+ "watch_trades": True,
114
+ # Market state rides on full market data; there is no lifecycle
115
+ # channel for markets not subscribed.
116
+ "watch_market_status": "partial",
117
+ }
118
+
119
+ def __init__(self, credentials: PolymarketUSCredentials, **kwargs: Any):
120
+ super().__init__(credentials, **kwargs)
121
+ self.books: dict[str, LocalBook] = {}
122
+ self.states: dict[str, str] = {}
123
+
124
+ async def watch_order_book(self, market_ids: list[str], *, debounced: bool = False) -> None:
125
+ slugs = _slugs(market_ids)
126
+ """Full visible book and market state for these markets."""
127
+ for slug in slugs:
128
+ self.books.setdefault(slug, LocalBook())
129
+ await self._subscribe("SUBSCRIPTION_TYPE_MARKET_DATA", slugs)
130
+ if debounced:
131
+ for body in list(self._subscriptions.values())[-((len(slugs) - 1) // MAX_SLUGS + 1):]:
132
+ body["responsesDebounced"] = True
133
+
134
+ async def watch_ticker(self, market_ids: list[str]) -> None:
135
+ await self._subscribe("SUBSCRIPTION_TYPE_MARKET_DATA_LITE", _slugs(market_ids))
136
+
137
+ async def watch_trades(self, market_ids: list[str]) -> None:
138
+ await self._subscribe("SUBSCRIPTION_TYPE_TRADE", _slugs(market_ids))
139
+
140
+ def on_disconnect(self) -> None:
141
+ for book in self.books.values():
142
+ book.invalidate()
143
+
144
+ def book(self, market_id: str, side: str = "yes") -> LocalBook | None:
145
+ book = self.books.get(ids.native(VENUE, market_id))
146
+ return None if book is None else book if side != "no" else book.mirrored()
147
+
148
+ def handle(self, message: Any) -> list[Event]:
149
+ if not isinstance(message, dict):
150
+ return []
151
+ common = self._common(message)
152
+ if common is not None:
153
+ return common
154
+ data = _first(message, "marketData", "market_data")
155
+ if data is not None:
156
+ return self._market_data(data)
157
+ lite = _first(message, "marketDataLite", "market_data_lite")
158
+ if lite is not None:
159
+ slug = str(lite.get("marketSlug") or "")
160
+ return [QuoteEvent(
161
+ venue=VENUE, market_id=ids.qualify(VENUE, slug),
162
+ bid=maybe_D(lite.get("bestBid")), ask=maybe_D(lite.get("bestAsk")),
163
+ last=maybe_D(lite.get("lastTradePx")), volume=maybe_D(lite.get("sharesTraded")),
164
+ open_interest=maybe_D(lite.get("openInterest")), info=lite,
165
+ )]
166
+ trade = message.get("trade")
167
+ if trade is not None:
168
+ slug = str(trade.get("marketSlug") or "")
169
+ taker = (trade.get("taker") or {}).get("side")
170
+ return [TradeEvent(
171
+ venue=VENUE, market_id=ids.qualify(VENUE, slug), id=trade.get("tradeId") or trade.get("id"),
172
+ price=D(trade["price"]), amount=D(trade.get("quantity") or trade.get("qty")),
173
+ taker_side=Side.BUY if taker == "ORDER_SIDE_BUY" else Side.SELL if taker == "ORDER_SIDE_SELL" else None,
174
+ timestamp=parse_ts(trade.get("tradeTime")), info=trade,
175
+ )]
176
+ return []
177
+
178
+ def _market_data(self, data: dict[str, Any]) -> list[Event]:
179
+ slug = str(data.get("marketSlug") or "")
180
+ book = self.books.setdefault(slug, LocalBook())
181
+ book.replace(
182
+ [(D(level["px"]), D(level["qty"])) for level in data.get("bids") or []],
183
+ [(D(level["px"]), D(level["qty"])) for level in data.get("offers") or []],
184
+ )
185
+ stamp = parse_ts(data.get("transactTime"))
186
+ book.timestamp = stamp
187
+ bids, asks = book.levels()
188
+ events: list[Event] = [BookEvent(
189
+ venue=VENUE, market_id=ids.qualify(VENUE, slug), kind="snapshot", bids=bids, asks=asks,
190
+ best_bid=book.best_bid, best_ask=book.best_ask, timestamp=stamp,
191
+ info={"state": data.get("state"), "stats": data.get("stats")},
192
+ )]
193
+ native = data.get("state")
194
+ if native and self.states.get(slug) != native:
195
+ self.states[slug] = native
196
+ events.append(MarketStatusEvent(
197
+ venue=VENUE, market_id=ids.qualify(VENUE, slug), state=MARKET_STATE.get(native, "updated"), # type: ignore[arg-type]
198
+ native=native, timestamp=stamp, info={"state": native},
199
+ ))
200
+ return events
201
+
202
+
203
+ class PolymarketUSPrivateStream(_SignedStream):
204
+ """This account's orders, fills, positions and balance."""
205
+
206
+ venue = VENUE
207
+ name = "private"
208
+ path = PRIVATE_PATH
209
+ private = True
210
+ has: dict[str, Capability] = {
211
+ "watch_orders": True,
212
+ "watch_my_trades": True,
213
+ "watch_positions": True,
214
+ "watch_balance": True,
215
+ }
216
+
217
+ async def watch_orders(self, market_ids: list[str] | None = None) -> None:
218
+ slugs = _slugs(market_ids)
219
+ """Open orders as a snapshot, then every execution: acceptance, fills,
220
+ cancels, replaces, rejects. Fills arrive as `FillEvent`s too."""
221
+ await self._subscribe("SUBSCRIPTION_TYPE_ORDER", slugs)
222
+
223
+ watch_my_trades = watch_orders
224
+
225
+ async def watch_positions(self, market_ids: list[str] | None = None) -> None:
226
+ await self._subscribe("SUBSCRIPTION_TYPE_POSITION", _slugs(market_ids))
227
+
228
+ async def watch_balance(self) -> None:
229
+ await self._subscribe("SUBSCRIPTION_TYPE_ACCOUNT_BALANCE", None)
230
+
231
+ def handle(self, message: Any) -> list[Event]:
232
+ if not isinstance(message, dict):
233
+ return []
234
+ common = self._common(message)
235
+ if common is not None:
236
+ return common
237
+ snapshot = _first(message, "orderSubscriptionSnapshot", "ordersSnapshot")
238
+ if snapshot is not None:
239
+ return [OrderEvent(venue=VENUE, order=order_of(row, account=self.account), native="snapshot") for row in snapshot.get("orders") or []]
240
+ update = _first(message, "orderSubscriptionUpdate", "orderUpdate")
241
+ if update is not None:
242
+ return self._execution(update.get("execution") or update)
243
+ position = _first(message, "positionSubscription", "positionSubscriptionUpdate", "positionUpdate")
244
+ if position is not None:
245
+ return self._position(position)
246
+ positions = _first(message, "positionSubscriptionSnapshot", "positionsSnapshot")
247
+ if positions is not None:
248
+ rows = positions.get("positions") or {}
249
+ return [PositionEvent(venue=VENUE, position=position_of(slug, row, account=self.account)) for slug, row in rows.items()]
250
+ balances = _first(
251
+ message, "accountBalancesSnapshot", "accountBalanceSubscriptionSnapshot",
252
+ "accountBalancesUpdate", "accountBalanceSubscriptionUpdate", "accountBalanceUpdate",
253
+ )
254
+ if balances is not None:
255
+ return self._balance(balances)
256
+ return []
257
+
258
+ def _execution(self, execution: dict[str, Any]) -> list[Event]:
259
+ raw_order = execution.get("order") or {}
260
+ order = order_of(raw_order, account=self.account)
261
+ kind = str(execution.get("type") or "")
262
+ events: list[Event] = [OrderEvent(venue=VENUE, order=order, native=kind)]
263
+ if kind in ("EXECUTION_TYPE_PARTIAL_FILL", "EXECUTION_TYPE_FILL") and execution.get("lastShares"):
264
+ wire_price = maybe_D(execution.get("lastPx"))
265
+ aggressor = execution.get("aggressor")
266
+ events.append(FillEvent(venue=VENUE, fill=Fill(
267
+ id=str(execution.get("tradeId") or execution.get("id") or ""), order_id=order.id, venue=VENUE,
268
+ account=self.account, market_id=order.market_id, side=order.side,
269
+ price=wire_price if wire_price is not None else Decimal("0"),
270
+ amount=D(execution["lastShares"]),
271
+ fee=maybe_D(execution.get("commissionNotionalCollected")), fee_currency="USD",
272
+ liquidity=Liquidity.TAKER if aggressor is True else Liquidity.MAKER if aggressor is False else Liquidity.UNKNOWN,
273
+ timestamp=parse_ts(execution.get("transactTime")) or 0, info=execution,
274
+ )))
275
+ return events
276
+
277
+ def _position(self, body: dict[str, Any]) -> list[Event]:
278
+ row = body.get("afterPosition") or body.get("position") or {}
279
+ slug = str(body.get("marketSlug") or (row.get("marketMetadata") or {}).get("slug") or "")
280
+ position = position_of(slug, row, account=self.account)
281
+ stamp = parse_ts(body.get("updateTime"))
282
+ return [PositionEvent(venue=VENUE, position=position.model_copy(update={
283
+ "timestamp": stamp or position.timestamp, "info": body,
284
+ }))]
285
+
286
+ def _balance(self, body: dict[str, Any]) -> list[Event]:
287
+ if "balances" in body:
288
+ return [BalanceEvent(venue=VENUE, balance=balance_of(body, account=self.account))]
289
+ after = (body.get("balanceChange") or {}).get("afterBalance")
290
+ if after is not None:
291
+ return [BalanceEvent(venue=VENUE, balance=balance_of({"balances": [after]}, account=self.account))]
292
+ if "balance" in body or "buyingPower" in body:
293
+ row = {"currentBalance": body.get("balance"), "buyingPower": body.get("buyingPower"), "currency": "USD"}
294
+ return [BalanceEvent(venue=VENUE, balance=balance_of({"balances": [row]}, account=self.account))]
295
+ return []
296
+
297
+
298
+ def _slugs(market_ids: list[str] | None) -> list[str] | None:
299
+ return None if market_ids is None else [ids.native(VENUE, m) for m in market_ids]