← Назад
""" Binance Futures Trader — authenticated client for order execution. Handles position opening, partial closes, leverage, and quantity calculation. """ import logging import math import os from binance.client import Client from binance.exceptions import BinanceAPIException logger = logging.getLogger(__name__) class BinanceFuturesTrader: """Authenticated Binance Futures client wrapper.""" def __init__(self): api_key = os.environ.get("BINANCE_API_KEY", "") api_secret = os.environ.get("BINANCE_API_SECRET", "") if not api_key or not api_secret: raise ValueError("BINANCE_API_KEY and BINANCE_API_SECRET must be set") self.client = Client(api_key, api_secret) self._exchange_info_cache = {} logger.info("Binance Futures trader initialized (REAL mode)") def get_symbol_info(self, symbol: str) -> dict | None: """Fetch exchange info for a symbol (cached).""" if symbol in self._exchange_info_cache: return self._exchange_info_cache[symbol] try: info = self.client.futures_exchange_info() for s in info["symbols"]: if s["symbol"] == symbol: self._exchange_info_cache[symbol] = s return s except Exception as e: logger.error(f"Failed to get exchange info for {symbol}: {e}") return None def get_quantity_precision(self, symbol: str) -> int: """Get quantity precision (decimal places) for a symbol.""" info = self.get_symbol_info(symbol) if info: return info.get("quantityPrecision", 3) return 3 def get_price_precision(self, symbol: str) -> int: """Get price precision for a symbol.""" info = self.get_symbol_info(symbol) if info: return info.get("pricePrecision", 2) return 2 def get_step_size(self, symbol: str) -> float: """Get lot step size from filters.""" info = self.get_symbol_info(symbol) if info: for f in info.get("filters", []): if f["filterType"] == "LOT_SIZE": return float(f["stepSize"]) return 0.001 def get_min_qty(self, symbol: str) -> float: """Get minimum order quantity.""" info = self.get_symbol_info(symbol) if info: for f in info.get("filters", []): if f["filterType"] == "LOT_SIZE": return float(f["minQty"]) return 0.001 def get_min_notional(self, symbol: str) -> float: """Get minimum notional value for an order.""" info = self.get_symbol_info(symbol) if info: for f in info.get("filters", []): if f["filterType"] == "MIN_NOTIONAL": return float(f.get("notional", 5)) return 5.0 def round_quantity(self, symbol: str, qty: float) -> float: """Round quantity to valid step size.""" step = self.get_step_size(symbol) precision = self.get_quantity_precision(symbol) if step > 0: qty = math.floor(qty / step) * step return round(qty, precision) def calculate_quantity(self, symbol: str, usdt_amount: float, price: float, leverage: int) -> float: """ Calculate order quantity. qty = (usdt_margin * leverage) / price Then round to valid precision. """ if price <= 0: return 0 raw_qty = (usdt_amount * leverage) / price qty = self.round_quantity(symbol, raw_qty) min_qty = self.get_min_qty(symbol) if qty < min_qty: logger.warning(f"{symbol}: calculated qty {qty} < min {min_qty}") return 0 # Check min notional notional = qty * price min_notional = self.get_min_notional(symbol) if notional < min_notional: logger.warning(f"{symbol}: notional {notional:.2f} < min {min_notional}") return 0 return qty def set_leverage(self, symbol: str, leverage: int) -> bool: """Set leverage for a symbol.""" try: self.client.futures_change_leverage(symbol=symbol, leverage=leverage) logger.info(f"Leverage set to {leverage}x for {symbol}") return True except BinanceAPIException as e: # -4028 means leverage already set if e.code == -4028: return True logger.error(f"Failed to set leverage for {symbol}: {e}") return False def set_margin_type(self, symbol: str, margin_type: str = "ISOLATED") -> bool: """Set margin type (ISOLATED or CROSSED).""" try: self.client.futures_change_margin_type(symbol=symbol, marginType=margin_type) logger.info(f"Margin type set to {margin_type} for {symbol}") return True except BinanceAPIException as e: # -4046 means already set to this type if e.code == -4046: return True logger.error(f"Failed to set margin type for {symbol}: {e}") return False def get_mark_price(self, symbol: str) -> float | None: """Get current mark price (used for liquidation/PnL, more reliable than last price).""" try: data = self.client.futures_mark_price(symbol=symbol) return float(data["markPrice"]) except Exception as e: logger.error(f"Failed to get mark price for {symbol}: {e}") return None def get_account_balance(self) -> float: """Get available USDT balance in futures wallet.""" try: balances = self.client.futures_account_balance() for b in balances: if b["asset"] == "USDT": return float(b["availableBalance"]) except Exception as e: logger.error(f"Failed to get account balance: {e}") return 0 def get_position(self, symbol: str) -> dict | None: """Get current open position for a symbol. Returns None if no position.""" try: positions = self.client.futures_position_information(symbol=symbol) for p in positions: if p["symbol"] == symbol and float(p["positionAmt"]) != 0: return { "symbol": p["symbol"], "side": "BUY" if float(p["positionAmt"]) > 0 else "SELL", "quantity": abs(float(p["positionAmt"])), "entry_price": float(p["entryPrice"]), "unrealized_pnl": float(p["unRealizedProfit"]), "leverage": int(p.get("leverage", 5)), "margin_type": p.get("marginType", "isolated"), } except Exception as e: logger.error(f"Failed to get position for {symbol}: {e}") return None def open_position(self, symbol: str, side: str, usdt_margin: float, leverage: int) -> dict | None: """ Open a new futures position. Args: symbol: e.g. "BTCUSDT" side: "BUY" (long) or "SELL" (short) usdt_margin: margin amount in USDT (e.g. 10) leverage: leverage multiplier (e.g. 5) Returns: dict with fill info or None on error """ try: # Set leverage and margin type if not self.set_leverage(symbol, leverage): return None self.set_margin_type(symbol, "ISOLATED") # Get current price for qty calculation price = self.get_mark_price(symbol) if not price: return None # Calculate quantity qty = self.calculate_quantity(symbol, usdt_margin, price, leverage) if qty <= 0: logger.error(f"Invalid quantity for {symbol}: margin={usdt_margin}, price={price}, lev={leverage}") return None # Place market order order = self.client.futures_create_order( symbol=symbol, side=side, type="MARKET", quantity=qty, ) # Get fill price from order response fill_price = 0 # Try avgPrice first if order.get("avgPrice") and float(order["avgPrice"]) > 0: fill_price = float(order["avgPrice"]) # Try fills array if fill_price == 0 and order.get("fills"): fills = order["fills"] total_qty = sum(float(f["qty"]) for f in fills) if total_qty > 0: fill_price = sum(float(f["price"]) * float(f["qty"]) for f in fills) / total_qty # Fallback: fetch actual entry from position info if fill_price == 0: logger.warning(f"No fill price from order response, fetching from position info") try: pos_info = self.client.futures_position_information(symbol=symbol) for pi in pos_info: if pi["symbol"] == symbol and float(pi["positionAmt"]) != 0: fill_price = float(pi["entryPrice"]) break except Exception: pass # Last fallback: mark price if fill_price == 0: fill_price = price logger.warning(f"Using mark price as fill price fallback: {price}") result = { "orderId": order["orderId"], "symbol": symbol, "side": side, "quantity": qty, "fill_price": fill_price, "leverage": leverage, "usdt_margin": usdt_margin, "status": order["status"], } logger.info(f"Position opened: {side} {qty} {symbol} @ ${fill_price:.6f} ({leverage}x)") return result except BinanceAPIException as e: logger.error(f"Binance API error opening {side} {symbol}: {e}") return None except Exception as e: logger.error(f"Error opening position {side} {symbol}: {e}") return None def round_price(self, symbol: str, price: float) -> float: """Round price to valid tick size.""" precision = self.get_price_precision(symbol) info = self.get_symbol_info(symbol) if info: for f in info.get("filters", []): if f["filterType"] == "PRICE_FILTER": tick = float(f["tickSize"]) if tick > 0: price = math.floor(price / tick) * tick return round(price, precision) def open_limit_order(self, symbol: str, side: str, quantity: float, price: float, reduce_only: bool = False) -> dict | None: """ Place a LIMIT order on Binance Futures. Returns dict with orderId, status, fill_price (if filled immediately), or None. """ try: rounded_price = self.round_price(symbol, price) qty = self.round_quantity(symbol, quantity) if qty <= 0: return None params = { "symbol": symbol, "side": side, "type": "LIMIT", "quantity": qty, "price": rounded_price, "timeInForce": "GTC", } if reduce_only: params["reduceOnly"] = True order = self.client.futures_create_order(**params) fill_price = 0 if order.get("avgPrice") and float(order["avgPrice"]) > 0: fill_price = float(order["avgPrice"]) status = order.get("status", "NEW") logger.info(f"Limit order: {side} {qty} {symbol} @ ${rounded_price:.6f} → {status}") return { "orderId": order["orderId"], "symbol": symbol, "side": side, "quantity": qty, "price": rounded_price, "fill_price": fill_price if fill_price > 0 else None, "status": status, } except BinanceAPIException as e: logger.error(f"Binance API error limit order {side} {symbol}: {e}") return None except Exception as e: logger.error(f"Error limit order {side} {symbol}: {e}") return None def get_order_status(self, symbol: str, order_id: int) -> dict | None: """Check status of an existing order.""" try: order = self.client.futures_get_order(symbol=symbol, orderId=order_id) fill_price = 0 if order.get("avgPrice") and float(order["avgPrice"]) > 0: fill_price = float(order["avgPrice"]) return { "orderId": order["orderId"], "status": order["status"], "fill_price": fill_price, "executedQty": float(order.get("executedQty", 0)), "origQty": float(order.get("origQty", 0)), } except Exception as e: logger.error(f"Failed to get order status {symbol} #{order_id}: {e}") return None def cancel_order(self, symbol: str, order_id: int) -> bool: """Cancel an open order. Returns True if cancelled or already done.""" try: self.client.futures_cancel_order(symbol=symbol, orderId=order_id) logger.info(f"Cancelled order {symbol} #{order_id}") return True except BinanceAPIException as e: # -2011 = order already filled/cancelled if e.code == -2011: return True logger.error(f"Failed to cancel order {symbol} #{order_id}: {e}") return False except Exception as e: logger.error(f"Error cancelling order {symbol} #{order_id}: {e}") return False # === Exchange-side TP/SL orders === def place_take_profit_limit(self, symbol: str, position_side: str, quantity: float, stop_price: float, limit_price: float) -> dict | None: """ Place a TAKE_PROFIT limit order (reduceOnly) on Binance Futures. Triggers when price hits stop_price, then places limit at limit_price. Earns maker fee (0.02%) instead of taker (0.04%). Args: symbol: e.g. "BTCUSDT" position_side: original position side ("BUY" for long, "SELL" for short) quantity: amount to close stop_price: trigger price limit_price: actual fill price (slightly beyond stop_price for guaranteed fill) """ try: closing_side = "SELL" if position_side == "BUY" else "BUY" qty = self.round_quantity(symbol, quantity) sp = self.round_price(symbol, stop_price) lp = self.round_price(symbol, limit_price) if qty <= 0: return None order = self.client.futures_create_order( symbol=symbol, side=closing_side, type="TAKE_PROFIT", stopPrice=sp, price=lp, quantity=qty, timeInForce="GTC", reduceOnly=True, ) # Binance returns algoId for conditional orders (TAKE_PROFIT), orderId for regular order_id = order.get("orderId") or order.get("algoId") status = order.get("status") or order.get("algoStatus", "NEW") logger.info(f"TP limit placed: {closing_side} {qty} {symbol} trigger=${sp} limit=${lp} id={order_id}") return { "orderId": order_id, "symbol": symbol, "status": status, "is_algo": "algoId" in order, } except BinanceAPIException as e: logger.error(f"Binance API error TP limit {symbol}: {e}") return None except Exception as e: logger.error(f"Error TP limit {symbol}: {e}") return None def place_stop_market(self, symbol: str, position_side: str, quantity: float, stop_price: float) -> dict | None: """ Place a STOP_MARKET order with explicit quantity on Binance Futures. Uses reduceOnly + quantity instead of closePosition=True. closePosition=True creates unkillable algo orders that survive cancel_all_open_orders. Args: position_side: original position side ("BUY" for long, "SELL" for short) quantity: amount to close on SL trigger stop_price: trigger price """ try: closing_side = "SELL" if position_side == "BUY" else "BUY" sp = self.round_price(symbol, stop_price) qty = self.round_quantity(symbol, quantity) if qty <= 0: logger.error(f"Invalid SL quantity for {symbol}: {quantity}") return None order = self.client.futures_create_order( symbol=symbol, side=closing_side, type="STOP_MARKET", stopPrice=sp, quantity=qty, reduceOnly=True, ) order_id = order.get("orderId") or order.get("algoId") status = order.get("status") or order.get("algoStatus", "NEW") logger.info(f"Stop market placed: {closing_side} {qty} {symbol} trigger=${sp} id={order_id}") return { "orderId": order_id, "symbol": symbol, "status": status, "is_algo": "algoId" in order, } except BinanceAPIException as e: logger.error(f"Binance API error stop market {symbol}: {e}") return None except Exception as e: logger.error(f"Error stop market {symbol}: {e}") return None def cancel_all_orders(self, symbol: str) -> bool: """Cancel ALL open orders for a symbol (regular + algo/conditional). Retries up to 3 times and verifies via get_open_orders that everything is gone.""" import time as _time max_attempts = 3 for attempt in range(1, max_attempts + 1): try: self.client.futures_cancel_all_open_orders(symbol=symbol) logger.info(f"Cancelled all orders for {symbol} (attempt {attempt})") except BinanceAPIException as e: if e.code == -2011: # no orders to cancel — already clean return True logger.warning(f"Cancel attempt {attempt} for {symbol}: {e}") # Verify: check if any orders remain _time.sleep(0.5) remaining = self.get_open_orders(symbol) if not remaining: logger.info(f"Verified: 0 orders remaining for {symbol}") return True logger.warning(f"Cancel {symbol}: {len(remaining)} orders survived attempt {attempt}") # Try cancelling individually as fallback for order in remaining: try: self.client.futures_cancel_order(symbol=symbol, orderId=order["orderId"]) logger.info(f"Individual cancel: {symbol} #{order['orderId']} ({order['type']})") except BinanceAPIException as e: if e.code != -2011: logger.warning(f"Individual cancel failed {symbol} #{order['orderId']}: {e}") _time.sleep(0.5) # Final verification remaining = self.get_open_orders(symbol) if remaining: logger.error(f"FAILED to cancel all orders for {symbol}: {len(remaining)} still alive") return False return True def cancel_all_account_orders(self) -> int: """Cancel ALL open orders on ALL symbols. Used for startup cleanup. Returns number of symbols cleaned.""" cleaned = 0 try: orders = self.client.futures_get_open_orders() symbols_with_orders = set(o["symbol"] for o in orders) if not symbols_with_orders: logger.info("Startup cleanup: no open orders on account") return 0 logger.info(f"Startup cleanup: found orders on {len(symbols_with_orders)} symbols: {symbols_with_orders}") for sym in symbols_with_orders: if self.cancel_all_orders(sym): cleaned += 1 logger.info(f"Startup cleanup: cleared {sym}") else: logger.error(f"Startup cleanup: FAILED to clear {sym}") except Exception as e: logger.error(f"Startup cleanup error: {e}") return cleaned def get_open_orders(self, symbol: str) -> list[dict]: """Get all open orders for a symbol (for recovery).""" try: orders = self.client.futures_get_open_orders(symbol=symbol) return [{ "orderId": o["orderId"], "type": o["type"], "side": o["side"], "status": o["status"], "stopPrice": float(o.get("stopPrice", 0)), "price": float(o.get("price", 0)), "origQty": float(o.get("origQty", 0)), "executedQty": float(o.get("executedQty", 0)), } for o in orders] except Exception as e: logger.error(f"Failed to get open orders {symbol}: {e}") return [] def close_partial(self, symbol: str, side: str, quantity: float) -> dict | None: """ Close part of position (reduce-only). Args: symbol: e.g. "BTCUSDT" side: original position side ("BUY" for long, "SELL" for short) quantity: amount to close """ try: opposite = "SELL" if side == "BUY" else "BUY" qty = self.round_quantity(symbol, quantity) if qty <= 0: return None order = self.client.futures_create_order( symbol=symbol, side=opposite, type="MARKET", quantity=qty, reduceOnly=True, ) fill_price = float(order.get("avgPrice", 0)) logger.info(f"Partial close: {qty} {symbol} @ ${fill_price:.6f}") return { "orderId": order["orderId"], "symbol": symbol, "quantity": qty, "fill_price": fill_price, "status": order["status"], } except BinanceAPIException as e: logger.error(f"Binance API error closing partial {symbol}: {e}") return None except Exception as e: logger.error(f"Error closing partial {symbol}: {e}") return None def close_full(self, symbol: str, side: str) -> dict | None: """Close entire position.""" pos = self.get_position(symbol) if not pos: logger.warning(f"No open position to close for {symbol}") return None return self.close_partial(symbol, side, pos["quantity"])