โ ะะฐะทะฐะด"""
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