def test_seqnum(self):
        # first message should be 0
        client = BcexClient([Symbol.ALGOUSD])
        client.exit = Mock()
        client._on_heartbeat_updates = Mock()
        assert client._seqnum == -1

        client.on_message(
            json.dumps({
                "seqnum": 0,
                "channel": Channel.HEARTBEAT
            }))
        assert client.exit.call_count == 0

        # if first message is not 0 it exits
        client = BcexClient([Symbol.ALGOUSD])
        client.exit = Mock()
        client._on_heartbeat_updates = Mock()

        client.on_message(
            json.dumps({
                "seqnum": 1,
                "channel": Channel.HEARTBEAT
            }))
        assert client.exit.call_count == 1

        # if one message has not been received it exits
        client = BcexClient([Symbol.ALGOUSD])
        client.exit = Mock()
        client._on_heartbeat_updates = Mock()

        client.on_message(
            json.dumps({
                "seqnum": 0,
                "channel": Channel.HEARTBEAT
            }))
        client.on_message(
            json.dumps({
                "seqnum": 1,
                "channel": Channel.HEARTBEAT
            }))
        client.on_message(
            json.dumps({
                "seqnum": 2,
                "channel": Channel.HEARTBEAT
            }))
        client.on_message(
            json.dumps({
                "seqnum": 3,
                "channel": Channel.HEARTBEAT
            }))
        assert client.exit.call_count == 0
        client.on_message(
            json.dumps({
                "seqnum": 5,
                "channel": Channel.HEARTBEAT
            }))
        assert client.exit.call_count == 1
    def test_on_ping_checks_heartbeat(self, mock_datetime):
        client = BcexClient([Symbol.ALGOUSD])
        client.exit = Mock()
        # initially we do not have a heartbeat reference
        assert client._last_heartbeat is None

        # if no heartbeats subscribed we ignore it
        dt0 = datetime(2017, 1, 2, 1, tzinfo=pytz.UTC)
        mock_datetime.now = Mock(return_value=dt0)
        client.on_ping()
        assert client._last_heartbeat is None
        assert mock_datetime.now.call_count == 0
        assert client.exit.call_count == 0

        # heartbeats subscribed will initiate the last heartbeat reference
        client.channel_status[Channel.HEARTBEAT] = ChannelStatus.SUBSCRIBED
        client.on_ping()
        assert client._last_heartbeat == dt0
        assert mock_datetime.now.call_count == 1
        assert client.exit.call_count == 0

        # last heartbeat request was less than 5 seconds ago
        mock_datetime.now = Mock(return_value=dt0 + timedelta(seconds=2))
        client.on_ping()
        assert client._last_heartbeat == dt0
        assert mock_datetime.now.call_count == 1
        assert client.exit.call_count == 0

        # last heartbeat request was between 5 and 10 seconds ago
        mock_datetime.now = Mock(return_value=dt0 + timedelta(seconds=7))
        client.on_ping()
        assert client._last_heartbeat == dt0
        assert mock_datetime.now.call_count == 1
        assert client.exit.call_count == 0

        # last heartbeat request was more than 10 seconds ago : we exit
        mock_datetime.now = Mock(return_value=dt0 + timedelta(seconds=13))
        client.on_ping()
        assert mock_datetime.now.call_count == 1
        assert client.exit.call_count == 1
