← Back
"""
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

📜 Git History

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