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