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.
- synpath/__init__.py +183 -0
- synpath/__main__.py +66 -0
- synpath/base.py +723 -0
- synpath/bucket.py +154 -0
- synpath/client.py +356 -0
- synpath/engine/__init__.py +37 -0
- synpath/engine/__main__.py +354 -0
- synpath/engine/alerts.py +170 -0
- synpath/engine/engine.py +888 -0
- synpath/engine/eod.py +154 -0
- synpath/engine/events.py +140 -0
- synpath/engine/fair_values.py +117 -0
- synpath/engine/feeds.py +220 -0
- synpath/engine/journal.py +907 -0
- synpath/engine/ledger.py +353 -0
- synpath/engine/orders/__init__.py +42 -0
- synpath/engine/orders/base.py +441 -0
- synpath/engine/orders/day.py +72 -0
- synpath/engine/orders/iceberg.py +121 -0
- synpath/engine/orders/manager.py +223 -0
- synpath/engine/orders/oco.py +255 -0
- synpath/engine/orders/peg.py +168 -0
- synpath/engine/orders/routed.py +496 -0
- synpath/engine/orders/stop.py +240 -0
- synpath/engine/orders/taker.py +187 -0
- synpath/engine/orders/twap.py +190 -0
- synpath/engine/paper.py +532 -0
- synpath/engine/reconcile.py +279 -0
- synpath/engine/risk.py +403 -0
- synpath/engine/router.py +261 -0
- synpath/errors.py +98 -0
- synpath/history.py +71 -0
- synpath/hosted.py +86 -0
- synpath/hosted_auth.py +201 -0
- synpath/ids.py +61 -0
- synpath/kalshi.py +1378 -0
- synpath/matching.py +86 -0
- synpath/polymarket.py +1004 -0
- synpath/polymarket_us.py +989 -0
- synpath/remote.py +195 -0
- synpath/server/__init__.py +98 -0
- synpath/server/__main__.py +118 -0
- synpath/server/api.py +439 -0
- synpath/server/errors.py +87 -0
- synpath/server/local.py +96 -0
- synpath/server/models.py +75 -0
- synpath/server/serve.py +236 -0
- synpath/server/store.py +363 -0
- synpath/server/trading.py +764 -0
- synpath/trading/__init__.py +79 -0
- synpath/trading/__main__.py +69 -0
- synpath/trading/base.py +126 -0
- synpath/trading/credentials.py +400 -0
- synpath/trading/errors.py +94 -0
- synpath/trading/init.py +233 -0
- synpath/trading/instruments.py +162 -0
- synpath/trading/kalshi.py +957 -0
- synpath/trading/limiter.py +177 -0
- synpath/trading/money.py +172 -0
- synpath/trading/polymarket.py +1362 -0
- synpath/trading/polymarket_signing.py +478 -0
- synpath/trading/polymarket_us.py +705 -0
- synpath/trading/polymarket_us_exchange.py +825 -0
- synpath/trading/types.py +414 -0
- synpath/types.py +608 -0
- synpath/ws/__init__.py +55 -0
- synpath/ws/base.py +544 -0
- synpath/ws/grpc.py +578 -0
- synpath/ws/kalshi.py +418 -0
- synpath/ws/polymarket.py +430 -0
- synpath/ws/polymarket_us.py +299 -0
- synpath/ws/polymarket_us_exchange.py +754 -0
- synpath-0.1.0.dist-info/METADATA +224 -0
- synpath-0.1.0.dist-info/RECORD +77 -0
- synpath-0.1.0.dist-info/WHEEL +4 -0
- synpath-0.1.0.dist-info/entry_points.txt +2 -0
- 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]
|