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.
- synpath/__init__.py +183 -0
- synpath/__main__.py +66 -0
- synpath/base.py +723 -0
- synpath/bucket.py +154 -0
- synpath/client.py +356 -0
- synpath/engine/__init__.py +37 -0
- synpath/engine/__main__.py +354 -0
- synpath/engine/alerts.py +170 -0
- synpath/engine/engine.py +888 -0
- synpath/engine/eod.py +154 -0
- synpath/engine/events.py +140 -0
- synpath/engine/fair_values.py +117 -0
- synpath/engine/feeds.py +220 -0
- synpath/engine/journal.py +907 -0
- synpath/engine/ledger.py +353 -0
- synpath/engine/orders/__init__.py +42 -0
- synpath/engine/orders/base.py +441 -0
- synpath/engine/orders/day.py +72 -0
- synpath/engine/orders/iceberg.py +121 -0
- synpath/engine/orders/manager.py +223 -0
- synpath/engine/orders/oco.py +255 -0
- synpath/engine/orders/peg.py +168 -0
- synpath/engine/orders/routed.py +496 -0
- synpath/engine/orders/stop.py +240 -0
- synpath/engine/orders/taker.py +187 -0
- synpath/engine/orders/twap.py +190 -0
- synpath/engine/paper.py +532 -0
- synpath/engine/reconcile.py +279 -0
- synpath/engine/risk.py +403 -0
- synpath/engine/router.py +261 -0
- synpath/errors.py +98 -0
- synpath/history.py +71 -0
- synpath/hosted.py +86 -0
- synpath/hosted_auth.py +201 -0
- synpath/ids.py +61 -0
- synpath/kalshi.py +1378 -0
- synpath/matching.py +86 -0
- synpath/polymarket.py +1004 -0
- synpath/polymarket_us.py +989 -0
- synpath/remote.py +195 -0
- synpath/server/__init__.py +98 -0
- synpath/server/__main__.py +118 -0
- synpath/server/api.py +439 -0
- synpath/server/errors.py +87 -0
- synpath/server/local.py +96 -0
- synpath/server/models.py +75 -0
- synpath/server/serve.py +236 -0
- synpath/server/store.py +363 -0
- synpath/server/trading.py +764 -0
- synpath/trading/__init__.py +79 -0
- synpath/trading/__main__.py +69 -0
- synpath/trading/base.py +126 -0
- synpath/trading/credentials.py +400 -0
- synpath/trading/errors.py +94 -0
- synpath/trading/init.py +233 -0
- synpath/trading/instruments.py +162 -0
- synpath/trading/kalshi.py +957 -0
- synpath/trading/limiter.py +177 -0
- synpath/trading/money.py +172 -0
- synpath/trading/polymarket.py +1362 -0
- synpath/trading/polymarket_signing.py +478 -0
- synpath/trading/polymarket_us.py +705 -0
- synpath/trading/polymarket_us_exchange.py +825 -0
- synpath/trading/types.py +414 -0
- synpath/types.py +608 -0
- synpath/ws/__init__.py +55 -0
- synpath/ws/base.py +544 -0
- synpath/ws/grpc.py +578 -0
- synpath/ws/kalshi.py +418 -0
- synpath/ws/polymarket.py +430 -0
- synpath/ws/polymarket_us.py +299 -0
- synpath/ws/polymarket_us_exchange.py +754 -0
- synpath-0.1.0.dist-info/METADATA +224 -0
- synpath-0.1.0.dist-info/RECORD +77 -0
- synpath-0.1.0.dist-info/WHEEL +4 -0
- synpath-0.1.0.dist-info/entry_points.txt +2 -0
- 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'")
|