synpath 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. synpath/__init__.py +183 -0
  2. synpath/__main__.py +66 -0
  3. synpath/base.py +723 -0
  4. synpath/bucket.py +154 -0
  5. synpath/client.py +356 -0
  6. synpath/engine/__init__.py +37 -0
  7. synpath/engine/__main__.py +354 -0
  8. synpath/engine/alerts.py +170 -0
  9. synpath/engine/engine.py +888 -0
  10. synpath/engine/eod.py +154 -0
  11. synpath/engine/events.py +140 -0
  12. synpath/engine/fair_values.py +117 -0
  13. synpath/engine/feeds.py +220 -0
  14. synpath/engine/journal.py +907 -0
  15. synpath/engine/ledger.py +353 -0
  16. synpath/engine/orders/__init__.py +42 -0
  17. synpath/engine/orders/base.py +441 -0
  18. synpath/engine/orders/day.py +72 -0
  19. synpath/engine/orders/iceberg.py +121 -0
  20. synpath/engine/orders/manager.py +223 -0
  21. synpath/engine/orders/oco.py +255 -0
  22. synpath/engine/orders/peg.py +168 -0
  23. synpath/engine/orders/routed.py +496 -0
  24. synpath/engine/orders/stop.py +240 -0
  25. synpath/engine/orders/taker.py +187 -0
  26. synpath/engine/orders/twap.py +190 -0
  27. synpath/engine/paper.py +532 -0
  28. synpath/engine/reconcile.py +279 -0
  29. synpath/engine/risk.py +403 -0
  30. synpath/engine/router.py +261 -0
  31. synpath/errors.py +98 -0
  32. synpath/history.py +71 -0
  33. synpath/hosted.py +86 -0
  34. synpath/hosted_auth.py +201 -0
  35. synpath/ids.py +61 -0
  36. synpath/kalshi.py +1378 -0
  37. synpath/matching.py +86 -0
  38. synpath/polymarket.py +1004 -0
  39. synpath/polymarket_us.py +989 -0
  40. synpath/remote.py +195 -0
  41. synpath/server/__init__.py +98 -0
  42. synpath/server/__main__.py +118 -0
  43. synpath/server/api.py +439 -0
  44. synpath/server/errors.py +87 -0
  45. synpath/server/local.py +96 -0
  46. synpath/server/models.py +75 -0
  47. synpath/server/serve.py +236 -0
  48. synpath/server/store.py +363 -0
  49. synpath/server/trading.py +764 -0
  50. synpath/trading/__init__.py +79 -0
  51. synpath/trading/__main__.py +69 -0
  52. synpath/trading/base.py +126 -0
  53. synpath/trading/credentials.py +400 -0
  54. synpath/trading/errors.py +94 -0
  55. synpath/trading/init.py +233 -0
  56. synpath/trading/instruments.py +162 -0
  57. synpath/trading/kalshi.py +957 -0
  58. synpath/trading/limiter.py +177 -0
  59. synpath/trading/money.py +172 -0
  60. synpath/trading/polymarket.py +1362 -0
  61. synpath/trading/polymarket_signing.py +478 -0
  62. synpath/trading/polymarket_us.py +705 -0
  63. synpath/trading/polymarket_us_exchange.py +825 -0
  64. synpath/trading/types.py +414 -0
  65. synpath/types.py +608 -0
  66. synpath/ws/__init__.py +55 -0
  67. synpath/ws/base.py +544 -0
  68. synpath/ws/grpc.py +578 -0
  69. synpath/ws/kalshi.py +418 -0
  70. synpath/ws/polymarket.py +430 -0
  71. synpath/ws/polymarket_us.py +299 -0
  72. synpath/ws/polymarket_us_exchange.py +754 -0
  73. synpath-0.1.0.dist-info/METADATA +224 -0
  74. synpath-0.1.0.dist-info/RECORD +77 -0
  75. synpath-0.1.0.dist-info/WHEEL +4 -0
  76. synpath-0.1.0.dist-info/entry_points.txt +2 -0
  77. synpath-0.1.0.dist-info/licenses/LICENSE +21 -0
