"""Kraken Spot REST client. Verified 2026-10-21 against https://docs.kraken.com/api/docs/guides/spot-rest-auth and https://docs.kraken.com/api/docs/guides/spot-rest-ratelimits: * base URL https://api.kraken.com/0 ; public GET /public/ ; private POST /private/ * API-Sign = base64( HMAC-SHA512( path + SHA256(nonce + POSTDATA), base64decode(secret) ) ) * nonce: strictly increasing uint64 per key (ms timestamp); private call counter 15/20/20 by tier, decaying 0.33/0.5/1 per second; ledger/trade-history calls cost 2 points. * /public/OHLC returns at most 720 most recent candles (older history must be accumulated incrementally or sourced from a licensed dataset) — documented limitation. * AssetPairs `fees`/`fees_maker` are deprecated (empty since 2026-09-08): use /private/TradeVolume. """ from __future__ import annotations import asyncio import base64 import hashlib import hmac import time import urllib.parse from decimal import Decimal from typing import Any import httpx from ktrade.kraken.models import Candle, PairMeta, SystemStatus, Trade from ktrade.logging import get_logger log = get_logger("kraken.rest") OHLC_INTERVALS = (1, 5, 15, 30, 60, 240, 1440, 10080, 21600) OHLC_MAX_ROWS = 720 TIER_LIMITS = {"starter": (15, 0.33), "intermediate": (20, 0.5), "pro": (20, 1.0)} class KrakenAPIError(Exception): def __init__(self, errors: list[str], method: str): super().__init__(f"{method}: {', '.join(errors)}") self.errors = errors self.method = method @property def is_rate_limit(self) -> bool: return any("Rate limit" in e or "Throttled" in e for e in self.errors) @property def is_permission(self) -> bool: return any("Permission denied" in e for e in self.errors) @property def is_nonce(self) -> bool: return any("Invalid nonce" in e for e in self.errors) def sign(path: str, data: dict[str, Any] | str, secret: str, nonce: str | None = None) -> str: """Kraken signature. `data` is either a dict (form-urlencoded, must contain nonce) or the raw JSON body.""" if isinstance(data, dict): nonce = str(data["nonce"]) postdata = urllib.parse.urlencode(data) else: assert nonce is not None postdata = data encoded = (nonce + postdata).encode() message = path.encode() + hashlib.sha256(encoded).digest() mac = hmac.new(base64.b64decode(secret), message, hashlib.sha512) return base64.b64encode(mac.digest()).decode() class NonceSource: """Monotonic nonce (ms) — never goes backwards even if the clock does, and unique within a process.""" def __init__(self) -> None: self._last = 0 self._lock = asyncio.Lock() async def next(self) -> str: async with self._lock: n = int(time.time() * 1000) if n <= self._last: n = self._last + 1 self._last = n return str(n) class CallCounter: """Private REST call counter (points with linear decay) — mirrors Kraken's tiered limiter so we throttle *before* the exchange does.""" def __init__(self, tier: str = "starter", safety: float = 0.8) -> None: self.max_points, self.decay = TIER_LIMITS.get(tier, TIER_LIMITS["starter"]) self.safety = safety self.points = 0.0 self._t = time.monotonic() self._lock = asyncio.Lock() def _refresh(self) -> None: now = time.monotonic() self.points = max(0.0, self.points - (now - self._t) * self.decay) self._t = now async def acquire(self, cost: int = 1) -> None: async with self._lock: self._refresh() budget = self.max_points * self.safety while self.points + cost > budget: wait = (self.points + cost - budget) / self.decay log.info("kraken.ratelimit.wait", seconds=round(wait, 2), points=round(self.points, 2)) await asyncio.sleep(wait) self._refresh() self.points += cost class KrakenREST: def __init__( self, base_url: str = "https://api.kraken.com", api_key: str = "", api_secret: str = "", tier: str = "starter", timeout: float = 15.0, public_min_interval: float = 0.35, ) -> None: self.base_url = base_url.rstrip("/") self.api_key = api_key self.api_secret = api_secret self._client = httpx.AsyncClient(base_url=self.base_url, timeout=timeout, headers={"User-Agent": "ktrade/0.1 (+crypto-trade-bot.app)"}) self._nonce = NonceSource() self._counter = CallCounter(tier) self._pub_lock = asyncio.Lock() self._pub_last = 0.0 self._pub_min_interval = public_min_interval async def aclose(self) -> None: await self._client.aclose() @property def has_credentials(self) -> bool: return bool(self.api_key and self.api_secret) # ---------- transport ---------- async def _public(self, method: str, params: dict[str, Any] | None = None) -> Any: async with self._pub_lock: # gentle pacing of public calls (public limit is undocumented; be polite) dt = time.monotonic() - self._pub_last if dt < self._pub_min_interval: await asyncio.sleep(self._pub_min_interval - dt) self._pub_last = time.monotonic() for attempt in range(4): try: r = await self._client.get(f"/0/public/{method}", params=params or {}) r.raise_for_status() body = r.json() except (httpx.HTTPError, ValueError) as e: if attempt == 3: raise await asyncio.sleep(0.5 * 2**attempt) log.warning("kraken.public.retry", method=method, attempt=attempt, error=str(e)) continue if body.get("error"): err = KrakenAPIError(body["error"], method) if err.is_rate_limit and attempt < 3: await asyncio.sleep(2.0 * 2**attempt) continue raise err return body["result"] raise RuntimeError("unreachable") async def _private(self, method: str, data: dict[str, Any] | None = None, cost: int = 1) -> Any: if not self.has_credentials: raise KrakenAPIError(["EAPI:No credentials configured"], method) await self._counter.acquire(cost) path = f"/0/private/{method}" payload = dict(data or {}) payload["nonce"] = await self._nonce.next() headers = { "API-Key": self.api_key, "API-Sign": sign(path, payload, self.api_secret), "Content-Type": "application/x-www-form-urlencoded", } r = await self._client.post(path, data=payload, headers=headers) r.raise_for_status() body = r.json() if body.get("error"): raise KrakenAPIError(body["error"], method) return body["result"] # ---------- public ---------- async def time(self) -> int: return int((await self._public("Time"))["unixtime"]) async def system_status(self) -> SystemStatus: r = await self._public("SystemStatus") return SystemStatus(status=r["status"], timestamp=r["timestamp"]) async def asset_pairs(self, pairs: list[str] | None = None) -> dict[str, PairMeta]: params = {"pair": ",".join(pairs)} if pairs else None r = await self._public("AssetPairs", params) return {k: PairMeta.from_kraken(k, v) for k, v in r.items()} async def ticker(self, pairs: list[str]) -> dict[str, dict]: return await self._public("Ticker", {"pair": ",".join(pairs)}) async def ohlc(self, pair: str, interval: int = 1, since: int | None = None) -> tuple[list[Candle], int]: if interval not in OHLC_INTERVALS: raise ValueError(f"interval must be one of {OHLC_INTERVALS}") params: dict[str, Any] = {"pair": pair, "interval": interval} if since is not None: params["since"] = since r = await self._public("OHLC", params) last = int(r.pop("last")) rows = next(iter(r.values())) if r else [] candles = [Candle(ts=int(x[0]), open=float(x[1]), high=float(x[2]), low=float(x[3]), close=float(x[4]), vwap=float(x[5]), volume=float(x[6]), count=int(x[7])) for x in rows] return candles, last async def depth(self, pair: str, count: int = 100) -> dict[str, list[tuple[Decimal, Decimal, int]]]: count = max(1, min(500, count)) r = await self._public("Depth", {"pair": pair, "count": count}) book = next(iter(r.values())) return { "asks": [(Decimal(a[0]), Decimal(a[1]), int(a[2])) for a in book["asks"]], "bids": [(Decimal(b[0]), Decimal(b[1]), int(b[2])) for b in book["bids"]], } async def trades(self, pair: str, since: str | None = None, count: int = 1000) -> tuple[list[Trade], str]: params: dict[str, Any] = {"pair": pair, "count": max(1, min(1000, count))} if since: params["since"] = since r = await self._public("Trades", params) last = str(r.pop("last")) rows = next(iter(r.values())) if r else [] out = [ Trade(ts=float(x[2]), price=float(x[0]), volume=float(x[1]), side="buy" if x[3] == "b" else "sell", order_type="market" if x[4] == "m" else "limit", trade_id=int(x[6]) if len(x) > 6 else None) for x in rows ] return out, last # ---------- private (read) ---------- async def balance(self) -> dict[str, Decimal]: r = await self._private("Balance") return {k: Decimal(v) for k, v in r.items()} async def trade_balance(self, asset: str = "ZUSD") -> dict[str, Any]: return await self._private("TradeBalance", {"asset": asset}) async def open_orders(self) -> dict[str, Any]: return await self._private("OpenOrders") async def closed_orders(self, start: int | None = None) -> dict[str, Any]: return await self._private("ClosedOrders", {"start": start} if start else None) async def trades_history(self, start: int | None = None) -> dict[str, Any]: return await self._private("TradesHistory", {"start": start} if start else None, cost=2) async def trade_volume(self, pairs: list[str]) -> dict[str, Any]: return await self._private("TradeVolume", {"pair": ",".join(pairs)}) async def websockets_token(self) -> str: return (await self._private("GetWebSocketsToken"))["token"] # ---------- private (trade) — only reachable through the live adapter, which is gated ---------- async def add_order(self, *, pair: str, side: str, ordertype: str, volume: str, price: str | None = None, cl_ord_id: str | None = None, timeinforce: str = "GTC", oflags: str | None = None, validate: bool = True) -> dict[str, Any]: data: dict[str, Any] = {"pair": pair, "type": side, "ordertype": ordertype, "volume": volume, "timeinforce": timeinforce} if price is not None: data["price"] = price if cl_ord_id: data["cl_ord_id"] = cl_ord_id if oflags: data["oflags"] = oflags if validate: data["validate"] = "true" return await self._private("AddOrder", data) async def cancel_order(self, txid: str | None = None, cl_ord_id: str | None = None) -> dict[str, Any]: data = {"txid": txid} if txid else {"cl_ord_id": cl_ord_id} return await self._private("CancelOrder", data) async def cancel_all(self) -> dict[str, Any]: return await self._private("CancelAll")