SPB Git forge
1commits 1branches 0releases
189.0 KBsize
maindefault branch
5 h agolast push
Python 54.7% TypeScript 42.5% SQL 1.9%
11.5 KB · 279 lines python
Raw Blame History
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