synpath/kalshi.py ADDED
@@ -0,0 +1,1378 @@
1
+ """Kalshi Trade API v2.
2
+
3
+ Public read endpoints, no credentials needed.
4
+
5
+ Two facts about this venue shape the whole adapter:
6
+
7
+ **One book, two views.** A Kalshi binary market has a single order book. A bid
8
+ on NO at 0.88 *is* an ask on YES at 0.12 — the same resting order seen from the
9
+ other side. `/orderbook` returns two arrays, `yes_dollars` and `no_dollars`,
10
+ and both are **bids**. The YES ask side is the NO bid side reflected through
11
+ the market's face value. This adapter does that reflection explicitly and marks
12
+ the result `derived=True`, rather than pretending two books exist.
13
+
14
+ **Zero and one are placeholders, not prices.** An absent bid prints as 0, an
15
+ absent ask as 1, and a market that has never traded prints last = 0. All three
16
+ are read as `None` here. Passing them through is how a library ends up
17
+ reporting that a live market is worth nothing.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import base64
22
+ import hashlib
23
+ import json
24
+ import time
25
+ from collections import OrderedDict
26
+ from datetime import datetime, timezone
27
+ from typing import Any, Iterator
28
+
29
+ from . import ids
30
+ from .base import (
31
+ MAX_PAGE_LIMIT, Capability, Exchange, HttpClient, RateLimiter, check_sort,
32
+ check_status, enough_bars, page_limit, pick_bars, sort_page, timeframe_seconds,
33
+ )
34
+ from .errors import BadRequest, ExchangeError, MarketNotFound, SynpathError
35
+ from .types import (
36
+ BookSide, Candle, Event, FeeSchedule, Market, MarketStats, OrderBook, Outcome,
37
+ OrderLevel, Page, Quote, Series, Trade, iso,
38
+ )
39
+
40
+ BASE_URL = "https://external-api.kalshi.com/trade-api/v2"
41
+
42
+ SEARCH_URL = "https://api.elections.kalshi.com"
43
+ """Where Kalshi's text search lives.
44
+
45
+ Not part of the documented Trade API, which has no search at all: its `/events`
46
+ endpoint accepts a `query` parameter and silently ignores it, returning the
47
+ unfiltered first page. This is the endpoint kalshi.com's own search box calls,
48
+ on a different host -- `external-api.kalshi.com` answers 404 for it.
49
+
50
+ Being undocumented, it can change or start requiring credentials without
51
+ notice. When it fails, `search_markets` raises rather than falling back to
52
+ scanning the catalog: a scan for a term that matches nothing walks ~13,000
53
+ events, and turning a fast failure into a hundred-second one is not a
54
+ kindness.
55
+ """
56
+
57
+ SEARCH_LIMITER = RateLimiter(2.0, burst=2)
58
+ """Deliberately slower than the trade API's limiter. This host publishes no
59
+ limits and is not part of the documented surface, so it gets the conservative
60
+ end of the guess."""
61
+
62
+ VENUE = "kalshi"
63
+
64
+ LIMITER = RateLimiter(5.0, burst=5)
65
+ """Kalshi rate-limits its public reads without documenting it in a header.
66
+ Measured: an unpaced burst starts returning 429 around 8 req/s, 5.2 req/s ran
67
+ clean, and a 429 clears in about a second. Module-level, so every client in the
68
+ process shares one budget — which is what the venue actually counts."""
69
+
70
+ MARKET_BATCH = 200
71
+ """Tickers per `/markets` call. The ceiling is URI length, not a count (500 is
72
+ fine, 800 returns 414), so this sits well under it rather than relying on a
73
+ limit that depends on how long tickers happen to be."""
74
+
75
+ _SETTLED = {"settled", "determined", "finalized"}
76
+ _OPEN = {"active", "open"}
77
+ _UNOPENED = {"unopened", "initialized"}
78
+
79
+ SEARCH_PAGE = 50
80
+ """Results per search page. The venue's own page size; asking for more is
81
+ ignored."""
82
+
83
+ EVENT_BATCH = 100
84
+ """Event tickers per `/events?tickers=` call. A full page of search results is
85
+ at most `MAX_PAGE_LIMIT` rows, so one batch covers it; 100 tickers measured at
86
+ under 1,900 characters of URL."""
87
+
88
+ VENUE_EVENT_PAGE = 200
89
+ """The most events Kalshi returns per `/events` call. Used when walking the
90
+ whole catalog, where there is no page contract to honour and every extra
91
+ request is spent rate budget."""
92
+
93
+ EVENT_PAGE = 25
94
+
95
+ MAX_FILL_PAGES = 10
96
+ """Most event pages one `fetch_markets` call will read while filling a page.
97
+
98
+ A status filter can leave an event page with few matching markets, and filling
99
+ `limit` from sparse pages must not turn into walking the catalog inside one
100
+ call. Past this, the call returns what it has with a cursor to continue, which
101
+ is what a short page with a cursor means."""
102
+ """Events fetched per underlying request while filling a market page.
103
+
104
+ Kalshi averages about nine markets per event, so 25 events covers a full
105
+ `MAX_PAGE_LIMIT` page of markets in one request most of the time, while a page
106
+ that overflows is resumed mid-way rather than re-walked.
107
+ """
108
+
109
+ FACE_VALUE_CACHE = 2048
110
+ """How many markets' face values to remember. Bounded because the server keeps
111
+ one adapter for the life of the process."""
112
+
113
+ CANDLE_INTERVALS = {"1m": 1, "1h": 60, "1d": 1440}
114
+ """The only period_interval values Kalshi accepts, in minutes."""
115
+
116
+ MAX_CANDLES = 5000
117
+ """Most bars Kalshi answers per candlestick request; a wider window is refused
118
+ with `max candlesticks: 5000` (about 3.5 days of 1m, 208 days of 1h). Longer
119
+ windows are read in pieces of `MAX_CANDLES - 1` periods and joined."""
120
+
121
+ HISTORICAL_CURSOR = "historical:"
122
+ """Prefix of a `fetch_trades` cursor that has moved past the live tape into
123
+ `/historical/trades`. Kalshi keeps recent data on its live endpoints and moves
124
+ anything older than a cutoff (see `/historical/cutoff`) to `/historical/*`:
125
+ markets settled before it, and trades created before it, are no longer on the
126
+ live endpoints at all. A plain cursor is the live tape's own."""
127
+
128
+ ARCHIVE_STATUSES = {"settled", "all"}
129
+ """Statuses whose listings continue past the live catalog into
130
+ `/historical/markets`, where Kalshi keeps markets settled before its cutoff.
131
+ The live `/events` endpoint still lists those events, but with no markets
132
+ inside them."""
133
+
134
+ ARCHIVE_PAGE = 1000
135
+ """Most markets `/historical/markets` returns per call; 1001 is a 400."""
136
+
137
+ CUTOFF_TTL = 3600.0
138
+ """Seconds to remember the historical cutoff. Kalshi moves it forward in steps
139
+ of weeks, so asking once an hour is plenty."""
140
+
141
+
142
+ # ---------------------------------------------------------------------------
143
+ # Pure normalizers. No network, no client state — a recorded payload in, a
144
+ # unified type out. Tested directly, and portable to any other runtime.
145
+ # ---------------------------------------------------------------------------
146
+
147
+ def to_float(value: Any) -> float | None:
148
+ if value in (None, ""):
149
+ return None
150
+ try:
151
+ return float(value)
152
+ except (TypeError, ValueError):
153
+ return None
154
+
155
+
156
+ def parse_ts(value: Any) -> int | None:
157
+ """Kalshi timestamps: ISO 8601 strings, or unix seconds on some endpoints."""
158
+ if value in (None, ""):
159
+ return None
160
+ if isinstance(value, (int, float)):
161
+ return int(value * 1000)
162
+ try:
163
+ return int(datetime.fromisoformat(str(value).replace("Z", "+00:00")).timestamp() * 1000)
164
+ except ValueError:
165
+ return None
166
+
167
+
168
+ def quoted(value: Any, *, face_value: float = 1.0) -> float | None:
169
+ """A price, or `None` when Kalshi's placeholder means the side is empty.
170
+
171
+ Real resting orders sit strictly inside (0, face_value). A 0 means nobody
172
+ is bidding, a face-value ask means nobody is offering, and a 0 ask has been
173
+ observed on an otherwise empty book. None of the three is a price.
174
+ """
175
+ price = to_float(value)
176
+ if price is None or price <= 0 or price >= face_value:
177
+ return None
178
+ return price
179
+
180
+
181
+ def status_of(market: dict[str, Any]) -> tuple[str, bool]:
182
+ """(normalized status, accepting orders)."""
183
+ native = str(market.get("status") or "").lower()
184
+ if native in _SETTLED:
185
+ return "settled", False
186
+ if native == "closed":
187
+ return "closed", False
188
+ if native in _UNOPENED:
189
+ return "unopened", False
190
+ if native in _OPEN:
191
+ return "open", True
192
+ return "unopened", False
193
+
194
+
195
+ def tick_size_of(market: dict[str, Any]) -> float | None:
196
+ """The minimum price increment, read from the venue's own price ladder.
197
+
198
+ `price_ranges` describes the ladder as ranges with a step. A market can in
199
+ principle use a finer step near the extremes, so the smallest step present
200
+ is the one an order has to satisfy everywhere.
201
+ """
202
+ steps = [to_float(r.get("step")) for r in (market.get("price_ranges") or [])]
203
+ valid = [s for s in steps if s]
204
+ return min(valid) if valid else None
205
+
206
+
207
+ def market_url(series_ticker: str | None, event_ticker: str | None, ticker: str | None = None) -> str:
208
+ """The kalshi.com page for an event; markets sit on it, there is no per-market page.
209
+
210
+ The path is `/markets/<series>/<slug>/<event>` and the slug segment is not
211
+ checked — any value redirects to the canonical one — so the series ticker
212
+ stands in for it.
213
+ """
214
+ series = (series_ticker or (event_ticker or "").split("-")[0]).lower()
215
+ event = (event_ticker or "").lower()
216
+ url = f"https://kalshi.com/markets/{series}/{series}/{event}"
217
+ return f"{url}/{ticker.lower()}" if ticker else url
218
+
219
+
220
+ def normalize_quotes(
221
+ market: dict[str, Any], face_value: float,
222
+ ) -> tuple[Quote, Quote, float | None]:
223
+ """YES and NO quotes from one market payload, plus the 24h price change.
224
+
225
+ The NO sizes are not published separately and do not need to be: in a
226
+ shared book the orders resting on NO's bid are the same orders resting on
227
+ YES's ask, so the sizes mirror along with the prices.
228
+ """
229
+ yes_bid = quoted(market.get("yes_bid_dollars"), face_value=face_value)
230
+ yes_ask = quoted(market.get("yes_ask_dollars"), face_value=face_value)
231
+ no_bid = quoted(market.get("no_bid_dollars"), face_value=face_value)
232
+ no_ask = quoted(market.get("no_ask_dollars"), face_value=face_value)
233
+ yes_bid_size = to_float(market.get("yes_bid_size_fp"))
234
+ yes_ask_size = to_float(market.get("yes_ask_size_fp"))
235
+
236
+ last = quoted(market.get("last_price_dollars"), face_value=face_value)
237
+ last_ts = parse_ts(market.get("updated_time"))
238
+ previous = quoted(market.get("previous_price_dollars"), face_value=face_value)
239
+ change = round(last - previous, 6) if last is not None and previous is not None else None
240
+
241
+ def mid(bid: float | None, ask: float | None) -> float | None:
242
+ return round((bid + ask) / 2, 6) if bid is not None and ask is not None else None
243
+
244
+ yes = Quote(
245
+ bid=yes_bid, bid_size=yes_bid_size, ask=yes_ask, ask_size=yes_ask_size,
246
+ mid=mid(yes_bid, yes_ask),
247
+ last=last, last_timestamp=last_ts if last is not None else None,
248
+ last_datetime=iso(last_ts) if last is not None else None,
249
+ )
250
+ no_last = round(face_value - last, 6) if last is not None else None
251
+ no = Quote(
252
+ bid=no_bid, bid_size=yes_ask_size, ask=no_ask, ask_size=yes_bid_size,
253
+ mid=mid(no_bid, no_ask),
254
+ last=no_last, last_timestamp=last_ts if no_last is not None else None,
255
+ last_datetime=iso(last_ts) if no_last is not None else None,
256
+ )
257
+ return yes, no, change
258
+
259
+
260
+ def normalize_market(market: dict[str, Any], event: dict[str, Any] | None = None) -> Market:
261
+ """One Kalshi market payload as a unified `Market`."""
262
+ event = event or {}
263
+ ticker = market["ticker"]
264
+ face_value = to_float(market.get("notional_value_dollars")) or 1.0
265
+ status, active = status_of(market)
266
+ yes_quote, no_quote, change = normalize_quotes(market, face_value)
267
+ series_id = event.get("series_ticker") or ticker.split("-")[0]
268
+ event_ticker = market.get("event_ticker") or event.get("event_ticker")
269
+
270
+ outcome_label = market.get("yes_sub_title") or market.get("subtitle") or None
271
+ yes_label = outcome_label or "Yes"
272
+ no_label = market.get("no_sub_title") or "No"
273
+ yes = Outcome(label=yes_label, quote=yes_quote, price_change_24h=change)
274
+ no = Outcome(label=no_label, quote=no_quote, price_change_24h=-change if change is not None else None)
275
+
276
+ liquidity = to_float(market.get("liquidity_dollars"))
277
+ rules = "\n\n".join(
278
+ text for text in (market.get("rules_primary"), market.get("rules_secondary")) if text
279
+ )
280
+ return Market(
281
+ id=ids.qualify(VENUE, ticker),
282
+ venue=VENUE,
283
+ venue_market_id=ticker,
284
+ event_id=ids.qualify(VENUE, event_ticker) if event_ticker else None,
285
+ title=market.get("title") or ticker,
286
+ description=rules or None,
287
+ slug=ticker.lower(),
288
+ yes=yes,
289
+ no=no,
290
+ status=status,
291
+ native_status=market.get("status"),
292
+ active=active,
293
+ market_type=market.get("market_type") or "binary",
294
+ open_timestamp=parse_ts(market.get("open_time")),
295
+ open_datetime=iso(parse_ts(market.get("open_time"))),
296
+ close_timestamp=parse_ts(market.get("close_time")),
297
+ close_datetime=iso(parse_ts(market.get("close_time"))),
298
+ resolution_timestamp=parse_ts(market.get("expected_expiration_time")),
299
+ resolution_datetime=iso(parse_ts(market.get("expected_expiration_time"))),
300
+ tick_size=tick_size_of(market),
301
+ face_value=face_value,
302
+ book_model="shared_complement",
303
+ stats=MarketStats(
304
+ volume_24h=to_float(market.get("volume_24h_fp")),
305
+ volume_total=to_float(market.get("volume_fp")),
306
+ # Kalshi reports 0.0000 here on every open market sampled, so a zero
307
+ # is an absent figure rather than an illiquid book. Storing the zero
308
+ # would mean publishing a number the venue is not actually claiming.
309
+ liquidity=liquidity or None,
310
+ open_interest=to_float(market.get("open_interest_fp")),
311
+ volume_unit="contracts",
312
+ liquidity_unit="collateral",
313
+ as_of=parse_ts(market.get("updated_time")),
314
+ ),
315
+ url=market_url(series_id, event_ticker, ticker),
316
+ category=event.get("category"),
317
+ tags=[t for t in [event.get("category")] if t],
318
+ series_id=series_id,
319
+ outcome_label=outcome_label,
320
+ neg_risk=event.get("mutually_exclusive"),
321
+ # Kalshi publishes these per event, so a market only has them when it
322
+ # was read with its event -- which every path in this adapter does.
323
+ settlement_sources=list(event.get("settlement_sources") or []),
324
+ info=market,
325
+ )
326
+
327
+
328
+ def normalize_event(event: dict[str, Any]) -> Event:
329
+ markets = event.get("markets") or []
330
+ unified = [normalize_market(m, event) for m in markets]
331
+ closes = [m.close_timestamp for m in unified if m.close_timestamp]
332
+ statuses = {m.status for m in unified}
333
+ for candidate in ("open", "closed", "settled", "unopened"):
334
+ if candidate in statuses:
335
+ status = candidate
336
+ break
337
+ else:
338
+ status = "unopened"
339
+ ticker = event["event_ticker"]
340
+ return Event(
341
+ id=ids.qualify(VENUE, ticker),
342
+ venue=VENUE,
343
+ venue_event_id=ticker,
344
+ title=event.get("title") or ticker,
345
+ description=event.get("sub_title"),
346
+ slug=ticker.lower(),
347
+ markets=unified,
348
+ status=status, # type: ignore[arg-type]
349
+ category=event.get("category"),
350
+ tags=[t for t in [event.get("category")] if t],
351
+ series_id=event.get("series_ticker"),
352
+ mutually_exclusive=event.get("mutually_exclusive"),
353
+ close_timestamp=max(closes) if closes else None,
354
+ close_datetime=iso(max(closes)) if closes else None,
355
+ settlement_sources=list(event.get("settlement_sources") or []),
356
+ url=market_url(event.get("series_ticker"), ticker),
357
+ info={k: v for k, v in event.items() if k != "markets"},
358
+ )
359
+
360
+
361
+ def normalize_order_book(
362
+ payload: dict[str, Any], *, ticker: str, side: str, face_value: float = 1.0,
363
+ depth: int | None = None,
364
+ ) -> OrderBook:
365
+ """The shared book, presented from one side.
366
+
367
+ `/orderbook` gives `yes_dollars` and `no_dollars`, both bid ladders. For the
368
+ YES view: YES bids are `yes_dollars` as-is, and YES asks are the NO bids
369
+ reflected — a NO bid at 0.88 is a YES ask at `face_value - 0.88`. The NO
370
+ view is the same operation with the arrays swapped.
371
+ """
372
+ book = payload.get("orderbook_fp") or payload.get("orderbook") or {}
373
+ own = book.get(f"{side}_dollars") or book.get(side) or []
374
+ other = book.get(("no" if side == "yes" else "yes") + "_dollars") or []
375
+
376
+ def levels(rows: Any) -> list[OrderLevel]:
377
+ out = []
378
+ for row in rows or []:
379
+ if not isinstance(row, (list, tuple)) or len(row) < 2:
380
+ continue
381
+ price, size = to_float(row[0]), to_float(row[1])
382
+ if price is None or size is None or size <= 0:
383
+ continue
384
+ out.append(OrderLevel(price=price, size=size))
385
+ return out
386
+
387
+ bids = sorted(levels(own), key=lambda level: level.price, reverse=True)
388
+ asks = sorted(
389
+ (OrderLevel(price=round(face_value - level.price, 6), size=level.size)
390
+ for level in levels(other)),
391
+ key=lambda level: level.price,
392
+ )
393
+ if depth:
394
+ bids, asks = bids[:depth], asks[:depth]
395
+ return OrderBook(
396
+ market_id=ids.qualify(VENUE, ticker),
397
+ side=side, # type: ignore[arg-type]
398
+ venue=VENUE,
399
+ bids=bids,
400
+ asks=asks,
401
+ book_model="shared_complement",
402
+ derived=True,
403
+ depth_scope="top_n" if depth else "full",
404
+ info=payload,
405
+ )
406
+
407
+
408
+ def normalize_trade(trade: dict[str, Any], *, face_value: float = 1.0) -> Trade:
409
+ """One execution, in the YES price, with what the taker did on the YES leg.
410
+
411
+ Kalshi prints both legs of every trade (`yes_price_dollars` and
412
+ `no_price_dollars`) because one fill creates a position on both sides, and
413
+ `taker_side` says which leg the aggressor bought. A taker who bought NO at
414
+ 0.30 is reported as `side="sell"` at 0.70: the same order, seen from the
415
+ YES leg every other price in this library is quoted on.
416
+ """
417
+ ticker = trade.get("ticker") or ""
418
+ taker = str(trade.get("taker_side") or trade.get("taker_outcome_side") or "").lower()
419
+ price = to_float(trade.get("yes_price_dollars"))
420
+ if price is None:
421
+ no_price = to_float(trade.get("no_price_dollars"))
422
+ price = round(face_value - no_price, 6) if no_price is not None else None
423
+ timestamp = parse_ts(trade.get("created_time")) or 0
424
+ return Trade(
425
+ id=str(trade.get("trade_id") or ""),
426
+ market_id=ids.qualify(VENUE, ticker),
427
+ timestamp=timestamp,
428
+ datetime=iso(timestamp) or "",
429
+ price=price or 0.0,
430
+ amount=to_float(trade.get("count_fp")) or to_float(trade.get("count")) or 0.0,
431
+ side="buy" if taker == "yes" else "sell" if taker == "no" else "unknown",
432
+ info=trade,
433
+ )
434
+
435
+
436
+ def reflect_candle(candle: Candle, *, face_value: float = 1.0) -> Candle:
437
+ """A YES-denominated bar seen from the NO side.
438
+
439
+ Prices invert, and the extremes swap with them: the period's highest YES
440
+ price is the period's *lowest* NO price. Bid and ask swap for the same
441
+ reason — the best NO bid is the reflection of the best YES ask.
442
+ """
443
+ def flip(price: float | None) -> float | None:
444
+ return round(face_value - price, 6) if price is not None else None
445
+
446
+ return candle.model_copy(update={
447
+ "open": flip(candle.open),
448
+ "high": flip(candle.low),
449
+ "low": flip(candle.high),
450
+ "close": flip(candle.close),
451
+ "bid_close": flip(candle.ask_close),
452
+ "ask_close": flip(candle.bid_close),
453
+ })
454
+
455
+
456
+ def normalize_candle(candle: dict[str, Any], *, interval_seconds: int) -> Candle:
457
+ """One Kalshi candlestick, labelled by where its OHLC came from.
458
+
459
+ Kalshi returns three blocks per period: `price` (executions), `yes_bid` and
460
+ `yes_ask` (the book). In a period with no trades the `price` block carries
461
+ only `previous_dollars` — there is no open, high, low or close, because
462
+ nothing traded. This builds the OHLC from executions when they exist and
463
+ falls back to the bid/ask midpoint otherwise, saying which in
464
+ `price_source` and leaving `volume` null rather than reporting a traded
465
+ price that does not exist.
466
+ """
467
+ end_ts = int(candle.get("end_period_ts") or 0)
468
+ start_ms = (end_ts - interval_seconds) * 1000
469
+ price = candle.get("price") or {}
470
+ bid = candle.get("yes_bid") or {}
471
+ ask = candle.get("yes_ask") or {}
472
+
473
+ def field(block: dict[str, Any], name: str) -> float | None:
474
+ # Live bars name prices `close_dollars`; bars from `/historical/...`
475
+ # name the same dollar amounts `close`.
476
+ value = block.get(f"{name}_dollars")
477
+ return to_float(value if value is not None else block.get(name))
478
+
479
+ bid_close, ask_close = field(bid, "close"), field(ask, "close")
480
+ traded = field(price, "close")
481
+ if traded is not None:
482
+ ohlc = (field(price, "open"), field(price, "high"), field(price, "low"), traded)
483
+ source = "trade"
484
+ else:
485
+ def midpoint(name: str) -> float | None:
486
+ low, high = field(bid, name), field(ask, name)
487
+ return round((low + high) / 2, 6) if low is not None and high is not None else None
488
+
489
+ ohlc = (midpoint("open"), midpoint("high"), midpoint("low"), midpoint("close"))
490
+ source = "bid_ask_mid"
491
+
492
+ volume = to_float(candle.get("volume_fp") if candle.get("volume_fp") is not None else candle.get("volume"))
493
+ return Candle(
494
+ timestamp=start_ms,
495
+ datetime=iso(start_ms) or "",
496
+ open=ohlc[0], high=ohlc[1], low=ohlc[2], close=ohlc[3],
497
+ volume=volume if source == "trade" else (volume if volume else None),
498
+ price_source=source, # type: ignore[arg-type]
499
+ bid_close=bid_close,
500
+ ask_close=ask_close,
501
+ info=candle,
502
+ )
503
+
504
+
505
+ def normalize_series(series: dict[str, Any]) -> Series:
506
+ ticker = series.get("ticker") or ""
507
+ fee = None
508
+ if series.get("fee_type"):
509
+ fee = FeeSchedule(
510
+ venue=VENUE,
511
+ scope="series",
512
+ scope_id=ticker,
513
+ fee_type=str(series["fee_type"]),
514
+ multiplier=to_float(series.get("fee_multiplier")),
515
+ rounding="up_to_cent",
516
+ info=series,
517
+ )
518
+ return Series(
519
+ id=ticker,
520
+ venue=VENUE,
521
+ title=series.get("title"),
522
+ category=series.get("category"),
523
+ tags=[str(t) for t in (series.get("tags") or [])],
524
+ fee=fee,
525
+ settlement_sources=list(series.get("settlement_sources") or []),
526
+ info=series,
527
+ )
528
+
529
+
530
+ # ---------------------------------------------------------------------------
531
+ # Adapter
532
+ # ---------------------------------------------------------------------------
533
+
534
+ class Kalshi(Exchange):
535
+ """Kalshi, read-only.
536
+
537
+ ```python
538
+ import synpath
539
+
540
+ kalshi = synpath.Kalshi()
541
+ markets = kalshi.fetch_markets(limit=10)
542
+ book = kalshi.fetch_order_book(markets[0].id)
543
+ ```
544
+ """
545
+
546
+ id = VENUE
547
+ name = "Kalshi"
548
+ book_model = "shared_complement"
549
+ has: dict[str, Capability] = {
550
+ "fetch_markets": True,
551
+ "fetch_events": True,
552
+ "fetch_market": True,
553
+ "fetch_markets_by_ids": True,
554
+ # /events accepts `order=`, `sort=` and `order_by=` and ignores all
555
+ # three, so the page is ordered here after it is read. See
556
+ # `fetch_markets` for which venue figure each key reads.
557
+ "sort": True,
558
+ "fetch_order_book": True,
559
+ # No batch endpoint: one request per market, both sides of it from
560
+ # the same response. See `fetch_order_books`.
561
+ "fetch_order_books": True,
562
+ "fetch_trades": True,
563
+ "fetch_ohlcv": True,
564
+ "fetch_series": True,
565
+ "fetch_fee_schedule": True,
566
+ # Real server-side search, though on an undocumented host. See SEARCH_URL.
567
+ "search": True,
568
+ "watch_order_book": False,
569
+ # Whether a market here is the same question as one somewhere else is
570
+ # not a question this venue can be asked. See `synpath.match_market`.
571
+ "match_market": False,
572
+ "match_event": False,
573
+ }
574
+
575
+ def __init__(
576
+ self,
577
+ *,
578
+ base_url: str = BASE_URL,
579
+ search_url: str = SEARCH_URL,
580
+ timeout: float = 30.0,
581
+ limiter: RateLimiter | None = LIMITER,
582
+ search_limiter: RateLimiter | None = SEARCH_LIMITER,
583
+ client: Any = None,
584
+ ):
585
+ self.http = HttpClient(base_url, limiter=limiter, timeout=timeout, client=client, venue=VENUE)
586
+ self.search = HttpClient(
587
+ search_url, limiter=search_limiter, timeout=timeout, client=client, venue=VENUE,
588
+ )
589
+ self._face_values: OrderedDict[str, float] = OrderedDict()
590
+ self._cutoff: tuple[float, dict[str, int]] | None = None
591
+
592
+ # -- catalog ------------------------------------------------------------
593
+
594
+ def fetch_events(
595
+ self, *, query: str | None = None, limit: int | None = None,
596
+ cursor: str | None = None, status: str = "open",
597
+ ) -> Page[Event]:
598
+ """One page of events with their markets nested.
599
+
600
+ With `query`, this asks the venue's search, the same as
601
+ `fetch_markets(query=...)`. It used to filter a single page locally,
602
+ so the same argument found ten markets through one method and zero
603
+ events through the other.
604
+ """
605
+ if query:
606
+ return self.search_events(query, limit=limit, cursor=cursor, status=status)
607
+ wanted = page_limit(limit) or MAX_PAGE_LIMIT
608
+ if cursor and cursor.startswith(HISTORICAL_CURSOR):
609
+ check_status(status)
610
+ events, more = self._archived_events(cursor.removeprefix(HISTORICAL_CURSOR) or None, wanted)
611
+ return Page(_with_status(events, status), next_cursor=HISTORICAL_CURSOR + more if more else None)
612
+ payload = self._event_page(cursor=cursor, status=status, limit=wanted)
613
+ events = _with_status(_live_events(payload), status)
614
+ next_cursor = payload.get("cursor") or None
615
+ if next_cursor is None and status in ARCHIVE_STATUSES:
616
+ next_cursor = HISTORICAL_CURSOR # the archive comes next
617
+ return Page(events[:wanted], next_cursor=next_cursor)
618
+
619
+ def _archived_markets(self, cursor: str | None, limit: int) -> tuple[list[Market], str | None]:
620
+ """One page of markets settled before Kalshi's historical cutoff, newest
621
+ first, and the venue's cursor for the next."""
622
+ payload = self.http.get("/historical/markets", {"limit": min(limit, ARCHIVE_PAGE), "cursor": cursor})
623
+ markets = [normalize_market(raw) for raw in payload.get("markets") or []]
624
+ return markets, payload.get("cursor") or None
625
+
626
+ def _archived_events(self, cursor: str | None, limit: int) -> tuple[list[Event], str | None]:
627
+ """One page of archived events: a page of archived markets grouped by
628
+ event, each event's own fields read from the live `/events` (which
629
+ still has them, just not their markets). An event whose markets
630
+ straddle two pages comes back on both, each with its own markets."""
631
+ # `limit` markets, so never more than `limit` events.
632
+ payload = self.http.get("/historical/markets", {"limit": min(limit, ARCHIVE_PAGE), "cursor": cursor})
633
+ grouped: dict[str, list[dict[str, Any]]] = {}
634
+ for raw in payload.get("markets") or []:
635
+ if raw.get("event_ticker"):
636
+ grouped.setdefault(raw["event_ticker"], []).append(raw)
637
+ raw_events = self._raw_events_by_ticker(list(grouped))
638
+ events = [
639
+ normalize_event({**raw_events.get(ticker, {"event_ticker": ticker}), "markets": markets})
640
+ for ticker, markets in grouped.items()
641
+ ]
642
+ return events, payload.get("cursor") or None
643
+
644
+ def _event_page(self, *, cursor: str | None, status: str, limit: int) -> dict[str, Any]:
645
+ """One raw page of events from the venue."""
646
+ check_status(status)
647
+ return self.http.get("/events", {
648
+ "limit": min(limit, 200),
649
+ "with_nested_markets": "true",
650
+ # "all" is this library's word for "do not filter", not one of
651
+ # Kalshi's own statuses -- sending it verbatim is a 400.
652
+ "status": None if status == "all" else status,
653
+ "cursor": cursor,
654
+ })
655
+
656
+ def fetch_markets(
657
+ self, *, query: str | None = None, limit: int | None = None,
658
+ cursor: str | None = None, status: str = "open", sort: str | None = None,
659
+ ) -> Page[Market]:
660
+ """One page of markets, flattened out of the events that hold them.
661
+
662
+ Kalshi has no market-level catalog endpoint, so this walks event pages
663
+ and unpacks them. Within a page, markets are ordered by ticker, and the
664
+ cursor records the last ticker returned rather than a row number. Kalshi's
665
+ own event cursor works the same way -- it carries the last event's
666
+ ticker -- so the whole walk resumes "after the last thing you saw".
667
+
668
+ That is what keeps a walk correct while the catalog changes underneath
669
+ it. A row number shifts when a market is added or removed ahead of it,
670
+ repeating one market or skipping another; "after ticker X" does not.
671
+ A walk never repeats a market and never skips one that existed when it
672
+ started. A market listed mid-walk that sorts before the cursor is picked
673
+ up on the next walk.
674
+
675
+ `query` is handed to `search_markets`, which asks the venue.
676
+
677
+ `sort` orders the page after it is read, because the venue ignores
678
+ every sort parameter it accepts. It is "this page, ordered by", the
679
+ way ccxt and pmxt do it, not "the top of the whole catalog". Keys:
680
+ `volume` is the venue's 24-hour contract volume; `liquidity` is the
681
+ size resting at the touch, bid plus ask, the only liquidity figure the
682
+ catalog publishes (`liquidity_dollars` is 0 on every open market);
683
+ `newest` is the open time. A market without the figure sorts last.
684
+ """
685
+ check_sort(sort, venue=VENUE, supported=bool(self.has["sort"]))
686
+ if query:
687
+ page = self.search_markets(query, limit=limit, cursor=cursor, status=status)
688
+ if sort:
689
+ return Page(sort_page(page, sort_key(sort)), next_cursor=page.next_cursor)
690
+ return page
691
+ check_status(status)
692
+ wanted = page_limit(limit)
693
+ fingerprint = query_fingerprint(status=status, kind="markets")
694
+ event_cursor, after = decode_market_cursor(cursor, fingerprint)
695
+ if after is not None and not isinstance(after, str):
696
+ raise BadRequest(f"kalshi: malformed cursor {cursor!r}")
697
+ collected: list[Market] = []
698
+ next_cursor: str | None = None
699
+ pages_read = 0
700
+ live_done = bool(event_cursor and event_cursor.startswith(HISTORICAL_CURSOR))
701
+
702
+ while not live_done:
703
+ payload = self._event_page(
704
+ cursor=event_cursor, status=status, limit=EVENT_PAGE,
705
+ )
706
+ pages_read += 1
707
+ # Kalshi's status filter selects events, not markets: an event it
708
+ # calls settled can still hold markets that are trading. Filtering
709
+ # the markets themselves is what makes status="settled" return
710
+ # settled markets, as it does on every other venue.
711
+ markets = sorted(
712
+ _with_status(
713
+ [
714
+ market
715
+ for event in (payload.get("events") or [])
716
+ for market in normalize_event(event).markets
717
+ ],
718
+ status,
719
+ ),
720
+ key=lambda market: market.venue_market_id,
721
+ )
722
+ if after is not None:
723
+ markets = [market for market in markets if market.venue_market_id > after]
724
+ room = len(markets) if wanted is None else wanted - len(collected)
725
+ taken = markets[:max(room, 0)]
726
+ collected.extend(taken)
727
+
728
+ if len(taken) < len(markets):
729
+ # Stopped part-way through this event page; resume after the
730
+ # last market handed out, whatever else has changed meanwhile.
731
+ last = taken[-1].venue_market_id if taken else after
732
+ next_cursor = encode_market_cursor(event_cursor, last, fingerprint)
733
+ break
734
+ page_cursor = payload.get("cursor")
735
+ if not page_cursor:
736
+ live_done, event_cursor = True, HISTORICAL_CURSOR
737
+ break
738
+ event_cursor, after = page_cursor, None
739
+ if wanted is None or len(collected) >= wanted or pages_read >= MAX_FILL_PAGES:
740
+ next_cursor = encode_market_cursor(event_cursor, None, fingerprint)
741
+ break
742
+
743
+ if live_done and next_cursor is None and status in ARCHIVE_STATUSES:
744
+ # Past the live catalog: markets settled before the historical
745
+ # cutoff, which the live events still list but no longer contain.
746
+ archive = (event_cursor or HISTORICAL_CURSOR).removeprefix(HISTORICAL_CURSOR) or None
747
+ while wanted is None or len(collected) < wanted:
748
+ if pages_read >= MAX_FILL_PAGES:
749
+ next_cursor = encode_market_cursor(HISTORICAL_CURSOR + (archive or ""), None, fingerprint)
750
+ break
751
+ room = ARCHIVE_PAGE if wanted is None else wanted - len(collected)
752
+ markets, more = self._archived_markets(archive, room)
753
+ pages_read += 1
754
+ collected.extend(_with_status(markets, status))
755
+ if not more:
756
+ break
757
+ archive = more
758
+ if wanted is None or len(collected) >= wanted:
759
+ next_cursor = encode_market_cursor(HISTORICAL_CURSOR + more, None, fingerprint)
760
+ break
761
+
762
+ if sort:
763
+ collected = sort_page(collected, sort_key(sort))
764
+ return Page(collected, next_cursor=next_cursor)
765
+
766
+ def fetch_markets_by_ids(self, market_ids: list[str]) -> list[Market]:
767
+ """Many markets in one request, in the order asked for.
768
+
769
+ Kalshi's ceiling here is URI length rather than a count, so batches are
770
+ sized by `MARKET_BATCH` instead of relying on a limit that depends on
771
+ how long tickers happen to be. The event context each market would get
772
+ from a listing is not fetched: this is the path a price loop uses, and
773
+ an extra request per batch for category and tags is not what it came
774
+ for.
775
+ """
776
+ tickers = [self.native(market_id) for market_id in market_ids]
777
+ found: dict[str, Market] = {}
778
+ for start in range(0, len(tickers), MARKET_BATCH):
779
+ batch = tickers[start:start + MARKET_BATCH]
780
+ payload = self.http.get(
781
+ "/markets", {"tickers": ",".join(batch), "limit": len(batch)},
782
+ )
783
+ for raw in payload.get("markets") or []:
784
+ if raw.get("ticker"):
785
+ found[raw["ticker"]] = normalize_market(raw)
786
+ # Markets settled before the historical cutoff are only on `/historical/markets`.
787
+ missing = [ticker for ticker in dict.fromkeys(tickers) if ticker not in found]
788
+ for start in range(0, len(missing), MARKET_BATCH):
789
+ batch = missing[start:start + MARKET_BATCH]
790
+ payload = self.http.get(
791
+ "/historical/markets", {"tickers": ",".join(batch), "limit": len(batch)},
792
+ )
793
+ for raw in payload.get("markets") or []:
794
+ if raw.get("ticker"):
795
+ found[raw["ticker"]] = normalize_market(raw)
796
+ return [found[ticker] for ticker in tickers if ticker in found]
797
+
798
+ def fetch_market(self, market_id: str) -> Market:
799
+ """One market, live or settled. A market that settled before Kalshi's
800
+ historical cutoff is gone from `/markets` and read from
801
+ `/historical/markets` instead."""
802
+ ticker = self.native(market_id)
803
+ raw = self._raw_market(ticker)
804
+ if not raw:
805
+ raise MarketNotFound(f"kalshi: no market {ticker}")
806
+ market = normalize_market(raw, self._event_of(raw))
807
+ self._remember_face_value(market.venue_market_id, market.face_value)
808
+ return market
809
+
810
+ def _raw_market(self, ticker: str) -> dict[str, Any] | None:
811
+ """The venue's market record, from the live endpoint or, when that has
812
+ no such market, the historical one."""
813
+ try:
814
+ return self.http.get(f"/markets/{ticker}").get("market")
815
+ except MarketNotFound:
816
+ pass
817
+ try:
818
+ return self.http.get(f"/historical/markets/{ticker}").get("market")
819
+ except MarketNotFound:
820
+ return None
821
+
822
+ def historical_cutoff(self) -> dict[str, int]:
823
+ """Kalshi's historical cutoffs, in epoch milliseconds: `trades` (trades
824
+ created before it are only on `/historical/trades`) and `markets`
825
+ (markets settled before it are only on `/historical/markets`).
826
+ Remembered for `CUTOFF_TTL` seconds."""
827
+ now = time.monotonic()
828
+ if self._cutoff and now - self._cutoff[0] < CUTOFF_TTL:
829
+ return self._cutoff[1]
830
+ payload = self.http.get("/historical/cutoff")
831
+ cutoff = {
832
+ "trades": parse_ts(payload.get("trades_created_ts")) or 0,
833
+ "markets": parse_ts(payload.get("market_settled_ts")) or 0,
834
+ }
835
+ self._cutoff = (now, cutoff)
836
+ return cutoff
837
+
838
+ def _reaches_historical_trades(self, since: int | None) -> bool:
839
+ """Whether a trade read starting at `since` can need trades older than
840
+ the cutoff. When the cutoff cannot be read, assume it can: one extra
841
+ request is better than silently missing trades."""
842
+ if since is None:
843
+ return True
844
+ try:
845
+ return since < self.historical_cutoff()["trades"]
846
+ except SynpathError:
847
+ return True
848
+
849
+ def _event_of(self, market: dict[str, Any]) -> dict[str, Any] | None:
850
+ """The market's parent event, for the fields that only live there.
851
+
852
+ `/markets/{ticker}` carries no category, tags or mutual-exclusivity, so
853
+ without this the same market would come back with different content
854
+ depending on whether it was fetched singly or listed -- and `neg_risk`
855
+ would read as `unknown` rather than its real value, which under this
856
+ library's tri-state convention means something different.
857
+
858
+ Enrichment only: if the event cannot be read, the market is still
859
+ returned rather than the whole call failing.
860
+ """
861
+ ticker = market.get("event_ticker")
862
+ if not ticker:
863
+ return None
864
+ try:
865
+ return self.http.get(f"/events/{ticker}").get("event")
866
+ except SynpathError:
867
+ return None
868
+
869
+ def _remember_face_value(self, ticker: str, face_value: float) -> None:
870
+ """Cache a face value, oldest evicted first.
871
+
872
+ Bounded because the server holds one adapter for the life of the
873
+ process: an unbounded dict would accumulate an entry for every market
874
+ ever seen, including ones that settled weeks ago.
875
+ """
876
+ self._face_values[ticker] = face_value
877
+ self._face_values.move_to_end(ticker)
878
+ while len(self._face_values) > FACE_VALUE_CACHE:
879
+ self._face_values.popitem(last=False)
880
+
881
+ def search_markets(
882
+ self, query: str, *, limit: int | None = None, cursor: str | None = None,
883
+ status: str = "open",
884
+ ) -> Page[Market]:
885
+ """Markets matching `query`, most relevant first.
886
+
887
+ Two requests per page, whatever the catalog size: one to the search
888
+ endpoint, one batch call to the documented API for the full events
889
+ behind the hits. Reading whole events rather than bare markets is what
890
+ gives search results the same `neg_risk`, category and series a listing
891
+ returns; search hits alone carry no mutual-exclusivity flag.
892
+
893
+ Results stay in relevance order, so paging deep into a result set is
894
+ best-effort: if the venue re-ranks between two calls, a market can be
895
+ repeated or missed. For a complete walk, use `fetch_markets()` without
896
+ a query.
897
+ """
898
+ check_status(status)
899
+ wanted = page_limit(limit) or MAX_PAGE_LIMIT
900
+ fingerprint = query_fingerprint(q=query, status=status, kind="search")
901
+ page_cursor, offset = decode_market_cursor(cursor, fingerprint)
902
+ offset = 0 if offset is None else offset
903
+ if not isinstance(offset, int):
904
+ raise BadRequest(f"kalshi: malformed cursor {cursor!r}")
905
+
906
+ hits, venue_next = self._search_page(query, page_cursor)
907
+ rows: list[tuple[str, str]] = [
908
+ (market["ticker"], hit["event_ticker"])
909
+ for hit in hits
910
+ if hit.get("event_ticker")
911
+ for market in (hit.get("markets") or [])
912
+ if market.get("ticker")
913
+ ]
914
+ window = rows[offset:offset + wanted]
915
+ archived: dict[str, list[str]] = {}
916
+ for ticker, event in window:
917
+ archived.setdefault(event, []).append(ticker)
918
+ found = self._events_by_ticker([event for _, event in window],
919
+ archived=archived if status != "open" else None)
920
+ by_ticker = {
921
+ market.venue_market_id: market
922
+ for event in found.values()
923
+ for market in event.markets
924
+ }
925
+ markets = _with_status(
926
+ [by_ticker[ticker] for ticker, _ in window if ticker in by_ticker], status,
927
+ )
928
+ offset += len(window)
929
+
930
+ if offset < len(rows):
931
+ next_cursor = encode_market_cursor(page_cursor, offset, fingerprint)
932
+ elif venue_next:
933
+ next_cursor = encode_market_cursor(venue_next, 0, fingerprint)
934
+ else:
935
+ next_cursor = None
936
+ return Page(markets, next_cursor=next_cursor)
937
+
938
+ def search_events(
939
+ self, query: str, *, limit: int | None = None, cursor: str | None = None,
940
+ status: str = "open",
941
+ ) -> Page[Event]:
942
+ """Events matching `query`, most relevant first, with markets nested.
943
+
944
+ The same two requests as `search_markets`, paged by event instead of
945
+ by market.
946
+ """
947
+ check_status(status)
948
+ wanted = page_limit(limit) or MAX_PAGE_LIMIT
949
+ fingerprint = query_fingerprint(q=query, status=status, kind="search_events")
950
+ page_cursor, offset = decode_market_cursor(cursor, fingerprint)
951
+ offset = 0 if offset is None else offset
952
+ if not isinstance(offset, int):
953
+ raise BadRequest(f"kalshi: malformed cursor {cursor!r}")
954
+
955
+ hits, venue_next = self._search_page(query, page_cursor)
956
+ tickers = list(dict.fromkeys(
957
+ hit["event_ticker"] for hit in hits if hit.get("event_ticker")
958
+ ))
959
+ window = tickers[offset:offset + wanted]
960
+ archived: dict[str, list[str]] = {}
961
+ for hit in hits:
962
+ for market in hit.get("markets") or []:
963
+ if hit.get("event_ticker") in window and market.get("ticker"):
964
+ archived.setdefault(hit["event_ticker"], []).append(market["ticker"])
965
+ found = self._events_by_ticker(window, archived=archived if status != "open" else None)
966
+ events = _with_status([found[ticker] for ticker in window if ticker in found], status)
967
+ offset += len(window)
968
+
969
+ if offset < len(tickers):
970
+ next_cursor = encode_market_cursor(page_cursor, offset, fingerprint)
971
+ elif venue_next:
972
+ next_cursor = encode_market_cursor(venue_next, 0, fingerprint)
973
+ else:
974
+ next_cursor = None
975
+ return Page(events, next_cursor=next_cursor)
976
+
977
+ def _search_page(
978
+ self, query: str, cursor: str | None,
979
+ ) -> tuple[list[dict[str, Any]], str | None]:
980
+ """One page of the venue's search, checked before it is trusted.
981
+
982
+ The endpoint is undocumented, so its most likely failure is a change of
983
+ shape that still answers 200. Read with a default, a renamed field
984
+ becomes an empty result with no cursor -- indistinguishable from a
985
+ term that matched nothing, which is the one wrong answer nobody would
986
+ think to question. A genuine no-match still carries `current_page: []`,
987
+ so requiring the field costs nothing on the honest path.
988
+ """
989
+ payload = self.search.get("/v1/search/series", {"query": query, "cursor": cursor})
990
+ hits = payload.get("current_page") if isinstance(payload, dict) else None
991
+ if not isinstance(hits, list):
992
+ raise ExchangeError(
993
+ "kalshi: the search endpoint answered in a shape this library does "
994
+ "not recognise (no `current_page` list). It is undocumented and may "
995
+ "have changed. Walk fetch_markets() without a query instead."
996
+ )
997
+ return hits, payload.get("next_cursor") or None
998
+
999
+ def _events_by_ticker(
1000
+ self, tickers: list[str], *, archived: dict[str, list[str]] | None = None,
1001
+ ) -> dict[str, Event]:
1002
+ """Full events with nested markets, keyed by event ticker.
1003
+
1004
+ Uses `tickers=`, not `event_ticker=`. The latter looks right, answers
1005
+ 200, and is silently ignored: the venue returns its unfiltered first
1006
+ page, which reads as results rather than as an error. The batch also
1007
+ comes back in the venue's order rather than the order asked for, so the
1008
+ caller re-indexes by ticker to keep relevance order.
1009
+ """
1010
+ raw_events = self._raw_events_by_ticker(tickers)
1011
+ if archived:
1012
+ # An event settled before the historical cutoff comes back with no
1013
+ # markets; its markets are on `/historical/markets`, one batch by ticker.
1014
+ wanted = [ticker for event, found in raw_events.items() if not found.get("markets")
1015
+ for ticker in archived.get(event, [])]
1016
+ for start in range(0, len(wanted), MARKET_BATCH):
1017
+ batch = wanted[start:start + MARKET_BATCH]
1018
+ payload = self.http.get("/historical/markets", {"tickers": ",".join(batch), "limit": len(batch)})
1019
+ for raw in payload.get("markets") or []:
1020
+ event = raw_events.get(raw.get("event_ticker") or "")
1021
+ if event is not None:
1022
+ event.setdefault("markets", [])
1023
+ event["markets"] = [*(event["markets"] or []), raw]
1024
+ return {ticker: normalize_event(raw) for ticker, raw in raw_events.items()}
1025
+
1026
+ def _raw_events_by_ticker(self, tickers: list[str]) -> dict[str, dict[str, Any]]:
1027
+ """The venue's event records with nested markets, keyed by ticker."""
1028
+ unique = list(dict.fromkeys(tickers))
1029
+ found: dict[str, dict[str, Any]] = {}
1030
+ for start in range(0, len(unique), EVENT_BATCH):
1031
+ batch = unique[start:start + EVENT_BATCH]
1032
+ payload = self.http.get("/events", {
1033
+ "tickers": ",".join(batch),
1034
+ "with_nested_markets": "true",
1035
+ "limit": len(batch),
1036
+ })
1037
+ for raw in payload.get("events") or []:
1038
+ if raw.get("event_ticker"):
1039
+ found[raw["event_ticker"]] = raw
1040
+ return found
1041
+
1042
+ def iter_events(self, *, status: str = "open") -> Iterator[Event]:
1043
+ """Every event the venue exposes, paging until the cursor runs out.
1044
+
1045
+ Pages at the venue's maximum rather than through `fetch_events`, whose
1046
+ page size is the library's shared 100: an iterator has no page contract
1047
+ to honour, and the difference is 65 requests for the catalog against
1048
+ 130.
1049
+ """
1050
+ cursor: str | None = None
1051
+ while True:
1052
+ payload = self._event_page(cursor=cursor, status=status, limit=VENUE_EVENT_PAGE)
1053
+ yield from _with_status(_live_events(payload), status)
1054
+ cursor = payload.get("cursor")
1055
+ if not cursor or not payload.get("events"):
1056
+ break
1057
+ if status not in ARCHIVE_STATUSES:
1058
+ return
1059
+ archive: str | None = None
1060
+ while True:
1061
+ events, archive = self._archived_events(archive, VENUE_EVENT_PAGE)
1062
+ yield from _with_status(events, status)
1063
+ if not archive:
1064
+ return
1065
+
1066
+ # -- market data --------------------------------------------------------
1067
+
1068
+ def fetch_order_book(
1069
+ self, market_id: str, *, side: BookSide = "yes", depth: int | None = None,
1070
+ ) -> OrderBook:
1071
+ ticker = self.native(market_id)
1072
+ _check_side(side)
1073
+ payload = self.http.get(f"/markets/{ticker}/orderbook", {"depth": depth})
1074
+ return normalize_order_book(
1075
+ payload, ticker=ticker, side=side,
1076
+ face_value=self._face_value(ticker), depth=depth,
1077
+ )
1078
+
1079
+ def fetch_order_books(
1080
+ self, market_ids: list[str], *, side: BookSide = "yes", depth: int | None = None,
1081
+ ) -> dict[str, OrderBook]:
1082
+ """Books for many markets, keyed by Synpath market id.
1083
+
1084
+ Kalshi has no batch endpoint, so this is one request per market,
1085
+ against the same shared rate budget every other call draws on.
1086
+ """
1087
+ _check_side(side)
1088
+ books: dict[str, OrderBook] = {}
1089
+ for ticker in dict.fromkeys(self.native(m) for m in market_ids):
1090
+ payload = self.http.get(f"/markets/{ticker}/orderbook", {"depth": depth})
1091
+ books[ids.qualify(VENUE, ticker)] = normalize_order_book(
1092
+ payload, ticker=ticker, side=side, face_value=self._face_value(ticker), depth=depth,
1093
+ )
1094
+ return books
1095
+
1096
+ def fetch_trades(
1097
+ self, market_id: str, *, since: int | None = None, limit: int | None = None,
1098
+ cursor: str | None = None,
1099
+ ) -> Page[Trade]:
1100
+ """Executions for a market, newest page first, each page oldest first.
1101
+
1102
+ Kalshi serves recent trades on `/markets/trades` and moves those older
1103
+ than its cutoff to `/historical/trades`. Paging walks both as one tape:
1104
+ when the live tape runs out and the window reaches before the cutoff,
1105
+ the same page is filled from the historical one and the cursor carries
1106
+ on there (it then starts with `historical:`).
1107
+ """
1108
+ ticker = self.native(market_id)
1109
+ wanted = min(limit or 100, 1000)
1110
+ min_ts = int(since / 1000) if since else None
1111
+ rows: list[dict[str, Any]] = []
1112
+ next_cursor: str | None
1113
+ if cursor and cursor.startswith(HISTORICAL_CURSOR):
1114
+ next_cursor = cursor
1115
+ else:
1116
+ payload = self.http.get("/markets/trades", {
1117
+ "ticker": ticker, "limit": wanted, "min_ts": min_ts, "cursor": cursor,
1118
+ })
1119
+ rows = payload.get("trades") or []
1120
+ next_cursor = payload.get("cursor") or None
1121
+ if next_cursor is None and self._reaches_historical_trades(since):
1122
+ next_cursor = HISTORICAL_CURSOR
1123
+ if next_cursor and next_cursor.startswith(HISTORICAL_CURSOR) and len(rows) < wanted:
1124
+ payload = self.http.get("/historical/trades", {
1125
+ "ticker": ticker, "limit": wanted - len(rows), "min_ts": min_ts,
1126
+ "cursor": next_cursor.removeprefix(HISTORICAL_CURSOR) or None,
1127
+ })
1128
+ rows += payload.get("trades") or []
1129
+ more = payload.get("cursor")
1130
+ next_cursor = HISTORICAL_CURSOR + more if more else None
1131
+ face_value = self._face_value(ticker)
1132
+ trades = [normalize_trade(t, face_value=face_value) for t in rows]
1133
+ return Page(sorted(trades, key=lambda trade: trade.timestamp), next_cursor=next_cursor)
1134
+
1135
+ def fetch_ohlcv(
1136
+ self, market_id: str, *, timeframe: str = "1h", since: int | None = None,
1137
+ until: int | None = None, limit: int | None = None,
1138
+ ) -> list[Candle]:
1139
+ """Candles for a market, in the YES price.
1140
+
1141
+ Kalshi accepts three periods only — 1m, 1h and 1d. Anything else raises
1142
+ rather than being silently rounded to a period you did not ask for.
1143
+ A window wider than Kalshi's 5,000 bars per request is read in pieces
1144
+ (`MAX_CANDLES`), one request each.
1145
+
1146
+ With `since`, bars are read forward from it and the first `limit` are
1147
+ returned, fetching only as many pieces as that takes. Without it,
1148
+ `limit` also sets how far back to look: the newest `limit` periods
1149
+ ending at `until` (or now). Kalshi emits a bar only for periods where
1150
+ something moved, so a thin market can return fewer bars than asked
1151
+ for -- that is the venue having no more, not a truncated response.
1152
+ A window of more than one piece is first clipped to the market's own
1153
+ life, open to close, so no request is spent on time it did not exist.
1154
+
1155
+ A market that settled before Kalshi's historical cutoff has its bars on
1156
+ `/historical/markets/{ticker}/candlesticks`, read when the live
1157
+ endpoint no longer knows the market.
1158
+ """
1159
+ if timeframe not in CANDLE_INTERVALS:
1160
+ raise BadRequest(
1161
+ f"kalshi: timeframe {timeframe!r} not offered; "
1162
+ f"supported: {', '.join(CANDLE_INTERVALS)}"
1163
+ )
1164
+ ticker = self.native(market_id)
1165
+ seconds = timeframe_seconds(timeframe)
1166
+ end = int((until or _now_ms()) / 1000)
1167
+ start = int(since / 1000) if since else end - seconds * (limit or 100)
1168
+ series_id = ticker.split("-")[0]
1169
+ path = f"/series/{series_id}/markets/{ticker}/candlesticks"
1170
+ span = seconds * (MAX_CANDLES - 1)
1171
+ if end - start > span:
1172
+ start, end = self._clip_to_life(ticker, start, end, seconds)
1173
+ by_time: dict[int, Candle] = {}
1174
+ for piece_start in range(start, max(end, start + 1), span):
1175
+ if enough_bars(list(by_time), since=since, limit=limit):
1176
+ break
1177
+ window = {"start_ts": piece_start, "end_ts": min(piece_start + span, end),
1178
+ "period_interval": CANDLE_INTERVALS[timeframe]}
1179
+ try:
1180
+ payload = self.http.get(path, window)
1181
+ except MarketNotFound:
1182
+ if path.startswith("/historical/"):
1183
+ raise
1184
+ # Settled before the historical cutoff: its bars moved with it.
1185
+ path = f"/historical/markets/{ticker}/candlesticks"
1186
+ payload = self.http.get(path, window)
1187
+ for raw in payload.get("candlesticks") or []:
1188
+ candle = normalize_candle(raw, interval_seconds=seconds)
1189
+ by_time[candle.timestamp] = candle # a bar on a piece boundary comes back twice
1190
+ candles = [by_time[stamp] for stamp in sorted(by_time)]
1191
+ return pick_bars(candles, since=since, limit=limit)
1192
+
1193
+ def _clip_to_life(self, ticker: str, start: int, end: int, seconds: int) -> tuple[int, int]:
1194
+ """`start`..`end` (epoch seconds) narrowed to when the market existed,
1195
+ with a period of slack each side. Unchanged when the market or its
1196
+ dates cannot be read: the candle request will say why."""
1197
+ try:
1198
+ raw = self._raw_market(ticker) or {}
1199
+ except SynpathError:
1200
+ return start, end
1201
+ opened = parse_ts(raw.get("open_time") or raw.get("created_time"))
1202
+ closed = parse_ts(raw.get("close_time"))
1203
+ if opened:
1204
+ start = max(start, opened // 1000 - seconds)
1205
+ if closed:
1206
+ end = min(end, closed // 1000 + seconds)
1207
+ return start, max(start, end)
1208
+
1209
+ # -- reference ----------------------------------------------------------
1210
+
1211
+ def fetch_series(self, series_id: str) -> Series:
1212
+ payload = self.http.get(f"/series/{series_id}")
1213
+ raw = payload.get("series")
1214
+ if not raw:
1215
+ raise MarketNotFound(f"kalshi: no series {series_id}")
1216
+ return normalize_series(raw)
1217
+
1218
+ def fetch_fee_schedule(self, market_id: str) -> FeeSchedule:
1219
+ """The fee schedule that applies to a market.
1220
+
1221
+ Kalshi attaches fees to the *series*, not the market, so this resolves
1222
+ the series from the ticker and reads it there. Knowing the cost before
1223
+ trading is the difference between a price comparison and a real edge.
1224
+ """
1225
+ ticker = self.native(market_id)
1226
+ series = self.fetch_series(ticker.split("-")[0])
1227
+ if series.fee is None:
1228
+ raise MarketNotFound(f"kalshi: series {series.id} publishes no fee schedule")
1229
+ return series.fee
1230
+
1231
+ # -- internals ----------------------------------------------------------
1232
+
1233
+ def _face_value(self, ticker: str) -> float:
1234
+ """This market's face value, asked of the venue once and remembered.
1235
+
1236
+ Both venues pay 1.00 per contract today. The value is still read rather
1237
+ than assumed, because every complement transform depends on it and a
1238
+ wrong constant would silently invert prices.
1239
+ """
1240
+ cached = self._face_values.get(ticker)
1241
+ if cached is not None:
1242
+ self._face_values.move_to_end(ticker)
1243
+ return cached
1244
+ try:
1245
+ # Deliberately not `fetch_market`, which also reads the parent event
1246
+ # for category and tags. Those are display fields a book or trade
1247
+ # read has no use for, and the extra round trip is a third of this
1248
+ # venue's measured request budget.
1249
+ raw = self._raw_market(ticker) or {}
1250
+ value = to_float(raw.get("notional_value_dollars")) or 1.0
1251
+ except MarketNotFound:
1252
+ value = 1.0
1253
+ self._remember_face_value(ticker, value)
1254
+ return value
1255
+
1256
+ def close(self) -> None:
1257
+ self.http.close()
1258
+ self.search.close()
1259
+
1260
+
1261
+ def _now_ms() -> int:
1262
+ return int(datetime.now(tz=timezone.utc).timestamp() * 1000)
1263
+
1264
+
1265
+ def sort_key(sort: str):
1266
+ """The figure a sort key reads on this venue. See `Kalshi.fetch_markets`."""
1267
+ if sort == "volume":
1268
+ return lambda market: market.stats.volume_24h
1269
+ if sort == "liquidity":
1270
+ def resting_at_touch(market):
1271
+ quote = market.yes.quote if market.yes else None
1272
+ if quote is None or (quote.bid_size is None and quote.ask_size is None):
1273
+ return None
1274
+ return (quote.bid_size or 0.0) + (quote.ask_size or 0.0)
1275
+ return resting_at_touch
1276
+ return lambda market: market.open_timestamp
1277
+
1278
+
1279
+ def _live_events(payload: dict[str, Any]) -> list[Event]:
1280
+ """The events of a live `/events` page that still hold markets. One settled
1281
+ before the historical cutoff is listed with none; it is read from the
1282
+ archive instead (see `ARCHIVE_STATUSES`), not returned empty here."""
1283
+ return [normalize_event(raw) for raw in payload.get("events") or [] if raw.get("markets")]
1284
+
1285
+
1286
+ def _with_status(items: list, status: str) -> list:
1287
+ """Keep what is actually in `status`, judged by the item's own status.
1288
+
1289
+ The venue's filter is applied first to keep pages small, but it is not the
1290
+ last word: Kalshi's selects events, so a "settled" page can carry markets
1291
+ that are still trading. Everything is checked against the normalized status
1292
+ before it is returned.
1293
+ """
1294
+ if status == "all":
1295
+ return items
1296
+ return [item for item in items if item.status == status]
1297
+
1298
+
1299
+ def _matches(query: str, *fields: str | None) -> bool:
1300
+ needle = query.lower()
1301
+ return any(needle in (field or "").lower() for field in fields)
1302
+
1303
+
1304
+ def query_fingerprint(**terms: Any) -> str:
1305
+ """A short, stable tag for the query a cursor belongs to."""
1306
+ material = json.dumps(terms, sort_keys=True, separators=(",", ":"))
1307
+ return hashlib.sha256(material.encode()).hexdigest()[:8]
1308
+
1309
+
1310
+ def encode_market_cursor(
1311
+ page_cursor: str | None, position: int | str | None, fingerprint: str,
1312
+ ) -> str:
1313
+ """Pack "which venue page, where in it, and for which query".
1314
+
1315
+ `position` is the last ticker returned for a listing, or a row offset for
1316
+ search, where relevance order has no stable key to resume after.
1317
+
1318
+ Kalshi has no market-level catalog endpoint: markets are reached by paging
1319
+ events and unpacking them. A page holds a variable number of markets, so a
1320
+ cursor that only remembered the page would skip every market the caller's
1321
+ `limit` trimmed off the end -- which is what it did, losing 87% of the
1322
+ catalog on a walk with `limit=5`.
1323
+
1324
+ The offset lives in the cursor rather than on the adapter, so paging
1325
+ survives a restart, works from another process, and cannot be disturbed by
1326
+ a concurrent caller. The fingerprint is what makes that safe: an offset
1327
+ counts rows of one particular query, so resuming with a different `status`
1328
+ would land somewhere arbitrary. Recording which query the offset belongs to
1329
+ turns that from silent misalignment into a clear error.
1330
+ """
1331
+ packed = json.dumps(
1332
+ {"c": page_cursor, "o": position, "f": fingerprint}, separators=(",", ":"),
1333
+ )
1334
+ return base64.urlsafe_b64encode(packed.encode()).decode().rstrip("=")
1335
+
1336
+
1337
+ def decode_market_cursor(
1338
+ cursor: str | None, fingerprint: str,
1339
+ ) -> tuple[str | None, int | str | None]:
1340
+ """`(page cursor, position in that page)`, checked against this query.
1341
+
1342
+ A cursor this library did not issue is rejected rather than guessed at: the
1343
+ alternative is silently restarting the walk, which reads as duplicate data
1344
+ rather than as an error.
1345
+ """
1346
+ if not cursor:
1347
+ return None, None
1348
+ try:
1349
+ padded = cursor + "=" * (-len(cursor) % 4)
1350
+ state = json.loads(base64.urlsafe_b64decode(padded.encode()))
1351
+ position = state["o"]
1352
+ page_cursor = state["c"]
1353
+ carried = state["f"]
1354
+ except Exception:
1355
+ raise BadRequest(
1356
+ f"kalshi: {cursor!r} is not a cursor this API issued; "
1357
+ f"pass the `next_cursor` from a previous page, or omit it to start over"
1358
+ ) from None
1359
+ position_ok = (
1360
+ position is None
1361
+ or isinstance(position, str)
1362
+ or (isinstance(position, int) and not isinstance(position, bool) and position >= 0)
1363
+ )
1364
+ if not position_ok or (page_cursor is not None and not isinstance(page_cursor, str)):
1365
+ raise BadRequest(f"kalshi: malformed cursor {cursor!r}")
1366
+ if carried != fingerprint:
1367
+ raise BadRequest(
1368
+ "kalshi: this cursor belongs to a different query -- the offset it "
1369
+ "carries counts rows of the search and status it was issued for. "
1370
+ "Keep those arguments the same while paging, or start over without "
1371
+ "a cursor."
1372
+ )
1373
+ return page_cursor, position
1374
+
1375
+
1376
+ def _check_side(side: str) -> None:
1377
+ if side not in ("yes", "no"):
1378
+ raise BadRequest(f"kalshi: unknown side {side!r}; expected 'yes' or 'no'")