den4ik2003

Untitled

Nov 6th, 2025
455
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 17.37 KB | None | 0 0
  1. # Bitunix Spot (minimal) parser per your interface spec.
  2. # Docs: https://api-doc.bitunix.com/en_us/  (public/user/order)  [см. ссылки в пояснении]
  3.  
  4. from datetime import datetime
  5. import time
  6. import json
  7. import hashlib
  8. import hmac
  9. import random
  10. import string
  11. import requests
  12. from typing import Tuple, Dict, Any, List, Optional
  13. from logger import MarketLogger
  14. from utility import ExceptionLogger
  15.  
  16. BASE_URL = "https://openapi.bitunix.com"  # хост у публичной доки = api-doc.*, но REST ходит на основной домен
  17.  
  18. def _now_ms() -> int:
  19.     return int(time.time() * 1000)
  20.  
  21. def _rand_nonce(n=32) -> str:
  22.     return "".join(random.choice(string.ascii_letters + string.digits) for _ in range(n))
  23.  
  24. class BitunixSpot:
  25.     def set_logger(self, logger):
  26.         self.logger = logger
  27.  
  28.     def __init__(self, args: Dict[str, str], symbol: Tuple[str, str, str, int, int], logger_dir=None, **kwargs):
  29.         """
  30.        args: {'api': <api_key>, 'secret': <secret>}
  31.        symbol: (name, base, quote, price_prec, qty_prec)
  32.        """
  33.         self.api_key = args.get("api")
  34.         self.secret = args.get("secret")
  35.         self.market = "bitunix.spot"
  36.         self.full_name = f"{symbol[0]}.{self.market}"
  37.  
  38.         if logger_dir is None:
  39.             self.logger = MarketLogger()
  40.         else:
  41.             self.logger = MarketLogger(prefix=f'{logger_dir}/{self.full_name}', market=self.market)
  42.        
  43.         self.symbol = (f'{symbol[1]}{symbol[2]}', symbol[1], symbol[2], int(symbol[3]), int(symbol[4]))
  44.         self.min_notional = 5.0
  45.  
  46.         # инкрементальные марк./приватные трейды (миллисекунды)
  47.         self._last_public_trade_ts_ms: Optional[int] = None
  48.         self._last_private_trade_ts_ms = None
  49.         self.last_orders = {}
  50.  
  51.         # подхватим реальные точности/минимумы из справочника пар
  52.         try:
  53.             r = requests.get(f"{BASE_URL}/api/spot/v1/common/coin_pair/list", timeout=15)
  54.             r.raise_for_status()
  55.             data = r.json().get("data") or []
  56.             for row in data:
  57.                 if row.get("id") == self.symbol[0] or row.get("symbol") == self.symbol[0] or row.get("base")+row.get("quote","") == self.symbol[0]:
  58.                     base = row.get("base") or self.symbol[1]
  59.                     quote = row.get("quote") or self.symbol[2]
  60.                     # qty_prec = basePrecision, price_prec = quotePrecision (Dec places)
  61.                     try:
  62.                         qty_prec = int(row.get("basePrecision")) if row.get("basePrecision") is not None else self.symbol[4]
  63.                         price_prec = int(row.get("quotePrecision")) if row.get("quotePrecision") is not None else self.symbol[3]
  64.                     except Exception:
  65.                         qty_prec = self.symbol[4]
  66.                         price_prec = self.symbol[3]
  67.                     # minPrice в доке описан как "Minimum trading amount" — используем как min_notional по котируемой
  68.                     try:
  69.                         self.min_notional = float(row.get("minPrice") or 0)  # quote amount
  70.                     except Exception:
  71.                         self.min_notional = 0.0
  72.                     self.symbol = (self.symbol[0], base, quote, int(price_prec), int(qty_prec))
  73.                     break
  74.         except Exception as e:
  75.             print("coin_pair/list load error:", e)
  76.  
  77.     # ---------------- низкоуровневые помощники ----------------
  78.  
  79.     def _sign_headers(self, method: str, path: str, query: Dict[str, Any], body_obj: Any) -> Dict[str, str]:
  80.         """
  81.        Подпись (двойной SHA256) согласно https://api-doc.bitunix.com/en_us/sign/
  82.        queryParams: конкатенируем key=value в ASCII-сортировке (без '&')
  83.        body: JSON без пробелов
  84.        sign = SHA256( SHA256(nonce + timestamp + api_key + queryParams + body) + secret )
  85.        """
  86.         api_key = self.api_key or ""
  87.         secret = self.secret or ""
  88.         nonce = _rand_nonce(32)
  89.         ts = str(_now_ms())
  90.  
  91.         # 1) query string без разделителей и без URL-кодирования
  92.         query = query or {}
  93.         sorted_items = sorted((k, "" if v is None else str(v)) for k, v in query.items())
  94.         query_concat = "".join([f"{k}={v}" for k, v in sorted_items])
  95.  
  96.         # 2) body -> json без пробелов
  97.         if body_obj is None or (isinstance(body_obj, dict) and len(body_obj) == 0):
  98.             body_min = ""
  99.         else:
  100.             body_min = json.dumps(body_obj, separators=(",", ":"), ensure_ascii=False)
  101.  
  102.         digest_src = f"{nonce}{ts}{api_key}{query_concat}{body_min}"
  103.         digest = hashlib.sha256(digest_src.encode("utf-8")).hexdigest()
  104.         sign = hashlib.sha256((digest + secret).encode("utf-8")).hexdigest()
  105.  
  106.         return {
  107.             "api-key": api_key,
  108.             "nonce": nonce,
  109.             "timestamp": ts,
  110.             "sign": sign,
  111.             "Content-Type": "application/json",
  112.         }
  113.  
  114.     def _request(self, method: str, path: str, query = None, body: Any = None, private: bool = False) -> dict:
  115.         url = f"{BASE_URL}{path}"
  116.         headers = {"Content-Type": "application/json"}
  117.         if private:
  118.             headers = self._sign_headers(method, path, query or {}, body)
  119.  
  120.         if method.upper() == "GET":
  121.             resp = requests.get(url, params=query or {}, headers=headers, timeout=20)
  122.         elif method.upper() == "POST":
  123.             resp = requests.post(url, params=query or {}, data=json.dumps(body or {}, separators=(",", ":")), headers=headers, timeout=20)
  124.         elif method.upper() == "DELETE":
  125.             resp = requests.delete(url, params=query or {}, data=json.dumps(body or {}, separators=(",", ":")), headers=headers, timeout=20)
  126.         else:
  127.             raise ValueError("Unsupported HTTP method")
  128.         resp.raise_for_status()
  129.         return resp.json()
  130.  
  131.     # ---------------- обязательные методы ----------------
  132.  
  133.     def get_info(self) -> Dict[str, Any]:
  134.         """
  135.        {'book': {'ask_price': float, 'bid_price': float}, 'balance': {'base': float, 'quote': float}}
  136.        Ошибки книги -> печатаем и возвращаем 0 для недоступных значений.
  137.        Ошибки баланса -> не обрабатываем (пусть пробрасываются).
  138.        """
  139.         out = {'book': {'ask_price': 0.0, 'bid_price': 0.0}, 'balance': {'base': 0.0, 'quote': 0.0}}
  140.  
  141.         # 1) лучшая цена/стакан
  142.         try:
  143.             # depth требует precision: используем точность цены как разумный параметр
  144.             q = {"symbol": self.symbol[0]}
  145.             d = self._request("GET", "/api/spot/v1/market/depth", query=q, private=False)
  146.             book = d.get("data") or {}
  147.             asks = book.get("asks") or []
  148.             bids = book.get("bids") or []
  149.             # asks/bids элементы вида {"price": "xxxxx", "volume": "yyyyy"}
  150.             if asks:
  151.                 try:
  152.                     out["book"]["ask_price"] = float(asks[0].get("price", "0"))
  153.                 except Exception:
  154.                     out["book"]["ask_price"] = 0.0
  155.             if bids:
  156.                 try:
  157.                     out["book"]["bid_price"] = float(bids[0].get("price", "0"))
  158.                 except Exception:
  159.                     out["book"]["bid_price"] = 0.0
  160.         except Exception as e:
  161.             print("orderbook parse error:", e)
  162.             out["book"]["ask_price"] = 0.0
  163.             out["book"]["bid_price"] = 0.0
  164.  
  165.         # 2) баланс (НЕ перехватывать исключения)
  166.         acc = self._request("GET", "/api/spot/v1/user/account", private=True)
  167.         base_ccy = self.symbol[1]
  168.         quote_ccy = self.symbol[2]
  169.         for row in acc.get("data") or []:
  170.             coin = row.get("coin")
  171.             free = float(row.get("balance", 0))
  172.             locked = float(row.get("balanceLocked", 0))
  173.             # В API нет явного "available/locked" раздельно на free/used; balance и balanceLocked даны отдельно
  174.             if coin == base_ccy:
  175.                 out["balance"]["base"] = free + locked
  176.             if coin == quote_ccy:
  177.                 out["balance"]["quote"] = free + locked
  178.  
  179.         return out
  180.  
  181.     def proper_round(self, x, precision):
  182.         return f"%.{precision}f" % round(float(x), int(precision))
  183.  
  184.     def new_limit(self, price: float, quantity: float, is_bid: bool):
  185.         """
  186.        Создать лимитный ордер.
  187.        """
  188.         body = {
  189.             "side": 2 if is_bid else 1,            # 1 Sell, 2 Buy
  190.             "type": 1,                              # 1 Limit, 2 Market
  191.             "volume": self.proper_round(quantity, self.symbol[4]),  # qty (base)
  192.             "price": self.proper_round(price, self.symbol[3]),
  193.             "symbol": self.symbol[0],
  194.         }
  195.         try:
  196.             r = self._request("POST", "/api/spot/v1/order/place_order", body=body, private=True)
  197.             print(r)
  198.             if isinstance(r, dict) and r.get("code") not in (None, 0, "0"):
  199.                 print("Error creating limit order:", r)
  200.         except Exception as e:
  201.             print("Exception creating limit order:", e)
  202.  
  203.     def new_limit_maker(self, price: float, quantity: float, is_bid: bool):
  204.         """
  205.        Пост-онли лимит — Bitunix Spot явно не документирует PostOnly.
  206.        Попытаемся с неофициальными флагами; при отказе — fallback на обычный LIMIT.
  207.        """
  208.         tried = False
  209.         for payload in (
  210.             {"postOnly": True, "timeInForce": "PO"},
  211.             {"postOnly": True},
  212.             {"timeInForce": "PO"},
  213.         ):
  214.             body = {
  215.                 "side": 2 if is_bid else 1,
  216.                 "type": 1,
  217.                 "volume": self.proper_round(quantity, self.symbol[4]),
  218.                 "price": self.proper_round(price, self.symbol[3]),
  219.                 "symbol": self.symbol[0],
  220.                 **payload
  221.             }
  222.             tried = True
  223.             try:
  224.                 r = self._request("POST", "/api/spot/v1/order/place_order", body=body, private=True)
  225.                 print(r)
  226.                 if isinstance(r, dict) and r.get("code") in (None, 0, "0"):
  227.                     return
  228.                 else:
  229.                     print("Post-only attempt rejected:", r)
  230.             except Exception as e:
  231.                 print("Post-only attempt error:", e)
  232.         if not tried or True:
  233.             # fallback
  234.             self.new_limit(price, quantity, is_bid)
  235.  
  236.     def cancel(self, order_id: str):
  237.         """
  238.        Отмена по ID (эндпойнт принимает список).
  239.        """
  240.         try:
  241.             body = {
  242.                 "orderIdList": [
  243.                     {"orderId": str(order_id), "symbol": self.symbol[0]}
  244.                 ]
  245.             }
  246.             r = self._request("POST", "/api/spot/v1/order/cancel", body=body, private=True)
  247.             if isinstance(r, dict) and r.get("code") not in (None, 0, "0"):
  248.                 print("Error cancelling order:", r)
  249.         except Exception as e:
  250.             print("Exception cancelling order:", e)
  251.  
  252.     def get_orders(self) -> Dict[str, List[Dict[str, Any]]]:
  253.         """
  254.        Открытые ордера: {'ask': [...], 'bid': [...]}
  255.        """
  256.         out = {'ask': [], 'bid': []}
  257.         try:
  258.             body = {"symbol": self.symbol[0]}
  259.             r = self._request("POST", "/api/spot/v1/order/pending/list", body=body, private=True)
  260.             rows = r.get("data") or []
  261.             for o in rows:
  262.                 side = str(o.get("side", "")).upper()  # в доке 1 Sell / 2 Buy, иногда строкой
  263.                 try:
  264.                     side_num = int(o.get("side"))
  265.                 except Exception:
  266.                     side_num = 2 if side in ("BUY", "2") else 1
  267.                 price = float(o.get("price") or 0)
  268.                 qty = float(o.get("volume") or o.get("leftVolume") or 0)
  269.                 item = {"id": str(o.get("orderId")), "quantity": qty, "price": price}
  270.                 if side_num == 1:
  271.                     out["ask"].append(item)
  272.                 else:
  273.                     out["bid"].append(item)
  274.         except Exception:
  275.             return out
  276.         return out
  277.  
  278.     def get_depth(self) -> Dict[str, List[tuple]]:
  279.         """
  280.        Стакан: {'asks': [(price, qty), ...], 'bids': [...]}
  281.        """
  282.         out = {'asks': [], 'bids': []}
  283.         try:
  284.             q = {"symbol": self.symbol[0]}
  285.             r = self._request("GET", "/api/spot/v1/market/depth", query=q, private=False)
  286.             book = r.get("data") or {}
  287.             asks = book.get("asks") or []
  288.             bids = book.get("bids") or []
  289.             out["asks"] = [(float(x["price"]), float(x["volume"])) for x in asks][:200]
  290.             out["bids"] = [(float(x["price"]), float(x["volume"])) for x in bids][:200]
  291.         except Exception:
  292.             return {'asks': [], 'bids': []}
  293.         return out
  294.  
  295.     def get_new_public_trades(self) -> List[Dict[str, Any]]:
  296.         """
  297.        Публичных REST-трейдов у Spot в доке нет — возвращаем [] по ТЗ.
  298.        Если появится /market/trades — можно доработать.
  299.        """
  300.         if self._last_public_trade_ts_ms is None:
  301.             self._last_public_trade_ts_ms = _now_ms()
  302.         return []
  303.  
  304.     def get_new_private_trades(self) -> List[Dict[str, Any]]:
  305.         try:
  306.             body = {
  307.                 "symbol": self.symbol[0],
  308.                 "page": 1,
  309.                 "pageSize": 100
  310.             }
  311.             r = self._request("POST", "/api/spot/v1/order/history/page", body=body, private=True)
  312.             data = r.get("data") or {}
  313.             rows = data.get("data") or []
  314.             trades = []
  315.             max_ts = self._last_private_trade_ts_ms or 0
  316.            
  317.             for o in rows:
  318.                 ts_dt = datetime.fromisoformat(o.get("utime"))
  319.                 ts = int(ts_dt.timestamp() * 1000)
  320.                
  321.                 deal_qty = float(o.get("dealVolume") or 0)
  322.                
  323.                 if self._last_private_trade_ts_ms is None:
  324.                     if ts > max_ts:
  325.                         max_ts = ts
  326.                     continue
  327.                    
  328.                 if deal_qty > 0 and ts > self._last_private_trade_ts_ms:
  329.                     side_num = int(o.get("side") or 0)
  330.                     side = "sell" if side_num == 1 else "buy"
  331.                     price = float(o.get("avgPrice") or o.get("price") or 0)
  332.                     trades.append({
  333.                         "price": price,
  334.                         "quantity": deal_qty,
  335.                         "side": side,
  336.                         "timestamp": ts,
  337.                         "isTaker": None
  338.                     })
  339.                     if ts > max_ts:
  340.                         max_ts = ts
  341.                        
  342.             if self._last_private_trade_ts_ms is None:
  343.                 self._last_private_trade_ts_ms = max_ts or _now_ms()
  344.                 return []
  345.             else:
  346.                 if max_ts:
  347.                     self._last_private_trade_ts_ms = max_ts
  348.                 return trades
  349.                
  350.         except Exception as e:
  351.             print("private trades error:", e)
  352.             return []
  353.    
  354.  
  355.     def cancel_open_orders(self):
  356.         """
  357.        Массовая отмена: получаем pending и шлём /order/cancel списком.
  358.        """
  359.         try:
  360.             cur = self.get_orders()
  361.             ids = []
  362.             for side in ("ask", "bid"):
  363.                 for o in cur.get(side, []):
  364.                     ids.append({"orderId": str(o["id"]), "symbol": self.symbol[0]})
  365.             if ids:
  366.                 body = {"orderIdList": ids}
  367.                 self._request("POST", "/api/spot/v1/order/cancel", body=body, private=True)
  368.         except Exception as e:
  369.             print("cancel_open_orders error:", e)
  370.  
  371.     def get_price(self) -> float:
  372.         """
  373.        Текущая цена: mid(best bid/ask) при наличии, иначе last_price.
  374.        """
  375.         info = self.get_info()
  376.         a = info["book"].get("ask_price") or 0.0
  377.         b = info["book"].get("bid_price") or 0.0
  378.         try:
  379.             a = float(a)
  380.             b = float(b)
  381.         except Exception:
  382.             a, b = 0.0, 0.0
  383.         if a > 0 and b > 0:
  384.             return (a + b) / 2.0
  385.         try:
  386.             r = self._request("GET", "/api/spot/v1/market/last_price", query={"symbol": self.symbol[0]}, private=False)
  387.             lp = r.get("data")
  388.             return float(lp or 0.0)
  389.         except Exception:
  390.             return a if a > 0 else (b if b > 0 else 0.0)
  391.  
  392.     def get_fees(self) -> Dict[str, float]:
  393.         """
  394.        Публично задекларированные тарифы VIP0: maker 0.08%, taker 0.10%.
  395.        У аккаунта может быть иной VIP — при необходимости можно параметризовать.
  396.        """
  397.         return {"maker": 0.0008, "taker": 0.0010}
  398.  
Advertisement
Add Comment
Please, Sign In to add comment