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.
- mostlyrightmd_markets-0.1.3/.gitignore +68 -0
- mostlyrightmd_markets-0.1.3/PKG-INFO +40 -0
- mostlyrightmd_markets-0.1.3/README.md +7 -0
- mostlyrightmd_markets-0.1.3/pyproject.toml +79 -0
- mostlyrightmd_markets-0.1.3/src/mostlyright/markets/__init__.py +19 -0
- mostlyrightmd_markets-0.1.3/src/mostlyright/markets/_kalshi_client.py +292 -0
- mostlyrightmd_markets-0.1.3/src/mostlyright/markets/_per_event_station.py +275 -0
- mostlyrightmd_markets-0.1.3/src/mostlyright/markets/_polymarket_client.py +242 -0
- mostlyrightmd_markets-0.1.3/src/mostlyright/markets/_trades_cache.py +241 -0
- mostlyrightmd_markets-0.1.3/src/mostlyright/markets/catalog/__init__.py +20 -0
- mostlyrightmd_markets-0.1.3/src/mostlyright/markets/catalog/kalshi_nhigh.py +89 -0
- mostlyrightmd_markets-0.1.3/src/mostlyright/markets/catalog/kalshi_nlow.py +82 -0
- mostlyrightmd_markets-0.1.3/src/mostlyright/markets/catalog/kalshi_stations.py +123 -0
- mostlyrightmd_markets-0.1.3/src/mostlyright/markets/kalshi_trades.py +421 -0
- mostlyrightmd_markets-0.1.3/src/mostlyright/markets/polymarket.py +774 -0
- mostlyrightmd_markets-0.1.3/src/mostlyright/markets/polymarket_city_citations.py +81 -0
- mostlyrightmd_markets-0.1.3/src/mostlyright/markets/polymarket_city_stations.json +60 -0
- mostlyrightmd_markets-0.1.3/src/mostlyright/markets/polymarket_trades.py +303 -0
- mostlyrightmd_markets-0.1.3/tests/catalog/test_kalshi_stations.py +167 -0
- mostlyrightmd_markets-0.1.3/tests/test_kalshi_trades.py +516 -0
- mostlyrightmd_markets-0.1.3/tests/test_per_event_station.py +495 -0
- mostlyrightmd_markets-0.1.3/tests/test_polymarket_real.py +625 -0
- mostlyrightmd_markets-0.1.3/tests/test_polymarket_trades.py +288 -0
- mostlyrightmd_markets-0.1.3/tests/test_polymarket_us_coverage.py +149 -0
- 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
|
+
]
|