Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- # 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': <api_key>, 'secret': <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}
Advertisement
Add Comment
Please, Sign In to add comment