class BotBase: def __init__(self, _market, market_type, api_key, api_secret, subaccount): self.ftx = FTX( market=_market, api_key=api_key, api_secret=api_secret, subaccount=subaccount) self.logger = setup_logger(f'log/{BOT_NAME.lower()}.log') self.BOT_NAME: str = BOT_NAME self.MAX_ORDER_NUMBER: int = MAX_ORDER_NUMBER self.SUBACCOUNT = subaccount self.MARKET: str = _market self.MARKET_TYPE: str = market_type self.MAX_POSITION_SIZE = MAX_POSITION_SIZE self.position: Dict[str, Any] = {} self.open_orders: List[Dict[str, Any]] = [] self.error_tracking = {'count': 0, 'error_message': '', 'timestamp': 0} self.next_update_time = time.time() self.logger.info(f'ENV:{PYTHON_ENV} {self.SUBACCOUNT} {self.BOT_NAME} {self.MARKET}') # タスクの設定およびイベントループの開始 # loop = asyncio.get_event_loop() # tasks = [self.run()] # loop.run_until_complete(asyncio.wait(tasks)) # ---------------------------------------- # # bot main # ---------------------------------------- # async def run(self, interval): while True: try: await self.main(interval) await asyncio.sleep(0) except Exception as e: self.logger.error(f'RUN_CYCLE_ERROR: {str(e)}') self.push_message(str(e)) async def get_single_market(self): try: self.ftx.single_market() res = await self.ftx.send() if res[0]['success']: return res[0]['result'], True else: raise APIRequestError(res[0]['error']) except Exception as e: self.logger.error(str(e)) return {}, False async def get_markets(self): try: self.ftx.market() res = await self.ftx.send() if res[0]['success']: return res[0]['result'], True else: raise APIRequestError(res[0]['error']) except Exception as e: self.logger.error(str(e)) return {}, False async def get_open_orders(self): try: self.ftx.open_orders() res = await self.ftx.send() if res[0]['success']: return res[0]['result'], True else: raise APIRequestError(res[0]['error']) except Exception as e: self.push_message(str(e)) self.logger.error(str(e)) return {}, False async def require_num_open_orders_within(self, max_order_number): """ オープンオーダーが与えられたオーダー数なら,`CycleError`エラーを投げる """ open_orders, success = await self.get_open_orders() if success: if len(open_orders) >= max_order_number: msg = f'TOO_MANY_OPEN_ORDERS: {len(open_orders)}' self.logger.warn(msg) self.push_message(msg) raise CycleError(msg) def isvalid_reduce_only(self, size): reduce_only_size = 0.0 pos = self.position if not self.has_position(): return False for op_ord in self.open_orders: if op_ord['reduceOnly']: reduce_only_size += op_ord['size'] if reduce_only_size + size >= pos['size']: self.logger.warn('Invalid ResuceOnly order') self.push_message('Invalid ResuceOnly order') return False else: return True def isvalid_size(self, size): return ('size' in self.position) and (self.MAX_POSITION_SIZE >= self.position['size']) async def place_order(self, side, ord_type, size, price='', ioc=False, reduceOnly=False, postOnly=False, sec_to_expire=SEC_TO_EXPIRE, delay=5): """ place_order 新規オーダーを置く.オーダーの成功・失敗を通知する レスポンスのオーダー情報とリクエストの可否のタプルを返す. """ try: # if self.isvalid_size(size): # return {}, False if reduceOnly: if not self.isvalid_reduce_only(size): return {}, False if not sec_to_expire: sec_to_expire = SEC_TO_EXPIRE self.ftx.place_order( market=self.MARKET, side=side, ord_type=ord_type, size=size, price=price, ioc=ioc, reduceOnly=reduceOnly, postOnly=postOnly) res = await self.ftx.send() if res[0]['success']: data = res[0]['result'] new_order = {} if data['status'] != 'cancelled': new_order = { 'orderId': data['id'], 'side': data['side'], 'type': data['type'], 'size': data['size'], 'price': data['price'], 'status': data['status'], 'orderTime': time.time(), 'expireTime': time.time() + float(sec_to_expire), 'cancelTime': None, 'excutedSize': data['filledSize'], } self.open_orders.append(new_order) self.logger.info(self._message(new_order, 'new')) if PUSH_NOTIF: self.push_message(data) await asyncio.sleep(delay) return data, True else: raise APIRequestError(res[0]['error']) except Exception as e: self.logger.error(str(e)) self.push_message(str(e)) return {}, False async def cancel_expired_orders(self, delay=1): """ 期限切れの全てのオーダーをキャンセルする. ステータスがopenまたはnewではない時に限る. """ self.logger.debug('[Cycle] Cancel expired orders...') for order in self.open_orders: if (order['status'] in ['new', 'open']) and float( order['expireTime']) < time.time() and order['cancelTime'] is None: _, success = await self.cancel_order(order) try: if not success: raise OrderCycleError(order, 'cancel') # self.logger.error('CANCEL_EXPIRED_ORDERS: cancel_order failed') except Exception as e: self.logger.error(str(e)) await asyncio.sleep(delay) async def update_orders_status(self, delay=1): """ オープンオーダーリストの`status`がopenまたはnewのオーダーのステータスをリクエストして更新する. 約定済み,キャンセルになったオーダーはリストから削除し,ポジションを自炊更新する. """ self.logger.debug('[Cycle] Updating orders status...') for order in self.open_orders: try: if order['status'] in ['open', 'new']: self.ftx.order_status(order['orderId']) res = await self.ftx.send() if res[0]['success']: if 'result' in res[0]: data = res[0]['result'] self._update_per_order(data, order) else: self.logger.warn(f'key `result` not in {res[0]}') else: self.logger.error(res[0]) raise APIRequestError(res[0]['error']) await asyncio.sleep(delay) except Exception as e: self.logger.error(f'[Cycle] UPDATE_ORDERS_STATUS_ERROR {str(e)}') def _update_per_order(self, data, order): """ `data`で`order`の情報を更新する,および `order`の情報で現在ポジションとオープンオーダーのリストを更新する. """ try: if isinstance(data, Dict): if 'status' in data and 'filledSize' in data: order['status'] = data['status'] order['excutedSize'] = data['filledSize'] if order['status'] == 'closed': self.logger.debug(self._message(order, 'update')) else: self.logger.warn(f'`No valid key in data`:{data}') else: self.logger.warn(f'Expected type [Dict] `data`:{data}') # new if order['status'] == 'new': # FTXではcancelledかfilledはclosedとして表わされる. pass # open if order['status'] == 'open': pass # cancelled if order['cancelTime'] is not None: # orderがキューに入ってstatusが更新されていないときcancelledとみなす order['status'] = 'cancelled' if order['status'] == 'cancelled': pass # filled or cancelled if order['status'] == 'closed' and order['cancelTime'] is not None: # cancelされた注文はcancelTimeが数値になる pass if order['status'] == 'closed' and order['cancelTime'] is None: self._update_position_by(order) if order['status'] == 'filled' and order['cancelTime'] is None: self._update_position_by(order) return self._update_open_order_by(order) except Exception as e: self.logger.error(f'_update_open_order_status {str(e)}') raise async def cancel_order(self, order): """ オーダーをキャンセルをリクエストする キャンセルリクエストのタイムスタンプをオーダーに追加する.および,オープンオーダーのリストから削除する. リクエストが失敗した時のみ,通知する """ try: self.ftx.cancel_order(order['orderId']) res = await self.ftx.send() if res[0]['success']: data = res[0]['result'] self.logger.info(self._message(data, 'cancel')) order['cancelTime'] = time.time() self._update_per_order(data, order) return data, True else: raise APIRequestError(f'ERROR:{res[0]["error"]}:orderId {order["orderId"]}') except Exception as e: self.logger.error(str(e)) self.push_message(str(e)) return {}, False def _update_open_order_by(self, order): """ `order`でオープンオーダーリストを更新する ステータスがキャンセルまたは,約定済みならばリストから削除する """ try: # cancelled if order['status'] == 'cancelled': self.open_orders.remove(order) # cancelled elif order['status'] == 'closed' and order['cancelTime'] is not None: self.open_orders.remove(order) # filled elif order['status'] == 'closed' and order['cancelTime'] is None: self.open_orders.remove(order) elif order['status'] == 'filled' and order['cancelTime'] is None: self.open_orders.remove(order) elif order['status'] == 'open' or order['status'] == 'new': pass else: self.logger.warn(f'Unexpected Order status{order["status"]}') except Exception as e: self.logger.error(str(e)) raise OrderCycleError(order, 'update') # raise Exception(f'UPDATE_OPEN_ORDER_BY_STATUS {str(e)}') def remove_not_open_orders(self): """ オープンオーダーリストのオーダーでステータスがキャンセルのものを全て削除する """ self.logger.debug('[Cycle] Removec canceled orders...') for order in self.open_orders: self._update_open_order_by(order) def _update_position_by(self, order): """ `order`でポジションを自炊更新する """ try: net_excuted = order['excutedSize'] if order['side'] == 'buy' else - order['excutedSize'] self.position['size'] += abs(net_excuted) # sizeは絶対値 self.position['netSize'] += net_excuted self.position['side'] = 'buy' if float(self.position['size']) > 0 else 'sell' except KeyError as e: raise KeyError('KeyError', order) except Exception as e: self.logger.error(str(e)) raise PositionCycleError(order, 'update') async def get_position(self, market=None): if market is None: market = self.MARKET self.ftx.positions() res = await self.ftx.send() try: if res[0]['success']: data = res[0]['result'] for pos in data: key = 'future' if 'future' in pos else 'name' if market in pos[key]: return pos, True else: raise Exception('GET_POSITION', data) else: raise APIRequestError(res[0]['error']) except Exception as e: self.logger.error(str(e)) return {}, False async def sync_position(self, delay=0): """ ポジションをリクエストして最新の状態に同期させる. """ try: self.logger.debug('[Cycle] Sync position...') pos, success = await self.get_position() await asyncio.sleep(delay) if success: self.position = pos else: raise PositionCycleError('SYNC_POSITION_ERROR', '') except PositionCycleError as e: self.logger.error(str(e)) def log_status(self): if VERBOSE: self.logger.debug(f'self.position :>> {self.position["netSize"]}') self.logger.debug(f'self.open_orders lenth :>> {len(self.open_orders)}') def has_position(self): return self.position != {} and self.position['size'] > 0 def _message(self, data='', msg_type=''): return _message(data, msg_type) def push_message(self, data, msg_type=''): """ ボットの基本情報+引数のデータ型に応じたテキストを追加して送信する. - `data`がstrなら,そのまま送信 - `position`なら,sizeとsideを送信 - `order`ならpriceとtype,sideを送信 """ bot_info = f'{self.SUBACCOUNT}:{self.BOT_NAME}\n{self.MARKET}' text = self._message(data, msg_type) push_message(f'{bot_info}\n{text}') def _update(self, interval): if time.time() > self.next_update_time: self.next_update_time += interval return True else: return False async def main(self, interval): try: if self._update(60): await self.require_num_open_orders_within(self.MAX_ORDER_NUMBER) await self.update_orders_status(delay=2) await asyncio.sleep(5) await self.cancel_expired_orders(delay=2) self.remove_not_open_orders() if self.MARKET_TYPE.lower() == 'future': await self.sync_position(delay=5) elif self.MARKET_TYPE.lower() == 'spot': pass self.log_status() await asyncio.sleep(interval) except CycleError as e: self.ftx.cancel_all_orders() res = await self.ftx.send() print("res[0] :>>", res[0]) if res[0]['success'] and 'result' in res[0]: msg = '[Cycle] CANCEL_ALL_ORDERS' self.logger.info(msg) self.push_message(msg) else: raise APIRequestError(res[0]['error']) except Exception as e: self.logger.error(str(e)) self.push_message(str(e))
class Bot: DEFAULT_USD_SIZE = config.getfloat('DEFAULT_USD_SIZE') SPECIFIC_NAMES = config['SPECIFIC_NAMES'] SPECIFIC_USD_SIZE = config.getfloat('SPECIFIC_USD_SIZE') def __init__(self, api_key, api_secret): self.ftx = FTX("", api_key=api_key, api_secret=api_secret, subaccount=SUBACCOUNT) self.cg = CoinGeckoAPI() self.logger = setup_logger("log/listed_and_long.log") self.prev_markets: List[Dict[str, Union[str, float]]] = [] self.positions = [] self.HODL_TIME = config.getfloat('HODL_TIME') self.logger.info( f"BOT:{BOT_NAME} ENV:{PYTHON_ENV} SUBACCOUNT:{SUBACCOUNT}") # タスクの設定およびイベントループの開始 loop = asyncio.get_event_loop() tasks = [self.run()] loop.run_until_complete(asyncio.wait(tasks)) # ---------------------------------------- # # bot main # ---------------------------------------- # async def run(self): while True: try: await self.main(5) await asyncio.sleep(0) except KeyError as e: push_message("KeyError: {}".format(e)) self.logger.error('An exception occurred', str(e)) except Exception as e: push_message(str(e)) self.logger.error('An unhandled exception occurred' + str(e)) exit(1) async def main(self, interval): # main処理 listed = [] new_listed = [] self.ftx.market() response = await self.ftx.send() # print(json.dumps(response[0]['result'], indent=2, sort_keys=False)) # 引数に与えた条件に当てはまる上場銘柄をリストに抽出する listed = self.extract_markets(markets=response[0]['result'], market_type=["spot", "future"], future_type='-PERP', exclude=[ 'HEDGE', 'BULL', 'BEAR', 'HALF', 'BVOL', '-0326', "MOVE-" ]) VERBOSE and self.logger.debug(listed) # 前回の上場銘柄リストがあるならば,現在の上場リストと比較して新規上場銘柄があるか調べる if len(self.prev_markets) > 0: # 条件に合格した新規上場銘柄を抽出する new_listed, _ = self.extract_new_listed(self.prev_markets, listed, RANK) if len(new_listed) > 0: self.logger.info(f'上場銘柄差分:{_}') self.logger.info(f'合格した新規上場銘柄:{new_listed}') try: for new in new_listed: # SNS通知 msg = f"New Listing: {new['name']}" self.logger.info(msg) push_message(msg) # トレードを許可しているならば,エントリー if TRADABLE: ord_type = 'market' market = new['name'] price = float(new['bid']) key = 'underlying' if new[ 'type'] == 'future' else 'baseCurrency' usd = self.SPECIFIC_USD_SIZE if str(new[key]).upper( ) in self.SPECIFIC_NAMES else self.DEFAULT_USD_SIZE size = usd / (float(new['bid']) + float(new['ask'])) / 2 if PYTHON_ENV != 'production': price = 1000 market = 'ETH-PERP' size = 0.001 ord_type = 'limit' responce, _ = await self.entry(market, size, ord_type, 'buy', price) self.positions.append({ 'orderTime': time.time(), 'market': market, 'size': size, 'side': 'buy', 'price': responce[0]['result']['price'] }) await self.entry(market, size, 'limit', 'sell', price * 1.08) except Exception as e: self.logger.error(str(e)) # ---------共通の処理---------- # 最新の上場のリストを更新 self.prev_markets = listed listed = [] self.logger.debug("Snapshot markets...") if len(self.positions) > 0: self.logger.info("Current positions :>>") self.logger.info(self.positions) await self.settle(market_type=["future"]) await asyncio.sleep(interval) def extract_markets( self, markets, market_type=["spot", "future"], future_type='-PERP', exclude=["HEDGE", "BULL", "BEAR", "HALF", "BVOL", "MOVE", "-0326"]): satsfied = [] has_spot = "spot" in market_type has_future = "future" in market_type for market in markets: if market["enabled"]: if has_spot and market['type'] == "spot" and market[ "quoteCurrency"] == 'USD': is_excluded = True for keyword in exclude: is_excluded = is_excluded and keyword not in market[ "name"] if is_excluded: satsfied.append(market) if has_future and market[ "type"] == 'future' and future_type in market["name"]: satsfied.append(market) return satsfied def extract_new_listed(self, prev_markets, current_markets, rank): """ リスティングの差分をとり,coingeckoに上場していてかつ,coingeckoでの時価総額ランキングが`rank`以上のマーケットのリストを返す ただし,`SPECIFIC_NAMES`に一致する名前を持つ場合はcoingeckoに上場していなくても含まれる """ specifics = [] diff = self.extract_listing_diff(prev_markets, current_markets) for new in diff: key = 'underlying' if new['type'] == 'future' else 'baseCurrency' if str(new[key]).upper() in self.SPECIFIC_NAMES: specifics.append(new.copy()) markets = self.fileter_by_market_cap(diff, rank) return markets + specifics, diff def extract_listing_diff( self, prev_markets: List[Dict[str, Union[str, float]]], current_markets: List[Dict[str, Union[str, float]]] ) -> List[Dict[str, Union[str, float]]]: """ 引数に与えられた二つのリスティング情報の差分をとる. """ new_listed = [] if len(current_markets) == 0: return new_listed prev_market_names = [ prev_market["name"] for prev_market in prev_markets ] for current_market in current_markets: if current_market["name"] not in prev_market_names: new_listed.append(current_market) return new_listed def fileter_by_market_cap(self, markets: List[Dict[str, Union[str, float]]], rank=500): """ marketにcg_idが存在し,時価総額が`rank`以上のマーケットをフィルターし返す. """ if (markets is None) or len(markets) == 0: return markets coins = self.cg.get_coins_list( ) # List all supported coins id, name and symbol markets = self.append_cg_id(markets, coins) for market in markets: try: # `cg_id`が空文字でないならcoingeckoに上場している.そうでないなら,リストから落とす if 'cg_id' in market and len(str(market['cg_id'])) > 0: # coingeckoでmarket情報を取得し,時価総額が条件を満たさないならリストから落とす print("market['cg_id'] :>>", market['cg_id']) result = self.cg.get_coins_markets( ids=market['cg_id'], vs_currency='usd', category='coin category') if len(result) > 0: market_cap_rank = result[0]['market_cap_rank'] if isinstance(market_cap_rank, int): if market_cap_rank == 0 or market_cap_rank > rank: markets.remove(market) elif 'error' in result: raise Exception('COIN_GECKO_API ERROR', market, result) else: self.logger.info( f'DOES NOT FOUND {market["name"]} coin gecko') markets.remove(market) except Exception as e: self.logger.error(f'COIN_GECKO_API ERROR {str(e)}') return markets def append_cg_id(self, markets: List[Dict[str, Union[str, float]]], cg_coins): """ 引数に与えられたmarketsにcoingeckoのidの要素`cg_id`を追加する. coingeckoに対応するidが見つからないときは空文字とする """ try: for market in markets: symbol = '' if market['type'] == "spot": symbol = str(market['baseCurrency']).lower() elif market['type'] == 'future': symbol = str(market['underlying']).lower() for coin in cg_coins: if symbol == coin['symbol']: market['cg_id'] = coin['id'] break else: market['cg_id'] = '' return markets except Exception as e: self.logger.error(str(e)) async def entry(self, market, size, ord_type, side, price="", postOnly=False, reduceOnly=False): try: self.ftx.place_order(ord_type=ord_type, market=market, side=side, price=price, size=size, postOnly=postOnly, reduceOnly=reduceOnly) response = await self.ftx.send() if response[0]['success']: msg = f"BOT:{BOT_NAME}\nOrdered\n{market}\nSIDE:{side}\nSIZE:{size}" self.logger.info(msg) push_message(msg) return response, True else: raise Exception(response[0]) except Exception as e: msg = f"BOT:{BOT_NAME}\nERROR: {str(e)}" self.logger.error(msg) push_message(msg) return {}, False async def settle(self, market_type=["future"]): has_future = "future" in market_type for pos in self.positions: if has_future and ("/USD" not in pos["market"]): try: if time.time() - pos["orderTime"] >= self.HODL_TIME: price = "" ord_type = 'market' _, success = await self.entry(market=pos["market"], size=pos["size"], ord_type=ord_type, price=price, side='sell', postOnly=False, reduceOnly=True) if success: self.positions.remove(pos) except KeyError as e: self.logger.error("KeyError" + str(e)) except Exception as e: self.logger.error("Exception: " + str(e))