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