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/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}>"