pykalshi 0.1.0__py3-none-any.whl → 0.2.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.
- pykalshi/__init__.py +144 -0
- pykalshi/api_keys.py +59 -0
- pykalshi/client.py +526 -0
- pykalshi/enums.py +54 -0
- pykalshi/events.py +87 -0
- pykalshi/exceptions.py +115 -0
- pykalshi/exchange.py +37 -0
- pykalshi/feed.py +592 -0
- pykalshi/markets.py +234 -0
- pykalshi/models.py +552 -0
- pykalshi/orderbook.py +146 -0
- pykalshi/orders.py +144 -0
- pykalshi/portfolio.py +542 -0
- pykalshi/py.typed +0 -0
- pykalshi/rate_limiter.py +171 -0
- {pykalshi-0.1.0.dist-info → pykalshi-0.2.0.dist-info}/METADATA +8 -8
- pykalshi-0.2.0.dist-info/RECORD +35 -0
- pykalshi-0.2.0.dist-info/top_level.txt +1 -0
- pykalshi-0.1.0.dist-info/RECORD +0 -20
- pykalshi-0.1.0.dist-info/top_level.txt +0 -1
- {pykalshi-0.1.0.dist-info → pykalshi-0.2.0.dist-info}/WHEEL +0 -0
- {pykalshi-0.1.0.dist-info → pykalshi-0.2.0.dist-info}/licenses/LICENSE +0 -0
pykalshi/feed.py
ADDED
|
@@ -0,0 +1,592 @@
|
|
|
1
|
+
"""Real-time data feed via WebSocket.
|
|
2
|
+
|
|
3
|
+
This module provides streaming market data through Kalshi's WebSocket API.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import itertools
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
from typing import Any, Callable, Optional, TYPE_CHECKING, Union
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel, ConfigDict
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from .client import KalshiClient
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
# WebSocket endpoints
|
|
24
|
+
DEFAULT_WS_BASE = "wss://api.elections.kalshi.com/trade-api/ws/v2"
|
|
25
|
+
DEMO_WS_BASE = "wss://demo-api.kalshi.co/trade-api/ws/v2"
|
|
26
|
+
_WS_SIGN_PATH = "/trade-api/ws/v2"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# --- WebSocket Message Models ---
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TickerMessage(BaseModel):
|
|
33
|
+
"""Real-time market ticker update.
|
|
34
|
+
|
|
35
|
+
Sent when price, volume, or open interest changes for a subscribed market.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
market_ticker: str
|
|
39
|
+
price: Optional[int] = None
|
|
40
|
+
yes_bid: Optional[int] = None
|
|
41
|
+
yes_ask: Optional[int] = None
|
|
42
|
+
volume: Optional[int] = None
|
|
43
|
+
open_interest: Optional[int] = None
|
|
44
|
+
dollar_volume: Optional[int] = None
|
|
45
|
+
dollar_open_interest: Optional[int] = None
|
|
46
|
+
ts: Optional[int] = None
|
|
47
|
+
|
|
48
|
+
model_config = ConfigDict(extra="ignore")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class OrderbookSnapshotMessage(BaseModel):
|
|
52
|
+
"""Full orderbook state received on initial subscription.
|
|
53
|
+
|
|
54
|
+
Contains all current price levels. After this, you'll receive
|
|
55
|
+
OrderbookDeltaMessage for incremental updates.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
market_ticker: str
|
|
59
|
+
yes: Optional[list[tuple[int, int]]] = None # [(price, quantity), ...]
|
|
60
|
+
no: Optional[list[tuple[int, int]]] = None
|
|
61
|
+
|
|
62
|
+
model_config = ConfigDict(extra="ignore")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class OrderbookDeltaMessage(BaseModel):
|
|
66
|
+
"""Incremental orderbook update.
|
|
67
|
+
|
|
68
|
+
Represents a change at a single price level. Apply to local orderbook state.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
market_ticker: str
|
|
72
|
+
price: int
|
|
73
|
+
delta: int # Positive = added, negative = removed
|
|
74
|
+
side: str # "yes" or "no"
|
|
75
|
+
|
|
76
|
+
model_config = ConfigDict(extra="ignore")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class TradeMessage(BaseModel):
|
|
80
|
+
"""Public trade execution.
|
|
81
|
+
|
|
82
|
+
Sent when any trade occurs on subscribed markets.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
market_ticker: Optional[str] = None
|
|
86
|
+
ticker: Optional[str] = None
|
|
87
|
+
trade_id: Optional[str] = None
|
|
88
|
+
count: Optional[int] = None
|
|
89
|
+
yes_price: Optional[int] = None
|
|
90
|
+
no_price: Optional[int] = None
|
|
91
|
+
taker_side: Optional[str] = None
|
|
92
|
+
ts: Optional[int] = None
|
|
93
|
+
|
|
94
|
+
model_config = ConfigDict(extra="ignore")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class FillMessage(BaseModel):
|
|
98
|
+
"""User fill notification (private channel).
|
|
99
|
+
|
|
100
|
+
Sent when your orders are filled.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
trade_id: Optional[str] = None
|
|
104
|
+
ticker: Optional[str] = None
|
|
105
|
+
order_id: Optional[str] = None
|
|
106
|
+
side: Optional[str] = None
|
|
107
|
+
action: Optional[str] = None
|
|
108
|
+
count: Optional[int] = None
|
|
109
|
+
yes_price: Optional[int] = None
|
|
110
|
+
no_price: Optional[int] = None
|
|
111
|
+
is_taker: Optional[bool] = None
|
|
112
|
+
ts: Optional[int] = None
|
|
113
|
+
|
|
114
|
+
model_config = ConfigDict(extra="ignore")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class PositionMessage(BaseModel):
|
|
118
|
+
"""Real-time position update (private channel).
|
|
119
|
+
|
|
120
|
+
Sent when your position in a market changes (after fills settle).
|
|
121
|
+
Includes realized P&L and current exposure.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
ticker: str
|
|
125
|
+
position: Optional[int] = None # Net contracts (positive = yes, negative = no)
|
|
126
|
+
market_exposure: Optional[int] = None # Current exposure in cents
|
|
127
|
+
realized_pnl: Optional[int] = None # Realized P&L in cents
|
|
128
|
+
total_traded: Optional[int] = None # Total contracts traded
|
|
129
|
+
resting_orders_count: Optional[int] = None # Open orders count
|
|
130
|
+
fees_paid: Optional[int] = None # Fees paid in cents
|
|
131
|
+
ts: Optional[int] = None
|
|
132
|
+
|
|
133
|
+
model_config = ConfigDict(extra="ignore")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class MarketLifecycleMessage(BaseModel):
|
|
137
|
+
"""Market lifecycle state change (public channel).
|
|
138
|
+
|
|
139
|
+
Sent when a market's status changes (open, closed, settled, etc.).
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
market_ticker: str
|
|
143
|
+
status: Optional[str] = None
|
|
144
|
+
result: Optional[str] = None # Settlement result ("yes" or "no")
|
|
145
|
+
ts: Optional[int] = None
|
|
146
|
+
|
|
147
|
+
model_config = ConfigDict(extra="ignore")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class OrderGroupUpdateMessage(BaseModel):
|
|
151
|
+
"""Order group lifecycle update (private channel).
|
|
152
|
+
|
|
153
|
+
Sent when an order group's status changes (triggered, canceled, etc.).
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
order_group_id: str
|
|
157
|
+
status: Optional[str] = None # "active", "triggered", "canceled"
|
|
158
|
+
ts: Optional[int] = None
|
|
159
|
+
|
|
160
|
+
model_config = ConfigDict(extra="ignore")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# Type alias for orderbook messages (handlers receive either type)
|
|
164
|
+
OrderbookMessage = Union[OrderbookSnapshotMessage, OrderbookDeltaMessage]
|
|
165
|
+
|
|
166
|
+
# Maps message "type" field to model class
|
|
167
|
+
_MESSAGE_MODELS: dict[str, type[BaseModel]] = {
|
|
168
|
+
"ticker": TickerMessage,
|
|
169
|
+
"orderbook_snapshot": OrderbookSnapshotMessage,
|
|
170
|
+
"orderbook_delta": OrderbookDeltaMessage,
|
|
171
|
+
"trade": TradeMessage,
|
|
172
|
+
"fill": FillMessage,
|
|
173
|
+
"market_position": PositionMessage,
|
|
174
|
+
"market_lifecycle": MarketLifecycleMessage,
|
|
175
|
+
"order_group_update": OrderGroupUpdateMessage,
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
# Maps message types to channel name for handler lookup
|
|
179
|
+
_TYPE_TO_CHANNEL: dict[str, str] = {
|
|
180
|
+
"orderbook_snapshot": "orderbook_delta",
|
|
181
|
+
"orderbook_delta": "orderbook_delta",
|
|
182
|
+
"ticker": "ticker",
|
|
183
|
+
"trade": "trade",
|
|
184
|
+
"fill": "fill",
|
|
185
|
+
"market_position": "market_positions",
|
|
186
|
+
"market_lifecycle": "market_lifecycle",
|
|
187
|
+
"order_group_update": "order_group_updates",
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class Feed:
|
|
192
|
+
"""Real-time streaming data feed via WebSocket.
|
|
193
|
+
|
|
194
|
+
Provides a clean interface to Kalshi's WebSocket API with automatic
|
|
195
|
+
reconnection, typed message models, and callback-based handling.
|
|
196
|
+
|
|
197
|
+
Usage:
|
|
198
|
+
feed = client.feed()
|
|
199
|
+
|
|
200
|
+
@feed.on("ticker")
|
|
201
|
+
def handle_ticker(msg: TickerMessage):
|
|
202
|
+
print(f"{msg.market_ticker}: {msg.yes_bid}/{msg.yes_ask}")
|
|
203
|
+
|
|
204
|
+
@feed.on("orderbook_delta")
|
|
205
|
+
def handle_orderbook(msg: OrderbookMessage):
|
|
206
|
+
if isinstance(msg, OrderbookSnapshotMessage):
|
|
207
|
+
# Initialize local orderbook
|
|
208
|
+
pass
|
|
209
|
+
else:
|
|
210
|
+
# Apply delta
|
|
211
|
+
pass
|
|
212
|
+
|
|
213
|
+
feed.subscribe("ticker", market_ticker="KXBTC-26JAN")
|
|
214
|
+
feed.subscribe("orderbook_delta", market_ticker="KXBTC-26JAN")
|
|
215
|
+
|
|
216
|
+
feed.start() # Runs in background thread
|
|
217
|
+
# ... do other work ...
|
|
218
|
+
feed.stop()
|
|
219
|
+
|
|
220
|
+
# Or use as context manager:
|
|
221
|
+
with client.feed() as feed:
|
|
222
|
+
feed.on("ticker", my_handler)
|
|
223
|
+
feed.subscribe("ticker", market_ticker="KXBTC-26JAN")
|
|
224
|
+
time.sleep(60)
|
|
225
|
+
|
|
226
|
+
Available channels:
|
|
227
|
+
- "ticker": Market price/volume updates (public)
|
|
228
|
+
- "trade": Public trade executions (public)
|
|
229
|
+
- "orderbook_delta": Orderbook snapshots and deltas (requires auth)
|
|
230
|
+
- "fill": Your order fills (requires auth, no market filter)
|
|
231
|
+
- "market_positions": Real-time position updates with P&L (requires auth, no market filter)
|
|
232
|
+
- "market_lifecycle": Market state changes (public)
|
|
233
|
+
- "order_group_updates": Order group lifecycle changes (requires auth)
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
def __init__(self, client: KalshiClient) -> None:
|
|
237
|
+
"""Initialize the feed.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
client: Authenticated KalshiClient instance.
|
|
241
|
+
"""
|
|
242
|
+
self._client = client
|
|
243
|
+
self._handlers: dict[str, list[Callable]] = {}
|
|
244
|
+
self._active_subs: list[dict] = []
|
|
245
|
+
self._ws: Any = None
|
|
246
|
+
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
|
247
|
+
self._thread: Optional[threading.Thread] = None
|
|
248
|
+
self._running = False
|
|
249
|
+
self._cmd_id_counter = itertools.count(1) # Thread-safe counter
|
|
250
|
+
self._connected = threading.Event()
|
|
251
|
+
self._lock = threading.Lock()
|
|
252
|
+
self._metrics_lock = threading.Lock()
|
|
253
|
+
|
|
254
|
+
# Latency and health tracking (protected by _metrics_lock)
|
|
255
|
+
self._connected_at: Optional[float] = None
|
|
256
|
+
self._last_message_at: Optional[float] = None
|
|
257
|
+
self._last_server_ts: Optional[int] = None # Server timestamp in ms
|
|
258
|
+
self._message_count: int = 0
|
|
259
|
+
self._reconnect_count: int = 0
|
|
260
|
+
|
|
261
|
+
# Determine WS URL from client's API base
|
|
262
|
+
self._ws_url = DEMO_WS_BASE if "demo" in client.api_base else DEFAULT_WS_BASE
|
|
263
|
+
|
|
264
|
+
def on(
|
|
265
|
+
self, channel: str, handler: Optional[Callable] = None
|
|
266
|
+
) -> Callable:
|
|
267
|
+
"""Register a handler for a channel.
|
|
268
|
+
|
|
269
|
+
Can be used as a decorator or called directly:
|
|
270
|
+
|
|
271
|
+
@feed.on("ticker")
|
|
272
|
+
def handle(msg: TickerMessage):
|
|
273
|
+
...
|
|
274
|
+
|
|
275
|
+
# or
|
|
276
|
+
feed.on("ticker", my_handler)
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
channel: Channel name ("ticker", "orderbook_delta", "trade", "fill", "market_positions").
|
|
280
|
+
handler: Optional handler function. If None, returns a decorator.
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
The handler function (for decorator chaining).
|
|
284
|
+
"""
|
|
285
|
+
if handler is not None:
|
|
286
|
+
self._handlers.setdefault(channel, []).append(handler)
|
|
287
|
+
return handler
|
|
288
|
+
|
|
289
|
+
def decorator(fn: Callable) -> Callable:
|
|
290
|
+
self._handlers.setdefault(channel, []).append(fn)
|
|
291
|
+
return fn
|
|
292
|
+
|
|
293
|
+
return decorator
|
|
294
|
+
|
|
295
|
+
def subscribe(
|
|
296
|
+
self,
|
|
297
|
+
channel: str,
|
|
298
|
+
*,
|
|
299
|
+
market_ticker: Optional[str] = None,
|
|
300
|
+
market_tickers: Optional[list[str]] = None,
|
|
301
|
+
) -> None:
|
|
302
|
+
"""Subscribe to a channel.
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
channel: Channel name ("ticker", "orderbook_delta", "trade", "fill", "market_positions").
|
|
306
|
+
market_ticker: Filter to a single market.
|
|
307
|
+
market_tickers: Filter to multiple markets.
|
|
308
|
+
|
|
309
|
+
Note:
|
|
310
|
+
- For "fill" and "market_positions" channels, market filters are ignored
|
|
311
|
+
(you get all your fills/positions).
|
|
312
|
+
- Can be called before or after start(). If called after, subscription
|
|
313
|
+
is sent immediately.
|
|
314
|
+
"""
|
|
315
|
+
params: dict[str, Any] = {"channels": [channel]}
|
|
316
|
+
if market_ticker is not None:
|
|
317
|
+
params["market_ticker"] = market_ticker
|
|
318
|
+
if market_tickers is not None:
|
|
319
|
+
params["market_tickers"] = market_tickers
|
|
320
|
+
|
|
321
|
+
with self._lock:
|
|
322
|
+
self._active_subs.append(params)
|
|
323
|
+
|
|
324
|
+
# Send immediately if connected
|
|
325
|
+
if self._loop and self._connected.is_set():
|
|
326
|
+
asyncio.run_coroutine_threadsafe(
|
|
327
|
+
self._send_cmd("subscribe", params), self._loop
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
def unsubscribe(
|
|
331
|
+
self,
|
|
332
|
+
channel: str,
|
|
333
|
+
*,
|
|
334
|
+
market_ticker: Optional[str] = None,
|
|
335
|
+
) -> None:
|
|
336
|
+
"""Unsubscribe from a channel.
|
|
337
|
+
|
|
338
|
+
Args:
|
|
339
|
+
channel: Channel name.
|
|
340
|
+
market_ticker: Market to unsubscribe from (must match subscribe call).
|
|
341
|
+
"""
|
|
342
|
+
params: dict[str, Any] = {"channels": [channel]}
|
|
343
|
+
if market_ticker is not None:
|
|
344
|
+
params["market_ticker"] = market_ticker
|
|
345
|
+
|
|
346
|
+
# Remove from active subs
|
|
347
|
+
with self._lock:
|
|
348
|
+
self._active_subs = [
|
|
349
|
+
s
|
|
350
|
+
for s in self._active_subs
|
|
351
|
+
if not (
|
|
352
|
+
s.get("channels") == [channel]
|
|
353
|
+
and s.get("market_ticker") == market_ticker
|
|
354
|
+
)
|
|
355
|
+
]
|
|
356
|
+
|
|
357
|
+
if self._loop and self._connected.is_set():
|
|
358
|
+
asyncio.run_coroutine_threadsafe(
|
|
359
|
+
self._send_cmd("unsubscribe", params), self._loop
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
def start(self) -> None:
|
|
363
|
+
"""Start the feed in a background thread.
|
|
364
|
+
|
|
365
|
+
Blocks briefly (up to 10s) until the initial connection is established.
|
|
366
|
+
If connection fails, the feed continues retrying in the background.
|
|
367
|
+
"""
|
|
368
|
+
with self._lock:
|
|
369
|
+
if self._running:
|
|
370
|
+
return
|
|
371
|
+
self._running = True
|
|
372
|
+
self._connected.clear()
|
|
373
|
+
self._thread = threading.Thread(
|
|
374
|
+
target=self._run, name="kalshi-feed", daemon=True
|
|
375
|
+
)
|
|
376
|
+
self._thread.start()
|
|
377
|
+
self._connected.wait(timeout=10)
|
|
378
|
+
|
|
379
|
+
def stop(self) -> None:
|
|
380
|
+
"""Stop the feed and disconnect."""
|
|
381
|
+
with self._lock:
|
|
382
|
+
if not self._running:
|
|
383
|
+
return
|
|
384
|
+
self._running = False
|
|
385
|
+
if self._loop and self._loop.is_running():
|
|
386
|
+
self._loop.call_soon_threadsafe(self._loop.stop)
|
|
387
|
+
if self._thread:
|
|
388
|
+
self._thread.join(timeout=5)
|
|
389
|
+
self._thread = None
|
|
390
|
+
self._connected.clear()
|
|
391
|
+
self._connected_at = None
|
|
392
|
+
|
|
393
|
+
@property
|
|
394
|
+
def is_connected(self) -> bool:
|
|
395
|
+
"""Whether the WebSocket is currently connected."""
|
|
396
|
+
return self._connected.is_set()
|
|
397
|
+
|
|
398
|
+
@property
|
|
399
|
+
def latency_ms(self) -> Optional[float]:
|
|
400
|
+
"""Estimated latency in milliseconds based on last message timestamp.
|
|
401
|
+
|
|
402
|
+
Returns None if no messages with timestamps have been received.
|
|
403
|
+
This measures the difference between the server's timestamp and
|
|
404
|
+
when we received the message locally. Assumes clocks are synchronized.
|
|
405
|
+
"""
|
|
406
|
+
with self._metrics_lock:
|
|
407
|
+
if self._last_server_ts is None or self._last_message_at is None:
|
|
408
|
+
return None
|
|
409
|
+
local_ms = self._last_message_at * 1000
|
|
410
|
+
return local_ms - self._last_server_ts
|
|
411
|
+
|
|
412
|
+
@property
|
|
413
|
+
def messages_received(self) -> int:
|
|
414
|
+
"""Total number of messages received since feed started."""
|
|
415
|
+
with self._metrics_lock:
|
|
416
|
+
return self._message_count
|
|
417
|
+
|
|
418
|
+
@property
|
|
419
|
+
def uptime_seconds(self) -> Optional[float]:
|
|
420
|
+
"""Seconds since connection was established. None if not connected."""
|
|
421
|
+
with self._metrics_lock:
|
|
422
|
+
if self._connected_at is None or not self.is_connected:
|
|
423
|
+
return None
|
|
424
|
+
return time.time() - self._connected_at
|
|
425
|
+
|
|
426
|
+
@property
|
|
427
|
+
def seconds_since_last_message(self) -> Optional[float]:
|
|
428
|
+
"""Seconds since last message was received. None if no messages yet."""
|
|
429
|
+
with self._metrics_lock:
|
|
430
|
+
if self._last_message_at is None:
|
|
431
|
+
return None
|
|
432
|
+
return time.time() - self._last_message_at
|
|
433
|
+
|
|
434
|
+
@property
|
|
435
|
+
def reconnect_count(self) -> int:
|
|
436
|
+
"""Number of times the feed has reconnected (0 on first connection)."""
|
|
437
|
+
with self._metrics_lock:
|
|
438
|
+
return self._reconnect_count
|
|
439
|
+
|
|
440
|
+
def _run(self) -> None:
|
|
441
|
+
"""Background thread entry point."""
|
|
442
|
+
self._loop = asyncio.new_event_loop()
|
|
443
|
+
asyncio.set_event_loop(self._loop)
|
|
444
|
+
try:
|
|
445
|
+
self._loop.run_until_complete(self._connect_loop())
|
|
446
|
+
except Exception as e:
|
|
447
|
+
logger.error("Feed loop crashed: %s", e)
|
|
448
|
+
finally:
|
|
449
|
+
self._loop.close()
|
|
450
|
+
self._loop = None
|
|
451
|
+
|
|
452
|
+
async def _connect_loop(self) -> None:
|
|
453
|
+
"""Main connection loop with auto-reconnect."""
|
|
454
|
+
try:
|
|
455
|
+
import websockets
|
|
456
|
+
except ImportError:
|
|
457
|
+
raise ImportError(
|
|
458
|
+
"websockets is required for Feed. Install with: pip install websockets"
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
backoff = 0.5
|
|
462
|
+
max_backoff = 30
|
|
463
|
+
|
|
464
|
+
while self._running:
|
|
465
|
+
try:
|
|
466
|
+
headers = self._auth_headers()
|
|
467
|
+
async with websockets.connect(
|
|
468
|
+
self._ws_url,
|
|
469
|
+
additional_headers=headers,
|
|
470
|
+
ping_interval=20,
|
|
471
|
+
ping_timeout=10,
|
|
472
|
+
) as ws:
|
|
473
|
+
self._ws = ws
|
|
474
|
+
backoff = 0.5 # Reset on successful connect
|
|
475
|
+
|
|
476
|
+
# Track connection time
|
|
477
|
+
with self._metrics_lock:
|
|
478
|
+
if self._connected_at is not None:
|
|
479
|
+
self._reconnect_count += 1
|
|
480
|
+
self._connected_at = time.time()
|
|
481
|
+
|
|
482
|
+
# Replay all active subscriptions
|
|
483
|
+
with self._lock:
|
|
484
|
+
subs = list(self._active_subs)
|
|
485
|
+
for params in subs:
|
|
486
|
+
await self._send_cmd("subscribe", params)
|
|
487
|
+
|
|
488
|
+
self._connected.set()
|
|
489
|
+
logger.info("Feed connected to %s", self._ws_url)
|
|
490
|
+
|
|
491
|
+
async for raw_msg in ws:
|
|
492
|
+
self._dispatch(raw_msg)
|
|
493
|
+
|
|
494
|
+
except asyncio.CancelledError:
|
|
495
|
+
break
|
|
496
|
+
except Exception as e:
|
|
497
|
+
self._connected.clear()
|
|
498
|
+
self._ws = None
|
|
499
|
+
if not self._running:
|
|
500
|
+
break
|
|
501
|
+
logger.warning(
|
|
502
|
+
"Feed disconnected (%s), reconnecting in %.1fs",
|
|
503
|
+
type(e).__name__,
|
|
504
|
+
backoff,
|
|
505
|
+
)
|
|
506
|
+
await asyncio.sleep(backoff)
|
|
507
|
+
backoff = min(backoff * 2, max_backoff)
|
|
508
|
+
|
|
509
|
+
self._connected.clear()
|
|
510
|
+
self._ws = None
|
|
511
|
+
|
|
512
|
+
def _auth_headers(self) -> dict[str, str]:
|
|
513
|
+
"""Generate authentication headers for WebSocket handshake."""
|
|
514
|
+
timestamp, signature = self._client._sign_request("GET", _WS_SIGN_PATH)
|
|
515
|
+
return {
|
|
516
|
+
"KALSHI-ACCESS-KEY": self._client.api_key_id,
|
|
517
|
+
"KALSHI-ACCESS-SIGNATURE": signature,
|
|
518
|
+
"KALSHI-ACCESS-TIMESTAMP": timestamp,
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
def _next_id(self) -> int:
|
|
522
|
+
"""Get next command ID (thread-safe)."""
|
|
523
|
+
return next(self._cmd_id_counter)
|
|
524
|
+
|
|
525
|
+
async def _send_cmd(self, cmd: str, params: dict) -> None:
|
|
526
|
+
"""Send a command over the WebSocket."""
|
|
527
|
+
if self._ws:
|
|
528
|
+
msg = json.dumps({"id": self._next_id(), "cmd": cmd, "params": params})
|
|
529
|
+
await self._ws.send(msg)
|
|
530
|
+
logger.debug("Sent %s: %s", cmd, msg)
|
|
531
|
+
|
|
532
|
+
def _dispatch(self, raw: str | bytes) -> None:
|
|
533
|
+
"""Parse incoming message and dispatch to handlers."""
|
|
534
|
+
receive_time = time.time()
|
|
535
|
+
with self._metrics_lock:
|
|
536
|
+
self._last_message_at = receive_time
|
|
537
|
+
self._message_count += 1
|
|
538
|
+
|
|
539
|
+
try:
|
|
540
|
+
data = json.loads(raw)
|
|
541
|
+
except (json.JSONDecodeError, TypeError):
|
|
542
|
+
logger.warning("Malformed message: %.200s", raw)
|
|
543
|
+
return
|
|
544
|
+
|
|
545
|
+
msg_type = data.get("type")
|
|
546
|
+
if not msg_type:
|
|
547
|
+
return
|
|
548
|
+
|
|
549
|
+
# Extract server timestamp if present (in milliseconds)
|
|
550
|
+
payload = data.get("msg", data)
|
|
551
|
+
if isinstance(payload, dict):
|
|
552
|
+
ts = payload.get("ts")
|
|
553
|
+
if ts is not None:
|
|
554
|
+
with self._metrics_lock:
|
|
555
|
+
self._last_server_ts = ts
|
|
556
|
+
|
|
557
|
+
# Resolve channel for handler lookup
|
|
558
|
+
channel = _TYPE_TO_CHANNEL.get(msg_type, msg_type)
|
|
559
|
+
handlers = self._handlers.get(channel)
|
|
560
|
+
if not handlers:
|
|
561
|
+
return
|
|
562
|
+
|
|
563
|
+
# Parse payload into typed model (payload already extracted above)
|
|
564
|
+
model_cls = _MESSAGE_MODELS.get(msg_type)
|
|
565
|
+
if model_cls:
|
|
566
|
+
try:
|
|
567
|
+
parsed = model_cls.model_validate(payload)
|
|
568
|
+
except Exception:
|
|
569
|
+
logger.debug("Failed to parse %s, passing raw dict", msg_type)
|
|
570
|
+
parsed = payload
|
|
571
|
+
else:
|
|
572
|
+
parsed = payload
|
|
573
|
+
|
|
574
|
+
for handler in handlers:
|
|
575
|
+
try:
|
|
576
|
+
handler(parsed)
|
|
577
|
+
except Exception:
|
|
578
|
+
logger.exception("Handler error on channel %s", channel)
|
|
579
|
+
|
|
580
|
+
def __enter__(self):
|
|
581
|
+
self.start()
|
|
582
|
+
return self
|
|
583
|
+
|
|
584
|
+
def __exit__(self, *args):
|
|
585
|
+
self.stop()
|
|
586
|
+
|
|
587
|
+
def __repr__(self) -> str:
|
|
588
|
+
status = "connected" if self.is_connected else "disconnected"
|
|
589
|
+
n = len(self._active_subs)
|
|
590
|
+
latency = self.latency_ms
|
|
591
|
+
latency_str = f" latency={latency:.1f}ms" if latency is not None else ""
|
|
592
|
+
return f"<Feed {status} subs={n} msgs={self._message_count}{latency_str}>"
|