from __future__ import annotations import base64 import hashlib import hmac import logging import time import urllib.parse import requests log = logging.getLogger(__name__) _BASE_URL = "https://api.kraken.com" class KrakenError(Exception): pass class KrakenClient: def __init__(self, api_key: str = "", api_secret: str = ""): self.api_key = api_key self.api_secret = api_secret self._session = requests.Session() self._session.headers["User-Agent"] = "crypto-trader/1.0" # ── Auth ───────────────────────────────────────────────────────────────── def _sign(self, urlpath: str, data: dict) -> str: postdata = urllib.parse.urlencode(data) encoded = (str(data["nonce"]) + postdata).encode() message = urlpath.encode() + hashlib.sha256(encoded).digest() mac = hmac.new(base64.b64decode(self.api_secret), message, hashlib.sha512) return base64.b64encode(mac.digest()).decode() # ── Low-level request helpers ───────────────────────────────────────────── def _public(self, endpoint: str, params: dict | None = None) -> dict: url = f"{_BASE_URL}/0/public/{endpoint}" resp = self._session.get(url, params=params, timeout=15) resp.raise_for_status() body = resp.json() if body.get("error"): raise KrakenError(body["error"]) return body["result"] def _private(self, endpoint: str, data: dict | None = None) -> dict: if not self.api_key or not self.api_secret: raise KrakenError("API credentials not set — check KRAKEN_API_KEY / KRAKEN_API_SECRET") urlpath = f"/0/private/{endpoint}" payload = dict(data or {}) payload["nonce"] = str(int(time.time() * 1000)) headers = { "API-Key": self.api_key, "API-Sign": self._sign(urlpath, payload), } resp = self._session.post( f"{_BASE_URL}{urlpath}", data=payload, headers=headers, timeout=15 ) resp.raise_for_status() body = resp.json() if body.get("error"): raise KrakenError(body["error"]) return body["result"] # ── Public market data ──────────────────────────────────────────────────── def get_asset_pairs(self) -> dict[str, dict]: """Return all asset pair metadata keyed by Kraken's internal pair name.""" return self._public("AssetPairs") def get_tickers(self, pairs: list[str]) -> dict[str, dict]: """ Fetch ticker data for a list of pair names (altnames or internal names). Splits into chunks of 100 to stay within API limits. Returns a dict keyed by whatever name Kraken echoes back. """ results: dict[str, dict] = {} for i in range(0, len(pairs), 100): chunk = pairs[i : i + 100] data = self._public("Ticker", params={"pair": ",".join(chunk)}) results.update(data) return results # ── Private account data ────────────────────────────────────────────────── def get_all_balances(self) -> dict[str, float]: """Return all asset balances with non-zero amounts.""" raw = self._private("Balance") return {k: float(v) for k, v in raw.items() if float(v) > 0} def get_usd_balance(self) -> float: """Return available USD balance (ZUSD key in Kraken).""" balance = self._private("Balance") return float(balance.get("ZUSD", balance.get("USD", 0.0))) # ── Order placement ─────────────────────────────────────────────────────── def market_buy(self, pair: str, volume: float, paper: bool = True) -> str: """Place a market buy order. Returns a transaction/order ID.""" if paper: order_id = f"PAPER-BUY-{pair}-{int(time.time())}" log.info("[PAPER] BUY %s qty=%.8f order=%s", pair, volume, order_id) return order_id result = self._private("AddOrder", { "pair": pair, "type": "buy", "ordertype": "market", "volume": f"{volume:.8f}", }) txids = result.get("txid", []) order_id = txids[0] if txids else "unknown" log.info("BUY order placed: %s pair=%s qty=%.8f", order_id, pair, volume) return order_id def market_sell(self, pair: str, volume: float, paper: bool = True) -> str: """Place a market sell order. Returns a transaction/order ID.""" if paper: order_id = f"PAPER-SELL-{pair}-{int(time.time())}" log.info("[PAPER] SELL %s qty=%.8f order=%s", pair, volume, order_id) return order_id result = self._private("AddOrder", { "pair": pair, "type": "sell", "ordertype": "market", "volume": f"{volume:.8f}", }) txids = result.get("txid", []) order_id = txids[0] if txids else "unknown" log.info("SELL order placed: %s pair=%s qty=%.8f", order_id, pair, volume) return order_id