spb/crypto-trade-bot
Public
Python 54.7%
TypeScript 42.5%
SQL 1.9%
1"""Kraken Spot REST client.23Verified 2026-10-21 against https://docs.kraken.com/api/docs/guides/spot-rest-auth and4https://docs.kraken.com/api/docs/guides/spot-rest-ratelimits:5 * base URL https://api.kraken.com/0 ; public GET /public/<Method> ; private POST /private/<Method>6 * API-Sign = base64( HMAC-SHA512( path + SHA256(nonce + POSTDATA), base64decode(secret) ) )7 * nonce: strictly increasing uint64 per key (ms timestamp); private call counter 15/20/20 by tier,8 decaying 0.33/0.5/1 per second; ledger/trade-history calls cost 2 points.9 * /public/OHLC returns at most 720 most recent candles (older history must be accumulated incrementally10 or sourced from a licensed dataset) — documented limitation.11 * AssetPairs `fees`/`fees_maker` are deprecated (empty since 2026-09-08): use /private/TradeVolume.12"""1314from __future__ import annotations1516import asyncio17import base6418import hashlib19import hmac20import time21import urllib.parse22from decimal import Decimal23from typing import Any2425import httpx2627from ktrade.kraken.models import Candle, PairMeta, SystemStatus, Trade28from ktrade.logging import get_logger2930log = get_logger("kraken.rest")3132OHLC_INTERVALS = (1, 5, 15, 30, 60, 240, 1440, 10080, 21600)33OHLC_MAX_ROWS = 72034TIER_LIMITS = {"starter": (15, 0.33), "intermediate": (20, 0.5), "pro": (20, 1.0)}353637class KrakenAPIError(Exception):38 def __init__(self, errors: list[str], method: str):39 super().__init__(f"{method}: {', '.join(errors)}")40 self.errors = errors41 self.method = method4243 @property44 def is_rate_limit(self) -> bool:45 return any("Rate limit" in e or "Throttled" in e for e in self.errors)4647 @property48 def is_permission(self) -> bool:49 return any("Permission denied" in e for e in self.errors)5051 @property52 def is_nonce(self) -> bool:53 return any("Invalid nonce" in e for e in self.errors)545556def sign(path: str, data: dict[str, Any] | str, secret: str, nonce: str | None = None) -> str:57 """Kraken signature. `data` is either a dict (form-urlencoded, must contain nonce) or the raw JSON body."""58 if isinstance(data, dict):59 nonce = str(data["nonce"])60 postdata = urllib.parse.urlencode(data)61 else:62 assert nonce is not None63 postdata = data64 encoded = (nonce + postdata).encode()65 message = path.encode() + hashlib.sha256(encoded).digest()66 mac = hmac.new(base64.b64decode(secret), message, hashlib.sha512)67 return base64.b64encode(mac.digest()).decode()686970class NonceSource:71 """Monotonic nonce (ms) — never goes backwards even if the clock does, and unique within a process."""7273 def __init__(self) -> None:74 self._last = 075 self._lock = asyncio.Lock()7677 async def next(self) -> str:78 async with self._lock:79 n = int(time.time() * 1000)80 if n <= self._last:81 n = self._last + 182 self._last = n83 return str(n)848586class CallCounter:87 """Private REST call counter (points with linear decay) — mirrors Kraken's tiered limiter so we88 throttle *before* the exchange does."""8990 def __init__(self, tier: str = "starter", safety: float = 0.8) -> None:91 self.max_points, self.decay = TIER_LIMITS.get(tier, TIER_LIMITS["starter"])92 self.safety = safety93 self.points = 0.094 self._t = time.monotonic()95 self._lock = asyncio.Lock()9697 def _refresh(self) -> None:98 now = time.monotonic()99 self.points = max(0.0, self.points - (now - self._t) * self.decay)100 self._t = now101102 async def acquire(self, cost: int = 1) -> None:103 async with self._lock:104 self._refresh()105 budget = self.max_points * self.safety106 while self.points + cost > budget:107 wait = (self.points + cost - budget) / self.decay108 log.info("kraken.ratelimit.wait", seconds=round(wait, 2), points=round(self.points, 2))109 await asyncio.sleep(wait)110 self._refresh()111 self.points += cost112113114class KrakenREST:115 def __init__(116 self,117 base_url: str = "https://api.kraken.com",118 api_key: str = "",119 api_secret: str = "",120 tier: str = "starter",121 timeout: float = 15.0,122 public_min_interval: float = 0.35,123 ) -> None:124 self.base_url = base_url.rstrip("/")125 self.api_key = api_key126 self.api_secret = api_secret127 self._client = httpx.AsyncClient(base_url=self.base_url, timeout=timeout, headers={"User-Agent": "ktrade/0.1 (+crypto-trade-bot.app)"})128 self._nonce = NonceSource()129 self._counter = CallCounter(tier)130 self._pub_lock = asyncio.Lock()131 self._pub_last = 0.0132 self._pub_min_interval = public_min_interval133134 async def aclose(self) -> None:135 await self._client.aclose()136137 @property138 def has_credentials(self) -> bool:139 return bool(self.api_key and self.api_secret)140141 # ---------- transport ----------142 async def _public(self, method: str, params: dict[str, Any] | None = None) -> Any:143 async with self._pub_lock: # gentle pacing of public calls (public limit is undocumented; be polite)144 dt = time.monotonic() - self._pub_last145 if dt < self._pub_min_interval:146 await asyncio.sleep(self._pub_min_interval - dt)147 self._pub_last = time.monotonic()148 for attempt in range(4):149 try:150 r = await self._client.get(f"/0/public/{method}", params=params or {})151 r.raise_for_status()152 body = r.json()153 except (httpx.HTTPError, ValueError) as e:154 if attempt == 3:155 raise156 await asyncio.sleep(0.5 * 2**attempt)157 log.warning("kraken.public.retry", method=method, attempt=attempt, error=str(e))158 continue159 if body.get("error"):160 err = KrakenAPIError(body["error"], method)161 if err.is_rate_limit and attempt < 3:162 await asyncio.sleep(2.0 * 2**attempt)163 continue164 raise err165 return body["result"]166 raise RuntimeError("unreachable")167168 async def _private(self, method: str, data: dict[str, Any] | None = None, cost: int = 1) -> Any:169 if not self.has_credentials:170 raise KrakenAPIError(["EAPI:No credentials configured"], method)171 await self._counter.acquire(cost)172 path = f"/0/private/{method}"173 payload = dict(data or {})174 payload["nonce"] = await self._nonce.next()175 headers = {176 "API-Key": self.api_key,177 "API-Sign": sign(path, payload, self.api_secret),178 "Content-Type": "application/x-www-form-urlencoded",179 }180 r = await self._client.post(path, data=payload, headers=headers)181 r.raise_for_status()182 body = r.json()183 if body.get("error"):184 raise KrakenAPIError(body["error"], method)185 return body["result"]186187 # ---------- public ----------188 async def time(self) -> int:189 return int((await self._public("Time"))["unixtime"])190191 async def system_status(self) -> SystemStatus:192 r = await self._public("SystemStatus")193 return SystemStatus(status=r["status"], timestamp=r["timestamp"])194195 async def asset_pairs(self, pairs: list[str] | None = None) -> dict[str, PairMeta]:196 params = {"pair": ",".join(pairs)} if pairs else None197 r = await self._public("AssetPairs", params)198 return {k: PairMeta.from_kraken(k, v) for k, v in r.items()}199200 async def ticker(self, pairs: list[str]) -> dict[str, dict]:201 return await self._public("Ticker", {"pair": ",".join(pairs)})202203 async def ohlc(self, pair: str, interval: int = 1, since: int | None = None) -> tuple[list[Candle], int]:204 if interval not in OHLC_INTERVALS:205 raise ValueError(f"interval must be one of {OHLC_INTERVALS}")206 params: dict[str, Any] = {"pair": pair, "interval": interval}207 if since is not None:208 params["since"] = since209 r = await self._public("OHLC", params)210 last = int(r.pop("last"))211 rows = next(iter(r.values())) if r else []212 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]213 return candles, last214215 async def depth(self, pair: str, count: int = 100) -> dict[str, list[tuple[Decimal, Decimal, int]]]:216 count = max(1, min(500, count))217 r = await self._public("Depth", {"pair": pair, "count": count})218 book = next(iter(r.values()))219 return {220 "asks": [(Decimal(a[0]), Decimal(a[1]), int(a[2])) for a in book["asks"]],221 "bids": [(Decimal(b[0]), Decimal(b[1]), int(b[2])) for b in book["bids"]],222 }223224 async def trades(self, pair: str, since: str | None = None, count: int = 1000) -> tuple[list[Trade], str]:225 params: dict[str, Any] = {"pair": pair, "count": max(1, min(1000, count))}226 if since:227 params["since"] = since228 r = await self._public("Trades", params)229 last = str(r.pop("last"))230 rows = next(iter(r.values())) if r else []231 out = [232 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)233 for x in rows234 ]235 return out, last236237 # ---------- private (read) ----------238 async def balance(self) -> dict[str, Decimal]:239 r = await self._private("Balance")240 return {k: Decimal(v) for k, v in r.items()}241242 async def trade_balance(self, asset: str = "ZUSD") -> dict[str, Any]:243 return await self._private("TradeBalance", {"asset": asset})244245 async def open_orders(self) -> dict[str, Any]:246 return await self._private("OpenOrders")247248 async def closed_orders(self, start: int | None = None) -> dict[str, Any]:249 return await self._private("ClosedOrders", {"start": start} if start else None)250251 async def trades_history(self, start: int | None = None) -> dict[str, Any]:252 return await self._private("TradesHistory", {"start": start} if start else None, cost=2)253254 async def trade_volume(self, pairs: list[str]) -> dict[str, Any]:255 return await self._private("TradeVolume", {"pair": ",".join(pairs)})256257 async def websockets_token(self) -> str:258 return (await self._private("GetWebSocketsToken"))["token"]259260 # ---------- private (trade) — only reachable through the live adapter, which is gated ----------261 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]:262 data: dict[str, Any] = {"pair": pair, "type": side, "ordertype": ordertype, "volume": volume, "timeinforce": timeinforce}263 if price is not None:264 data["price"] = price265 if cl_ord_id:266 data["cl_ord_id"] = cl_ord_id267 if oflags:268 data["oflags"] = oflags269 if validate:270 data["validate"] = "true"271 return await self._private("AddOrder", data)272273 async def cancel_order(self, txid: str | None = None, cl_ord_id: str | None = None) -> dict[str, Any]:274 data = {"txid": txid} if txid else {"cl_ord_id": cl_ord_id}275 return await self._private("CancelOrder", data)276277 async def cancel_all(self) -> dict[str, Any]:278 return await self._private("CancelAll")279