def test_on_balance_updates(self): client = BcexClient(symbols=["BTC-USD"]) msg = { "seqnum": 2, "event": "snapshot", "channel": "balances", "balances": [ { "currency": "BTC", "balance": 0.00366963, "available": 0.00266963, "balance_local": 38.746779155, "available_local": 28.188009155, "rate": 10558.77, }, { "currency": "USD", "balance": 11.66, "available": 0.0, "balance_local": 11.66, "available_local": 0.0, "rate": 1.0, }, { "currency": "ETH", "balance": 0.18115942, "available": 0.18115942, "balance_local": 37.289855013, "available_local": 37.289855013, "rate": 205.84, }, ], "total_available_local": 65.477864168, "total_balance_local": 87.696634168, } client._on_balance_updates(msg) assert client.balances == { "BTC": { "available": 0.00266963, "balance": 0.00366963 }, "USD": { "available": 0.0, "balance": 11.66 }, "ETH": { "available": 0.18115942, "balance": 0.18115942 }, }
def test_on_heartbeat_updates(self): client = BcexClient(symbols=["BTC-USD"]) msg = { "seqnum": 1, "event": "updated", "channel": "heartbeat", "timestamp": "2019-05-31T08:36:45.666753Z", } client._on_heartbeat_updates(msg) assert client._last_heartbeat == iso8601.parse_date( "2019-05-31T08:36:45.666753Z")
def test_send_subscriptions_to_ws(self, mock_time): mock_time.sleep = Mock() channels = [Channel.HEARTBEAT, Channel.SYMBOLS] client = BcexClient([Symbol.ETHBTC, Symbol.ALGOUSD], channels=channels) subscriptions = [ (Channel.HEARTBEAT, None), (Channel.SYMBOLS, Symbol.ETHBTC), (Channel.SYMBOLS, Symbol.ALGOUSD), ] client.channel_status[ Channel.HEARTBEAT] = ChannelStatus.WAITING_CONFIRMATION client.channel_status[Channel.SYMBOLS][ Symbol.ETHBTC] = ChannelStatus.WAITING_CONFIRMATION client.channel_status[Channel.SYMBOLS][ Symbol.ALGOUSD] = ChannelStatus.WAITING_CONFIRMATION client._wait_for_confirmation(subscriptions) assert mock_time.sleep.call_count == 5 mock_time.sleep.reset_mock() client.channel_status[Channel.HEARTBEAT] = ChannelStatus.SUBSCRIBED client.channel_status[Channel.SYMBOLS][ Symbol.ALGOUSD] = ChannelStatus.SUBSCRIBED client._wait_for_confirmation(subscriptions) assert mock_time.sleep.call_count == 5 mock_time.sleep.reset_mock() client.channel_status[Channel.SYMBOLS][ Symbol.ETHBTC] = ChannelStatus.SUBSCRIBED client._wait_for_confirmation(subscriptions) assert mock_time.sleep.call_count == 1
def test_init(self, mock_init_channel_status): mock_init_channel_status.return_value = Mock() client = BcexClient( symbols=[Symbol.ETHBTC, Symbol.BTCPAX], channels=None, channel_kwargs={ Channel.TICKER: { "extra_dummy_ticket_arg": "dummy_val" }, Channel.PRICES: { "granularity": 300 }, }, env=Environment.STAGING, api_secret="dummy_key", cancel_position_on_exit=False, ) assert client.symbols == [Symbol.ETHBTC, Symbol.BTCPAX] assert (client.channel_kwargs[Channel.TICKER]["extra_dummy_ticket_arg"] == "dummy_val") assert client.channel_kwargs[Channel.PRICES]["granularity"] == 300 # channel_kwargs might contain extra default kwargs assert client.api_secret == "dummy_key" assert client.ws_url == "wss://ws.staging.blockchain.info/mercury-gateway/v1/ws" assert client.origin == "https://pit.staging.blockchain.info" assert not client.cancel_position_on_exit assert mock_init_channel_status.call_count == 1 client = BcexClient( symbols=[Symbol.ETHBTC, Symbol.BTCPAX], channels=None, channel_kwargs={ Channel.TICKER: { "extra_dummy_ticket_arg": "dummy_val" }, Channel.PRICES: { "granularity": 300 }, }, env=Environment.PROD, api_secret="dummy_key", cancel_position_on_exit=False, ) assert client.ws_url == "wss://ws.prod.blockchain.info/mercury-gateway/v1/ws" assert client.origin == "https://exchange.blockchain.com" with pytest.raises(ValueError): BcexClient(symbols=[Symbol.BTCUSD], env="dummy_env")
def test_subscribe_channels(self): client = BcexClient( [Symbol.ETHBTC, Symbol.ALGOUSD], channels=[Channel.HEARTBEAT, Channel.SYMBOLS], api_secret="my_api_key", ) client._public_subscription = Mock() client._private_subscription = Mock() client._subscribe_channels() assert client._public_subscription.call_count == 1 assert client._private_subscription.call_count == 1 client._api_secret = None client._subscribe_channels() assert client._public_subscription.call_count == 2 assert client._private_subscription.call_count == 1
def test_on_ticker_updates(self): client = BcexClient(symbols=["BTC-USD"]) # snapshot event - with last_trade_price msg_1 = { "seqnum": 1, "event": "snapshot", "channel": "ticker", "symbol": "BTC-USD", "price_24h": 7500.0, "volume_24h": 0.0141, "last_trade_price": 7499.0, } client._on_ticker_updates(msg_1) assert client.tickers["BTC-USD"]["price_24h"] == 7500.0 assert client.tickers["BTC-USD"]["volume_24h"] == 0.0141 assert (client.tickers["BTC-USD"]["last_trade_price"] == 7499.0 ) # from previous update # updated event - no last_trade_price msg_2 = { "seqnum": 24, "event": "updated", "channel": "ticker", "symbol": "BTC-USD", "price_24h": 7500.1, "volume_24h": 0.0142, } client._on_ticker_updates(msg_2) assert client.tickers["BTC-USD"]["price_24h"] == 7500.1 assert client.tickers["BTC-USD"]["volume_24h"] == 0.0142 assert (client.tickers["BTC-USD"]["last_trade_price"] == 7499.0 ) # from previous update
def test_on_price_updates(self): client = BcexClient(symbols=["BTC-USD"]) # error message msg = { "seqnum": 18, "event": "rejected", "channel": "prices", "text": "No granularity set", } client._on_price_updates(msg) msg = { "seqnum": 2, "event": "updated", "channel": "prices", "symbol": "BTC-USD", "price": [1559039640, 8697.24, 8700.98, 8697.27, 8700.98, 0.431], } client._on_price_updates(msg) assert client.candles["BTC-USD"] == [[ 1559039640, 8697.24, 8700.98, 8697.27, 8700.98, 0.431 ]]
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 test_public_subscriptions(self): client = BcexClient( [Symbol.ETHBTC, Symbol.ALGOUSD], channels=[Channel.HEARTBEAT, Channel.SYMBOLS], channel_kwargs={ Channel.SYMBOLS: { "dummy_arg": "dummy_val" }, Channel.HEARTBEAT: { "t": 5 }, }, ) client._wait_for_confirmation = Mock() # will test separately ws_mock = Mock() ws_mock.send = Mock() client.ws = ws_mock client._public_subscription() assert ws_mock.send.call_count == 3 # 2 for the 2 symbols and 1 for heartbeat subscriptions = [args[0][0] for args in ws_mock.send.call_args_list] assert set(subscriptions) == { json.dumps({ "action": Action.SUBSCRIBE, "channel": Channel.SYMBOLS, "symbol": Symbol.ALGOUSD, "dummy_arg": "dummy_val", }), json.dumps({ "action": Action.SUBSCRIBE, "channel": Channel.SYMBOLS, "symbol": Symbol.ETHBTC, "dummy_arg": "dummy_val", }), json.dumps({ "action": Action.SUBSCRIBE, "channel": Channel.HEARTBEAT, "t": 5 }), } assert (client.channel_status[Channel.HEARTBEAT] == ChannelStatus.WAITING_CONFIRMATION) assert (client.channel_status[Channel.SYMBOLS][Symbol.ETHBTC] == ChannelStatus.WAITING_CONFIRMATION) assert (client.channel_status[Channel.SYMBOLS][Symbol.ALGOUSD] == ChannelStatus.WAITING_CONFIRMATION) # make sure we will wait for the correct channels assert client._wait_for_confirmation.call_count == 1 assert set(client._wait_for_confirmation.call_args[0][0]) == { (Channel.HEARTBEAT, None), (Channel.SYMBOLS, Symbol.ETHBTC), (Channel.SYMBOLS, Symbol.ALGOUSD), }
def test_on_market_trade_updates(self): client = BcexClient(symbols=["BTC-USD"]) msg = { "seqnum": 21, "event": "updated", "channel": "trades", "symbol": "BTC-USD", "timestamp": "2019-08-13T11:30:06.100140Z", "side": "sell", "qty": 8.5e-5, "price": 11252.4, "trade_id": "12884909920", } client._on_market_trade_updates(msg) trade = client.market_trades["BTC-USD"][0] assert trade.symbol == "BTC-USD" assert trade.quantity == 8.5e-5 assert trade.side == "sell" assert trade.timestamp == "2019-08-13T11:30:06.100140Z" assert trade.trade_id == "12884909920" assert trade.price == 11252.4
def test_on_auth_updates(self): client = BcexClient(symbols=["BTC-USD"]) msg = {"seqnum": 0, "event": "subscribed", "channel": "auth"} client._on_auth_updates(msg) assert client.authenticated client = BcexClient(symbols=["BTC-USD"]) msg = { "seqnum": 0, "event": "rejected", "channel": "auth", "text": "Authentication Failed", } client._on_auth_updates(msg) assert client.channel_status["auth"] == ChannelStatus.REJECTED
def test_private_subscriptions(self): client = BcexClient( [Symbol.ETHBTC, Symbol.ALGOUSD], channels=[Channel.TRADING], api_secret="my_api_tokennn", ) client._wait_for_confirmation = Mock() # will test separately client._wait_for_authentication = Mock() # will test separately ws_mock = Mock() ws_mock.send = Mock() client.ws = ws_mock client._private_subscription() assert ws_mock.send.call_count == 2 # one for authentication, one for channel # auth message sent auth = ws_mock.send.call_args_list[0][0][0] assert auth == json.dumps({ "channel": Channel.AUTH, "action": Action.SUBSCRIBE, "token": "my_api_tokennn", }) # subscription channel message sent subscriptions = ws_mock.send.call_args_list[1][0][0] assert subscriptions == json.dumps({ "action": Action.SUBSCRIBE, "channel": Channel.TRADING }) assert (client.channel_status[Channel.TRADING] == ChannelStatus.WAITING_CONFIRMATION) # make sure we will wait for the correct channels assert client._wait_for_confirmation.call_count == 1 assert client._wait_for_authentication.call_count == 1 assert set(client._wait_for_confirmation.call_args[0][0]) == { (Channel.TRADING, None) }
def test_wait_for_authentication(self, mock_time): mock_time.sleep = Mock() client = BcexClient( [Symbol.ETHBTC, Symbol.ALGOUSD], channels=[Channel.TRADING], api_secret="my_api_tokennn", ) client.channel_status[Channel.AUTH] = ChannelStatus.UNSUBSCRIBED with pytest.raises(WebSocketTimeoutException): client._wait_for_authentication() assert mock_time.sleep.call_count >= 1 mock_time.sleep.reset_mock() client.channel_status[Channel.AUTH] = ChannelStatus.SUBSCRIBED client._wait_for_authentication() assert mock_time.sleep.call_count == 0
def test_connect(self, mock_webs, mock_threading): ws_mock = Mock() thread_mock = Mock() thread_mock.start = Mock() thread_mock.daemon = False mock_webs.WebSocketApp = Mock(return_value=ws_mock) mock_threading.Thread = Mock(return_value=thread_mock) client = BcexClient(symbols=["BTC-USD"]) client._subscribe_channels = Mock() client._wait_for_ws_connect = Mock() client.connect() assert mock_webs.WebSocketApp.call_count == 1 assert mock_threading.Thread.call_count == 1 assert client.wst == thread_mock assert client.wst.daemon is True assert client.wst.start.call_count == 1 assert client._wait_for_ws_connect.call_count == 1 assert client._subscribe_channels.call_count == 1
def test_on_on_symbols_updates(self): client = BcexClient(symbols=["BTC-USD"]) # error message msg = { "seqnum": 1, "event": "snapshot", "channel": "symbols", "symbol": "BTC-USD", "base_currency": "BTC", "base_currency_scale": 8, "counter_currency": "USD", "counter_currency_scale": 2, "min_price_increment": 10, "min_price_increment_scale": 0, "min_order_size": 50, "min_order_size_scale": 2, "max_order_size": 0, "max_order_size_scale": 8, "lot_size": 5, "lot_size_scale": 2, "status": "halt", "id": 1, "auction_price": 0.0, "auction_size": 0.0, "auction_time": "", "imbalance": 0.0, } client._on_symbols_updates(msg) assert client.symbol_details["BTC-USD"] == msg msg.update({"event": "updated"}) client._on_symbols_updates(msg) assert client.symbol_details["BTC-USD"] == msg
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
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_message(self): client = BcexClient([Symbol.ALGOUSD]) client._check_message_seqnum = Mock() channels = [ "dummy_channel", Channel.TRADES, Channel.TICKER, Channel.PRICES, Channel.AUTH, Channel.BALANCES, Channel.HEARTBEAT, Channel.SYMBOLS, Channel.L2, Channel.L3, ] # set up the mocks mocks = [Mock() for _ in range(len(channels))] client._on_message_unsupported = mocks[0] client._on_market_trade_updates = mocks[1] client._on_ticker_updates = mocks[2] client._on_price_updates = mocks[3] client._on_auth_updates = mocks[4] client._on_balance_updates = mocks[5] client._on_heartbeat_updates = mocks[6] client._on_symbols_updates = mocks[7] client._on_l2_updates = mocks[8] client._on_l3_updates = mocks[9] # checks msg = {} for i, ch in enumerate(channels): msg.update({"channel": ch}) client.on_message(json.dumps(msg)) assert client._check_message_seqnum.call_count == i + 1 for j in range(i): assert mocks[j].call_count == 1 for j in range(i + 1, len(channels)): assert mocks[j].call_count == 0 assert mocks[i].call_args[0][0] == msg client.on_message(None)
def test_on_message_unsupported(self): client = BcexClient([Symbol.ALGOUSD]) client._on_message_unsupported({"channel": Event.SNAPSHOT}) client._on_message_unsupported({"channel": "dummy_channel"})
def test_on_trading_updates(self): client = BcexClient(symbols=["BTC-USD"]) msg = { "seqnum": 3, "event": "snapshot", "channel": "trading", "orders": [{ "orderID": "12891851020", "clOrdID": "78502a08-c8f1-4eff-b", "symbol": "BTC-USD", "side": "sell", "ordType": "limit", "orderQty": 5.0e-4, "leavesQty": 5.0e-4, "cumQty": 0.0, "avgPx": 0.0, "ordStatus": "open", "timeInForce": "GTC", "text": "New order", "execType": "0", "execID": "11321871", "transactTime": "2019-08-13T11:30:03.000593290Z", "msgType": 8, "lastPx": 0.0, "lastShares": 0.0, "tradeId": "0", "price": 15000.0, }], } client._on_trading_updates(msg) order = client.open_orders["BTC-USD"]["12891851020"] assert order.client_order_id == "78502a08-c8f1-4eff-b" assert order.price == 15000 assert order.order_quantity == 5.0e-4 assert order.average_price == 0 assert order.order_status == "open" msg = { "seqnum": 3, "event": "snapshot", "channel": "trading", "orders": [{ "orderID": "12891851020", "clOrdID": "78502a08-c8f1-4eff-b", "symbol": "BTC-USD", "side": "sell", "ordType": "limit", "orderQty": 5.0e-4, "leavesQty": 5.0e-4, "cumQty": 0.0, "avgPx": 0.0, "ordStatus": "filled", "timeInForce": "GTC", "text": "New order", "execType": "0", "execID": "11321871", "transactTime": "2019-08-13T11:30:03.000593290Z", "msgType": 8, "lastPx": 0.0, "lastShares": 0.0, "tradeId": "0", "price": 15000.0, }], } client._on_trading_updates(msg) assert client.open_orders["BTC-USD"].get("12891851020") is None
def test_on_l2_updates(self): client = BcexClient(symbols=["BTC-USD"]) msg = { "seqnum": 2, "event": "snapshot", "channel": "l2", "symbol": "BTC-USD", "bids": [ { "px": 8723.45, "qty": 1.45, "num": 2 }, { "px": 8124.45, "qty": 123.45, "num": 1 }, ], "asks": [ { "px": 8730.0, "qty": 1.55, "num": 2 }, { "px": 8904.45, "qty": 13.66, "num": 2 }, ], } client._on_l2_updates(msg) assert client.l2_book["BTC-USD"] == { "bids": SortedDict({ 8124.45: 123.45, 8723.45: 1.45 }), "asks": SortedDict({ 8730.0: 1.55, 8904.45: 13.66 }), } msg = { "seqnum": 3, "event": "updated", "channel": "l2", "symbol": "BTC-USD", "bids": [{ "px": 8723.45, "qty": 1.1, "num": 1 }], "asks": [], } client._on_l2_updates(msg) assert client.l2_book["BTC-USD"] == { "bids": SortedDict({ 8124.45: 123.45, 8723.45: 1.1 }), "asks": SortedDict({ 8730.0: 1.55, 8904.45: 13.66 }), } msg = { "seqnum": 3, "event": "updated", "channel": "l2", "symbol": "BTC-USD", "bids": [{ "px": 8124.45, "qty": 0, "num": 1 }], "asks": [], } client._on_l2_updates(msg) assert client.l2_book["BTC-USD"] == { "bids": SortedDict({8723.45: 1.1}), "asks": SortedDict({ 8730.0: 1.55, 8904.45: 13.66 }), } msg = { "seqnum": 2, "event": "snapshot", "channel": "l2", "symbol": "BTC-USD", "bids": [{ "px": 8723.45, "qty": 1.45, "num": 2 }], "asks": [{ "px": 8730.0, "qty": 1.55, "num": 2 }], } client._on_l2_updates(msg) assert client.l2_book["BTC-USD"] == { "bids": SortedDict({8723.45: 1.45}), "asks": SortedDict({8730.0: 1.55}), }
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]
def test_on_unsupported_event_message(self): client = BcexClient([Symbol.ALGOUSD]) client._on_unsupported_event_message({"event": Event.SNAPSHOT}, Channel.HEARTBEAT) client._on_unsupported_event_message({"event": "dummy_event"}, Channel.HEARTBEAT)