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/client.py
ADDED
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Kalshi API Client
|
|
3
|
+
|
|
4
|
+
Core client class for authenticated API requests.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import time
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
from base64 import b64encode
|
|
12
|
+
from functools import cached_property
|
|
13
|
+
from typing import Any
|
|
14
|
+
from urllib.parse import urlparse, urlencode
|
|
15
|
+
|
|
16
|
+
import requests
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
21
|
+
from cryptography.hazmat.primitives.asymmetric import padding
|
|
22
|
+
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
|
|
23
|
+
|
|
24
|
+
from .exceptions import (
|
|
25
|
+
KalshiAPIError,
|
|
26
|
+
AuthenticationError,
|
|
27
|
+
InsufficientFundsError,
|
|
28
|
+
ResourceNotFoundError,
|
|
29
|
+
RateLimitError,
|
|
30
|
+
OrderRejectedError,
|
|
31
|
+
)
|
|
32
|
+
from .events import Event
|
|
33
|
+
from .markets import Market, Series
|
|
34
|
+
from .models import MarketModel, EventModel, SeriesModel, TradeModel, CandlestickResponse
|
|
35
|
+
from .portfolio import Portfolio
|
|
36
|
+
from .enums import MarketStatus, CandlestickPeriod
|
|
37
|
+
from .feed import Feed
|
|
38
|
+
from .exchange import Exchange
|
|
39
|
+
from .api_keys import APIKeys
|
|
40
|
+
from .rate_limiter import RateLimiterProtocol
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Default configuration
|
|
44
|
+
DEFAULT_API_BASE = "https://api.elections.kalshi.com/trade-api/v2"
|
|
45
|
+
DEMO_API_BASE = "https://demo-api.elections.kalshi.com/trade-api/v2"
|
|
46
|
+
|
|
47
|
+
_RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class KalshiClient:
|
|
51
|
+
"""Authenticated client for the Kalshi Trading API.
|
|
52
|
+
|
|
53
|
+
Usage:
|
|
54
|
+
client = KalshiClient.from_env() # Loads .env file
|
|
55
|
+
client = KalshiClient(api_key_id="...", private_key_path="...")
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
api_key_id: str | None = None,
|
|
61
|
+
private_key_path: str | None = None,
|
|
62
|
+
api_base: str | None = None,
|
|
63
|
+
demo: bool = False,
|
|
64
|
+
timeout: float = 10.0,
|
|
65
|
+
max_retries: int = 3,
|
|
66
|
+
rate_limiter: RateLimiterProtocol | None = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Initialize the Kalshi client.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
api_key_id: API key ID. Falls back to KALSHI_API_KEY_ID env var.
|
|
72
|
+
private_key_path: Path to private key file. Falls back to KALSHI_PRIVATE_KEY_PATH env var.
|
|
73
|
+
api_base: API base URL. Defaults to production or demo based on `demo` flag.
|
|
74
|
+
demo: If True, use demo environment. Ignored if api_base is provided.
|
|
75
|
+
timeout: Request timeout in seconds (default 10).
|
|
76
|
+
max_retries: Max retries for transient failures (default 3). Set to 0 to disable.
|
|
77
|
+
rate_limiter: Optional rate limiter for proactive throttling. See RateLimiter class.
|
|
78
|
+
"""
|
|
79
|
+
resolved_api_key_id = api_key_id or os.getenv("KALSHI_API_KEY_ID")
|
|
80
|
+
private_key_path = private_key_path or os.getenv("KALSHI_PRIVATE_KEY_PATH")
|
|
81
|
+
|
|
82
|
+
if not resolved_api_key_id:
|
|
83
|
+
raise ValueError(
|
|
84
|
+
"API key ID required. Set KALSHI_API_KEY_ID env var or pass api_key_id."
|
|
85
|
+
)
|
|
86
|
+
if not private_key_path:
|
|
87
|
+
raise ValueError(
|
|
88
|
+
"Private key path required. Set KALSHI_PRIVATE_KEY_PATH env var or pass private_key_path."
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
self.api_key_id: str = resolved_api_key_id
|
|
92
|
+
self.api_base = api_base or (DEMO_API_BASE if demo else DEFAULT_API_BASE)
|
|
93
|
+
self._api_path = urlparse(self.api_base).path
|
|
94
|
+
self.timeout = timeout
|
|
95
|
+
self.max_retries = max_retries
|
|
96
|
+
self.rate_limiter = rate_limiter
|
|
97
|
+
self.private_key = self._load_private_key(private_key_path)
|
|
98
|
+
self._session = requests.Session()
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def from_env(cls, **kwargs) -> "KalshiClient":
|
|
102
|
+
"""Create client from .env file.
|
|
103
|
+
|
|
104
|
+
Loads dotenv before reading env vars. All keyword arguments
|
|
105
|
+
are forwarded to the constructor.
|
|
106
|
+
"""
|
|
107
|
+
from dotenv import load_dotenv
|
|
108
|
+
load_dotenv()
|
|
109
|
+
return cls(**kwargs)
|
|
110
|
+
|
|
111
|
+
def _load_private_key(self, key_path: str) -> RSAPrivateKey:
|
|
112
|
+
"""Load RSA private key from PEM file."""
|
|
113
|
+
with open(key_path, "rb") as f:
|
|
114
|
+
key = serialization.load_pem_private_key(f.read(), password=None)
|
|
115
|
+
if not isinstance(key, RSAPrivateKey):
|
|
116
|
+
raise TypeError(f"Expected RSA private key, got {type(key).__name__}")
|
|
117
|
+
return key
|
|
118
|
+
|
|
119
|
+
def _sign_request(self, method: str, path: str) -> tuple[str, str]:
|
|
120
|
+
"""Create RSA-PSS signature for API request."""
|
|
121
|
+
timestamp = str(int(time.time() * 1000))
|
|
122
|
+
message = f"{timestamp}{method}{path}"
|
|
123
|
+
|
|
124
|
+
signature = self.private_key.sign(
|
|
125
|
+
message.encode(),
|
|
126
|
+
padding.PSS(
|
|
127
|
+
mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH
|
|
128
|
+
),
|
|
129
|
+
hashes.SHA256(),
|
|
130
|
+
)
|
|
131
|
+
return timestamp, b64encode(signature).decode()
|
|
132
|
+
|
|
133
|
+
def _get_headers(self, method: str, endpoint: str) -> dict[str, str]:
|
|
134
|
+
"""Generate authenticated headers."""
|
|
135
|
+
path_without_query = urlparse(endpoint).path
|
|
136
|
+
full_path = f"{self._api_path}{path_without_query}"
|
|
137
|
+
timestamp, signature = self._sign_request(method, full_path)
|
|
138
|
+
return {
|
|
139
|
+
"Content-Type": "application/json",
|
|
140
|
+
"KALSHI-ACCESS-KEY": self.api_key_id,
|
|
141
|
+
"KALSHI-ACCESS-SIGNATURE": signature,
|
|
142
|
+
"KALSHI-ACCESS-TIMESTAMP": timestamp,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
def _handle_response(
|
|
146
|
+
self,
|
|
147
|
+
response: requests.Response,
|
|
148
|
+
*,
|
|
149
|
+
method: str | None = None,
|
|
150
|
+
endpoint: str | None = None,
|
|
151
|
+
request_body: dict[str, Any] | None = None,
|
|
152
|
+
) -> dict[str, Any]:
|
|
153
|
+
"""Handle API response and raise custom exceptions with full context."""
|
|
154
|
+
status_code = int(response.status_code or 500)
|
|
155
|
+
|
|
156
|
+
if status_code < 400:
|
|
157
|
+
logger.debug("Response %s: Success", status_code)
|
|
158
|
+
if status_code == 204 or not response.content:
|
|
159
|
+
return {}
|
|
160
|
+
return response.json()
|
|
161
|
+
|
|
162
|
+
logger.error("Response %s: Error body: %s", status_code, response.text)
|
|
163
|
+
|
|
164
|
+
# Parse error details from response
|
|
165
|
+
response_body: dict[str, Any] | str | None = None
|
|
166
|
+
try:
|
|
167
|
+
error_data = response.json()
|
|
168
|
+
response_body = error_data
|
|
169
|
+
message = error_data.get("message") or error_data.get(
|
|
170
|
+
"error_message", "Unknown Error"
|
|
171
|
+
)
|
|
172
|
+
code = error_data.get("code") or error_data.get("error_code")
|
|
173
|
+
except (ValueError, requests.exceptions.JSONDecodeError):
|
|
174
|
+
message = response.text
|
|
175
|
+
response_body = response.text
|
|
176
|
+
code = None
|
|
177
|
+
|
|
178
|
+
# Map to specific exception types
|
|
179
|
+
if status_code in (401, 403):
|
|
180
|
+
raise AuthenticationError(
|
|
181
|
+
status_code, message, code,
|
|
182
|
+
method=method, endpoint=endpoint,
|
|
183
|
+
request_body=request_body, response_body=response_body,
|
|
184
|
+
)
|
|
185
|
+
elif status_code == 404:
|
|
186
|
+
raise ResourceNotFoundError(
|
|
187
|
+
status_code, message, code,
|
|
188
|
+
method=method, endpoint=endpoint,
|
|
189
|
+
request_body=request_body, response_body=response_body,
|
|
190
|
+
)
|
|
191
|
+
elif code in ("insufficient_funds", "insufficient_balance"):
|
|
192
|
+
raise InsufficientFundsError(
|
|
193
|
+
status_code, message, code,
|
|
194
|
+
method=method, endpoint=endpoint,
|
|
195
|
+
request_body=request_body, response_body=response_body,
|
|
196
|
+
)
|
|
197
|
+
elif code in (
|
|
198
|
+
"order_rejected",
|
|
199
|
+
"market_closed",
|
|
200
|
+
"market_settled",
|
|
201
|
+
"invalid_price",
|
|
202
|
+
"self_trade",
|
|
203
|
+
"post_only_rejected",
|
|
204
|
+
):
|
|
205
|
+
raise OrderRejectedError(
|
|
206
|
+
status_code, message, code,
|
|
207
|
+
method=method, endpoint=endpoint,
|
|
208
|
+
request_body=request_body, response_body=response_body,
|
|
209
|
+
)
|
|
210
|
+
else:
|
|
211
|
+
raise KalshiAPIError(
|
|
212
|
+
status_code, message, code,
|
|
213
|
+
method=method, endpoint=endpoint,
|
|
214
|
+
request_body=request_body, response_body=response_body,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
def _request(
|
|
218
|
+
self,
|
|
219
|
+
method: str,
|
|
220
|
+
endpoint: str,
|
|
221
|
+
**kwargs,
|
|
222
|
+
) -> requests.Response:
|
|
223
|
+
"""Execute an HTTP request with timeout and retry on transient failures.
|
|
224
|
+
|
|
225
|
+
Retries on 429/5xx status codes and connection errors with exponential backoff.
|
|
226
|
+
Re-signs each attempt to keep the timestamp fresh.
|
|
227
|
+
"""
|
|
228
|
+
url = f"{self.api_base}{endpoint}"
|
|
229
|
+
|
|
230
|
+
for attempt in range(self.max_retries + 1):
|
|
231
|
+
# Proactive throttling if rate limiter is configured
|
|
232
|
+
if self.rate_limiter is not None:
|
|
233
|
+
wait_time = self.rate_limiter.acquire()
|
|
234
|
+
if wait_time > 0:
|
|
235
|
+
logger.debug("Rate limiter waited %.3fs", wait_time)
|
|
236
|
+
|
|
237
|
+
headers = self._get_headers(method, endpoint)
|
|
238
|
+
try:
|
|
239
|
+
response = self._session.request(
|
|
240
|
+
method, url, headers=headers, timeout=self.timeout, **kwargs
|
|
241
|
+
)
|
|
242
|
+
except (
|
|
243
|
+
requests.exceptions.Timeout,
|
|
244
|
+
requests.exceptions.ConnectionError,
|
|
245
|
+
) as e:
|
|
246
|
+
if attempt == self.max_retries:
|
|
247
|
+
raise
|
|
248
|
+
wait = min(2 ** attempt * 0.5, 30)
|
|
249
|
+
logger.warning(
|
|
250
|
+
"%s %s failed (%s), retry %d/%d in %.1fs",
|
|
251
|
+
method, endpoint, type(e).__name__,
|
|
252
|
+
attempt + 1, self.max_retries, wait,
|
|
253
|
+
)
|
|
254
|
+
time.sleep(wait)
|
|
255
|
+
continue
|
|
256
|
+
|
|
257
|
+
# Update rate limiter from response headers
|
|
258
|
+
if self.rate_limiter is not None:
|
|
259
|
+
remaining = response.headers.get("X-RateLimit-Remaining")
|
|
260
|
+
reset_at = response.headers.get("X-RateLimit-Reset")
|
|
261
|
+
self.rate_limiter.update_from_headers(
|
|
262
|
+
remaining=int(remaining) if remaining else None,
|
|
263
|
+
reset_at=int(reset_at) if reset_at else None,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
if response.status_code not in _RETRYABLE_STATUS_CODES:
|
|
267
|
+
return response
|
|
268
|
+
if attempt == self.max_retries:
|
|
269
|
+
if response.status_code == 429:
|
|
270
|
+
raise RateLimitError(
|
|
271
|
+
429,
|
|
272
|
+
"Rate limit exceeded after retries",
|
|
273
|
+
method=method,
|
|
274
|
+
endpoint=endpoint,
|
|
275
|
+
)
|
|
276
|
+
return response
|
|
277
|
+
|
|
278
|
+
retry_after = response.headers.get("Retry-After")
|
|
279
|
+
try:
|
|
280
|
+
wait = float(retry_after) if retry_after else min(2 ** attempt * 0.5, 30)
|
|
281
|
+
except (ValueError, TypeError):
|
|
282
|
+
wait = min(2 ** attempt * 0.5, 30)
|
|
283
|
+
|
|
284
|
+
logger.warning(
|
|
285
|
+
"%s %s returned %d, retry %d/%d in %.1fs",
|
|
286
|
+
method, endpoint, response.status_code,
|
|
287
|
+
attempt + 1, self.max_retries, wait,
|
|
288
|
+
)
|
|
289
|
+
time.sleep(wait)
|
|
290
|
+
|
|
291
|
+
return response # unreachable, satisfies type checker
|
|
292
|
+
|
|
293
|
+
def get(self, endpoint: str) -> dict[str, Any]:
|
|
294
|
+
"""Make authenticated GET request."""
|
|
295
|
+
logger.debug("GET %s", endpoint)
|
|
296
|
+
response = self._request("GET", endpoint)
|
|
297
|
+
return self._handle_response(response, method="GET", endpoint=endpoint)
|
|
298
|
+
|
|
299
|
+
def paginated_get(
|
|
300
|
+
self,
|
|
301
|
+
path: str,
|
|
302
|
+
response_key: str,
|
|
303
|
+
params: dict[str, Any],
|
|
304
|
+
fetch_all: bool = False,
|
|
305
|
+
) -> list[dict]:
|
|
306
|
+
"""Fetch items with automatic cursor-based pagination.
|
|
307
|
+
|
|
308
|
+
Args:
|
|
309
|
+
path: API endpoint path (e.g., "/markets").
|
|
310
|
+
response_key: Key in response JSON containing the items list.
|
|
311
|
+
params: Query parameters (None values are filtered out).
|
|
312
|
+
fetch_all: If True, follow cursors to fetch all pages.
|
|
313
|
+
"""
|
|
314
|
+
params = dict(params) # Don't mutate caller's dict
|
|
315
|
+
all_items: list[dict] = []
|
|
316
|
+
while True:
|
|
317
|
+
filtered = {k: v for k, v in params.items() if v is not None}
|
|
318
|
+
endpoint = f"{path}?{urlencode(filtered)}" if filtered else path
|
|
319
|
+
response = self.get(endpoint)
|
|
320
|
+
all_items.extend(response.get(response_key, []))
|
|
321
|
+
cursor = response.get("cursor", "")
|
|
322
|
+
if not fetch_all or not cursor:
|
|
323
|
+
break
|
|
324
|
+
params["cursor"] = cursor
|
|
325
|
+
return all_items
|
|
326
|
+
|
|
327
|
+
def post(self, endpoint: str, data: dict[str, Any]) -> dict[str, Any]:
|
|
328
|
+
"""Make authenticated POST request."""
|
|
329
|
+
logger.debug("POST %s", endpoint)
|
|
330
|
+
body = json.dumps(data, separators=(",", ":"))
|
|
331
|
+
response = self._request("POST", endpoint, data=body)
|
|
332
|
+
return self._handle_response(
|
|
333
|
+
response, method="POST", endpoint=endpoint, request_body=data
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
def delete(self, endpoint: str) -> dict[str, Any]:
|
|
337
|
+
"""Make authenticated DELETE request."""
|
|
338
|
+
logger.debug("DELETE %s", endpoint)
|
|
339
|
+
response = self._request("DELETE", endpoint)
|
|
340
|
+
return self._handle_response(response, method="DELETE", endpoint=endpoint)
|
|
341
|
+
|
|
342
|
+
# --- Domain methods ---
|
|
343
|
+
|
|
344
|
+
@cached_property
|
|
345
|
+
def portfolio(self) -> Portfolio:
|
|
346
|
+
"""The authenticated user's portfolio."""
|
|
347
|
+
return Portfolio(self)
|
|
348
|
+
|
|
349
|
+
@cached_property
|
|
350
|
+
def exchange(self) -> Exchange:
|
|
351
|
+
"""Exchange status, schedule, and announcements."""
|
|
352
|
+
return Exchange(self)
|
|
353
|
+
|
|
354
|
+
@cached_property
|
|
355
|
+
def api_keys(self) -> APIKeys:
|
|
356
|
+
"""API key management and rate limits."""
|
|
357
|
+
return APIKeys(self)
|
|
358
|
+
|
|
359
|
+
def feed(self) -> Feed:
|
|
360
|
+
"""Create a new real-time data feed.
|
|
361
|
+
|
|
362
|
+
Returns a Feed instance for streaming market data via WebSocket.
|
|
363
|
+
Each call creates a new Feed - use a single Feed for all subscriptions.
|
|
364
|
+
|
|
365
|
+
Usage:
|
|
366
|
+
feed = client.feed()
|
|
367
|
+
|
|
368
|
+
@feed.on("ticker")
|
|
369
|
+
def handle_ticker(msg):
|
|
370
|
+
print(f"{msg.market_ticker}: {msg.yes_bid}/{msg.yes_ask}")
|
|
371
|
+
|
|
372
|
+
feed.subscribe("ticker", market_ticker="KXBTC-26JAN")
|
|
373
|
+
feed.start()
|
|
374
|
+
"""
|
|
375
|
+
return Feed(self)
|
|
376
|
+
|
|
377
|
+
def get_market(self, ticker: str) -> Market:
|
|
378
|
+
"""Get a Market by ticker."""
|
|
379
|
+
response = self.get(f"/markets/{ticker}")
|
|
380
|
+
model = MarketModel.model_validate(response["market"])
|
|
381
|
+
return Market(self, model)
|
|
382
|
+
|
|
383
|
+
def get_markets(
|
|
384
|
+
self,
|
|
385
|
+
series_ticker: str | None = None,
|
|
386
|
+
event_ticker: str | None = None,
|
|
387
|
+
status: MarketStatus | None = None,
|
|
388
|
+
limit: int = 100,
|
|
389
|
+
cursor: str | None = None,
|
|
390
|
+
fetch_all: bool = False,
|
|
391
|
+
) -> list[Market]:
|
|
392
|
+
"""Search for markets.
|
|
393
|
+
|
|
394
|
+
Args:
|
|
395
|
+
series_ticker: Filter by series ticker.
|
|
396
|
+
event_ticker: Filter by event ticker.
|
|
397
|
+
status: Filter by market status. Pass None for all statuses.
|
|
398
|
+
limit: Maximum results per page (default 100, max 1000).
|
|
399
|
+
cursor: Pagination cursor for fetching next page.
|
|
400
|
+
fetch_all: If True, automatically fetch all pages.
|
|
401
|
+
"""
|
|
402
|
+
params = {
|
|
403
|
+
"status": status.value if status is not None else None,
|
|
404
|
+
"limit": limit,
|
|
405
|
+
"series_ticker": series_ticker,
|
|
406
|
+
"event_ticker": event_ticker,
|
|
407
|
+
"cursor": cursor,
|
|
408
|
+
}
|
|
409
|
+
data = self.paginated_get("/markets", "markets", params, fetch_all)
|
|
410
|
+
return [Market(self, MarketModel.model_validate(m)) for m in data]
|
|
411
|
+
|
|
412
|
+
def get_event(self, event_ticker: str) -> Event:
|
|
413
|
+
"""Get an Event by ticker."""
|
|
414
|
+
response = self.get(f"/events/{event_ticker}")
|
|
415
|
+
model = EventModel.model_validate(response["event"])
|
|
416
|
+
return Event(self, model)
|
|
417
|
+
|
|
418
|
+
def get_events(
|
|
419
|
+
self,
|
|
420
|
+
series_ticker: str | None = None,
|
|
421
|
+
status: MarketStatus | None = None,
|
|
422
|
+
limit: int = 100,
|
|
423
|
+
cursor: str | None = None,
|
|
424
|
+
fetch_all: bool = False,
|
|
425
|
+
) -> list[Event]:
|
|
426
|
+
"""Search for events.
|
|
427
|
+
|
|
428
|
+
Args:
|
|
429
|
+
series_ticker: Filter by series ticker.
|
|
430
|
+
status: Filter by event status.
|
|
431
|
+
limit: Maximum results per page (default 100).
|
|
432
|
+
cursor: Pagination cursor for fetching next page.
|
|
433
|
+
fetch_all: If True, automatically fetch all pages.
|
|
434
|
+
"""
|
|
435
|
+
params = {
|
|
436
|
+
"limit": limit,
|
|
437
|
+
"series_ticker": series_ticker,
|
|
438
|
+
"status": status.value if status is not None else None,
|
|
439
|
+
"cursor": cursor,
|
|
440
|
+
}
|
|
441
|
+
data = self.paginated_get("/events", "events", params, fetch_all)
|
|
442
|
+
return [Event(self, EventModel.model_validate(e)) for e in data]
|
|
443
|
+
|
|
444
|
+
def get_series(self, series_ticker: str) -> Series:
|
|
445
|
+
"""Get a Series by ticker."""
|
|
446
|
+
response = self.get(f"/series/{series_ticker}")
|
|
447
|
+
model = SeriesModel.model_validate(response["series"])
|
|
448
|
+
return Series(self, model)
|
|
449
|
+
|
|
450
|
+
def get_all_series(
|
|
451
|
+
self,
|
|
452
|
+
category: str | None = None,
|
|
453
|
+
limit: int = 100,
|
|
454
|
+
cursor: str | None = None,
|
|
455
|
+
fetch_all: bool = False,
|
|
456
|
+
) -> list[Series]:
|
|
457
|
+
"""List all series.
|
|
458
|
+
|
|
459
|
+
Args:
|
|
460
|
+
category: Filter by category.
|
|
461
|
+
limit: Maximum results per page (default 100).
|
|
462
|
+
cursor: Pagination cursor for fetching next page.
|
|
463
|
+
fetch_all: If True, automatically fetch all pages.
|
|
464
|
+
"""
|
|
465
|
+
params = {"limit": limit, "category": category, "cursor": cursor}
|
|
466
|
+
data = self.paginated_get("/series", "series", params, fetch_all)
|
|
467
|
+
return [Series(self, SeriesModel.model_validate(s)) for s in data]
|
|
468
|
+
|
|
469
|
+
def get_trades(
|
|
470
|
+
self,
|
|
471
|
+
ticker: str | None = None,
|
|
472
|
+
min_ts: int | None = None,
|
|
473
|
+
max_ts: int | None = None,
|
|
474
|
+
limit: int = 100,
|
|
475
|
+
cursor: str | None = None,
|
|
476
|
+
fetch_all: bool = False,
|
|
477
|
+
) -> list[TradeModel]:
|
|
478
|
+
"""Get public trade history.
|
|
479
|
+
|
|
480
|
+
Args:
|
|
481
|
+
ticker: Filter by market ticker.
|
|
482
|
+
min_ts: Minimum timestamp (Unix seconds).
|
|
483
|
+
max_ts: Maximum timestamp (Unix seconds).
|
|
484
|
+
limit: Maximum trades per page (default 100).
|
|
485
|
+
cursor: Pagination cursor for fetching next page.
|
|
486
|
+
fetch_all: If True, automatically fetch all pages.
|
|
487
|
+
"""
|
|
488
|
+
params = {
|
|
489
|
+
"limit": limit,
|
|
490
|
+
"ticker": ticker,
|
|
491
|
+
"min_ts": min_ts,
|
|
492
|
+
"max_ts": max_ts,
|
|
493
|
+
"cursor": cursor,
|
|
494
|
+
}
|
|
495
|
+
data = self.paginated_get("/markets/trades", "trades", params, fetch_all)
|
|
496
|
+
return [TradeModel.model_validate(t) for t in data]
|
|
497
|
+
|
|
498
|
+
def get_candlesticks_batch(
|
|
499
|
+
self,
|
|
500
|
+
tickers: list[str],
|
|
501
|
+
start_ts: int,
|
|
502
|
+
end_ts: int,
|
|
503
|
+
period: CandlestickPeriod = CandlestickPeriod.ONE_HOUR,
|
|
504
|
+
) -> dict[str, CandlestickResponse]:
|
|
505
|
+
"""Batch fetch candlesticks for multiple markets.
|
|
506
|
+
|
|
507
|
+
Args:
|
|
508
|
+
tickers: List of market tickers.
|
|
509
|
+
start_ts: Start timestamp (Unix seconds).
|
|
510
|
+
end_ts: End timestamp (Unix seconds).
|
|
511
|
+
period: Candlestick period (ONE_MINUTE, ONE_HOUR, or ONE_DAY).
|
|
512
|
+
|
|
513
|
+
Returns:
|
|
514
|
+
Dict mapping ticker to CandlestickResponse.
|
|
515
|
+
"""
|
|
516
|
+
body = {
|
|
517
|
+
"tickers": tickers,
|
|
518
|
+
"start_ts": start_ts,
|
|
519
|
+
"end_ts": end_ts,
|
|
520
|
+
"period_interval": period.value,
|
|
521
|
+
}
|
|
522
|
+
response = self.post("/markets/candlesticks", body)
|
|
523
|
+
return {
|
|
524
|
+
ticker: CandlestickResponse.model_validate(data)
|
|
525
|
+
for ticker, data in response.get("candlesticks", {}).items()
|
|
526
|
+
}
|
pykalshi/enums.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Side(str, Enum):
|
|
5
|
+
YES = "yes"
|
|
6
|
+
NO = "no"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Action(str, Enum):
|
|
10
|
+
BUY = "buy"
|
|
11
|
+
SELL = "sell"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class OrderType(str, Enum):
|
|
15
|
+
LIMIT = "limit"
|
|
16
|
+
MARKET = "market"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class OrderStatus(str, Enum):
|
|
20
|
+
RESTING = "resting"
|
|
21
|
+
CANCELED = "canceled"
|
|
22
|
+
FILLED = "filled"
|
|
23
|
+
EXECUTED = "executed"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MarketStatus(str, Enum):
|
|
27
|
+
OPEN = "open"
|
|
28
|
+
CLOSED = "closed"
|
|
29
|
+
SETTLED = "settled"
|
|
30
|
+
ACTIVE = "active"
|
|
31
|
+
FINALIZED = "finalized"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class CandlestickPeriod(int, Enum):
|
|
35
|
+
"""Candlestick period intervals in minutes."""
|
|
36
|
+
|
|
37
|
+
ONE_MINUTE = 1
|
|
38
|
+
ONE_HOUR = 60
|
|
39
|
+
ONE_DAY = 1440
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class TimeInForce(str, Enum):
|
|
43
|
+
"""Order time-in-force options."""
|
|
44
|
+
|
|
45
|
+
GTC = "gtc" # Good till canceled (default)
|
|
46
|
+
IOC = "ioc" # Immediate or cancel - fill what you can, cancel rest
|
|
47
|
+
FOK = "fok" # Fill or kill - fill entirely or cancel entirely
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class SelfTradePrevention(str, Enum):
|
|
51
|
+
"""Self-trade prevention behavior."""
|
|
52
|
+
|
|
53
|
+
CANCEL_TAKER = "cancel_resting" # Cancel resting order on self-cross
|
|
54
|
+
CANCEL_MAKER = "cancel_aggressing" # Cancel incoming order on self-cross
|
pykalshi/events.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import TYPE_CHECKING
|
|
3
|
+
from .models import EventModel, ForecastPercentileHistory
|
|
4
|
+
|
|
5
|
+
if TYPE_CHECKING:
|
|
6
|
+
from .client import KalshiClient
|
|
7
|
+
from .markets import Market, Series
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Event:
|
|
11
|
+
"""Represents a Kalshi Event.
|
|
12
|
+
|
|
13
|
+
An event is a container for related markets (e.g., "Will X happen?" with
|
|
14
|
+
multiple outcome markets).
|
|
15
|
+
|
|
16
|
+
Key fields are exposed as typed properties for IDE support.
|
|
17
|
+
All other EventModel fields are accessible via attribute delegation.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, client: KalshiClient, data: EventModel) -> None:
|
|
21
|
+
self._client = client
|
|
22
|
+
self.data = data
|
|
23
|
+
|
|
24
|
+
# --- Typed properties for core fields ---
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def event_ticker(self) -> str:
|
|
28
|
+
return self.data.event_ticker
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def series_ticker(self) -> str:
|
|
32
|
+
return self.data.series_ticker
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def title(self) -> str | None:
|
|
36
|
+
return self.data.title
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def category(self) -> str | None:
|
|
40
|
+
return self.data.category
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def mutually_exclusive(self) -> bool:
|
|
44
|
+
return self.data.mutually_exclusive
|
|
45
|
+
|
|
46
|
+
# --- Domain logic ---
|
|
47
|
+
|
|
48
|
+
def get_markets(self) -> list[Market]:
|
|
49
|
+
"""Get all markets for this event."""
|
|
50
|
+
return self._client.get_markets(event_ticker=self.data.event_ticker)
|
|
51
|
+
|
|
52
|
+
def get_series(self) -> Series:
|
|
53
|
+
"""Get the parent Series for this event."""
|
|
54
|
+
return self._client.get_series(self.series_ticker)
|
|
55
|
+
|
|
56
|
+
def get_forecast_percentile_history(
|
|
57
|
+
self,
|
|
58
|
+
percentiles: list[int] | None = None,
|
|
59
|
+
) -> ForecastPercentileHistory:
|
|
60
|
+
"""Get historical forecast data at various percentiles.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
percentiles: List of percentiles to fetch (e.g., [10, 25, 50, 75, 90]).
|
|
64
|
+
If None, returns all available percentiles.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
ForecastPercentileHistory with percentile -> history mapping.
|
|
68
|
+
"""
|
|
69
|
+
endpoint = f"/events/{self.event_ticker}/forecast/percentile_history"
|
|
70
|
+
if percentiles:
|
|
71
|
+
endpoint += f"?percentiles={','.join(str(p) for p in percentiles)}"
|
|
72
|
+
response = self._client.get(endpoint)
|
|
73
|
+
return ForecastPercentileHistory.model_validate(response)
|
|
74
|
+
|
|
75
|
+
def __getattr__(self, name: str):
|
|
76
|
+
return getattr(self.data, name)
|
|
77
|
+
|
|
78
|
+
def __eq__(self, other: object) -> bool:
|
|
79
|
+
if not isinstance(other, Event):
|
|
80
|
+
return NotImplemented
|
|
81
|
+
return self.data.event_ticker == other.data.event_ticker
|
|
82
|
+
|
|
83
|
+
def __hash__(self) -> int:
|
|
84
|
+
return hash(self.data.event_ticker)
|
|
85
|
+
|
|
86
|
+
def __repr__(self) -> str:
|
|
87
|
+
return f"<Event {self.data.event_ticker}>"
|