kalshieye 0.2.1__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kalshi Eye
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: kalshieye
3
+ Version: 0.2.1
4
+ Summary: Download Kalshi Eye flat files (order book, book snapshots, top of book, bars, trades, spot, markets, settlements) and load them into pandas or Polars.
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://kalshieye.com
7
+ Project-URL: Documentation, https://kalshieye.com/docs/bulk/
8
+ Project-URL: Source, https://github.com/KalshiEye/kalshieye-python
9
+ Keywords: kalshi,prediction markets,order book,market data,parquet
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Intended Audience :: Financial and Insurance Industry
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Topic :: Office/Business :: Financial :: Investment
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Provides-Extra: pandas
18
+ Requires-Dist: pandas>=2.0; extra == "pandas"
19
+ Requires-Dist: pyarrow>=14; extra == "pandas"
20
+ Provides-Extra: polars
21
+ Requires-Dist: polars>=0.20; extra == "polars"
22
+ Dynamic: license-file
23
+
24
+ # kalshieye
25
+
26
+ Python client for Kalshi Eye flat files: daily Parquet for the order book (`book_l2`), 1-second top-10 book snapshots (`book_snap_1s`), top of book (`bbo`), one-minute bars (`bbo_1m`), trades, crypto spot (`spot`), and the reference tables (`markets`, `settlements`, `series`, `incidents`).
27
+
28
+ ```bash
29
+ pip install "kalshieye[pandas]" # or kalshieye[polars], or plain kalshieye
30
+ export KALSHIEYE_API_KEY=clab_... # create one under API keys
31
+ ```
32
+
33
+ ```python
34
+ from kalshieye import Client, replay_book
35
+
36
+ kx = Client()
37
+ kx.datasets() # schemas, coverage, required plan
38
+ kx.estimate(["bbo_1m", "trades"], "2026-10-12", "2026-10-18", series=["KXBTC*"])
39
+
40
+ kx.download(["bbo_1m", "trades"], "2026-10-12", "2026-10-18",
41
+ series=["KXBTC*"], dest="data", progress=True)
42
+
43
+ df = kx.load("bbo_1m", "2026-10-12", "2026-10-18", series="KXBTC15M")
44
+ fills = kx.load("trades", "2026-10-12", "2026-10-12", tickers=["KXBTC15M-26OCT121545-45"])
45
+
46
+ # Rebuild the book from book_l2 rows
47
+ book = kx.load("book_l2", "2026-10-12", "2026-10-12", tickers=["KXBTC15M-26OCT121545-45"])
48
+ for ts, ticker, bids, asks in replay_book(book):
49
+ ...
50
+
51
+ # One event in one zip: book, snapshots, trades, spot, metadata.json, settlement.json
52
+ kx.bundle("KXBTC15M-26OCT121545")
53
+
54
+ # CSV.gz copy of a single file
55
+ f = next(kx.files("trades", "2026-10-12", "2026-10-12", series="KXBTC15M"))
56
+ kx.csv(f["key"])
57
+
58
+ # S3 access (paid plans): a read-only key for aws s3 sync, rclone, DuckDB, boto3
59
+ key = kx.create_s3_key("laptop")
60
+ print(key["snippets"]["aws_cli"])
61
+ ```
62
+
63
+ - Files land under `dest` in the lake layout (`bbo_1m/date=2026-10-12/series=KXBTC15M/...parquet`), so DuckDB, Polars, and Spark can read the folder as a hive-partitioned dataset. `spot` is partitioned by `asset=` instead of `series=`.
64
+ - Every file is checked against its sha256. Files already on disk are skipped and do not count toward your monthly download allowance.
65
+ - `series` accepts wildcards (`KXBTC*`). `categories` filters by archive category (`crypto`, `politics`, ...). `tickers` picks the series files holding those markets, and `load` keeps only their rows.
66
+ - `bundle`, `csv`, and `replay` are built on the server the first time; the client waits for them.
67
+ - Errors raise `kalshieye.ApiError`. Use `err.code` to tell plan limits (`plan_required`, `history_limited`, `download_quota`) apart.
68
+
69
+ ## Without an account
70
+
71
+ `replay_book` works on any `book_l2` rows, including the free one-day sample on Hugging Face ([KalshiEye/kalshi-order-book-sample](https://huggingface.co/datasets/KalshiEye/kalshi-order-book-sample)), which DuckDB reads in place:
72
+
73
+ ```python
74
+ # pip install "kalshieye[pandas]" duckdb
75
+ import duckdb
76
+ from kalshieye import replay_book
77
+
78
+ # Bills vs Rams, YES if Buffalo wins: every book change up to 11 PM Eastern
79
+ book = duckdb.sql("""
80
+ SELECT * FROM 'hf://datasets/KalshiEye/kalshi-order-book-sample/kalshieye-data/book_l2/*/series=KXNFLGAME/*.parquet'
81
+ WHERE ticker = 'KXNFLGAME-26OCT12BUFLAR-BUF'
82
+ AND ts_recv::TIMESTAMPTZ <= '2026-10-13 03:00:00+00'::TIMESTAMPTZ
83
+ ORDER BY ts_recv, seq
84
+ """).df()
85
+
86
+ *_, (ts, ticker, bids, asks) = replay_book(book, levels=3)
87
+ print(bids) # [(0.51, 155253.51), (0.5, 405328.0), (0.49, 15126.0)]
88
+ print(asks) # [(0.52, 960851.07), (0.53, 862817.41), (0.54, 113351.03)]
89
+ ```
90
+
91
+ More runnable examples (a backtest, spreads and depth, calibration) are in [KalshiEye/kalshieye-examples](https://github.com/KalshiEye/kalshieye-examples). Every column is described in the [data dictionary](https://kalshieye.com/docs/datasets/).
@@ -0,0 +1,68 @@
1
+ # kalshieye
2
+
3
+ Python client for Kalshi Eye flat files: daily Parquet for the order book (`book_l2`), 1-second top-10 book snapshots (`book_snap_1s`), top of book (`bbo`), one-minute bars (`bbo_1m`), trades, crypto spot (`spot`), and the reference tables (`markets`, `settlements`, `series`, `incidents`).
4
+
5
+ ```bash
6
+ pip install "kalshieye[pandas]" # or kalshieye[polars], or plain kalshieye
7
+ export KALSHIEYE_API_KEY=clab_... # create one under API keys
8
+ ```
9
+
10
+ ```python
11
+ from kalshieye import Client, replay_book
12
+
13
+ kx = Client()
14
+ kx.datasets() # schemas, coverage, required plan
15
+ kx.estimate(["bbo_1m", "trades"], "2026-10-12", "2026-10-18", series=["KXBTC*"])
16
+
17
+ kx.download(["bbo_1m", "trades"], "2026-10-12", "2026-10-18",
18
+ series=["KXBTC*"], dest="data", progress=True)
19
+
20
+ df = kx.load("bbo_1m", "2026-10-12", "2026-10-18", series="KXBTC15M")
21
+ fills = kx.load("trades", "2026-10-12", "2026-10-12", tickers=["KXBTC15M-26OCT121545-45"])
22
+
23
+ # Rebuild the book from book_l2 rows
24
+ book = kx.load("book_l2", "2026-10-12", "2026-10-12", tickers=["KXBTC15M-26OCT121545-45"])
25
+ for ts, ticker, bids, asks in replay_book(book):
26
+ ...
27
+
28
+ # One event in one zip: book, snapshots, trades, spot, metadata.json, settlement.json
29
+ kx.bundle("KXBTC15M-26OCT121545")
30
+
31
+ # CSV.gz copy of a single file
32
+ f = next(kx.files("trades", "2026-10-12", "2026-10-12", series="KXBTC15M"))
33
+ kx.csv(f["key"])
34
+
35
+ # S3 access (paid plans): a read-only key for aws s3 sync, rclone, DuckDB, boto3
36
+ key = kx.create_s3_key("laptop")
37
+ print(key["snippets"]["aws_cli"])
38
+ ```
39
+
40
+ - Files land under `dest` in the lake layout (`bbo_1m/date=2026-10-12/series=KXBTC15M/...parquet`), so DuckDB, Polars, and Spark can read the folder as a hive-partitioned dataset. `spot` is partitioned by `asset=` instead of `series=`.
41
+ - Every file is checked against its sha256. Files already on disk are skipped and do not count toward your monthly download allowance.
42
+ - `series` accepts wildcards (`KXBTC*`). `categories` filters by archive category (`crypto`, `politics`, ...). `tickers` picks the series files holding those markets, and `load` keeps only their rows.
43
+ - `bundle`, `csv`, and `replay` are built on the server the first time; the client waits for them.
44
+ - Errors raise `kalshieye.ApiError`. Use `err.code` to tell plan limits (`plan_required`, `history_limited`, `download_quota`) apart.
45
+
46
+ ## Without an account
47
+
48
+ `replay_book` works on any `book_l2` rows, including the free one-day sample on Hugging Face ([KalshiEye/kalshi-order-book-sample](https://huggingface.co/datasets/KalshiEye/kalshi-order-book-sample)), which DuckDB reads in place:
49
+
50
+ ```python
51
+ # pip install "kalshieye[pandas]" duckdb
52
+ import duckdb
53
+ from kalshieye import replay_book
54
+
55
+ # Bills vs Rams, YES if Buffalo wins: every book change up to 11 PM Eastern
56
+ book = duckdb.sql("""
57
+ SELECT * FROM 'hf://datasets/KalshiEye/kalshi-order-book-sample/kalshieye-data/book_l2/*/series=KXNFLGAME/*.parquet'
58
+ WHERE ticker = 'KXNFLGAME-26OCT12BUFLAR-BUF'
59
+ AND ts_recv::TIMESTAMPTZ <= '2026-10-13 03:00:00+00'::TIMESTAMPTZ
60
+ ORDER BY ts_recv, seq
61
+ """).df()
62
+
63
+ *_, (ts, ticker, bids, asks) = replay_book(book, levels=3)
64
+ print(bids) # [(0.51, 155253.51), (0.5, 405328.0), (0.49, 15126.0)]
65
+ print(asks) # [(0.52, 960851.07), (0.53, 862817.41), (0.54, 113351.03)]
66
+ ```
67
+
68
+ More runnable examples (a backtest, spreads and depth, calibration) are in [KalshiEye/kalshieye-examples](https://github.com/KalshiEye/kalshieye-examples). Every column is described in the [data dictionary](https://kalshieye.com/docs/datasets/).
@@ -0,0 +1,7 @@
1
+ """Kalshi Eye flat-file client."""
2
+
3
+ from kalshieye.book import Book, replay_book
4
+ from kalshieye.client import ApiError, Client
5
+
6
+ __all__ = ["ApiError", "Book", "Client", "replay_book"]
7
+ __version__ = "0.2.1"
@@ -0,0 +1,97 @@
1
+ """Rebuild Kalshi order books from ``book_l2`` rows.
2
+
3
+ from kalshieye import replay_book
4
+ for ts, ticker, bids, asks in replay_book(df, ticker="KXBTC15M-26OCT121545-45"):
5
+ ... # bids/asks: [(price, size), ...] best first, YES terms
6
+
7
+ Kalshi books are bids only. A NO bid at p is a YES ask at 1 - p, so asks here
8
+ are derived from the NO side.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any, Iterable, Iterator
14
+
15
+
16
+ class Book:
17
+ """One ticker's book. ``apply`` takes book_l2 rows in file order."""
18
+
19
+ def __init__(self) -> None:
20
+ self.yes: dict[float, float] = {}
21
+ self.no: dict[float, float] = {}
22
+ self._snap_ts: Any = None
23
+
24
+ def apply(self, row: dict[str, Any]) -> None:
25
+ side, price, size = row.get("side"), row.get("price"), row.get("size")
26
+ if row.get("is_snapshot"):
27
+ if row.get("ts_recv") != self._snap_ts:
28
+ self.yes.clear()
29
+ self.no.clear()
30
+ self._snap_ts = row.get("ts_recv")
31
+ if side is None or price is None:
32
+ return
33
+ book = self.yes if side == "yes" else self.no
34
+ if size and size > 0:
35
+ book[float(price)] = float(size)
36
+ else:
37
+ book.pop(float(price), None)
38
+ return
39
+ self._snap_ts = None
40
+ if side is None or price is None or size is None:
41
+ return
42
+ book = self.yes if side == "yes" else self.no
43
+ p = float(price)
44
+ # Sizes are fractional; rounding keeps float residue from leaving a phantom level.
45
+ left = round(book.get(p, 0.0) + float(size), 6)
46
+ if left > 0:
47
+ book[p] = left
48
+ else:
49
+ book.pop(p, None)
50
+
51
+ def bids(self, levels: int = 10) -> list[tuple[float, float]]:
52
+ return sorted(self.yes.items(), reverse=True)[:levels]
53
+
54
+ def asks(self, levels: int = 10) -> list[tuple[float, float]]:
55
+ return [(round(1.0 - p, 6), s) for p, s in sorted(self.no.items(), reverse=True)[:levels]]
56
+
57
+ def best(self) -> tuple[float | None, float | None]:
58
+ b = max(self.yes) if self.yes else None
59
+ a = round(1.0 - max(self.no), 6) if self.no else None
60
+ return b, a
61
+
62
+
63
+ def _rows(data: Any) -> Iterable[dict[str, Any]]:
64
+ if hasattr(data, "to_pylist"):
65
+ return data.to_pylist()
66
+ if hasattr(data, "to_dicts"):
67
+ return data.to_dicts()
68
+ if hasattr(data, "to_dict") and hasattr(data, "columns"):
69
+ return data.to_dict("records")
70
+ return data
71
+
72
+
73
+ def replay_book(
74
+ data: Any, *, ticker: str | None = None, levels: int = 10
75
+ ) -> Iterator[tuple[Any, str, list[tuple[float, float]], list[tuple[float, float]]]]:
76
+ """Yield ``(ts_recv, ticker, bids, asks)`` after each book message.
77
+
78
+ ``data`` is a pandas or polars DataFrame, a pyarrow Table, or an iterable of
79
+ dicts with the book_l2 columns. Rows of one message share ts_recv; the state
80
+ is yielded once the message is fully applied.
81
+ """
82
+ books: dict[str, Book] = {}
83
+ pending: tuple[Any, str] | None = None
84
+ want = ticker.upper() if ticker else None
85
+ for row in _rows(data):
86
+ t = str(row.get("ticker") or "")
87
+ if want and t.upper() != want:
88
+ continue
89
+ key = (row.get("ts_recv"), t)
90
+ if pending is not None and key != pending:
91
+ b = books[pending[1]]
92
+ yield pending[0], pending[1], b.bids(levels), b.asks(levels)
93
+ books.setdefault(t, Book()).apply(row)
94
+ pending = key
95
+ if pending is not None:
96
+ b = books[pending[1]]
97
+ yield pending[0], pending[1], b.bids(levels), b.asks(levels)
@@ -0,0 +1,406 @@
1
+ """Small, dependency-free client for the Kalshi Eye data API.
2
+
3
+ from kalshieye import Client
4
+
5
+ kx = Client() # reads KALSHIEYE_API_KEY
6
+ kx.estimate(["bbo_1m"], "2026-10-01", "2026-10-07", series=["KXBTC*"])
7
+ paths = kx.download(["bbo_1m"], "2026-10-01", "2026-10-07", series=["KXBTC*"], dest="data")
8
+ df = kx.load("bbo_1m", "2026-10-01", "2026-10-07", series=["KXBTC15M"]) # pandas
9
+ df = kx.load("trades", "2026-10-12", "2026-10-12", tickers=["KXBTC15M-26OCT121545-45"])
10
+ zip_path = kx.bundle("KXBTC15M-26OCT121545") # one event: book, trades, spot, settlement
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import json
17
+ import os
18
+ import shutil
19
+ import time
20
+ import urllib.error
21
+ import urllib.parse
22
+ import urllib.request
23
+ from pathlib import Path
24
+ from typing import Any, Callable, Iterable, Iterator, Sequence
25
+
26
+ DEFAULT_BASE_URL = "https://kalshieye.com"
27
+ _LINK_BATCH = 200
28
+
29
+
30
+ class ApiError(RuntimeError):
31
+ def __init__(self, status: int, detail: Any) -> None:
32
+ self.status = status
33
+ self.detail = detail
34
+ if isinstance(detail, dict):
35
+ message = detail.get("message") or json.dumps(detail)
36
+ else:
37
+ message = str(detail)
38
+ super().__init__(f"{status}: {message}")
39
+
40
+ @property
41
+ def code(self) -> str | None:
42
+ return self.detail.get("code") if isinstance(self.detail, dict) else None
43
+
44
+
45
+ def _sha256(path: Path) -> str:
46
+ h = hashlib.sha256()
47
+ with path.open("rb") as fh:
48
+ for chunk in iter(lambda: fh.read(1 << 20), b""):
49
+ h.update(chunk)
50
+ return h.hexdigest()
51
+
52
+
53
+ def _as_list(value: str | Iterable[str] | None) -> list[str]:
54
+ if value is None:
55
+ return []
56
+ if isinstance(value, str):
57
+ return [value]
58
+ return [str(v) for v in value]
59
+
60
+
61
+ class Client:
62
+ def __init__(
63
+ self,
64
+ api_key: str | None = None,
65
+ *,
66
+ base_url: str | None = None,
67
+ timeout: float = 120.0,
68
+ poll_interval: float = 2.0,
69
+ ) -> None:
70
+ self.api_key = api_key if api_key is not None else os.environ.get("KALSHIEYE_API_KEY")
71
+ self.base_url = (base_url or os.environ.get("KALSHIEYE_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
72
+ self.timeout = timeout
73
+ self.poll_interval = poll_interval
74
+
75
+ # ------------------------------------------------------------------ transport
76
+
77
+ def _request(self, method: str, path: str, *, params: dict[str, Any] | None = None, body: Any = None) -> Any:
78
+ url = self.base_url + path
79
+ if params:
80
+ pairs: list[tuple[str, str]] = []
81
+ for k, v in params.items():
82
+ if v is None:
83
+ continue
84
+ for item in v if isinstance(v, (list, tuple)) else [v]:
85
+ pairs.append((k, str(item).lower() if isinstance(item, bool) else str(item)))
86
+ if pairs:
87
+ url += "?" + urllib.parse.urlencode(pairs)
88
+ headers = {"Accept": "application/json", "User-Agent": "kalshieye-python/0.2"}
89
+ if self.api_key:
90
+ headers["Authorization"] = f"Bearer {self.api_key}"
91
+ data = None
92
+ if body is not None:
93
+ data = json.dumps(body).encode()
94
+ headers["Content-Type"] = "application/json"
95
+ req = urllib.request.Request(url, data=data, headers=headers, method=method)
96
+ try:
97
+ with urllib.request.urlopen(req, timeout=self.timeout) as res:
98
+ raw = res.read()
99
+ except urllib.error.HTTPError as exc:
100
+ text = exc.read().decode("utf-8", errors="replace")
101
+ try:
102
+ detail = json.loads(text).get("detail", text)
103
+ except (json.JSONDecodeError, AttributeError):
104
+ detail = text
105
+ raise ApiError(exc.code, detail) from None
106
+ return json.loads(raw) if raw else None
107
+
108
+ def _fetch(self, url: str, dest: Path) -> None:
109
+ with urllib.request.urlopen(url, timeout=self.timeout) as res, dest.open("wb") as fh:
110
+ shutil.copyfileobj(res, fh, length=1 << 20)
111
+
112
+ def _need_key(self) -> None:
113
+ if not self.api_key:
114
+ raise ApiError(401, "Set KALSHIEYE_API_KEY or pass api_key= (create a key under API keys).")
115
+
116
+ def _poll(self, call: Callable[[], dict[str, Any]], *, wait: float) -> dict[str, Any]:
117
+ """Repeat ``call`` while the server reports ``building``."""
118
+ deadline = time.monotonic() + wait
119
+ while True:
120
+ out = call()
121
+ status = out.get("status")
122
+ if status == "error":
123
+ raise ApiError(500, out.get("error") or "build failed")
124
+ if status != "building" or time.monotonic() >= deadline:
125
+ return out
126
+ time.sleep(self.poll_interval)
127
+
128
+ def _save(self, f: dict[str, Any], dest: Path) -> Path:
129
+ dest.parent.mkdir(parents=True, exist_ok=True)
130
+ tmp = dest.with_name(dest.name + ".part")
131
+ self._fetch(f["url"], tmp)
132
+ tmp.replace(dest)
133
+ return dest
134
+
135
+ # ------------------------------------------------------------------ catalog
136
+
137
+ def datasets(self) -> list[dict[str, Any]]:
138
+ """Every dataset with columns, coverage, required plan, and sample links."""
139
+ return self._request("GET", "/api/v1/datasets")["datasets"]
140
+
141
+ def dataset(self, dataset_id: str) -> dict[str, Any]:
142
+ return self._request("GET", f"/api/v1/datasets/{dataset_id}")
143
+
144
+ def series(self, dataset_id: str, q: str | None = None, *, limit: int = 500) -> list[dict[str, Any]]:
145
+ """Per-series coverage (days, rows, bytes, first/last date) for a dataset."""
146
+ out: list[dict[str, Any]] = []
147
+ offset = 0
148
+ while True:
149
+ page = self._request(
150
+ "GET",
151
+ f"/api/v1/datasets/{dataset_id}/series",
152
+ params={"q": q, "offset": offset, "limit": min(limit, 500)},
153
+ )
154
+ out.extend(page["series"])
155
+ offset += len(page["series"])
156
+ if not page["series"] or offset >= page["total"]:
157
+ return out
158
+
159
+ def plan(self) -> dict[str, Any]:
160
+ self._need_key()
161
+ return self._request("GET", "/api/v1/plan")
162
+
163
+ # ------------------------------------------------------------------ selection
164
+
165
+ @staticmethod
166
+ def _selection(
167
+ datasets: str | Sequence[str],
168
+ start_date: str,
169
+ end_date: str,
170
+ series: str | Iterable[str] | None,
171
+ categories: str | Iterable[str] | None,
172
+ tickers: str | Iterable[str] | None = None,
173
+ ) -> dict[str, Any]:
174
+ return {
175
+ "datasets": _as_list(datasets),
176
+ "start_date": str(start_date),
177
+ "end_date": str(end_date),
178
+ "series": _as_list(series),
179
+ "categories": _as_list(categories),
180
+ "tickers": [t.upper() for t in _as_list(tickers)],
181
+ }
182
+
183
+ def estimate(
184
+ self,
185
+ datasets: str | Sequence[str],
186
+ start_date: str,
187
+ end_date: str,
188
+ *,
189
+ series: str | Iterable[str] | None = None,
190
+ categories: str | Iterable[str] | None = None,
191
+ tickers: str | Iterable[str] | None = None,
192
+ ) -> dict[str, Any]:
193
+ """Files, rows, and bytes for a selection, plus what your plan and allowance cover."""
194
+ return self._request(
195
+ "POST",
196
+ "/api/v1/requests/estimate",
197
+ body=self._selection(datasets, start_date, end_date, series, categories, tickers),
198
+ )
199
+
200
+ def files(
201
+ self,
202
+ datasets: str | Sequence[str],
203
+ start_date: str,
204
+ end_date: str,
205
+ *,
206
+ series: str | Iterable[str] | None = None,
207
+ categories: str | Iterable[str] | None = None,
208
+ tickers: str | Iterable[str] | None = None,
209
+ page_size: int = 500,
210
+ ) -> Iterator[dict[str, Any]]:
211
+ """Yield file entries (dataset, date, series, key, path, rows, bytes, sha256). No links, no quota.
212
+
213
+ ``tickers`` selects the series files that hold those markets; the files
214
+ themselves hold the whole series.
215
+ """
216
+ self._need_key()
217
+ sel = self._selection(datasets, start_date, end_date, series, categories, tickers)
218
+ cursor: str | None = None
219
+ while True:
220
+ page = self._request(
221
+ "GET",
222
+ "/api/v1/files",
223
+ params={
224
+ "dataset": sel["datasets"],
225
+ "start_date": sel["start_date"],
226
+ "end_date": sel["end_date"],
227
+ "series": sel["series"],
228
+ "category": sel["categories"],
229
+ "ticker": sel["tickers"],
230
+ "cursor": cursor,
231
+ "limit": page_size,
232
+ },
233
+ )
234
+ yield from page["files"]
235
+ cursor = page.get("next_cursor")
236
+ if not cursor:
237
+ return
238
+
239
+ def links(self, keys: Sequence[str]) -> list[dict[str, Any]]:
240
+ """Signed links (valid one hour) for file keys. New files count toward your monthly GB."""
241
+ self._need_key()
242
+ out: list[dict[str, Any]] = []
243
+ for i in range(0, len(keys), _LINK_BATCH):
244
+ out.extend(self._request("POST", "/api/v1/files/links", body={"keys": list(keys[i : i + _LINK_BATCH])})["files"])
245
+ return out
246
+
247
+ def download(
248
+ self,
249
+ datasets: str | Sequence[str],
250
+ start_date: str,
251
+ end_date: str,
252
+ *,
253
+ series: str | Iterable[str] | None = None,
254
+ categories: str | Iterable[str] | None = None,
255
+ tickers: str | Iterable[str] | None = None,
256
+ dest: str | Path = "kalshieye-data",
257
+ progress: bool = False,
258
+ ) -> list[Path]:
259
+ """Download a selection into ``dest`` (lake layout), verify sha256, skip files already there."""
260
+ root = Path(dest)
261
+ entries = list(
262
+ self.files(datasets, start_date, end_date, series=series, categories=categories, tickers=tickers)
263
+ )
264
+ paths: list[Path] = []
265
+ need: list[dict[str, Any]] = []
266
+ for f in entries:
267
+ p = root / f["path"]
268
+ paths.append(p)
269
+ if not (p.is_file() and (not f.get("sha256") or _sha256(p) == f["sha256"])):
270
+ need.append(f)
271
+ for f in self.links([f["key"] for f in need]):
272
+ p = root / f["path"]
273
+ p.parent.mkdir(parents=True, exist_ok=True)
274
+ tmp = p.with_name(p.name + ".part")
275
+ self._fetch(f["url"], tmp)
276
+ if f.get("sha256") and _sha256(tmp) != f["sha256"]:
277
+ tmp.unlink()
278
+ raise ApiError(0, f"checksum mismatch: {f['path']}")
279
+ tmp.replace(p)
280
+ if progress:
281
+ print(f"{f['path']} {f['bytes'] / 1e6:.1f} MB")
282
+ return paths
283
+
284
+ def load(
285
+ self,
286
+ dataset: str,
287
+ start_date: str,
288
+ end_date: str,
289
+ *,
290
+ series: str | Iterable[str] | None = None,
291
+ categories: str | Iterable[str] | None = None,
292
+ tickers: str | Iterable[str] | None = None,
293
+ dest: str | Path = "kalshieye-data",
294
+ engine: str = "pandas",
295
+ ):
296
+ """Download (if needed) and load one dataset: engine is "pandas", "polars", or "arrow".
297
+
298
+ With ``tickers``, only those markets' rows are kept.
299
+ """
300
+ if engine not in ("pandas", "polars", "arrow"):
301
+ raise ValueError("engine must be pandas, polars, or arrow")
302
+ want = sorted({t.upper() for t in _as_list(tickers)})
303
+ paths = self.download(
304
+ dataset, start_date, end_date, series=series, categories=categories, tickers=want or None, dest=dest
305
+ )
306
+ import pyarrow as pa
307
+ import pyarrow.compute as pc
308
+ import pyarrow.parquet as pq
309
+
310
+ tables = []
311
+ for p in paths:
312
+ t = pq.read_table(p)
313
+ if want and "ticker" in t.column_names:
314
+ col = t.column("ticker")
315
+ if pa.types.is_dictionary(col.type):
316
+ col = col.cast(pa.string())
317
+ t = t.filter(pc.is_in(col, value_set=pa.array(want)))
318
+ tables.append(t)
319
+ table = pa.concat_tables(tables, promote_options="default") if tables else pa.table({})
320
+ if engine == "polars":
321
+ import polars as pl
322
+
323
+ return pl.from_arrow(table) if tables else pl.DataFrame()
324
+ return table if engine == "arrow" else table.to_pandas()
325
+
326
+ # ------------------------------------------------------------------ derived files
327
+
328
+ def csv(
329
+ self,
330
+ key: str,
331
+ *,
332
+ tickers: str | Iterable[str] | None = None,
333
+ dest: str | Path = "kalshieye-data",
334
+ wait: float = 600.0,
335
+ ) -> Path:
336
+ """A gzipped CSV copy of one file from ``files()``, optionally cut to ``tickers``."""
337
+ self._need_key()
338
+ body = {"key": key, "tickers": [t.upper() for t in _as_list(tickers)]}
339
+ out = self._poll(lambda: self._request("POST", "/api/v1/files/csv", body=body), wait=wait)
340
+ if out.get("status") != "ready":
341
+ raise ApiError(504, "CSV is still being built; call again later.")
342
+ f = out["file"]
343
+ rel = f["path"][: -len(".parquet")] if f["path"].endswith(".parquet") else f["path"]
344
+ suffix = ".tickers.csv.gz" if body["tickers"] else ".csv.gz"
345
+ return self._save(f, Path(dest) / (rel + suffix))
346
+
347
+ def bundle(self, event: str, *, dest: str | Path = "kalshieye-bundles", wait: float = 900.0) -> Path:
348
+ """Zip of one Kalshi event: book_l2, book_snap_1s, bbo, trades, spot (what your
349
+ plan includes), plus metadata.json and settlement.json."""
350
+ self._need_key()
351
+ eid = event.strip().upper()
352
+ out = self._poll(lambda: self._request("POST", f"/api/v1/events/{urllib.parse.quote(eid)}/bundle"), wait=wait)
353
+ if out.get("status") != "ready":
354
+ raise ApiError(504, "Bundle is still being built; call again later.")
355
+ return self._save(out["file"], Path(dest) / out["file"]["filename"])
356
+
357
+ def replay(self, ticker: str, *, date: str | None = None, wait: float = 300.0) -> dict[str, Any]:
358
+ """Top-10 book frames (1s, thinned to at most 3600) and trades for one market-day."""
359
+ self._need_key()
360
+ params = {"date": date} if date else None
361
+ path = f"/api/v1/markets/{urllib.parse.quote(ticker.strip().upper())}/replay"
362
+ out = self._poll(lambda: self._request("GET", path, params=params), wait=wait)
363
+ if out.get("status") != "ready":
364
+ raise ApiError(504, "Replay is still being prepared; call again later.")
365
+ return out
366
+
367
+ def create_s3_key(self, name: str = "default") -> dict[str, Any]:
368
+ """Create a read-only S3 key for syncing whole datasets (paid plans).
369
+
370
+ Returns endpoint, bucket, access_key_id, secret_access_key (shown once), and
371
+ ready-to-paste snippets for aws, rclone, DuckDB, and boto3.
372
+ """
373
+ self._need_key()
374
+ return self._request("POST", "/api/v1/files/keys", body={"name": name})
375
+
376
+ def s3_keys(self) -> dict[str, Any]:
377
+ """Your S3 keys, with last use and data counted per key."""
378
+ self._need_key()
379
+ return self._request("GET", "/api/v1/files/keys")
380
+
381
+ def revoke_s3_key(self, key_id: str) -> dict[str, Any]:
382
+ self._need_key()
383
+ return self._request("DELETE", f"/api/v1/files/keys/{urllib.parse.quote(key_id)}")
384
+
385
+ # ------------------------------------------------------------------ saved requests
386
+
387
+ def save_request(
388
+ self,
389
+ datasets: str | Sequence[str],
390
+ start_date: str,
391
+ end_date: str,
392
+ *,
393
+ series: str | Iterable[str] | None = None,
394
+ categories: str | Iterable[str] | None = None,
395
+ tickers: str | Iterable[str] | None = None,
396
+ name: str | None = None,
397
+ ) -> dict[str, Any]:
398
+ self._need_key()
399
+ body = self._selection(datasets, start_date, end_date, series, categories, tickers)
400
+ if name:
401
+ body["name"] = name
402
+ return self._request("POST", "/api/v1/requests", body=body)
403
+
404
+ def requests(self, limit: int = 20) -> list[dict[str, Any]]:
405
+ self._need_key()
406
+ return self._request("GET", "/api/v1/requests", params={"limit": limit})["requests"]
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: kalshieye
3
+ Version: 0.2.1
4
+ Summary: Download Kalshi Eye flat files (order book, book snapshots, top of book, bars, trades, spot, markets, settlements) and load them into pandas or Polars.
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://kalshieye.com
7
+ Project-URL: Documentation, https://kalshieye.com/docs/bulk/
8
+ Project-URL: Source, https://github.com/KalshiEye/kalshieye-python
9
+ Keywords: kalshi,prediction markets,order book,market data,parquet
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Intended Audience :: Financial and Insurance Industry
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Topic :: Office/Business :: Financial :: Investment
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Provides-Extra: pandas
18
+ Requires-Dist: pandas>=2.0; extra == "pandas"
19
+ Requires-Dist: pyarrow>=14; extra == "pandas"
20
+ Provides-Extra: polars
21
+ Requires-Dist: polars>=0.20; extra == "polars"
22
+ Dynamic: license-file
23
+
24
+ # kalshieye
25
+
26
+ Python client for Kalshi Eye flat files: daily Parquet for the order book (`book_l2`), 1-second top-10 book snapshots (`book_snap_1s`), top of book (`bbo`), one-minute bars (`bbo_1m`), trades, crypto spot (`spot`), and the reference tables (`markets`, `settlements`, `series`, `incidents`).
27
+
28
+ ```bash
29
+ pip install "kalshieye[pandas]" # or kalshieye[polars], or plain kalshieye
30
+ export KALSHIEYE_API_KEY=clab_... # create one under API keys
31
+ ```
32
+
33
+ ```python
34
+ from kalshieye import Client, replay_book
35
+
36
+ kx = Client()
37
+ kx.datasets() # schemas, coverage, required plan
38
+ kx.estimate(["bbo_1m", "trades"], "2026-10-12", "2026-10-18", series=["KXBTC*"])
39
+
40
+ kx.download(["bbo_1m", "trades"], "2026-10-12", "2026-10-18",
41
+ series=["KXBTC*"], dest="data", progress=True)
42
+
43
+ df = kx.load("bbo_1m", "2026-10-12", "2026-10-18", series="KXBTC15M")
44
+ fills = kx.load("trades", "2026-10-12", "2026-10-12", tickers=["KXBTC15M-26OCT121545-45"])
45
+
46
+ # Rebuild the book from book_l2 rows
47
+ book = kx.load("book_l2", "2026-10-12", "2026-10-12", tickers=["KXBTC15M-26OCT121545-45"])
48
+ for ts, ticker, bids, asks in replay_book(book):
49
+ ...
50
+
51
+ # One event in one zip: book, snapshots, trades, spot, metadata.json, settlement.json
52
+ kx.bundle("KXBTC15M-26OCT121545")
53
+
54
+ # CSV.gz copy of a single file
55
+ f = next(kx.files("trades", "2026-10-12", "2026-10-12", series="KXBTC15M"))
56
+ kx.csv(f["key"])
57
+
58
+ # S3 access (paid plans): a read-only key for aws s3 sync, rclone, DuckDB, boto3
59
+ key = kx.create_s3_key("laptop")
60
+ print(key["snippets"]["aws_cli"])
61
+ ```
62
+
63
+ - Files land under `dest` in the lake layout (`bbo_1m/date=2026-10-12/series=KXBTC15M/...parquet`), so DuckDB, Polars, and Spark can read the folder as a hive-partitioned dataset. `spot` is partitioned by `asset=` instead of `series=`.
64
+ - Every file is checked against its sha256. Files already on disk are skipped and do not count toward your monthly download allowance.
65
+ - `series` accepts wildcards (`KXBTC*`). `categories` filters by archive category (`crypto`, `politics`, ...). `tickers` picks the series files holding those markets, and `load` keeps only their rows.
66
+ - `bundle`, `csv`, and `replay` are built on the server the first time; the client waits for them.
67
+ - Errors raise `kalshieye.ApiError`. Use `err.code` to tell plan limits (`plan_required`, `history_limited`, `download_quota`) apart.
68
+
69
+ ## Without an account
70
+
71
+ `replay_book` works on any `book_l2` rows, including the free one-day sample on Hugging Face ([KalshiEye/kalshi-order-book-sample](https://huggingface.co/datasets/KalshiEye/kalshi-order-book-sample)), which DuckDB reads in place:
72
+
73
+ ```python
74
+ # pip install "kalshieye[pandas]" duckdb
75
+ import duckdb
76
+ from kalshieye import replay_book
77
+
78
+ # Bills vs Rams, YES if Buffalo wins: every book change up to 11 PM Eastern
79
+ book = duckdb.sql("""
80
+ SELECT * FROM 'hf://datasets/KalshiEye/kalshi-order-book-sample/kalshieye-data/book_l2/*/series=KXNFLGAME/*.parquet'
81
+ WHERE ticker = 'KXNFLGAME-26OCT12BUFLAR-BUF'
82
+ AND ts_recv::TIMESTAMPTZ <= '2026-10-13 03:00:00+00'::TIMESTAMPTZ
83
+ ORDER BY ts_recv, seq
84
+ """).df()
85
+
86
+ *_, (ts, ticker, bids, asks) = replay_book(book, levels=3)
87
+ print(bids) # [(0.51, 155253.51), (0.5, 405328.0), (0.49, 15126.0)]
88
+ print(asks) # [(0.52, 960851.07), (0.53, 862817.41), (0.54, 113351.03)]
89
+ ```
90
+
91
+ More runnable examples (a backtest, spreads and depth, calibration) are in [KalshiEye/kalshieye-examples](https://github.com/KalshiEye/kalshieye-examples). Every column is described in the [data dictionary](https://kalshieye.com/docs/datasets/).
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ kalshieye/__init__.py
5
+ kalshieye/book.py
6
+ kalshieye/client.py
7
+ kalshieye.egg-info/PKG-INFO
8
+ kalshieye.egg-info/SOURCES.txt
9
+ kalshieye.egg-info/dependency_links.txt
10
+ kalshieye.egg-info/requires.txt
11
+ kalshieye.egg-info/top_level.txt
12
+ tests/test_book.py
@@ -0,0 +1,7 @@
1
+
2
+ [pandas]
3
+ pandas>=2.0
4
+ pyarrow>=14
5
+
6
+ [polars]
7
+ polars>=0.20
@@ -0,0 +1 @@
1
+ kalshieye
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "kalshieye"
7
+ version = "0.2.1"
8
+ description = "Download Kalshi Eye flat files (order book, book snapshots, top of book, bars, trades, spot, markets, settlements) and load them into pandas or Polars."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ keywords = ["kalshi", "prediction markets", "order book", "market data", "parquet"]
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "Intended Audience :: Financial and Insurance Industry",
16
+ "Intended Audience :: Science/Research",
17
+ "Topic :: Office/Business :: Financial :: Investment",
18
+ ]
19
+ dependencies = []
20
+
21
+ [project.optional-dependencies]
22
+ pandas = ["pandas>=2.0", "pyarrow>=14"]
23
+ polars = ["polars>=0.20"]
24
+
25
+ [project.urls]
26
+ Homepage = "https://kalshieye.com"
27
+ Documentation = "https://kalshieye.com/docs/bulk/"
28
+ Source = "https://github.com/KalshiEye/kalshieye-python"
29
+
30
+ [tool.setuptools]
31
+ packages = ["kalshieye"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,50 @@
1
+ from kalshieye import Book, replay_book
2
+
3
+
4
+ def _row(ts, side, price, size, snapshot=False, ticker="T"):
5
+ return {"ts_recv": ts, "ticker": ticker, "is_snapshot": snapshot, "side": side, "price": price, "size": size}
6
+
7
+
8
+ def test_snapshot_then_deltas():
9
+ rows = [
10
+ _row(0, "yes", 0.40, 100.0, snapshot=True),
11
+ _row(0, "no", 0.55, 50.0, snapshot=True),
12
+ _row(1, "yes", 0.41, 20.0),
13
+ _row(2, "yes", 0.40, -100.0),
14
+ _row(3, "no", 0.55, -10.5),
15
+ ]
16
+ states = list(replay_book(rows))
17
+ assert len(states) == 4 # one per message
18
+ _, ticker, bids, asks = states[-1]
19
+ assert ticker == "T"
20
+ assert bids == [(0.41, 20.0)]
21
+ assert asks == [(0.45, 39.5)] # a NO bid at 0.55 is a YES ask at 0.45
22
+
23
+
24
+ def test_snapshot_replaces_the_book():
25
+ book = Book()
26
+ book.apply(_row(0, "yes", 0.30, 5.0, snapshot=True))
27
+ book.apply(_row(1, "yes", 0.31, 5.0))
28
+ book.apply(_row(2, "yes", 0.20, 7.0, snapshot=True))
29
+ assert book.bids() == [(0.20, 7.0)]
30
+
31
+
32
+ def test_empty_snapshot_clears_the_book():
33
+ book = Book()
34
+ book.apply(_row(0, "yes", 0.30, 5.0, snapshot=True))
35
+ book.apply(_row(1, None, None, None, snapshot=True))
36
+ assert book.best() == (None, None)
37
+
38
+
39
+ def test_float_residue_does_not_leave_a_level():
40
+ # These sizes sum to exactly zero, but adding them as floats leaves about 1.4e-9.
41
+ sizes = [1121596.98, 1628450.55, 1595362.86, 1258899.31, -1569585.22, -4034724.48]
42
+ rows = [_row(0, "yes", 0.52, 10.0, snapshot=True), _row(0, "yes", 0.53, sizes[0], snapshot=True)]
43
+ rows += [_row(i, "yes", 0.53, size) for i, size in enumerate(sizes[1:], start=1)]
44
+ *_, (ts, ticker, bids, asks) = replay_book(rows)
45
+ assert bids == [(0.52, 10.0)]
46
+
47
+
48
+ def test_filters_one_ticker():
49
+ rows = [_row(0, "yes", 0.30, 5.0, snapshot=True, ticker="A"), _row(0, "yes", 0.60, 9.0, snapshot=True, ticker="B")]
50
+ assert [s[1] for s in replay_book(rows, ticker="b")] == ["B"]