← Back
"""
WT Bot v3 — Binance Futures Client
====================================
Обёртка над python-binance с учётом граблей из v2:

ГРАБЛИ (НЕ ПОВТОРЯТЬ):
1. НИКОГДА closePosition=True в STOP_MARKET — создаёт невидимые algo ордера
2. STOP_MARKET возвращает algoId, не orderId — нельзя query/cancel по ID
3. futures_get_open_orders() НЕ показывает algo ордера
4. ТОЛЬКО futures_cancel_all_open_orders(symbol) убивает ВСЕ ордера
5. При старте — nuclear cleanup: cancel все ордера на все символы с позициями

ПРАВИЛА:
- SL = STOP_MARKET с quantity + reduceOnly (НЕ closePosition!)
- TP = LIMIT с reduceOnly — maker fee 0.02%
- Cancel = futures_cancel_all_open_orders(symbol)
"""

import time
import logging
from binance.client import Client
from binance.exceptions import BinanceAPIException
from src.config import (
    BINANCE_API_KEY, BINANCE_API_SECRET,
    LEVERAGE, MAKER_FEE, TAKER_FEE
)

logger = logging.getLogger("exchange")


class Exchange:
    def __init__(self):
        self.client = Client(BINANCE_API_KEY, BINANCE_API_SECRET)
        self._exchange_info = None
        self._exchange_info_ts = 0
        logger.info("Binance client initialized")

    def _get_exchange_info(self):
        """Cached exchange info (refresh every 1h)."""
        now = time.time()
        if self._exchange_info is None or now - self._exchange_info_ts > 3600:
            self._exchange_info = self.client.futures_exchange_info()
            self._exchange_info_ts = now
        return self._exchange_info

    # ============================================================
    # MARKET DATA
    # ============================================================

    def get_all_tickers_24h(self):
        """Все фьючерсные тикеры за 24ч — один запрос."""
        return self.client.futures_ticker()

    def get_klines(self, symbol, interval, limit=500):
        """Получить свечи."""
        return self.client.futures_klines(
            symbol=symbol, interval=interval, limit=limit
        )

    def get_mark_price(self, symbol):
        """Текущая mark price."""
        data = self.client.futures_mark_price(symbol=symbol)
        return float(data["markPrice"])

    # ============================================================
    # ACCOUNT
    # ============================================================

    def get_balance(self):
        """USDT баланс."""
        balances = self.client.futures_account_balance()
        for b in balances:
            if b["asset"] == "USDT":
                return float(b["balance"])
        return 0.0

    def get_positions(self):
        """Все открытые позиции (positionAmt != 0)."""
        positions = self.client.futures_position_information()
        return [p for p in positions if float(p["positionAmt"]) != 0]

    def set_leverage(self, symbol):
        """Установить leverage. Игнорирует если уже установлен."""
        try:
            self.client.futures_change_leverage(
                symbol=symbol, leverage=LEVERAGE
            )
        except BinanceAPIException as e:
            if e.code != -4028:  # Already set
                logger.warning(f"Leverage error {symbol}: {e}")

    def set_margin_type(self, symbol, margin_type="CROSSED"):
        """Установить margin type. Игнорирует если уже установлен."""
        try:
            self.client.futures_change_margin_type(
                symbol=symbol, marginType=margin_type
            )
        except BinanceAPIException as e:
            if e.code != -4046:  # Already set
                logger.warning(f"Margin type error {symbol}: {e}")

    # ============================================================
    # SYMBOL INFO
    # ============================================================

    def get_symbol_info(self, symbol):
        """Получить precision, min qty и tick size для символа."""
        info = self._get_exchange_info()
        for s in info["symbols"]:
            if s["symbol"] == symbol:
                price_precision = s["pricePrecision"]
                qty_precision = s["quantityPrecision"]
                min_qty = None
                tick_size = None
                for f in s["filters"]:
                    if f["filterType"] == "LOT_SIZE":
                        min_qty = float(f["minQty"])
                    if f["filterType"] == "PRICE_FILTER":
                        tick_size = float(f["tickSize"])
                return {
                    "price_precision": price_precision,
                    "qty_precision": qty_precision,
                    "min_qty": min_qty,
                    "tick_size": tick_size,
                }
        return None

    def round_price(self, symbol_info, price):
        """Округлить цену до tick size (кратность)."""
        tick = symbol_info.get("tick_size")
        if tick and tick > 0:
            # Округляем до ближайшего кратного tick size
            from decimal import Decimal, ROUND_DOWN
            price_d = Decimal(str(price))
            tick_d = Decimal(str(tick))
            rounded = float((price_d / tick_d).quantize(Decimal('1'), rounding=ROUND_DOWN) * tick_d)
            return rounded
        return round(price, symbol_info["price_precision"])

    def round_qty(self, symbol_info, qty):
        """Округлить количество до нужной precision."""
        return round(qty, symbol_info["qty_precision"])

    # ============================================================
    # ORDERS — с учётом ВСЕХ граблей
    # ============================================================

    def open_market(self, symbol, side, qty):
        """Открыть позицию по маркету."""
        try:
            order = self.client.futures_create_order(
                symbol=symbol,
                side=side,  # "BUY" or "SELL"
                type="MARKET",
                quantity=qty,
                newOrderRespType="RESULT",  # ← нужен для avgPrice!
            )
            fill_price = float(order.get("avgPrice", 0))

            # Fallback 1: fills array
            if fill_price == 0 and order.get("fills"):
                fill_price = sum(
                    float(f["price"]) * float(f["qty"]) for f in order["fills"]
                ) / sum(float(f["qty"]) for f in order["fills"])

            # Fallback 2: query position from exchange
            if fill_price == 0:
                logger.warning(f"avgPrice=0 for {symbol}, querying position...")
                time.sleep(0.3)
                positions = self.client.futures_position_information(symbol=symbol)
                for p in positions:
                    if float(p["positionAmt"]) != 0:
                        fill_price = float(p["entryPrice"])
                        logger.info(f"Got fill from position: {fill_price}")
                        break

            logger.info(f"MARKET {side} {symbol} qty={qty} fill={fill_price}")
            return order, fill_price
        except BinanceAPIException as e:
            logger.error(f"Market order failed {symbol}: {e}")
            raise

    def place_sl(self, symbol, side, qty, stop_price, symbol_info):
        """
        Стоп-лосс = STOP_MARKET + quantity + reduceOnly.

        ⚠️ НИКОГДА не использовать closePosition=True!
        Это создаёт невидимые algo ордера которые не отменяются.
        """
        stop_price = self.round_price(symbol_info, stop_price)
        try:
            order = self.client.futures_create_order(
                symbol=symbol,
                side=side,  # "SELL" for long SL, "BUY" for short SL
                type="STOP_MARKET",
                stopPrice=stop_price,
                quantity=qty,           # ← quantity, НЕ closePosition!
                reduceOnly=True,
                workingType="MARK_PRICE",
            )
            logger.info(f"SL placed {symbol} side={side} stop={stop_price} qty={qty}")
            return order
        except BinanceAPIException as e:
            logger.error(f"SL order failed {symbol}: {e}")
            raise

    def place_tp(self, symbol, side, qty, price, symbol_info):
        """
        Тейк-профит = LIMIT + reduceOnly.
        Maker fee 0.02% (дешевле маркета).
        """
        price = self.round_price(symbol_info, price)
        try:
            order = self.client.futures_create_order(
                symbol=symbol,
                side=side,  # "SELL" for long TP, "BUY" for short TP
                type="LIMIT",
                price=price,
                quantity=qty,
                reduceOnly=True,
                timeInForce="GTC",
            )
            logger.info(f"TP placed {symbol} side={side} price={price} qty={qty}")
            return order
        except BinanceAPIException as e:
            logger.error(f"TP order failed {symbol}: {e}")
            raise

    def cancel_all_orders(self, symbol, retries=3):
        """
        Отменить ВСЕ ордера на символ.

        ⚠️ Это ЕДИНСТВЕННЫЙ надёжный способ убить и regular, и algo ордера.
        futures_cancel_order(orderId) НЕ работает для algo ордеров.
        """
        for attempt in range(retries):
            try:
                self.client.futures_cancel_all_open_orders(symbol=symbol)
                # Верификация
                remaining = self.client.futures_get_open_orders(symbol=symbol)
                if not remaining:
                    logger.info(f"All orders cancelled {symbol}")
                    return True
                logger.warning(
                    f"Cancel attempt {attempt+1}: {len(remaining)} orders remain on {symbol}"
                )
            except BinanceAPIException as e:
                logger.warning(f"Cancel error {symbol} attempt {attempt+1}: {e}")
            time.sleep(0.5)

        logger.error(f"Failed to cancel all orders on {symbol} after {retries} retries")
        return False

    def nuclear_cleanup(self):
        """
        При старте бота: cancel ВСЕ ордера на ВСЕ символы.
        Защита от накопления ордеров после рестартов.
        """
        positions = self.get_positions()
        symbols_with_positions = [p["symbol"] for p in positions]

        # Также проверяем все открытые ордера
        try:
            all_orders = self.client.futures_get_open_orders()
            for order in all_orders:
                sym = order["symbol"]
                if sym not in symbols_with_positions:
                    symbols_with_positions.append(sym)
        except Exception as e:
            logger.warning(f"Error getting open orders: {e}")

        cancelled_count = 0
        for sym in symbols_with_positions:
            try:
                self.client.futures_cancel_all_open_orders(symbol=sym)
                cancelled_count += 1
            except Exception:
                pass

        logger.info(f"Nuclear cleanup: cancelled orders on {cancelled_count} symbols")
        return cancelled_count

    def close_position(self, symbol, side, qty):
        """
        Закрыть позицию маркетом.
        side = "SELL" для закрытия long, "BUY" для закрытия short.
        Использует reduceOnly=True чтобы Binance пропускал мелкие остатки (<$5 notional).
        """
        # Сначала отменяем все ордера
        self.cancel_all_orders(symbol)
        # Потом закрываем маркетом с reduceOnly
        try:
            order = self.client.futures_create_order(
                symbol=symbol,
                side=side,
                type="MARKET",
                quantity=qty,
                reduceOnly=True,
                newOrderRespType="RESULT",
            )
            fill_price = float(order.get("avgPrice", 0))

            # Fallback: fills array
            if fill_price == 0 and order.get("fills"):
                fill_price = sum(
                    float(f["price"]) * float(f["qty"]) for f in order["fills"]
                ) / sum(float(f["qty"]) for f in order["fills"])

            logger.info(f"CLOSE MARKET {side} {symbol} qty={qty} fill={fill_price}")
            return fill_price
        except BinanceAPIException as e:
            logger.error(f"Close position failed {symbol}: {e}")
            raise

    def get_open_orders(self, symbol):
        """Получить открытые ордера на символ."""
        return self.client.futures_get_open_orders(symbol=symbol)

📜 Git History

c6f6bd5chore: initial commit — version control setup5 weeks ago
Show last diff
Loading...