← Назад
""" 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)