← 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, BINANCE_TESTNET,
    OF_LEVERAGE as LEVERAGE, MAKER_FEE, TAKER_FEE
)

logger = logging.getLogger("exchange")


class Exchange:
    def __init__(self):
        self.client = Client(BINANCE_API_KEY, BINANCE_API_SECRET, testnet=BINANCE_TESTNET)
        self._exchange_info = None
        self._exchange_info_ts = 0
        self._symbol_info_cache = {}
        logger.info(f"Binance client initialized ({'TESTNET' if BINANCE_TESTNET else 'MAINNET'})")

    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

    # ============================================================
    # ORDERS — OF-SPECIFIC (maker entry + bracket)
    # ============================================================

    def get_best_bid_ask(self, symbol):
        """Best bid/ask from book ticker (for maker entry pricing)."""
        try:
            t = self.client.futures_orderbook_ticker(symbol=symbol)
            return float(t["bidPrice"]), float(t["askPrice"])
        except BinanceAPIException as e:
            logger.error(f"Book ticker error {symbol}: {e}")
            return None, None

    def place_limit_maker(self, symbol, side, qty, price, symbol_info):
        """
        Post-only maker limit entry (timeInForce GTX → rejected if it would
        cross the book / take). Returns order dict, or None on failure.
        Taker entry is intentionally NOT offered: it kills the OF edge.
        """
        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:
            order = self.client.futures_create_order(
                symbol=symbol, side=side, type="LIMIT",
                price=price, quantity=qty, timeInForce="GTX",
            )
            logger.info(f"MAKER {side} {symbol} qty={qty} price={price} → #{order.get('orderId')}")
            return order
        except BinanceAPIException as e:
            # -5022 = GTX would immediately match (post-only rejected) — expected, retry on next chase tick
            if e.code == -5022:
                logger.debug(f"Maker post-only rejected (would take) {symbol} @ {price}")
                return None
            logger.error(f"Maker order failed {symbol} {side} p={price} q={qty}: {e}")
            return None

    @staticmethod
    def _order_id(order):
        """Binance returns orderId for normal orders, algoId for conditional/algo orders."""
        return order.get("orderId") or order.get("algoId") if order else None

    def place_sl_market(self, symbol, side, stop_price, symbol_info):
        """STOP_MARKET closePosition (side = exit side: SELL for long, BUY for short).
        closePosition=True → whole position, auto-cancels its TP twin on trigger and
        when the position goes flat. Returns the order, or None on failure."""
        stop_price = self.round_price(symbol_info, stop_price)
        try:
            order = self.client.futures_create_order(
                symbol=symbol, side=side, type="STOP_MARKET",
                stopPrice=stop_price, closePosition=True, workingType="MARK_PRICE",
            )
            logger.info(f"SL {side} {symbol} stop={stop_price} → #{self._order_id(order)}")
            return order
        except BinanceAPIException as e:
            logger.error(f"SL order failed {symbol} stop={stop_price}: {e}")
            return None

    def place_tp_market(self, symbol, side, stop_price, symbol_info):
        """TAKE_PROFIT_MARKET closePosition (side = exit side)."""
        stop_price = self.round_price(symbol_info, stop_price)
        try:
            order = self.client.futures_create_order(
                symbol=symbol, side=side, type="TAKE_PROFIT_MARKET",
                stopPrice=stop_price, closePosition=True, workingType="MARK_PRICE",
            )
            logger.info(f"TP {side} {symbol} stop={stop_price} → #{self._order_id(order)}")
            return order
        except BinanceAPIException as e:
            logger.error(f"TP order failed {symbol} stop={stop_price}: {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

495965ffix(of-trader): robust bracket — closePosition + algoId + naked-position guard4 weeks ago
a509d6ffeat(of-trader): exchange maker entry + bracket + book primitives (chunk 2a)4 weeks ago
120af73chore: archive grid-v3, scaffold of-trader from grid infra4 weeks ago
Show last diff
Loading...