CCCPaste

d3fef6eaf6754

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