# Bitunix Spot (minimal) parser per your interface spec. # Docs: https://api-doc.bitunix.com/en_us/ (public/user/order) [см. ссылки в пояснении] from datetime import datetime import time import json import hashlib import hmac import random import string import requests from typing import Tuple, Dict, Any, List, Optional from logger import MarketLogger from utility import ExceptionLogger BASE_URL = "https://openapi.bitunix.com" # хост у публичной доки = api-doc.*, но REST ходит на основной домен def _now_ms() -> int: return int(time.time() * 1000) def _rand_nonce(n=32) -> str: return "".join(random.choice(string.ascii_letters + string.digits) for _ in range(n)) class BitunixSpot: def set_logger(self, logger): self.logger = logger def __init__(self, args: Dict[str, str], symbol: Tuple[str, str, str, int, int], logger_dir=None, **kwargs): """ args: {'api': , 'secret': } symbol: (name, base, quote, price_prec, qty_prec) """ self.api_key = args.get("api") self.secret = args.get("secret") self.market = "bitunix.spot" self.full_name = f"{symbol[0]}.{self.market}" if logger_dir is None: self.logger = MarketLogger() else: self.logger = MarketLogger(prefix=f'{logger_dir}/{self.full_name}', market=self.market) self.symbol = (f'{symbol[1]}{symbol[2]}', symbol[1], symbol[2], int(symbol[3]), int(symbol[4])) self.min_notional = 5.0 # инкрементальные марк./приватные трейды (миллисекунды) self._last_public_trade_ts_ms: Optional[int] = None self._last_private_trade_ts_ms = None self.last_orders = {} # подхватим реальные точности/минимумы из справочника пар try: r = requests.get(f"{BASE_URL}/api/spot/v1/common/coin_pair/list", timeout=15) r.raise_for_status() data = r.json().get("data") or [] for row in data: if row.get("id") == self.symbol[0] or row.get("symbol") == self.symbol[0] or row.get("base")+row.get("quote","") == self.symbol[0]: base = row.get("base") or self.symbol[1] quote = row.get("quote") or self.symbol[2] # qty_prec = basePrecision, price_prec = quotePrecision (Dec places) try: qty_prec = int(row.get("basePrecision")) if row.get("basePrecision") is not None else self.symbol[4] price_prec = int(row.get("quotePrecision")) if row.get("quotePrecision") is not None else self.symbol[3] except Exception: qty_prec = self.symbol[4] price_prec = self.symbol[3] # minPrice в доке описан как "Minimum trading amount" — используем как min_notional по котируемой try: self.min_notional = float(row.get("minPrice") or 0) # quote amount except Exception: self.min_notional = 0.0 self.symbol = (self.symbol[0], base, quote, int(price_prec), int(qty_prec)) break except Exception as e: print("coin_pair/list load error:", e) # ---------------- низкоуровневые помощники ---------------- def _sign_headers(self, method: str, path: str, query: Dict[str, Any], body_obj: Any) -> Dict[str, str]: """ Подпись (двойной SHA256) согласно https://api-doc.bitunix.com/en_us/sign/ queryParams: конкатенируем key=value в ASCII-сортировке (без '&') body: JSON без пробелов sign = SHA256( SHA256(nonce + timestamp + api_key + queryParams + body) + secret ) """ api_key = self.api_key or "" secret = self.secret or "" nonce = _rand_nonce(32) ts = str(_now_ms()) # 1) query string без разделителей и без URL-кодирования query = query or {} sorted_items = sorted((k, "" if v is None else str(v)) for k, v in query.items()) query_concat = "".join([f"{k}={v}" for k, v in sorted_items]) # 2) body -> json без пробелов if body_obj is None or (isinstance(body_obj, dict) and len(body_obj) == 0): body_min = "" else: body_min = json.dumps(body_obj, separators=(",", ":"), ensure_ascii=False) digest_src = f"{nonce}{ts}{api_key}{query_concat}{body_min}" digest = hashlib.sha256(digest_src.encode("utf-8")).hexdigest() sign = hashlib.sha256((digest + secret).encode("utf-8")).hexdigest() return { "api-key": api_key, "nonce": nonce, "timestamp": ts, "sign": sign, "Content-Type": "application/json", } def _request(self, method: str, path: str, query = None, body: Any = None, private: bool = False) -> dict: url = f"{BASE_URL}{path}" headers = {"Content-Type": "application/json"} if private: headers = self._sign_headers(method, path, query or {}, body) if method.upper() == "GET": resp = requests.get(url, params=query or {}, headers=headers, timeout=20) elif method.upper() == "POST": resp = requests.post(url, params=query or {}, data=json.dumps(body or {}, separators=(",", ":")), headers=headers, timeout=20) elif method.upper() == "DELETE": resp = requests.delete(url, params=query or {}, data=json.dumps(body or {}, separators=(",", ":")), headers=headers, timeout=20) else: raise ValueError("Unsupported HTTP method") resp.raise_for_status() return resp.json() # ---------------- обязательные методы ---------------- def get_info(self) -> Dict[str, Any]: """ {'book': {'ask_price': float, 'bid_price': float}, 'balance': {'base': float, 'quote': float}} Ошибки книги -> печатаем и возвращаем 0 для недоступных значений. Ошибки баланса -> не обрабатываем (пусть пробрасываются). """ out = {'book': {'ask_price': 0.0, 'bid_price': 0.0}, 'balance': {'base': 0.0, 'quote': 0.0}} # 1) лучшая цена/стакан try: # depth требует precision: используем точность цены как разумный параметр q = {"symbol": self.symbol[0]} d = self._request("GET", "/api/spot/v1/market/depth", query=q, private=False) book = d.get("data") or {} asks = book.get("asks") or [] bids = book.get("bids") or [] # asks/bids элементы вида {"price": "xxxxx", "volume": "yyyyy"} if asks: try: out["book"]["ask_price"] = float(asks[0].get("price", "0")) except Exception: out["book"]["ask_price"] = 0.0 if bids: try: out["book"]["bid_price"] = float(bids[0].get("price", "0")) except Exception: out["book"]["bid_price"] = 0.0 except Exception as e: print("orderbook parse error:", e) out["book"]["ask_price"] = 0.0 out["book"]["bid_price"] = 0.0 # 2) баланс (НЕ перехватывать исключения) acc = self._request("GET", "/api/spot/v1/user/account", private=True) base_ccy = self.symbol[1] quote_ccy = self.symbol[2] for row in acc.get("data") or []: coin = row.get("coin") free = float(row.get("balance", 0)) locked = float(row.get("balanceLocked", 0)) # В API нет явного "available/locked" раздельно на free/used; balance и balanceLocked даны отдельно if coin == base_ccy: out["balance"]["base"] = free + locked if coin == quote_ccy: out["balance"]["quote"] = free + locked return out def proper_round(self, x, precision): return f"%.{precision}f" % round(float(x), int(precision)) def new_limit(self, price: float, quantity: float, is_bid: bool): """ Создать лимитный ордер. """ body = { "side": 2 if is_bid else 1, # 1 Sell, 2 Buy "type": 1, # 1 Limit, 2 Market "volume": self.proper_round(quantity, self.symbol[4]), # qty (base) "price": self.proper_round(price, self.symbol[3]), "symbol": self.symbol[0], } try: r = self._request("POST", "/api/spot/v1/order/place_order", body=body, private=True) print(r) if isinstance(r, dict) and r.get("code") not in (None, 0, "0"): print("Error creating limit order:", r) except Exception as e: print("Exception creating limit order:", e) def new_limit_maker(self, price: float, quantity: float, is_bid: bool): """ Пост-онли лимит — Bitunix Spot явно не документирует PostOnly. Попытаемся с неофициальными флагами; при отказе — fallback на обычный LIMIT. """ tried = False for payload in ( {"postOnly": True, "timeInForce": "PO"}, {"postOnly": True}, {"timeInForce": "PO"}, ): body = { "side": 2 if is_bid else 1, "type": 1, "volume": self.proper_round(quantity, self.symbol[4]), "price": self.proper_round(price, self.symbol[3]), "symbol": self.symbol[0], **payload } tried = True try: r = self._request("POST", "/api/spot/v1/order/place_order", body=body, private=True) print(r) if isinstance(r, dict) and r.get("code") in (None, 0, "0"): return else: print("Post-only attempt rejected:", r) except Exception as e: print("Post-only attempt error:", e) if not tried or True: # fallback self.new_limit(price, quantity, is_bid) def cancel(self, order_id: str): """ Отмена по ID (эндпойнт принимает список). """ try: body = { "orderIdList": [ {"orderId": str(order_id), "symbol": self.symbol[0]} ] } r = self._request("POST", "/api/spot/v1/order/cancel", body=body, private=True) if isinstance(r, dict) and r.get("code") not in (None, 0, "0"): print("Error cancelling order:", r) except Exception as e: print("Exception cancelling order:", e) def get_orders(self) -> Dict[str, List[Dict[str, Any]]]: """ Открытые ордера: {'ask': [...], 'bid': [...]} """ out = {'ask': [], 'bid': []} try: body = {"symbol": self.symbol[0]} r = self._request("POST", "/api/spot/v1/order/pending/list", body=body, private=True) rows = r.get("data") or [] for o in rows: side = str(o.get("side", "")).upper() # в доке 1 Sell / 2 Buy, иногда строкой try: side_num = int(o.get("side")) except Exception: side_num = 2 if side in ("BUY", "2") else 1 price = float(o.get("price") or 0) qty = float(o.get("volume") or o.get("leftVolume") or 0) item = {"id": str(o.get("orderId")), "quantity": qty, "price": price} if side_num == 1: out["ask"].append(item) else: out["bid"].append(item) except Exception: return out return out def get_depth(self) -> Dict[str, List[tuple]]: """ Стакан: {'asks': [(price, qty), ...], 'bids': [...]} """ out = {'asks': [], 'bids': []} try: q = {"symbol": self.symbol[0]} r = self._request("GET", "/api/spot/v1/market/depth", query=q, private=False) book = r.get("data") or {} asks = book.get("asks") or [] bids = book.get("bids") or [] out["asks"] = [(float(x["price"]), float(x["volume"])) for x in asks][:200] out["bids"] = [(float(x["price"]), float(x["volume"])) for x in bids][:200] except Exception: return {'asks': [], 'bids': []} return out def get_new_public_trades(self) -> List[Dict[str, Any]]: """ Публичных REST-трейдов у Spot в доке нет — возвращаем [] по ТЗ. Если появится /market/trades — можно доработать. """ if self._last_public_trade_ts_ms is None: self._last_public_trade_ts_ms = _now_ms() return [] def get_new_private_trades(self) -> List[Dict[str, Any]]: try: body = { "symbol": self.symbol[0], "page": 1, "pageSize": 100 } r = self._request("POST", "/api/spot/v1/order/history/page", body=body, private=True) data = r.get("data") or {} rows = data.get("data") or [] trades = [] max_ts = self._last_private_trade_ts_ms or 0 for o in rows: ts_dt = datetime.fromisoformat(o.get("utime")) ts = int(ts_dt.timestamp() * 1000) deal_qty = float(o.get("dealVolume") or 0) if self._last_private_trade_ts_ms is None: if ts > max_ts: max_ts = ts continue if deal_qty > 0 and ts > self._last_private_trade_ts_ms: side_num = int(o.get("side") or 0) side = "sell" if side_num == 1 else "buy" price = float(o.get("avgPrice") or o.get("price") or 0) trades.append({ "price": price, "quantity": deal_qty, "side": side, "timestamp": ts, "isTaker": None }) if ts > max_ts: max_ts = ts if self._last_private_trade_ts_ms is None: self._last_private_trade_ts_ms = max_ts or _now_ms() return [] else: if max_ts: self._last_private_trade_ts_ms = max_ts return trades except Exception as e: print("private trades error:", e) return [] def cancel_open_orders(self): """ Массовая отмена: получаем pending и шлём /order/cancel списком. """ try: cur = self.get_orders() ids = [] for side in ("ask", "bid"): for o in cur.get(side, []): ids.append({"orderId": str(o["id"]), "symbol": self.symbol[0]}) if ids: body = {"orderIdList": ids} self._request("POST", "/api/spot/v1/order/cancel", body=body, private=True) except Exception as e: print("cancel_open_orders error:", e) def get_price(self) -> float: """ Текущая цена: mid(best bid/ask) при наличии, иначе last_price. """ info = self.get_info() a = info["book"].get("ask_price") or 0.0 b = info["book"].get("bid_price") or 0.0 try: a = float(a) b = float(b) except Exception: a, b = 0.0, 0.0 if a > 0 and b > 0: return (a + b) / 2.0 try: r = self._request("GET", "/api/spot/v1/market/last_price", query={"symbol": self.symbol[0]}, private=False) lp = r.get("data") return float(lp or 0.0) except Exception: return a if a > 0 else (b if b > 0 else 0.0) def get_fees(self) -> Dict[str, float]: """ Публично задекларированные тарифы VIP0: maker 0.08%, taker 0.10%. У аккаунта может быть иной VIP — при необходимости можно параметризовать. """ return {"maker": 0.0008, "taker": 0.0010}