mostlyrightmd-markets 0.1.3__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.
Files changed (25) hide show
  1. mostlyrightmd_markets-0.1.3/.gitignore +68 -0
  2. mostlyrightmd_markets-0.1.3/PKG-INFO +40 -0
  3. mostlyrightmd_markets-0.1.3/README.md +7 -0
  4. mostlyrightmd_markets-0.1.3/pyproject.toml +79 -0
  5. mostlyrightmd_markets-0.1.3/src/mostlyright/markets/__init__.py +19 -0
  6. mostlyrightmd_markets-0.1.3/src/mostlyright/markets/_kalshi_client.py +292 -0
  7. mostlyrightmd_markets-0.1.3/src/mostlyright/markets/_per_event_station.py +275 -0
  8. mostlyrightmd_markets-0.1.3/src/mostlyright/markets/_polymarket_client.py +242 -0
  9. mostlyrightmd_markets-0.1.3/src/mostlyright/markets/_trades_cache.py +241 -0
  10. mostlyrightmd_markets-0.1.3/src/mostlyright/markets/catalog/__init__.py +20 -0
  11. mostlyrightmd_markets-0.1.3/src/mostlyright/markets/catalog/kalshi_nhigh.py +89 -0
  12. mostlyrightmd_markets-0.1.3/src/mostlyright/markets/catalog/kalshi_nlow.py +82 -0
  13. mostlyrightmd_markets-0.1.3/src/mostlyright/markets/catalog/kalshi_stations.py +123 -0
  14. mostlyrightmd_markets-0.1.3/src/mostlyright/markets/kalshi_trades.py +421 -0
  15. mostlyrightmd_markets-0.1.3/src/mostlyright/markets/polymarket.py +774 -0
  16. mostlyrightmd_markets-0.1.3/src/mostlyright/markets/polymarket_city_citations.py +81 -0
  17. mostlyrightmd_markets-0.1.3/src/mostlyright/markets/polymarket_city_stations.json +60 -0
  18. mostlyrightmd_markets-0.1.3/src/mostlyright/markets/polymarket_trades.py +303 -0
  19. mostlyrightmd_markets-0.1.3/tests/catalog/test_kalshi_stations.py +167 -0
  20. mostlyrightmd_markets-0.1.3/tests/test_kalshi_trades.py +516 -0
  21. mostlyrightmd_markets-0.1.3/tests/test_per_event_station.py +495 -0
  22. mostlyrightmd_markets-0.1.3/tests/test_polymarket_real.py +625 -0
  23. mostlyrightmd_markets-0.1.3/tests/test_polymarket_trades.py +288 -0
  24. mostlyrightmd_markets-0.1.3/tests/test_polymarket_us_coverage.py +149 -0
  25. mostlyrightmd_markets-0.1.3/tests/test_trades_cache.py +165 -0
