diff --git a/kalshi_client.py b/kalshi_client.py new file mode 100644 index 0000000..d1e5180 --- /dev/null +++ b/kalshi_client.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +import base64 +import datetime as dt +import os +from dataclasses import dataclass +from typing import Any, Dict, Optional +from urllib.parse import urljoin + +import requests +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import padding + + +@dataclass(frozen=True) +class KalshiConfig: + env: str # "demo" or "prod" + + +class KalshiClient: + """ + Minimal Kalshi REST client (public market data + authenticated trading). + Auth scheme: KALSHI-ACCESS-KEY, KALSHI-ACCESS-TIMESTAMP (ms), KALSHI-ACCESS-SIGNATURE + where signature is RSA-PSS-SHA256 of: timestamp + METHOD + path_without_query. :contentReference[oaicite:1]{index=1} + """ + + def __init__(self, cfg: KalshiConfig, session: Optional[requests.Session] = None): + self.cfg = cfg + self.session = session or requests.Session() + + # Base URL differs for demo vs prod per Kalshi quick-start docs. :contentReference[oaicite:2]{index=2} + if cfg.env.lower() == "demo": + self.base_url = "https://demo-api.kalshi.co" + elif cfg.env.lower() in ("prod", "production"): + self.base_url = "https://api.kalshi.com" + else: + raise ValueError("KALSHI_ENV must be 'demo' or 'prod'") + + # Auth fields are optional in paper mode; only required when placing orders + self.api_key_id = os.getenv("KALSHI_API_KEY_ID") + self.private_key_path = os.getenv("KALSHI_PRIVATE_KEY_PATH") + self._private_key = None + + def _load_private_key(self) -> None: + if self._private_key is not None: + return + if not self.private_key_path: + raise RuntimeError("Missing KALSHI_PRIVATE_KEY_PATH (did you source .env?)") + with open(self.private_key_path, "rb") as f: + self._private_key = serialization.load_pem_private_key(f.read(), password=None) + + def _timestamp_ms(self) -> str: + return str(int(dt.datetime.now(dt.timezone.utc).timestamp() * 1000)) + + def _sign(self, timestamp_ms: str, method: str, path: str) -> str: + """ + Sign timestamp + METHOD + path_without_query using RSA-PSS(SHA256), return base64 signature. + :contentReference[oaicite:3]{index=3} + """ + self._load_private_key() + assert self._private_key is not None + + path_wo_query = path.split("?", 1)[0] + msg = f"{timestamp_ms}{method.upper()}{path_wo_query}".encode("utf-8") + + sig = self._private_key.sign( + msg, + padding.PSS( + mgf=padding.MGF1(hashes.SHA256()), + salt_length=padding.PSS.DIGEST_LENGTH, + ), + hashes.SHA256(), + ) + return base64.b64encode(sig).decode("utf-8") + + def _auth_headers(self, method: str, path: str) -> Dict[str, str]: + if not self.api_key_id: + raise RuntimeError("Missing KALSHI_API_KEY_ID (did you source .env?)") + ts = self._timestamp_ms() + sig = self._sign(ts, method, path) + return { + "KALSHI-ACCESS-KEY": self.api_key_id, + "KALSHI-ACCESS-TIMESTAMP": ts, + "KALSHI-ACCESS-SIGNATURE": sig, + } + + def get_public(self, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + url = urljoin(self.base_url, path) + r = self.session.get(url, params=params, timeout=20) + r.raise_for_status() + return r.json() + + def get_authed(self, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + url = urljoin(self.base_url, path) + headers = self._auth_headers("GET", path) + r = self.session.get(url, headers=headers, params=params, timeout=20) + r.raise_for_status() + return r.json() + + def post_authed(self, path: str, json_body: Dict[str, Any]) -> Dict[str, Any]: + url = urljoin(self.base_url, path) + headers = self._auth_headers("POST", path) + headers["Content-Type"] = "application/json" + r = self.session.post(url, headers=headers, json=json_body, timeout=20) + r.raise_for_status() + return r.json() + + # Convenience wrappers + def list_open_markets(self, limit: int = 1000) -> Dict[str, Any]: + # public market data endpoint per quick-start :contentReference[oaicite:4]{index=4} + return self.get_public("/trade-api/v2/markets", params={"status": "open", "limit": str(limit)}) + + def get_market(self, ticker: str) -> Dict[str, Any]: + return self.get_public(f"/trade-api/v2/markets/{ticker}") + + def create_order(self, order_data: Dict[str, Any]) -> Dict[str, Any]: + # POST /trade-api/v2/portfolio/orders per quick-start :contentReference[oaicite:5]{index=5} + return self.post_authed("/trade-api/v2/portfolio/orders", order_data)