Exemple #3
0
class BcexInterface:
    """Interface for the Bcex Exchange

    Attributes
    ----------
    client: BcexClient
        websocket client to handle interactions with the exchange
    """

    REQUIRED_CHANNELS = [
        Channel.SYMBOLS,
        Channel.TICKER,
        Channel.TRADES,
    ]

    def __init__(
        self,
        symbols,
        api_secret=None,
        env=Environment.STAGING,
        channels=None,
        channel_kwargs=None,
        cancel_position_on_exit=True,
    ):
        """
        Parameters
        ----------
        symbols : list of str
            if multiple symbols then a list if a single symbol then a string or list.
            Symbols that you want the client to subscribe to
        channels : list of Channel,
            channels to subscribe to. if not provided all channels will be subscribed to.
            Some Public channels are symbols specific and will subscribe to provided symbols
        env : Environment
            environment to run in
            api key on exchange.blockchain.com gives access to Production environment
            To obtain access to staging environment, request to our support center needs to be made
        api_secret : str
            api key for the exchange which can be obtained once logged in, in settings (click on username) > Api
            if not provided, the api key will be taken from environment variable BCEX_API_SECRET
        """
        if channels is not None:
            # make sure we include the required channels
            channels = list(set(self.REQUIRED_CHANNELS + channels))

        self.client = BcexClient(
            symbols,
            channels=channels,
            channel_kwargs=channel_kwargs,
            api_secret=api_secret,
            env=env,
            cancel_position_on_exit=cancel_position_on_exit,
        )
        atexit.register(self.exit)
        signal.signal(signal.SIGTERM, self.exit)

    def connect(self):
        """Connects to the Blockchain.com Exchange Websocket"""
        # TODO: ensure that we are connected before moving forward
        self.client.connect()

    def exit(self):
        """Closes Websocket"""
        self.client.exit()

    def is_open(self):
        """Check that websockets are still open."""
        return self.client.ws is not None and not self.client.exited

    @staticmethod
    def _scale_quantity(symbol_details, quantity):
        """Scales the quantity for an order to the given scale

        Parameters
        ----------
        symbol_details : dict
            dictionary of details from the symbols from the symbols channel
        quantity : float
            quantity of order

        Returns
        -------
        quantity : float
            quantity of order scaled to required level
        """
        quantity = round(quantity, symbol_details["base_currency_scale"])
        return quantity

    @staticmethod
    def _scale_price(symbol_details, price):
        """Scales the price for an order to the given scale

        Parameters
        ----------
        symbol_details : dict
            dictionary of details from the symbols from the symbols channel
        price : float
            price of order

        Returns
        -------
        price : float
            price of order scaled to required level
        """
        price_multiple = (price *
                          10**symbol_details["min_price_increment_scale"]
                          ) / symbol_details["min_price_increment"]
        price = (math.floor(price_multiple) *
                 symbol_details["min_price_increment"] /
                 10**symbol_details["min_price_increment_scale"])
        return price

    @staticmethod
    def _check_quantity_within_limits(symbol_details, quantity):
        """Checks if the quantity for the order is acceptable for the given symbol

        Parameters
        ----------
        symbol_details : dict
            dictionary of details from the symbols from the symbols channel
        quantity : float
            quantity of order

        Returns
        -------
        result : bool
        """
        max_limit = symbol_details["max_order_size"] / (
            10**symbol_details["max_order_size_scale"])
        min_limit = symbol_details["min_order_size"] / (
            10**symbol_details["min_order_size_scale"])
        if quantity < min_limit:
            logging.warning(f"Quantity {quantity} less than min {min_limit}")
            return False
        if max_limit == 0:
            return True
        if quantity > max_limit:
            logging.warning(f"Quantity {quantity} more than max {max_limit}")
            return False
        return True

    def _check_available_balance(self, symbol_details, side, quantity, price):
        """Checks if the quantity requested is possible with given balance

        Parameters
        ----------
        symbol_details : dict
            dictionary of details from the symbols from the symbols channel
        side : OrderSide enum
        quantity : float
            quantity of order
        price : float
            price of order

        Returns
        -------
        result : bool
        """
        if side == OrderSide.BUY:
            currency = symbol_details["base_currency"]
            quantity_in_currency = quantity
        else:
            currency = symbol_details["counter_currency"]
            quantity_in_currency = quantity * price
        balances = self.get_balances()
        available_balance = balances[currency]["available"]
        if available_balance > quantity_in_currency:
            return True

        logging.warning(
            f"Not enough available balance {available_balance} in {currency} for trade quantity {quantity}"
        )
        return False

    def tick_size(self, symbol):
        """Gets the tick size for given symbol

        Parameters
        ----------
        symbol : Symbol

        Returns
        -------
        tick_size : float
        """
        if not self._has_symbol_details(symbol):
            return None

        details = self.client.symbol_details[symbol]
        return (details["min_price_increment"] /
                10**details["min_price_increment_scale"])

    def lot_size(self, symbol):
        """Gets the lot size for given symbol

        Parameters
        ----------
        symbol : Symbol

        Returns
        -------
        lot_size : float
        """
        if not self._has_symbol_details(symbol):
            return None

        details = self.client.symbol_details[symbol]
        return details["min_order_size"] / 10**details["min_order_size_scale"]

    def _create_order(
        self,
        symbol,
        side,
        quantity,
        price,
        order_type,
        time_in_force,
        minimum_quantity,
        expiry_date,
        stop_price,
        check_balance=False,
        post_only=False,
    ):
        """Creates orders in correct format

        Parameters
        ----------
        symbol : Symbol
        side : OrderSide enum
        quantity : float
            quantity of order
        price : float
            price of order
        order_type : OrderType
        time_in_force : TimeInForce
            Time in force, applicable for orders except market orders
        minimum_quantity : float
            The minimum quantity required for an TimeInForce.IOC fill
        expiry_date : int YYYYMMDD
            Expiry date required for GTD orders
        stop_price : float
            Price to trigger the stop order
        check_balance : bool
            check if balance is sufficient for order
        post_only: bool
            whether to make sure that the order  will not match liquidity immediately.
            It will be rejected instead of matching liquidity in the market.

        Returns
        -------
        order : Order or None
            Order if we could create the order with the provided details
            None if we could not
        """
        if not self._has_symbol_details(symbol):
            return None

        symbol_details = self.client.symbol_details[symbol]
        quantity = self._scale_quantity(symbol_details, quantity)
        if not self._check_quantity_within_limits(symbol_details, quantity):
            return None
        if price is not None:
            price = self._scale_price(symbol_details, price)
        if check_balance and order_type == OrderType.LIMIT:
            has_balance = self._check_available_balance(
                symbol_details, side, quantity, price)
            if not has_balance:
                return None
        return Order(
            order_type=order_type,
            symbol=symbol,
            side=side,
            price=price,
            order_quantity=quantity,
            time_in_force=time_in_force,
            minimum_quantity=minimum_quantity,
            expiry_date=expiry_date,
            stop_price=stop_price,
            post_only=post_only,
        )

    def buy(self, symbol, quantity):
        """Sends a market order to buy the given quantity
        """
        self.place_order(
            symbol,
            OrderSide.BUY,
            quantity,
            order_type=OrderType.MARKET,
            time_in_force=TimeInForce.GTC,
        )

    def sell(self, symbol, quantity):
        """Sends a market order to sell the given quantity
        """
        self.place_order(
            symbol,
            OrderSide.SELL,
            quantity,
            order_type=OrderType.MARKET,
            time_in_force=TimeInForce.GTC,
        )

    def place_order(
        self,
        symbol,
        side,
        quantity,
        price=None,
        order_type=OrderType.LIMIT,
        time_in_force=TimeInForce.GTC,
        minimum_quantity=None,
        expiry_date=None,
        stop_price=None,
        check_balance=False,
        post_only=False,
    ):
        """Place order with valid quantity and prices

        It uses information from the symbols table to ensure our price and quantity conform to the exchange requirements
        If necessary, prices and quantities will be rounded to make it a valid order.


        Parameters
        ----------
        symbol : Symbol
        side : OrderSide enum
        quantity : float
            quantity of order
        price : float
            price of order
        order_type : OrderType
        time_in_force : TimeInForce or None
            Time in force, applicable for orders except market orders
        minimum_quantity : float
            The minimum quantity required for an TimeInForce.IOC fill
        expiry_date : int YYYYMMDD
            Expiry date required for GTD orders
        stop_price : float
            Price to trigger the stop order
        check_balance : bool
            check if balance is sufficient for order
        post_only: bool
            whether to make sure that the order  will not match liquidity immediately.
            It will be rejected instead of matching liquidity in the market.

        """
        if not self._has_symbol_details(symbol):
            return

        order = self._create_order(
            symbol,
            side,
            quantity,
            price,
            order_type,
            time_in_force,
            minimum_quantity,
            expiry_date,
            stop_price,
            check_balance,
            post_only,
        )
        if order is not None:
            self.client.send_order(order)

    def _has_symbol_details(self, symbol):
        if (symbol in self.client.symbol_details
                and len(self.client.symbol_details[symbol]) > 0):
            return True
        else:
            # log why
            if (symbol not in self.client.channel_status[Channel.SYMBOLS]
                    or self.client.channel_status[Channel.SYMBOLS][symbol] !=
                    "subscribed"):
                logging.warning(
                    f"Could not find symbol details for symbol {symbol}. Websocket it is not subscribed to it"
                )
            else:
                logging.error(
                    f"Could not find symbol details for {symbol} even if we subscribed to it. Might come later ?"
                )

    def cancel_all_orders(self):
        """Cancel all orders

        Notes
        -----
        This also cancels the orders for symbols which are not in self.symbols
        """
        self.client.cancel_all_orders()
        # TODO: wait for a response that all orders have been cancelled - MAX_TIMEOUT then warn/err

    def cancel_order(self, order_id):
        """Cancel specific order

        Parameters
        ----------
        order_id : str
            order id to cancel

        """
        self.client.send_order(
            Order(
                OrderType.CANCEL,
                order_id=order_id,
                symbol=self.get_order_details(order_id).symbol,
            ))

    def cancel_orders_for_symbol(self, symbol):
        """Cancel all orders for symbol

        Parameters
        ----------
        symbol : Symbol

        """
        order_ids = self.client.open_orders[symbol].keys()
        for o in order_ids:
            self.cancel_order(o)

    def get_last_traded_price(self, symbol):
        """Get the last matched price for the given symbol

        Parameters
        ----------
        symbol : Symbol

        Returns
        -------
        last_traded_price : float
            last matched price for symbol
        """
        return self.client.tickers.get(symbol, {}).get("last_trade_price")

    def get_ask_price(self, symbol):
        """Get the ask price for the given symbol

        Parameters
        ----------
        symbol : Symbol

        Returns
        -------
        ask_price : float
            ask price for symbol
        """
        # sorted dict - first key is lowest price
        book = self.client.l2_book[symbol][Book.ASK]
        return book.peekitem(0) if len(book) > 0 else None

    def get_bid_price(self, symbol):
        """Get the bid price for the given symbol

        Parameters
        ----------
        symbol : Symbol

        Returns
        -------
        bid_price : float
            bid price for symbol
        """
        # sorted dict - last key is highest price
        book = self.client.l2_book[symbol][Book.BID]
        return book.peekitem(-1) if len(book) > 0 else None

    def get_all_open_orders(self, symbols=None, to_dict=False):
        """Gets all the open orders

        Parameters
        ----------
        symbols : Symbol
        to_dict : bool
            convert the OrderResponses to a dict

        Returns
        -------
        open_orders : dict
            dict of all the open orders, key is the order id and values are order details
        """
        open_orders = {}
        if symbols is None:
            symbols = self.client.open_orders.keys()
        for i in symbols:
            open_orders.update(self.client.open_orders[i])
        if to_dict:
            return {k: o.to_dict() for k, o in open_orders.items()}
        else:
            return open_orders

    def get_order_details(self, order_id, symbol=None):
        """Get order details for a specific order

        Parameters
        ----------
        order_id : str
            order id for requested order
        symbol : Symbol
            if none have to search all symbols until it is found

        Returns
        -------
        order_details : OrderResponse
            details for specific order type depends on the to dict value
        """
        if symbol is not None:
            symbols = [symbol]
        else:
            symbols = self.client.open_orders.keys()

        for i in symbols:
            order_details = self.client.open_orders[i].get(order_id)
            if order_details is not None:
                return order_details

        return None

    def get_available_balance(self, coin):
        """
        Returns
        -------
        float: the available balance of the coin
        """
        return self.client.balances.get(coin, {}).get("available", 0)

    def get_order_book(self, symbol):
        """Get full order book for

        Parameters
        ----------
        symbol : Symbol

        Returns
        -------
        order_book : Dict
        """
        return self.client.l2_book[symbol]

    def get_balances(self):
        """Get user balances"""
        return self.client.balances

    def get_symbols(self):
        """Get all the symbols"""
        return self.client.symbols

    def get_candles(self, symbol):
        """Get candles for symbol

        Parameters
        ----------
        symbol : Symbol

        Returns
        -------
        candles : list
            list of candles at timestamp
        """
        return self.client.candles[symbol]