tradebot.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  1. import time
  2. import logging
  3. import pandas as pd
  4. from binance.client import Client
  5. from binance.enums import *
  6. from binance import ThreadedWebsocketManager
  7. from twisted.internet import reactor
  8. from abc import ABC, abstractmethod # For abstract base class
  9. # Configure logging
  10. logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
  11. logger = logging.getLogger(__name__)
  12. # Signal Actions (extensible enum-like)
  13. ACTION_BUY = 'buy'
  14. ACTION_SELL_TP = 'sell_tp' # Take profit
  15. ACTION_SELL_SL = 'sell_sl' # Stop loss
  16. ACTION_CANCEL = 'cancel' # Cancel existing order
  17. class BaseStrategy(ABC):
  18. """Abstract base class for strategies. Inherit and implement generate_signal()."""
  19. def __init__(self, params=None):
  20. self.params = params or {} # Strategy-specific params (e.g., {'sma_periods': 50})
  21. @abstractmethod
  22. def generate_signal(self, symbol, data, current_position):
  23. """
  24. Generate a signal based on data.
  25. - data: pd.DataFrame with OHLCV.
  26. - current_position: dict {'amount': float, 'entry_price': float}.
  27. Returns: dict like {'action': 'buy', 'amount': float, 'limit_price': float} or None.
  28. """
  29. pass
  30. def pre_process_data(self, data):
  31. """Optional hook: Pre-process data before signal generation."""
  32. return data
  33. def post_signal(self, signal):
  34. """Optional hook: Post-process signal (e.g., adjust for risk)."""
  35. return signal
  36. class SMAStrategy(BaseStrategy):
  37. """Example strategy: Buy if price > SMA, sell on TP/SL."""
  38. def generate_signal(self, symbol, data, current_position):
  39. data = self.pre_process_data(data)
  40. periods = self.params.get('sma_periods', 50)
  41. buy_discount_pct = self.params.get('buy_discount_pct', 0.005)
  42. take_profit_pct = self.params.get('take_profit_pct', 0.05)
  43. stop_loss_pct = self.params.get('stop_loss_pct', -0.03)
  44. df = pd.DataFrame(data, columns=['timestamp', 'open', 'high', 'low', 'close', 'volume', 'close_time', 'quote_av', 'trades', 'tb_base_av', 'tb_quote_av', 'ignore'])
  45. df['close'] = df['close'].astype(float)
  46. sma = df['close'].rolling(window=periods).mean().iloc[-1]
  47. current_price = df['close'].iloc[-1]
  48. if current_position['amount'] > 0: # Check exit
  49. pnl_pct = (current_price - current_position['entry_price']) / current_position['entry_price']
  50. if pnl_pct >= take_profit_pct:
  51. limit_price = current_price * (1 + self.params.get('sell_premium_pct', 0.005))
  52. return self.post_signal({'action': ACTION_SELL_TP, 'amount': current_position['amount'], 'limit_price': limit_price})
  53. elif pnl_pct <= stop_loss_pct:
  54. limit_price = current_price * (1 - self.params.get('sell_premium_pct', 0.005))
  55. return self.post_signal({'action': ACTION_SELL_SL, 'amount': current_position['amount'], 'limit_price': limit_price, 'stop_price': limit_price})
  56. else: # Check entry
  57. if current_price > sma:
  58. amount = self.params.get('position_size_usd', 100) / current_price
  59. limit_price = current_price * (1 - buy_discount_pct)
  60. return self.post_signal({'action': ACTION_BUY, 'amount': amount, 'limit_price': limit_price})
  61. return None
  62. class TradingBot:
  63. def __init__(self, api_key, api_secret, strategy, config=None):
  64. self.client = Client(api_key, api_secret, testnet=False) # Set testnet=True for testing
  65. self.bm = ThreadedWebsocketManager(api_key, api_secret, testnet=False)
  66. self.strategy = strategy
  67. self.config = config or {
  68. 'symbols': ['BTCUSDT'],
  69. 'timeframe': '1h',
  70. 'poll_interval': 30,
  71. 'loop_interval': 60,
  72. 'order_timeout': 300 # Seconds before canceling unfilled orders
  73. }
  74. self.positions = {sym: {'amount': 0, 'entry_price': 0, 'order_id': None, 'order_timestamp': None} for sym in self.config['symbols']}
  75. self.symbols_info = {} # Stores exchangeInfo for each symbol
  76. self.load_symbols_info() # Fetch exchangeInfo on startup
  77. self.conn_key = None
  78. try:
  79. balance = self.client.get_account()
  80. print(balance) # Should print account details
  81. except Exception as e:
  82. print(e)
  83. self.start_websockets()
  84. def load_symbols_info(self):
  85. """Fetch and cache exchangeInfo for all configured symbols."""
  86. try:
  87. info = self.client.get_exchange_info()
  88. for symbol in self.config['symbols']:
  89. for sym_info in info['symbols']:
  90. if sym_info['symbol'] == symbol:
  91. self.symbols_info[symbol] = sym_info
  92. logger.info(f"Loaded symbol info for {symbol}")
  93. break
  94. logger.info("Exchange info loaded successfully.")
  95. except Exception as e:
  96. logger.error(f"Failed to load exchange info: {e}")
  97. raise
  98. def validate_order_params(self, symbol, amount, price):
  99. """
  100. Validate order amount/price against Binance rules.
  101. Returns: (valid, adjusted_amount, adjusted_price)
  102. """
  103. if symbol not in self.symbols_info:
  104. logger.error(f"No symbol info for {symbol}")
  105. return False, None, None
  106. sym_info = self.symbols_info[symbol]
  107. filters = {f['filterType']: f for f in sym_info['filters']}
  108. # --- Price Validation ---
  109. tick_size = float(filters['PRICE_FILTER']['tickSize'])
  110. price = round(price / tick_size) * tick_size # Round to nearest tick
  111. # --- Lot Size Validation ---
  112. lot_size = float(filters['LOT_SIZE']['stepSize'])
  113. amount = round(amount / lot_size) * lot_size # Round to nearest step
  114. # --- Min Notional Check ---
  115. min_notional = float(filters['MIN_NOTIONAL']['minNotional'])
  116. if price * amount < min_notional:
  117. logger.warning(
  118. f"Order notional too small for {symbol}: "
  119. f"Need {min_notional}, got {price * amount}"
  120. )
  121. return False, None, None
  122. return True, amount, price
  123. def start_websockets(self):
  124. #self.conn_key = self.bm.user_socket(self.process_user_message)
  125. self.bm.start()
  126. self.bm.start_user_socket(callback=self.process_user_message)
  127. def process_user_message(self, msg):
  128. if msg['e'] == 'executionReport':
  129. symbol = msg['s']
  130. order_id = msg['i']
  131. status = msg['X']
  132. side = msg['S']
  133. filled_amount = float(msg['z'])
  134. logger.info(f"[{symbol}] Order Update: ID={order_id}, Status={status}, Side={side}, Filled={filled_amount}")
  135. if symbol in self.positions:
  136. pos = self.positions[symbol]
  137. if status in ['FILLED', 'PARTIALLY_FILLED']:
  138. if side == 'BUY':
  139. pos['amount'] += filled_amount
  140. pos['entry_price'] = float(msg['L'])
  141. elif side == 'SELL':
  142. pos['amount'] -= filled_amount
  143. if status == 'FILLED':
  144. pos['order_id'] = None
  145. pos['order_timestamp'] = None
  146. elif status == 'CANCELED':
  147. pos['order_id'] = None
  148. pos['order_timestamp'] = None
  149. def fetch_data(self, symbol):
  150. return self.client.get_klines(symbol=symbol, interval=self.config['timeframe'], limit=self.strategy.params.get('sma_periods', 50) + 1)
  151. def place_limit_order(self, symbol, signal):
  152. action = signal['action']
  153. amount = signal['amount']
  154. limit_price = signal['limit_price']
  155. stop_price = signal.get('stop_price')
  156. # Validate parameters
  157. is_valid, adj_amount, adj_price = self.validate_order_params(
  158. symbol, amount, limit_price
  159. )
  160. if not is_valid:
  161. logger.error(f"Invalid order params for {symbol}")
  162. return None
  163. # Adjust stop price if needed
  164. if stop_price:
  165. _, _, adj_stop_price = self.validate_order_params(
  166. symbol, amount, stop_price
  167. )
  168. stop_price = adj_stop_price
  169. # Proceed with order placement
  170. try:
  171. order_params = {
  172. 'symbol': symbol,
  173. 'side': SIDE_BUY if action == ACTION_BUY else SIDE_SELL,
  174. 'type': ORDER_TYPE_LIMIT,
  175. 'timeInForce': TIME_IN_FORCE_GTC,
  176. 'quantity': adj_amount,
  177. 'price': adj_price
  178. }
  179. if stop_price:
  180. order_params.update({
  181. 'type': ORDER_TYPE_STOP_LOSS_LIMIT,
  182. 'stopPrice': stop_price
  183. })
  184. order = self.client.create_order(**order_params)
  185. logger.info(
  186. f"[{symbol}] Placed Order: {action}, "
  187. f"Amount={adj_amount}, Price={adj_price}"
  188. )
  189. return order['orderId']
  190. except Exception as e:
  191. logger.error(f"[{symbol}] Order placement failed: {e}")
  192. return None
  193. def cancel_order(self, symbol, order_id):
  194. try:
  195. self.client.cancel_order(symbol=symbol, orderId=order_id)
  196. logger.info(f"[{symbol}] Canceled Order: ID={order_id}")
  197. except Exception as e:
  198. logger.error(f"[{symbol}] Cancel error: {e}")
  199. def poll_updates(self):
  200. for symbol in self.config['symbols']:
  201. try:
  202. pos = self.positions[symbol]
  203. if pos['order_id']:
  204. order = self.client.get_order(symbol=symbol, orderId=pos['order_id'])
  205. logger.info(f"[{symbol}] Polled Order: ID={order['orderId']}, Status={order['status']}, Filled={order['executedQty']}")
  206. if order['status'] == 'FILLED':
  207. pos['order_id'] = None
  208. pos['order_timestamp'] = None
  209. # Cancel if timed out
  210. if pos['order_timestamp'] and time.time() - pos['order_timestamp'] > self.config['order_timeout']:
  211. self.cancel_order(symbol, pos['order_id'])
  212. pos['order_id'] = None
  213. pos['order_timestamp'] = None
  214. balance = self.client.get_asset_balance(asset=symbol[:-4]) # e.g., 'BTC' from 'BTCUSDT'
  215. usdt_balance = self.client.get_asset_balance(asset='USDT')
  216. logger.info(f"[{symbol}] Polled Position: Asset={balance['free']}, USDT={usdt_balance['free']}")
  217. pos['amount'] = float(balance['free']) # Sync position
  218. except Exception as e:
  219. logger.error(f"[{symbol}] Polling error: {e}")
  220. def run(self):
  221. while True:
  222. try:
  223. for symbol in self.config['symbols']:
  224. data = self.fetch_data(symbol)
  225. current_position = self.positions[symbol]
  226. signal = self.strategy.generate_signal(symbol, data, current_position)
  227. if signal and current_position['order_id'] is None: # No active order
  228. order_id = self.place_limit_order(symbol, signal)
  229. if order_id:
  230. self.positions[symbol]['order_id'] = order_id
  231. self.positions[symbol]['order_timestamp'] = time.time()
  232. self.poll_updates()
  233. time.sleep(self.config['loop_interval'])
  234. except Exception as e:
  235. logger.error(f"Error in main loop: {e}")
  236. time.sleep(10) # Retry
  237. if __name__ == "__main__":
  238. # Example usage: SMA Strategy with custom params
  239. strategy = SMAStrategy(params={
  240. 'sma_periods': 50,
  241. 'buy_discount_pct': 0.005,
  242. 'sell_premium_pct': 0.005,
  243. 'take_profit_pct': 0.05,
  244. 'stop_loss_pct': -0.03,
  245. 'position_size_usd': 100
  246. })
  247. bot = TradingBot(
  248. 'BJbjlf7yTcso2VS7lxMKQbybqDkzJt68CIYDdi005orbvxtgUJGYg5Jz5S3YED43', '9AMYjDGpfmxS8k7kNuwoVnwgJX9HSDaERYyR3Wl9qHsyviMef3mo8pyn6OfWTsHD',
  249. strategy=strategy,
  250. config={
  251. 'symbols': ['BTCUSDT', 'ETHUSDT', 'BNBUSDT', 'SOLUSDT', 'XRPUSDT', 'ADAUSDT', 'DOGEUSDT',
  252. 'AVAXUSDT', 'SHIBUSDT', 'DOTUSDT', 'LINKUSDT', 'TRXUSDT', 'UNIUSDT', 'LTCUSDT'], # Multi-symbol support
  253. 'timeframe': '1h',
  254. 'poll_interval': 30,
  255. 'loop_interval': 60,
  256. 'order_timeout': 300
  257. }
  258. )
  259. bot.run()