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

📜 Git History

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