Exemple #1
0
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))