# 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}