@@ -0,0 +1,68 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ *.egg
7
+ *.egg-info/
8
+ build/
9
+ dist/
10
+ .eggs/
11
+
12
+ # Virtual environments
13
+ .venv/
14
+ venv/
15
+ ENV/
16
+
17
+ # Testing / linting
18
+ .pytest_cache/
19
+ .ruff_cache/
20
+ .coverage
21
+ .coverage.*
22
+ htmlcov/
23
+ .tox/
24
+
25
+ # Local cache (tradewinds runtime cache lives in $HOME/.tradewinds/; this catches any test-time fixtures that leak)
26
+ .tradewinds-cache/
27
+
28
+ # Phase 4 CI-05 — drift watchdog populates tests/fixtures/drift/ from the
29
+ # weekly cron. The scripts + README are tracked; the captured parquets
30
+ # and the drift-report are not (rotation is the whole point — git history
31
+ # isn't the storage layer for drift fixtures).
32
+ tests/fixtures/drift/case_*.parquet
33
+ tests/fixtures/drift/drift-report.md
34
+
35
+ # uv — uv.lock IS committed for dev reproducibility across Lane F + Lane V (decision: lockfile-tracked for dev parity)
36
+
37
+ # IDE
38
+ .vscode/
39
+ .idea/
40
+ *.swp
41
+ *.swo
42
+
43
+ # OS
44
+ .DS_Store
45
+ Thumbs.db
46
+ .claude/scheduled_tasks.lock
47
+
48
+ # Planning artifacts — local GSD planning workspace; NOT for public consumption.
49
+ # Untracked since the repo went public (Phase 13 W0 cleanup commit, 2026-05-25).
50
+ # Operators keep these on disk for ongoing GSD workflow; nothing in `.planning/`
51
+ # should be required to build or run the SDK.
52
+ .planning/
53
+
54
+ # Dev-only TODO scratchpad (replaced by GSD TODOS in .planning/)
55
+ TODOS.md
56
+
57
+ # TypeScript / pnpm workspace (packages-ts/)
58
+ node_modules/
59
+ coverage/
60
+ .tsbuildinfo
61
+ packages-ts/*/dist/
62
+ packages-ts/*/coverage/
63
+ # Note: top-level `dist/` and `build/` already covered by Python section above.
64
+
65
+ # TS-W2 Plan 08 drift watchdog — output files are transient, not committed.
66
+ packages-ts/meta/tests/parity/drift/*.json
67
+ packages-ts/meta/tests/parity/drift-report.md
68
+ .claude/
@@ -0,0 +1,40 @@
1
+ Metadata-Version: 2.4
2
+ Name: mostlyrightmd-markets
3
+ Version: 0.1.3
4
+ Summary: Prediction market data for mostlyright: Kalshi (NHIGH/NLOW contract specs in v0.1.0rc1, market metadata in Sprint 0.5), Polymarket (Phase 3.3). Python module: `import mostlyright.markets`.
5
+ Author-email: Robert Tarabcak <tarabcakr@gmail.com>
6
+ License-Expression: MIT
7
+ Keywords: kalshi,polymarket,prediction-markets
8
+ Classifier: Development Status :: 2 - Pre-Alpha
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Requires-Python: >=3.11
14
+ Requires-Dist: httpx>=0.27
15
+ Requires-Dist: jsonschema>=4.21
16
+ Requires-Dist: mostlyrightmd<0.2,>=0.1.0
17
+ Provides-Extra: parquet
18
+ Requires-Dist: pandas<4.0,>=2.2; extra == 'parquet'
19
+ Requires-Dist: pyarrow<24.0,>=17.0; extra == 'parquet'
20
+ Provides-Extra: polars
21
+ Requires-Dist: narwhals<2.0,>=1.20; extra == 'polars'
22
+ Requires-Dist: pandas<4.0,>=2.2; extra == 'polars'
23
+ Requires-Dist: polars<2.0,>=1.0; extra == 'polars'
24
+ Requires-Dist: pyarrow<24.0,>=17.0; extra == 'polars'
25
+ Provides-Extra: polymarket
26
+ Requires-Dist: mostlyrightmd-weather<0.2,>=0.1.0; extra == 'polymarket'
27
+ Requires-Dist: pandas<4.0,>=2.2; extra == 'polymarket'
28
+ Provides-Extra: trades
29
+ Requires-Dist: filelock<4,>=3.20; extra == 'trades'
30
+ Requires-Dist: pandas<4.0,>=2.2; extra == 'trades'
31
+ Requires-Dist: pyarrow<24.0,>=17.0; extra == 'trades'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # mostlyrightmd-markets
35
+
36
+ Prediction market data for `mostlyright`: Kalshi, Polymarket.
37
+
38
+ 🚧 **v0.0.1 is a placeholder.** Real Kalshi metadata client lands in v0.1.0 (Sprint 0.5) — port from `therminal/therminal-ingest/src/sources/kalshi/` (TypeScript reference). Public endpoints, no auth required.
39
+
40
+ See the workspace [README](../../README.md) and [CLAUDE.md](../../CLAUDE.md) for project rules.
@@ -0,0 +1,7 @@
1
+ # mostlyrightmd-markets
2
+
3
+ Prediction market data for `mostlyright`: Kalshi, Polymarket.
4
+
5
+ 🚧 **v0.0.1 is a placeholder.** Real Kalshi metadata client lands in v0.1.0 (Sprint 0.5) — port from `therminal/therminal-ingest/src/sources/kalshi/` (TypeScript reference). Public endpoints, no auth required.
6
+
7
+ See the workspace [README](../../README.md) and [CLAUDE.md](../../CLAUDE.md) for project rules.
@@ -0,0 +1,79 @@
1
+ [project]
2
+ name = "mostlyrightmd-markets"
3
+ version = "0.1.3"
4
+ description = "Prediction market data for mostlyright: Kalshi (NHIGH/NLOW contract specs in v0.1.0rc1, market metadata in Sprint 0.5), Polymarket (Phase 3.3). Python module: `import mostlyright.markets`."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ authors = [{ name = "Robert Tarabcak", email = "tarabcakr@gmail.com" }]
8
+ requires-python = ">=3.11"
9
+ keywords = ["kalshi", "polymarket", "prediction-markets"]
10
+ classifiers = [
11
+ "Development Status :: 2 - Pre-Alpha",
12
+ "License :: OSI Approved :: MIT License",
13
+ "Programming Language :: Python :: 3.11",
14
+ "Programming Language :: Python :: 3.12",
15
+ "Programming Language :: Python :: 3.13",
16
+ ]
17
+ dependencies = [
18
+ # PKG-03: pin mostlyright (core) so users cannot mix markets/core releases
19
+ # across the canonical-schema boundary. The Kalshi resolvers use
20
+ # mostlyright.markets.catalog (this package) but the wider settlement
21
+ # pipeline reads mostlyright.core.* schemas; a stale core would silently
22
+ # serve the wrong column set.
23
+ "mostlyrightmd>=0.1.0,<0.2",
24
+ "httpx>=0.27",
25
+ "jsonschema>=4.21",
26
+ ]
27
+
28
+ [project.optional-dependencies]
29
+ # Mirrors the core `parquet` extra caps (PKG-05, PKG-06) so a future install
30
+ # cannot silently drift across the parity-critical pandas/pyarrow boundary.
31
+ parquet = [
32
+ "pyarrow>=17.0,<24.0",
33
+ "pandas>=2.2,<4.0",
34
+ ]
35
+ # Phase 3.3: Polymarket discovery + settlement. polymarket_discover()
36
+ # returns a DataFrame (pandas required); polymarket_settle() calls
37
+ # daily_extremes() which imports mostlyright.weather.cache (sibling
38
+ # weather package required). Codex iter-1 P2: without these the
39
+ # polymarket_* public surface raises ModuleNotFoundError on every call.
40
+ # Users opt in via `pip install mostlyrightmd-markets[polymarket]`.
41
+ polymarket = [
42
+ "pandas>=2.2,<4.0",
43
+ "mostlyrightmd-weather>=0.1.0,<0.2",
44
+ ]
45
+ # Phase 9: trade-history surface. kalshi_trades + polymarket_trades return
46
+ # DataFrames (pandas required); _trades_cache writes parquet (pyarrow
47
+ # required) with FileLock-guarded atomic writes (filelock required).
48
+ # Phase 9 iter-5 codex HIGH: the install hints in kalshi_trades.py /
49
+ # polymarket_trades.py / _trades_cache.py reference this extra by name;
50
+ # the extra was missing from pyproject.toml, so the advertised
51
+ # `pip install mostlyrightmd-markets[trades]` would fail with
52
+ # `WARNING: mostlyrightmd-markets X does not provide the extra 'trades'`
53
+ # and a clean install would still raise `ModuleNotFoundError: filelock`
54
+ # the first time the cache is hit. Pinned to the same parity-critical
55
+ # pandas/pyarrow boundaries as `[parquet]`/`[polymarket]` (PKG-05/06).
56
+ # NB: pandas pinned to <4.0 to match Phase 6's pandas-3 broadening.
57
+ trades = [
58
+ "pandas>=2.2,<4.0",
59
+ "pyarrow>=17.0,<24.0",
60
+ "filelock>=3.20,<4",
61
+ ]
62
+ # Phase 6 (POLARS-05): mirror mostlyrightmd[polars] so polymarket_discover
63
+ # can return polars frames via the `backend="polars"` kwarg.
64
+ # pandas required for the boundary shim's polars↔pandas conversion
65
+ # (codex iter-3 P2 fix). pyarrow required by pl.from_pandas/to_pandas
66
+ # for non-trivial dtypes (codex iter-5 P1 fix).
67
+ polars = [
68
+ "polars>=1.0,<2.0",
69
+ "narwhals>=1.20,<2.0",
70
+ "pandas>=2.2,<4.0",
71
+ "pyarrow>=17.0,<24.0",
72
+ ]
73
+
74
+ [build-system]
75
+ requires = ["hatchling"]
76
+ build-backend = "hatchling.build"
77
+
78
+ [tool.hatch.build.targets.wheel]
79
+ packages = ["src/mostlyright"]
@@ -0,0 +1,19 @@
1
+ """mostlyright.markets — prediction market data (Kalshi, Polymarket).
2
+
3
+ v0.0.1 is a PLACEHOLDER only. Real functionality lands in v0.1.0 (Sprint 0.5)
4
+ when Lane V ports the Kalshi metadata client from
5
+ ``therminal/therminal-ingest/src/sources/kalshi/`` (TypeScript reference)
6
+ using endpoints documented in ``therminal/research/notes/research-kalshi-api.md``
7
+ (no auth required for public market data).
8
+
9
+ Sprint 0 ships ONLY ``mostlyright`` + ``mostlyrightmd-weather`` at v0.1.0;
10
+ ``mostlyrightmd-markets`` stays at v0.0.1 placeholder until Sprint 0.5.
11
+
12
+ Roadmap:
13
+ - Sprint 0.5: ``mostlyright.markets.kalshi.{series, events, markets, candles, research_by_market}``
14
+ - Sprint 3: ``mostlyright.markets.polymarket.*`` (port from
15
+ ``monorepo-v0.14.1/src/mostlyright/_polymarket.py``)
16
+ """
17
+
18
+ __version__ = "0.0.1"
19
+ __all__ = ["__version__"]
@@ -0,0 +1,292 @@
1
+ """Kalshi public REST client — Phase 9.
2
+
3
+ Public read-only Kalshi market-data API at
4
+ ``https://api.elections.kalshi.com/trade-api/v2``. No auth required, no
5
+ API key, no key registration — the candlestick, trades, and orderbook
6
+ endpoints are documented as public.
7
+
8
+ Kalshi documents a 10 req/sec rate limit for public endpoints (see
9
+ ``.planning/research/MARKETS-RATE-LIMITS.md``). The 0.1s per-request
10
+ polite floor matches that ceiling exactly without throttling normal
11
+ backtest runs.
12
+
13
+ The module is deliberately narrow — only what
14
+ :mod:`mostlyright.markets.kalshi_trades` needs for v0.2 candles + fills +
15
+ orderbook. Authenticated endpoints (orders, positions, account state)
16
+ are out of scope and intentionally not exposed.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ import time
23
+ from typing import Any
24
+
25
+ import httpx
26
+
27
+ log = logging.getLogger(__name__)
28
+
29
+
30
+ #: Kalshi public REST endpoint base URL.
31
+ KALSHI_API_BASE: str = "https://api.elections.kalshi.com/trade-api/v2"
32
+
33
+
34
+ #: Per-request polite floor. Kalshi documents 10 req/sec ceiling for
35
+ #: public endpoints; 0.1s spaces requests to exactly match without
36
+ #: tripping the 429 burst threshold. Tests pass ``sleep_between=0`` to
37
+ #: skip.
38
+ _REQUEST_DELAY_S: float = 0.1
39
+
40
+
41
+ #: Per-page batch size for paginated trade endpoints.
42
+ _TRADES_PAGE_LIMIT: int = 1000
43
+
44
+
45
+ #: HTTP status codes that warrant a retry (transient).
46
+ _TRANSIENT_CODES: frozenset[int] = frozenset({429, 500, 502, 503, 504})
47
+
48
+
49
+ #: Bounded retry budget per request.
50
+ _MAX_RETRIES: int = 3
51
+
52
+
53
+ #: Base exponential backoff delay (doubled each retry).
54
+ _BASE_DELAY: float = 1.0
55
+
56
+
57
+ #: Upper cap on the per-attempt sleep induced by a server ``Retry-After``
58
+ #: header. A hostile or buggy upstream returning ``Retry-After: 86400``
59
+ #: would otherwise hang the SDK for ~24h per retry; ~2 retries → 48h.
60
+ #: 120s = 2-minute ceiling matches the AWS SDK default and is more than
61
+ #: enough headroom for any legitimate Kalshi rate-limit backoff hint.
62
+ #: Iter-2 python-architect HIGH.
63
+ _MAX_RETRY_AFTER_S: float = 120.0
64
+
65
+
66
+ #: Safety cap on total pages walked per `fetch_trades` call. 10k pages *
67
+ #: 1000 trades/page = 10M trades, well beyond any sane backtest scope.
68
+ _MAX_TRADES_PAGES: int = 10_000
69
+
70
+
71
+ #: User-Agent banner. Sites occasionally block blank UAs as bot traffic.
72
+ _USER_AGENT: str = "mostlyrightmd-markets/0.2 (+https://github.com/mostlyrightmd/mostlyright-sdk)"
73
+
74
+
75
+ def _request(
76
+ path: str,
77
+ *,
78
+ params: dict[str, Any] | None = None,
79
+ client: httpx.Client | None = None,
80
+ sleep_between: float | None = None,
81
+ timeout: float = 30.0,
82
+ ) -> Any:
83
+ """GET ``path`` and return decoded JSON.
84
+
85
+ Retries 429/5xx with exponential backoff. 4xx (other than 429)
86
+ surfaces immediately so callers see permanent errors (bad ticker,
87
+ bad params) rather than wasting the retry budget.
88
+
89
+ Args:
90
+ path: Path under ``KALSHI_API_BASE`` (must start with ``/``).
91
+ params: Optional query parameters.
92
+ client: Optional ``httpx.Client`` for connection reuse.
93
+ sleep_between: Per-request polite sleep. ``None`` uses the
94
+ default 0.1s floor; tests pass ``0``.
95
+ timeout: Per-request httpx timeout in seconds.
96
+
97
+ Returns:
98
+ Parsed JSON payload (``dict`` or ``list``).
99
+ """
100
+ if sleep_between is None:
101
+ sleep_between = _REQUEST_DELAY_S
102
+ url = f"{KALSHI_API_BASE}{path}"
103
+ headers = {"User-Agent": _USER_AGENT, "Accept": "application/json"}
104
+ owns_client = client is None
105
+ if client is None:
106
+ client = httpx.Client(timeout=timeout)
107
+ try:
108
+ delay = _BASE_DELAY
109
+ for attempt in range(_MAX_RETRIES):
110
+ response = client.get(url, params=params, headers=headers)
111
+ if response.status_code in _TRANSIENT_CODES and attempt < _MAX_RETRIES - 1:
112
+ # Architect iter-1 CRITICAL: honor the upstream Retry-After
113
+ # header if present. RFC 7231 §7.1.3 allows either an int
114
+ # (seconds) or an HTTP-date; we honor the integer form (Kalshi
115
+ # documents seconds). Use max(retry_after, delay) so a
116
+ # smaller server hint doesn't shorten our exponential
117
+ # backoff and lose monotonicity.
118
+ retry_after = _parse_retry_after_seconds(response.headers.get("Retry-After"))
119
+ # Iter-2 python-architect HIGH: cap Retry-After at
120
+ # _MAX_RETRY_AFTER_S so a hostile/buggy upstream cannot
121
+ # pin the SDK in time.sleep() for arbitrary durations.
122
+ if retry_after is not None:
123
+ retry_after = min(retry_after, _MAX_RETRY_AFTER_S)
124
+ sleep_s = max(retry_after, delay) if retry_after is not None else delay
125
+ log.warning(
126
+ "kalshi HTTP %d for %s, retry %d/%d in %.1fs%s",
127
+ response.status_code,
128
+ url,
129
+ attempt + 1,
130
+ _MAX_RETRIES,
131
+ sleep_s,
132
+ f" (Retry-After={retry_after}s)" if retry_after is not None else "",
133
+ )
134
+ time.sleep(sleep_s)
135
+ delay *= 2
136
+ continue
137
+ response.raise_for_status()
138
+ if sleep_between > 0:
139
+ time.sleep(sleep_between)
140
+ return response.json()
141
+ # Loop only exits via `continue` (transient + budget remaining) or
142
+ # `return`. Reaching here means the final attempt was transient and
143
+ # raise_for_status didn't fire — defensive RuntimeError.
144
+ raise RuntimeError( # pragma: no cover — unreachable
145
+ f"kalshi {url}: exhausted retries without raising"
146
+ )
147
+ finally:
148
+ if owns_client:
149
+ client.close()
150
+
151
+
152
+ def fetch_candlesticks(
153
+ ticker: str,
154
+ *,
155
+ start_ts: int,
156
+ end_ts: int,
157
+ period_interval: int,
158
+ client: httpx.Client | None = None,
159
+ sleep_between: float | None = None,
160
+ ) -> list[dict[str, Any]]:
161
+ """Fetch OHLCV candlesticks for a Kalshi market.
162
+
163
+ Args:
164
+ ticker: Full market ticker (e.g. ``KXHIGHNY-25MAY26-T79``). The
165
+ series ticker is parsed as the prefix before the first ``-``.
166
+ start_ts, end_ts: UNIX timestamps (seconds) bounding the window.
167
+ period_interval: Bucket size in MINUTES (Kalshi documents 1, 60, 1440).
168
+ client: Optional ``httpx.Client``.
169
+ sleep_between: Per-request polite sleep override.
170
+
171
+ Returns:
172
+ List of candle dicts as returned by Kalshi (shape:
173
+ ``{"end_period_ts": int, "price": {"open": int, "high": int,
174
+ "low": int, "close": int}, "volume": int, "open_interest": int}``).
175
+ Empty list when no candles in the window.
176
+ """
177
+ if "-" not in ticker:
178
+ raise ValueError(f"kalshi ticker must contain '-' to derive series prefix; got {ticker!r}")
179
+ series = ticker.split("-", 1)[0]
180
+ path = f"/series/{series}/markets/{ticker}/candlesticks"
181
+ params = {
182
+ "start_ts": start_ts,
183
+ "end_ts": end_ts,
184
+ "period_interval": period_interval,
185
+ }
186
+ payload = _request(path, params=params, client=client, sleep_between=sleep_between)
187
+ candles = payload.get("candlesticks") if isinstance(payload, dict) else None
188
+ return list(candles) if candles else []
189
+
190
+
191
+ def fetch_trades(
192
+ ticker: str,
193
+ *,
194
+ min_ts: int | None = None,
195
+ max_ts: int | None = None,
196
+ limit: int = _TRADES_PAGE_LIMIT,
197
+ client: httpx.Client | None = None,
198
+ sleep_between: float | None = None,
199
+ max_pages: int = _MAX_TRADES_PAGES,
200
+ ) -> list[dict[str, Any]]:
201
+ """Fetch historical fills for ``ticker``, paginated via cursor.
202
+
203
+ The cursor field is documented but its exact name varies — we
204
+ forward whatever key the upstream returned. When the cursor is
205
+ empty or missing, pagination terminates.
206
+
207
+ Args:
208
+ ticker: Full market ticker.
209
+ min_ts, max_ts: Optional UNIX timestamp bounds.
210
+ limit: Per-page count (default 1000, Kalshi's documented max).
211
+ client: Optional ``httpx.Client``.
212
+ sleep_between: Per-request polite sleep override.
213
+ max_pages: Safety cap on total pages walked. Raises
214
+ :class:`RuntimeError` if exceeded.
215
+
216
+ Returns:
217
+ List of trade dicts.
218
+ """
219
+ path = "/markets/trades"
220
+ out: list[dict[str, Any]] = []
221
+ cursor: str | None = None
222
+ pages = 0
223
+ while True:
224
+ params: dict[str, Any] = {"ticker": ticker, "limit": limit}
225
+ if min_ts is not None:
226
+ params["min_ts"] = min_ts
227
+ if max_ts is not None:
228
+ params["max_ts"] = max_ts
229
+ if cursor:
230
+ params["cursor"] = cursor
231
+ payload = _request(path, params=params, client=client, sleep_between=sleep_between)
232
+ trades = payload.get("trades") if isinstance(payload, dict) else None
233
+ if trades:
234
+ out.extend(trades)
235
+ cursor = (
236
+ payload.get("cursor") if isinstance(payload, dict) else None # type: ignore[arg-type]
237
+ )
238
+ pages += 1
239
+ if not cursor or not trades:
240
+ break
241
+ if pages >= max_pages:
242
+ raise RuntimeError(
243
+ f"kalshi fetch_trades({ticker!r}) exceeded max_pages={max_pages}; "
244
+ "narrow the window via min_ts/max_ts or raise the cap"
245
+ )
246
+ return out
247
+
248
+
249
+ def fetch_orderbook(
250
+ ticker: str,
251
+ *,
252
+ depth: int = 50,
253
+ client: httpx.Client | None = None,
254
+ sleep_between: float | None = None,
255
+ ) -> dict[str, Any]:
256
+ """Fetch current orderbook snapshot for ``ticker``."""
257
+ if depth < 1 or depth > 1000:
258
+ raise ValueError(f"depth out of range [1, 1000]: {depth}")
259
+ path = f"/markets/{ticker}/orderbook"
260
+ payload = _request(path, params={"depth": depth}, client=client, sleep_between=sleep_between)
261
+ if not isinstance(payload, dict):
262
+ raise RuntimeError(
263
+ f"kalshi orderbook for {ticker!r}: expected dict payload, got {type(payload).__name__}"
264
+ )
265
+ return payload
266
+
267
+
268
+ def _parse_retry_after_seconds(value: str | None) -> float | None:
269
+ """Parse an HTTP ``Retry-After`` header into seconds.
270
+
271
+ RFC 7231 §7.1.3 allows either delta-seconds (int) or an HTTP-date.
272
+ We honor the int form only — Kalshi documents seconds. HTTP-date is
273
+ rare in 429 contexts and not worth a date parser. Negative / unparseable
274
+ values return ``None`` so the caller falls back to its own backoff.
275
+ """
276
+ if not value:
277
+ return None
278
+ try:
279
+ s = float(value.strip())
280
+ except (TypeError, ValueError):
281
+ return None
282
+ if s < 0 or not (s == s) or s == float("inf"):
283
+ return None
284
+ return s
285
+
286
+
287
+ __all__ = [
288
+ "KALSHI_API_BASE",
289
+ "fetch_candlesticks",
290
+ "fetch_orderbook",
291
+ "fetch_trades",
292
+ ]