"""
WT Bot v3 — Binance Futures Client
====================================
Обёртка над python-binance с учётом граблей из v2:
ГРАБЛИ (НЕ ПОВТОРЯТЬ):
1. НИКОГДА closePosition=True в STOP_MARKET — создаёт невидимые algo ордера
2. STOP_MARKET возвращает algoId, не orderId — нельзя query/cancel по ID
3. futures_get_open_orders() НЕ показывает algo ордера
4. ТОЛЬКО futures_cancel_all_open_orders(symbol) убивает ВСЕ ордера
5. При старте — nuclear cleanup: cancel все ордера на все символы с позициями
ПРАВИЛА:
- SL = STOP_MARKET с quantity + reduceOnly (НЕ closePosition!)
- TP = LIMIT с reduceOnly — maker fee 0.02%
- Cancel = futures_cancel_all_open_orders(symbol)
"""
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
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):
"""Все фьючерсные тикеры за 24ч — один запрос."""
return self.client.futures_ticker()
def get_klines(self, symbol, interval, limit=500):
"""Получить свечи."""
return self.client.futures_klines(
symbol=symbol, interval=interval, limit=limit
)
def get_mark_price(self, symbol):
"""Текущая mark price."""
data = self.client.futures_mark_price(symbol=symbol)
return float(data["markPrice"])
# ============================================================
# ACCOUNT
# ============================================================
def get_balance(self):
"""USDT баланс."""
balances = self.client.futures_account_balance()
for b in balances:
if b["asset"] == "USDT":
return float(b["balance"])
return 0.0
def get_positions(self):
"""Все открытые позиции (positionAmt != 0)."""
positions = self.client.futures_position_information()
return [p for p in positions if float(p["positionAmt"]) != 0]
def set_leverage(self, symbol):
"""Установить leverage. Игнорирует если уже установлен."""
try:
self.client.futures_change_leverage(
symbol=symbol, leverage=LEVERAGE
)
except BinanceAPIException as e:
if e.code != -4028: # Already set
logger.warning(f"Leverage error {symbol}: {e}")
def set_margin_type(self, symbol, margin_type="CROSSED"):
"""Установить margin type. Игнорирует если уже установлен."""
try:
self.client.futures_change_margin_type(
symbol=symbol, marginType=margin_type
)
except BinanceAPIException as e:
if e.code != -4046: # Already set
logger.warning(f"Margin type error {symbol}: {e}")
# ============================================================
# SYMBOL INFO
# ============================================================
def get_symbol_info(self, symbol):
"""Получить precision, min qty и tick size для символа."""
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
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"])
return {
"price_precision": price_precision,
"qty_precision": qty_precision,
"min_qty": min_qty,
"tick_size": tick_size,
}
return None
def round_price(self, symbol_info, price):
"""Округлить цену до tick size (кратность)."""
tick = symbol_info.get("tick_size")
if tick and tick > 0:
# Округляем до ближайшего кратного tick size
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):
"""Округлить количество до нужной precision."""
return round(qty, symbol_info["qty_precision"])
# ============================================================
# ORDERS — с учётом ВСЕХ граблей
# ============================================================
def open_market(self, symbol, side, qty):
"""Открыть позицию по маркету."""
try:
order = self.client.futures_create_order(
symbol=symbol,
side=side, # "BUY" or "SELL"
type="MARKET",
quantity=qty,
newOrderRespType="RESULT", # ← нужен для avgPrice!
)
fill_price = float(order.get("avgPrice", 0))
# Fallback 1: fills array
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"])
# Fallback 2: query position from exchange
if fill_price == 0:
logger.warning(f"avgPrice=0 for {symbol}, querying position...")
time.sleep(0.3)
positions = self.client.futures_position_information(symbol=symbol)
for p in positions:
if float(p["positionAmt"]) != 0:
fill_price = float(p["entryPrice"])
logger.info(f"Got fill from position: {fill_price}")
break
logger.info(f"MARKET {side} {symbol} qty={qty} fill={fill_price}")
return order, fill_price
except BinanceAPIException as e:
logger.error(f"Market order failed {symbol}: {e}")
raise
def place_sl(self, symbol, side, qty, stop_price, symbol_info):
"""
Стоп-лосс = STOP_MARKET + quantity + reduceOnly.
⚠️ НИКОГДА не использовать closePosition=True!
Это создаёт невидимые algo ордера которые не отменяются.
"""
stop_price = self.round_price(symbol_info, stop_price)
try:
order = self.client.futures_create_order(
symbol=symbol,
side=side, # "SELL" for long SL, "BUY" for short SL
type="STOP_MARKET",
stopPrice=stop_price,
quantity=qty, # ← quantity, НЕ closePosition!
reduceOnly=True,
workingType="MARK_PRICE",
)
logger.info(f"SL placed {symbol} side={side} stop={stop_price} qty={qty}")
return order
except BinanceAPIException as e:
logger.error(f"SL order failed {symbol}: {e}")
raise
def place_tp(self, symbol, side, qty, price, symbol_info):
"""
Тейк-профит = LIMIT + reduceOnly.
Maker fee 0.02% (дешевле маркета).
"""
price = self.round_price(symbol_info, price)
try:
order = self.client.futures_create_order(
symbol=symbol,
side=side, # "SELL" for long TP, "BUY" for short TP
type="LIMIT",
price=price,
quantity=qty,
reduceOnly=True,
timeInForce="GTC",
)
logger.info(f"TP placed {symbol} side={side} price={price} qty={qty}")
return order
except BinanceAPIException as e:
logger.error(f"TP order failed {symbol}: {e}")
raise
def cancel_all_orders(self, symbol, retries=3):
"""
Отменить ВСЕ ордера на символ.
⚠️ Это ЕДИНСТВЕННЫЙ надёжный способ убить и regular, и algo ордера.
futures_cancel_order(orderId) НЕ работает для algo ордеров.
"""
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)} orders 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 nuclear_cleanup(self):
"""
При старте бота: cancel ВСЕ ордера на ВСЕ символы.
Защита от накопления ордеров после рестартов.
"""
positions = self.get_positions()
symbols_with_positions = [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_with_positions:
symbols_with_positions.append(sym)
except Exception as e:
logger.warning(f"Error getting open orders: {e}")
cancelled_count = 0
for sym in symbols_with_positions:
try:
self.client.futures_cancel_all_open_orders(symbol=sym)
cancelled_count += 1
except Exception:
pass
logger.info(f"Nuclear cleanup: cancelled orders on {cancelled_count} symbols")
return cancelled_count
def close_position(self, symbol, side, qty):
"""
Закрыть позицию маркетом.
side = "SELL" для закрытия long, "BUY" для закрытия short.
Использует reduceOnly=True чтобы Binance пропускал мелкие остатки (<$5 notional).
"""
# Сначала отменяем все ордера
self.cancel_all_orders(symbol)
# Потом закрываем маркетом с reduceOnly
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))
# Fallback: fills array
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}")
raise
def get_open_orders(self, symbol):
"""Получить открытые ордера на символ."""
return self.client.futures_get_open_orders(symbol=symbol)