โ† ะะฐะทะฐะด
""" Grid Bot โ€” Binance Futures Client ==================================== Based on wt-bot-v3 exchange.py, adapted for grid trading. Key additions: - place_limit_order() โ€” for grid levels (maker fee) - cancel_order() โ€” cancel single order by ID - get_order_status() โ€” check if limit order filled - place_grid_orders() โ€” batch place grid levels RULES (from wt-bot-v3 lessons): - SL = STOP_MARKET with quantity + reduceOnly (NOT closePosition!) - Grid orders = LIMIT (maker fee 0.02%) - Cancel = futures_cancel_all_open_orders(symbol) for full cleanup - Nuclear cleanup on startup """ 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 self._symbol_info_cache = {} 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): """All futures tickers 24h โ€” single request.""" return self.client.futures_ticker() def get_klines(self, symbol, interval, limit=500): """Get candles.""" return self.client.futures_klines( symbol=symbol, interval=interval, limit=limit ) def get_mark_price(self, symbol): """Current mark price.""" data = self.client.futures_mark_price(symbol=symbol) return float(data["markPrice"]) def get_ticker_price(self, symbol): """Current last price (faster than mark).""" data = self.client.futures_symbol_ticker(symbol=symbol) return float(data["price"]) def get_funding_rate(self, symbol): """Current last funding rate (from premiumIndex endpoint).""" try: data = self.client.futures_mark_price(symbol=symbol) return float(data.get("lastFundingRate", 0)) except Exception as e: logger.debug(f"Funding fetch failed {symbol}: {e}") return 0.0 # ============================================================ # ACCOUNT # ============================================================ def get_balance(self): """USDT balance.""" balances = self.client.futures_account_balance() for b in balances: if b["asset"] == "USDT": return float(b["balance"]) return 0.0 def get_available_balance(self): """USDT available (free) balance.""" account = self.client.futures_account() return float(account.get("availableBalance", 0)) def get_positions(self): """All open positions (positionAmt != 0).""" positions = self.client.futures_position_information() return [p for p in positions if float(p["positionAmt"]) != 0] def set_leverage(self, symbol): """Set leverage. Ignores if already set.""" try: self.client.futures_change_leverage( symbol=symbol, leverage=LEVERAGE ) except BinanceAPIException as e: if e.code != -4028: logger.warning(f"Leverage error {symbol}: {e}") def set_margin_type(self, symbol, margin_type="CROSSED"): """Set margin type. Ignores if already set.""" try: self.client.futures_change_margin_type( symbol=symbol, marginType=margin_type ) except BinanceAPIException as e: if e.code != -4046: logger.warning(f"Margin type error {symbol}: {e}") # ============================================================ # SYMBOL INFO # ============================================================ def get_symbol_info(self, symbol): """Get precision, min qty and tick size. Cached per session.""" if symbol in self._symbol_info_cache: return self._symbol_info_cache[symbol] 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 min_notional = 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"]) if f["filterType"] == "MIN_NOTIONAL": min_notional = float(f.get("notional", 5)) result = { "price_precision": price_precision, "qty_precision": qty_precision, "min_qty": min_qty, "tick_size": tick_size, "min_notional": min_notional, } self._symbol_info_cache[symbol] = result return result return None def round_price(self, symbol_info, price): """Round price to tick size.""" tick = symbol_info.get("tick_size") if tick and tick > 0: 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): """Round quantity to precision.""" return round(qty, symbol_info["qty_precision"]) # ============================================================ # ORDERS โ€” GRID-SPECIFIC # ============================================================ def place_limit_order(self, symbol, side, qty, price, symbol_info, reduce_only=False): """ Place limit order (maker fee 0.02%). Used for grid levels โ€” both entry and exit. """ price = self.round_price(symbol_info, price) qty = self.round_qty(symbol_info, qty) if qty <= 0: logger.warning(f"Qty <= 0 for {symbol}, skipping") return None try: params = { "symbol": symbol, "side": side, "type": "LIMIT", "price": price, "quantity": qty, "timeInForce": "GTC", } if reduce_only: params["reduceOnly"] = True order = self.client.futures_create_order(**params) order_id = order.get("orderId") logger.info(f"LIMIT {side} {symbol} qty={qty} price={price} โ†’ #{order_id}") return order except BinanceAPIException as e: logger.error(f"Limit order failed {symbol} {side} p={price} q={qty}: {e}") return None def cancel_order(self, symbol, order_id): """Cancel single order by ID.""" try: self.client.futures_cancel_order(symbol=symbol, orderId=order_id) logger.debug(f"Cancelled order #{order_id} on {symbol}") return True except BinanceAPIException as e: if e.code == -2011: # Unknown order / already filled/cancelled return True logger.warning(f"Cancel order error {symbol} #{order_id}: {e}") return False def cancel_all_orders(self, symbol, retries=3): """Cancel ALL orders on symbol (nuclear option).""" 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)} 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 get_open_orders(self, symbol): """Get open orders for symbol.""" try: return self.client.futures_get_open_orders(symbol=symbol) except BinanceAPIException as e: logger.error(f"Get open orders error {symbol}: {e}") return [] def get_order_status(self, symbol, order_id): """Check single order status.""" try: order = self.client.futures_get_order(symbol=symbol, orderId=order_id) return order except BinanceAPIException as e: logger.error(f"Get order status error {symbol} #{order_id}: {e}") return None def close_position_market(self, symbol, side, qty, symbol_info): """ Close position via market order. side = "SELL" for closing long, "BUY" for closing short. """ qty = self.round_qty(symbol_info, abs(qty)) if qty <= 0: return 0 self.cancel_all_orders(symbol) 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)) 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}") return 0 def nuclear_cleanup(self): """Startup: cancel ALL orders AND close ALL positions.""" positions = self.get_positions() symbols = [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: symbols.append(sym) except Exception as e: logger.warning(f"Error getting open orders: {e}") # 1. Cancel all open orders cancelled = 0 for sym in symbols: try: self.client.futures_cancel_all_open_orders(symbol=sym) cancelled += 1 except Exception: pass # 2. Close all open positions (prevent orphaned naked positions) closed_positions = 0 for pos in positions: amt = float(pos.get("positionAmt", 0)) if amt == 0: continue sym = pos["symbol"] try: close_side = "SELL" if amt > 0 else "BUY" self.client.futures_create_order( symbol=sym, side=close_side, type="MARKET", quantity=abs(amt), reduceOnly=True, ) closed_positions += 1 logger.info(f"Nuclear cleanup: closed {sym} amt={amt}") except Exception as e: logger.error(f"Nuclear cleanup: failed to close {sym}: {e}") logger.info(f"Nuclear cleanup: cancelled orders on {cancelled} symbols, closed {closed_positions} positions") return cancelled