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
synpath/ws/kalshi.py ADDED
@@ -0,0 +1,418 @@
1
+ """Kalshi's WebSocket: books, tickers, trades, and the account's own activity.
2
+
3
+ One authenticated connection carries every channel. The handshake is signed
4
+ like a REST request (`timestamp + GET + /trade-api/ws/v2`), even for public
5
+ market data.
6
+
7
+ **Sequence numbers.** Every message on a subscription that carries `seq`
8
+ (books, public trades, market lifecycle, order groups) numbers it, one
9
+ sequence per subscription across all its markets; a snapshot fetched later
10
+ takes its own number in the same sequence. A number that is not one more
11
+ than the last is a gap. For a book the stream asks for fresh snapshots of
12
+ that subscription's markets and ignores deltas until they arrive; for
13
+ trades and lifecycle events there is nothing to fetch again, so the gap is
14
+ reported for the caller to read over REST.
15
+
16
+ **Books are kept on the YES leg.** Kalshi publishes YES bids and NO bids;
17
+ a NO bid at `p` is a YES ask at `1 - p`. Book events are for `{ticker}:yes`;
18
+ `book("TICKER:no")` returns the mirrored view.
19
+
20
+ **Keepalive is protocol pings.** Kalshi pings every ten seconds at the
21
+ WebSocket layer, which the library answers and does not surface, so a demo
22
+ market can be silent for minutes on a healthy connection. The stream
23
+ therefore does not treat silence as death; the library's own ping/pong does
24
+ that.
25
+
26
+ **Private channels have no sequence.** Orders, fills and positions cannot be
27
+ checked for gaps and are not replayed after a reconnect, so a reconnect with
28
+ any of them subscribed is reported with `reconcile_required`.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import time
33
+ from dataclasses import dataclass, field
34
+ from decimal import Decimal
35
+ from typing import Any
36
+
37
+ from .. import ids
38
+ from ..base import Capability
39
+ from ..kalshi import parse_ts
40
+ from ..trading.credentials import KalshiCredentials
41
+ from ..trading.kalshi import KalshiSigner, fill_of, order_of, position_of
42
+ from ..trading.types import Account, Side
43
+ from .base import (
44
+ ONE, BookEvent, BookLevel, D, Event, LocalBook, MarketStatusEvent, OrderEvent, FillEvent, PositionEvent,
45
+ QuoteEvent, Stream, TradeEvent, VenueEvent, maybe_D,
46
+ )
47
+
48
+ VENUE = "kalshi"
49
+ PATH = "/trade-api/ws/v2"
50
+ URLS = {
51
+ "demo": "wss://external-api-ws.demo.kalshi.co" + PATH,
52
+ "prod": "wss://external-api-ws.kalshi.com" + PATH,
53
+ }
54
+
55
+ PRIVATE_CHANNELS = frozenset({"fill", "user_orders", "market_positions", "order_group_updates"})
56
+ MARKET_SCOPED = frozenset({"orderbook_delta", "ticker", "trade", "fill", "user_orders", "market_positions"})
57
+ """Channels that take a market filter; the rest take none."""
58
+
59
+ RESYNC_AFTER_S = 10.0
60
+ """Ask again for a snapshot that has not arrived after this long."""
61
+
62
+ LIFECYCLE_STATE = {
63
+ "created": "created",
64
+ "activated": "open",
65
+ "deactivated": "paused",
66
+ "close_date_updated": "updated",
67
+ "determined": "determined",
68
+ "settled": "settled",
69
+ "price_level_structure_updated": "updated",
70
+ }
71
+
72
+
73
+ @dataclass
74
+ class Channel:
75
+ """One Kalshi channel's subscription: what was asked for, and what the
76
+ server calls it on this connection."""
77
+
78
+ name: str
79
+ tickers: set[str] | None
80
+ """`None` means every market."""
81
+ sid: int | None = None
82
+ command_id: int | None = None
83
+ last_seq: int | None = None
84
+ pending_add: set[str] = field(default_factory=set)
85
+
86
+
87
+ class KalshiStream(Stream):
88
+ """Kalshi market data and account activity on one connection.
89
+
90
+ ```python
91
+ async with KalshiStream(creds) as stream:
92
+ await stream.watch_order_book(["KXBTCD-26SEP1717-T115000"])
93
+ await stream.watch_orders()
94
+ async for event in stream:
95
+ ...
96
+ ```
97
+ """
98
+
99
+ venue = VENUE
100
+ name = "trade-api"
101
+ has: dict[str, Capability] = {
102
+ "watch_order_book": True,
103
+ "watch_ticker": True,
104
+ "watch_trades": True,
105
+ "watch_market_status": True,
106
+ "watch_orders": True,
107
+ "watch_my_trades": True,
108
+ "watch_positions": True,
109
+ # No balance channel; balance is read over REST.
110
+ "watch_balance": False,
111
+ }
112
+
113
+ def __init__(self, credentials: KalshiCredentials, *, url: str | None = None, account_name: str = "default", **kwargs: Any):
114
+ super().__init__(url or URLS[credentials.env], **kwargs)
115
+ self.signer = KalshiSigner(credentials.key_id, credentials.private_key_pem)
116
+ self.account = Account(venue=VENUE, name=account_name)
117
+ self.channels: dict[str, Channel] = {}
118
+ self.books: dict[str, LocalBook] = {}
119
+ self._by_sid: dict[int, Channel] = {}
120
+ self._by_command: dict[int, Channel] = {}
121
+ self._next_id = 1
122
+ self._snapshot_requested: dict[str, float] = {}
123
+
124
+ @property
125
+ def private(self) -> bool: # type: ignore[override]
126
+ return any(name in PRIVATE_CHANNELS for name in self.channels)
127
+
128
+ def headers(self) -> dict[str, str]:
129
+ return self.signer.headers("GET", PATH)
130
+
131
+ # -- subscribing ----------------------------------------------------------
132
+
133
+ async def watch_order_book(self, market_ids: list[str]) -> None:
134
+ """Books for these markets: a snapshot each, then deltas."""
135
+ tickers = [ids.native(VENUE, m) for m in market_ids]
136
+ for ticker in tickers:
137
+ self.books.setdefault(ticker, LocalBook())
138
+ await self._watch("orderbook_delta", tickers)
139
+
140
+ async def watch_ticker(self, market_ids: list[str] | None = None) -> None:
141
+ await self._watch("ticker", _tickers(market_ids))
142
+
143
+ async def watch_trades(self, market_ids: list[str] | None = None) -> None:
144
+ await self._watch("trade", _tickers(market_ids))
145
+
146
+ async def watch_market_status(self) -> None:
147
+ """Every market's lifecycle: created, paused and resumed, closed, determined, settled."""
148
+ await self._watch("market_lifecycle_v2", None)
149
+
150
+ async def watch_orders(self, market_ids: list[str] | None = None) -> None:
151
+ tickers = _tickers(market_ids)
152
+ await self._watch("user_orders", tickers)
153
+
154
+ async def watch_my_trades(self, market_ids: list[str] | None = None) -> None:
155
+ tickers = _tickers(market_ids)
156
+ await self._watch("fill", tickers)
157
+
158
+ async def watch_positions(self, market_ids: list[str] | None = None) -> None:
159
+ tickers = _tickers(market_ids)
160
+ await self._watch("market_positions", tickers)
161
+
162
+ async def watch_order_groups(self) -> None:
163
+ await self._watch("order_group_updates", None)
164
+
165
+ async def _watch(self, name: str, tickers: list[str] | None) -> None:
166
+ channel = self.channels.get(name)
167
+ if channel is None:
168
+ channel = self.channels[name] = Channel(name, set(tickers) if tickers else None)
169
+ await self._subscribe(channel)
170
+ return
171
+ if channel.tickers is None:
172
+ return
173
+ if not tickers:
174
+ # Widening a market-filtered channel to every market.
175
+ channel.tickers = None
176
+ self._restart(channel)
177
+ return
178
+ new = set(tickers) - channel.tickers
179
+ if not new:
180
+ return
181
+ channel.tickers |= new
182
+ if channel.sid is None:
183
+ # Subscribe still in flight: add once the server has named it.
184
+ channel.pending_add |= new
185
+ else:
186
+ await self._send_command("update_subscription", {
187
+ "sids": [channel.sid], "market_tickers": sorted(new), "action": "add_markets",
188
+ })
189
+
190
+ async def _subscribe(self, channel: Channel) -> None:
191
+ params: dict[str, Any] = {"channels": [channel.name]}
192
+ if channel.tickers and channel.name in MARKET_SCOPED:
193
+ params["market_tickers"] = sorted(channel.tickers)
194
+ command_id = self._allocate()
195
+ channel.command_id, channel.sid, channel.last_seq = command_id, None, None
196
+ self._by_command[command_id] = channel
197
+ await self.send({"id": command_id, "cmd": "subscribe", "params": params})
198
+
199
+ async def _send_command(self, cmd: str, params: dict[str, Any]) -> None:
200
+ await self.send({"id": self._allocate(), "cmd": cmd, "params": params})
201
+
202
+ def _allocate(self) -> int:
203
+ command_id = self._next_id
204
+ self._next_id += 1
205
+ return command_id
206
+
207
+ async def on_connect(self) -> None:
208
+ for channel in self.channels.values():
209
+ channel.pending_add.clear()
210
+ await self._subscribe(channel)
211
+
212
+ def on_disconnect(self) -> None:
213
+ self._by_sid.clear()
214
+ self._by_command.clear()
215
+ self._snapshot_requested.clear()
216
+ for channel in self.channels.values():
217
+ channel.sid = channel.command_id = channel.last_seq = None
218
+ for book in self.books.values():
219
+ book.invalidate()
220
+
221
+ def book(self, market_id: str, side: str = "yes") -> LocalBook | None:
222
+ """The local book for a market, on either side; `None` if not watched."""
223
+ book = self.books.get(ids.native(VENUE, market_id))
224
+ if book is None:
225
+ return None
226
+ return book if side != "no" else book.mirrored()
227
+
228
+ # -- reading --------------------------------------------------------------
229
+
230
+ def handle(self, message: Any) -> list[Event]:
231
+ if not isinstance(message, dict):
232
+ return []
233
+ kind = message.get("type")
234
+ sid = message.get("sid")
235
+ channel = self._by_sid.get(sid) if sid is not None else None
236
+ if kind == "subscribed":
237
+ return self._on_subscribed(message)
238
+ if kind == "error":
239
+ return self._on_error(message)
240
+ if channel is not None and message.get("seq") is not None:
241
+ self._check_sequence(channel, int(message["seq"]), kind)
242
+ body = message.get("msg") or {}
243
+ if kind == "orderbook_snapshot":
244
+ return self._on_snapshot(message, body, channel)
245
+ if kind == "orderbook_delta":
246
+ return self._on_delta(message, body)
247
+ if kind == "ticker":
248
+ return [self._quote(body)]
249
+ if kind == "trade":
250
+ return [self._trade(body, message)]
251
+ if kind == "fill":
252
+ fill = fill_of(body, account=self.account)
253
+ stamp = body.get("ts_ms") or parse_ts(body.get("ts"))
254
+ return [FillEvent(venue=VENUE, fill=fill.model_copy(update={"timestamp": int(stamp or 0), "client_order_id": body.get("client_order_id")}))]
255
+ if kind == "user_order":
256
+ return [OrderEvent(venue=VENUE, order=order_of(body, account=self.account), native=str(body.get("status") or ""))]
257
+ if kind == "market_position":
258
+ row = {
259
+ "ticker": body.get("market_ticker"), "position_fp": body.get("position_fp"),
260
+ "market_exposure_dollars": body.get("position_cost_dollars"),
261
+ "realized_pnl_dollars": body.get("realized_pnl_dollars"), "fees_paid_dollars": body.get("fees_paid_dollars"),
262
+ }
263
+ position = position_of(row, account=self.account)
264
+ return [PositionEvent(venue=VENUE, position=position.model_copy(update={"info": body}))]
265
+ if kind == "order_group_updates":
266
+ return [VenueEvent(venue=VENUE, name="order_group", payload=body, timestamp=body.get("ts_ms"))]
267
+ if kind == "market_lifecycle_v2":
268
+ return [self._lifecycle(body)]
269
+ if kind in ("event_lifecycle", "multivariate_market_lifecycle", "multivariate_event_lifecycle"):
270
+ return [VenueEvent(venue=VENUE, name=kind, payload=body)]
271
+ return []
272
+
273
+ def _on_subscribed(self, message: dict[str, Any]) -> list[Event]:
274
+ body = message.get("msg") or {}
275
+ channel = self._by_command.pop(message.get("id"), None) or self.channels.get(str(body.get("channel")))
276
+ if channel is None:
277
+ return []
278
+ channel.sid = int(body["sid"])
279
+ self._by_sid[channel.sid] = channel
280
+ self.status("subscribed", channel.name, key=channel.name)
281
+ if channel.pending_add:
282
+ added, channel.pending_add = sorted(channel.pending_add), set()
283
+ self._later(self._send_command("update_subscription", {
284
+ "sids": [channel.sid], "market_tickers": added, "action": "add_markets",
285
+ }))
286
+ return []
287
+
288
+ def _on_error(self, message: dict[str, Any]) -> list[Event]:
289
+ body = message.get("msg") or {}
290
+ code = body.get("code")
291
+ detail = f"code {code}: {body.get('msg')}"
292
+ channel = self._by_command.pop(message.get("id"), None) or self._by_sid.get(message.get("sid"))
293
+ if code == 25 and channel is not None:
294
+ # The server dropped messages for this subscription: start it again.
295
+ self.status("gap", f"{channel.name}: {detail}", key=channel.name, reconcile=channel.name in PRIVATE_CHANNELS)
296
+ self._restart(channel)
297
+ return []
298
+ self.status("error", detail, key=channel.name if channel else None)
299
+ return []
300
+
301
+ def _check_sequence(self, channel: Channel, seq: int, kind: str | None) -> None:
302
+ last = channel.last_seq
303
+ channel.last_seq = seq
304
+ if last is None or seq == last + 1:
305
+ return
306
+ self.status(
307
+ "gap", f"{channel.name}: expected seq {last + 1}, got {seq}", key=channel.name,
308
+ reconcile=channel.name in PRIVATE_CHANNELS,
309
+ )
310
+ if channel.name == "orderbook_delta":
311
+ tickers = sorted(channel.tickers or [])
312
+ for ticker in tickers:
313
+ if ticker in self.books:
314
+ self.books[ticker].invalidate()
315
+ self._request_snapshots(channel, tickers)
316
+
317
+ def _request_snapshots(self, channel: Channel, tickers: list[str]) -> None:
318
+ now = time.monotonic()
319
+ wanted = [t for t in tickers if now - self._snapshot_requested.get(t, 0) > RESYNC_AFTER_S]
320
+ if not wanted or channel.sid is None:
321
+ return
322
+ for ticker in wanted:
323
+ self._snapshot_requested[ticker] = now
324
+ self._later(self._send_command("update_subscription", {
325
+ "sids": [channel.sid], "market_tickers": wanted, "action": "get_snapshot",
326
+ }))
327
+
328
+ def _restart(self, channel: Channel) -> None:
329
+ if channel.sid is not None:
330
+ self._by_sid.pop(channel.sid, None)
331
+ self._later(self._send_command("unsubscribe", {"sids": [channel.sid]}))
332
+ if channel.name == "orderbook_delta":
333
+ for ticker in channel.tickers or []:
334
+ if ticker in self.books:
335
+ self.books[ticker].invalidate()
336
+ self._later(self._subscribe(channel))
337
+
338
+ def _later(self, coroutine: Any) -> None:
339
+ import asyncio
340
+
341
+ asyncio.get_running_loop().create_task(coroutine)
342
+
343
+ def _on_snapshot(self, message: dict[str, Any], body: dict[str, Any], channel: Channel | None) -> list[Event]:
344
+ ticker = str(body.get("market_ticker") or "")
345
+ book = self.books.setdefault(ticker, LocalBook())
346
+ yes = [(D(p), D(s)) for p, s in body.get("yes_dollars_fp") or []]
347
+ no = [(ONE - D(p), D(s)) for p, s in body.get("no_dollars_fp") or []]
348
+ recovering = not book.ready and book.sequence is not None
349
+ book.replace(yes, no)
350
+ book.sequence = message.get("seq")
351
+ self._snapshot_requested.pop(ticker, None)
352
+ if recovering:
353
+ self.status("resynced", f"orderbook {ticker}", key=ticker)
354
+ bids, asks = book.levels()
355
+ return [BookEvent(
356
+ venue=VENUE, market_id=ids.qualify(VENUE, ticker), kind="snapshot", bids=bids, asks=asks,
357
+ best_bid=book.best_bid, best_ask=book.best_ask, sequence=book.sequence, info={"market_id": body.get("market_id")},
358
+ )]
359
+
360
+ def _on_delta(self, message: dict[str, Any], body: dict[str, Any]) -> list[Event]:
361
+ ticker = str(body.get("market_ticker") or "")
362
+ book = self.books.get(ticker)
363
+ seq = message.get("seq")
364
+ if book is None or not book.ready:
365
+ if book is not None:
366
+ book.sequence = seq
367
+ return []
368
+ price = D(body["price_dollars"])
369
+ delta = D(body["delta_fp"])
370
+ if body.get("side") == "yes":
371
+ size = book.add("bid", price, delta)
372
+ bids, asks = (BookLevel(price, size),), ()
373
+ else:
374
+ price = ONE - price
375
+ size = book.add("ask", price, delta)
376
+ bids, asks = (), (BookLevel(price, size),)
377
+ book.sequence = seq
378
+ book.timestamp = body.get("ts_ms")
379
+ return [BookEvent(
380
+ venue=VENUE, market_id=ids.qualify(VENUE, ticker), kind="delta", bids=bids, asks=asks,
381
+ best_bid=book.best_bid, best_ask=book.best_ask, sequence=seq, timestamp=body.get("ts_ms"),
382
+ info={"client_order_id": body["client_order_id"]} if body.get("client_order_id") else {},
383
+ )]
384
+
385
+ def _quote(self, body: dict[str, Any]) -> QuoteEvent:
386
+ ticker = str(body.get("market_ticker") or "")
387
+ return QuoteEvent(
388
+ venue=VENUE, market_id=ids.qualify(VENUE, ticker),
389
+ bid=maybe_D(body.get("yes_bid_dollars")), ask=maybe_D(body.get("yes_ask_dollars")),
390
+ bid_size=maybe_D(body.get("yes_bid_size_fp")), ask_size=maybe_D(body.get("yes_ask_size_fp")),
391
+ last=maybe_D(body.get("price_dollars")), volume=maybe_D(body.get("volume_fp")),
392
+ open_interest=maybe_D(body.get("open_interest_fp")), timestamp=body.get("ts_ms"), info=body,
393
+ )
394
+
395
+ def _trade(self, body: dict[str, Any], message: dict[str, Any]) -> TradeEvent:
396
+ ticker = str(body.get("market_ticker") or "")
397
+ book_side = body.get("taker_book_side")
398
+ return TradeEvent(
399
+ venue=VENUE, market_id=ids.qualify(VENUE, ticker), id=body.get("trade_id"),
400
+ price=D(body["yes_price_dollars"]), amount=D(body["count_fp"]),
401
+ taker_side=Side.BUY if book_side == "bid" else Side.SELL if book_side == "ask" else None,
402
+ timestamp=body.get("ts_ms"), info={**body, "seq": message.get("seq")},
403
+ )
404
+
405
+ def _lifecycle(self, body: dict[str, Any]) -> MarketStatusEvent:
406
+ native = str(body.get("event_type") or "")
407
+ state = LIFECYCLE_STATE.get(native, "updated")
408
+ if native in ("activated", "deactivated") and "is_deactivated" in body:
409
+ state = "paused" if body["is_deactivated"] else "open"
410
+ stamp = body.get("settled_ts") or body.get("determination_ts")
411
+ return MarketStatusEvent(
412
+ venue=VENUE, market_id=ids.qualify(VENUE, str(body.get("market_ticker") or "")), state=state, native=native, # type: ignore[arg-type]
413
+ result=body.get("result") or None, timestamp=int(stamp) * 1000 if stamp else None, info=body,
414
+ )
415
+
416
+
417
+ def _tickers(market_ids: list[str] | None) -> list[str] | None:
418
+ return None if market_ids is None else [ids.native(VENUE, m) for m in market_ids]