class BitfinexExchangeTests(TestCase):
    # the level is required to receive logs from the data source logger
    level = 0

    @classmethod
    def setUpClass(cls) -> None:
        super().setUpClass()
        cls.base_asset = "COINALPHA"
        cls.quote_asset = "HBOT"
        cls.trading_pair = f"{cls.base_asset}-{cls.quote_asset}"
        cls.symbol = f"{cls.base_asset}{cls.quote_asset}"
        cls.listen_key = "TEST_LISTEN_KEY"

    def setUp(self) -> None:
        super().setUp()

        self.log_records = []
        self.test_task: Optional[asyncio.Task] = None
        self.resume_test_event = asyncio.Event()

        self.exchange = CoinbaseProExchange(
            coinbase_pro_api_key="testAPIKey",
            coinbase_pro_secret_key="testSecret",
            coinbase_pro_passphrase="testPassphrase",
            trading_pairs=[self.trading_pair])

        self.exchange.logger().setLevel(1)
        self.exchange.logger().addHandler(self)

        self._initialize_event_loggers()

    def tearDown(self) -> None:
        self.test_task and self.test_task.cancel()
        super().tearDown()

    def _initialize_event_loggers(self):
        self.buy_order_completed_logger = EventLogger()
        self.sell_order_completed_logger = EventLogger()
        self.order_filled_logger = EventLogger()

        events_and_loggers = [
            (MarketEvent.BuyOrderCompleted, self.buy_order_completed_logger),
            (MarketEvent.SellOrderCompleted, self.sell_order_completed_logger),
            (MarketEvent.OrderFilled, self.order_filled_logger)
        ]

        for event, logger in events_and_loggers:
            self.exchange.add_listener(event, logger)

    def handle(self, record):
        self.log_records.append(record)

    def _is_logged(self, log_level: str, message: str) -> bool:
        return any(
            record.levelname == log_level and record.getMessage() == message
            for record in self.log_records)

    def async_run_with_timeout(self, coroutine: Awaitable, timeout: float = 1):
        ret = asyncio.get_event_loop().run_until_complete(
            asyncio.wait_for(coroutine, timeout))
        return ret

    def _return_calculation_and_set_done_event(self, calculation: Callable,
                                               *args, **kwargs):
        if self.resume_test_event.is_set():
            raise asyncio.CancelledError
        self.resume_test_event.set()
        return calculation(*args, **kwargs)

    def test_order_fill_event_takes_fee_from_update_event(self):
        self.exchange.start_tracking_order(
            order_id="OID1",
            trading_pair=self.trading_pair,
            order_type=OrderType.LIMIT,
            trade_type=TradeType.BUY,
            price=Decimal("10000"),
            amount=Decimal("1"),
        )

        order = self.exchange.in_flight_orders.get("OID1")
        order.update_exchange_order_id("EOID1")

        partial_fill = {
            "type": "match",
            "trade_id": 1,
            "sequence": 50,
            "maker_order_id": "EOID1",
            "taker_order_id": "132fb6ae-456b-4654-b4e0-d681ac05cea1",
            "time": "2014-11-07T08:19:27.028459Z",
            "product_id": "BTC-USDT",
            "size": "0.1",
            "price": "10050.0",
            "side": "buy",
            "taker_user_id": "5844eceecf7e803e259d0365",
            "user_id": "5844eceecf7e803e259d0365",
            "taker_profile_id": "765d1549-9660-4be2-97d4-fa2d65fa3352",
            "profile_id": "765d1549-9660-4be2-97d4-fa2d65fa3352",
            "taker_fee_rate": "0.005"
        }

        mock_user_stream = AsyncMock()
        mock_user_stream.get.side_effect = functools.partial(
            self._return_calculation_and_set_done_event, lambda: partial_fill)

        self.exchange.user_stream_tracker._user_stream = mock_user_stream

        self.test_task = asyncio.get_event_loop().create_task(
            self.exchange._user_stream_event_listener())
        self.async_run_with_timeout(self.resume_test_event.wait())

        expected_executed_quote_amount = Decimal(str(
            partial_fill["size"])) * Decimal(str(partial_fill["price"]))
        expected_partial_event_fee = (Decimal(partial_fill["taker_fee_rate"]) *
                                      expected_executed_quote_amount)

        self.assertEqual(expected_partial_event_fee, order.fee_paid)
        self.assertEqual(1, len(self.order_filled_logger.event_log))
        fill_event: OrderFilledEvent = self.order_filled_logger.event_log[0]
        self.assertEqual(Decimal("0.005"), fill_event.trade_fee.percent)
        self.assertEqual([], fill_event.trade_fee.flat_fees)
        self.assertTrue(
            self._is_logged(
                "INFO",
                f"Filled {Decimal(partial_fill['size'])} out of {order.amount} of the "
                f"{order.order_type_description} order {order.client_order_id}"
            ))

        self.assertEqual(0, len(self.buy_order_completed_logger.event_log))

        complete_fill = {
            "type": "match",
            "trade_id": 2,
            "sequence": 50,
            "maker_order_id": "EOID1",
            "taker_order_id": "132fb6ae-456b-4654-b4e0-d681ac05cea1",
            "time": "2014-11-07T08:19:27.028459Z",
            "product_id": "BTC-USDT",
            "size": "0.9",
            "price": "10050.0",
            "side": "buy",
            "taker_user_id": "5844eceecf7e803e259d0365",
            "user_id": "5844eceecf7e803e259d0365",
            "taker_profile_id": "765d1549-9660-4be2-97d4-fa2d65fa3352",
            "profile_id": "765d1549-9660-4be2-97d4-fa2d65fa3352",
            "taker_fee_rate": "0.001"
        }

        self.resume_test_event = asyncio.Event()
        mock_user_stream = AsyncMock()
        mock_user_stream.get.side_effect = functools.partial(
            self._return_calculation_and_set_done_event, lambda: complete_fill)

        self.exchange.user_stream_tracker._user_stream = mock_user_stream

        self.test_task = asyncio.get_event_loop().create_task(
            self.exchange._user_stream_event_listener())
        self.async_run_with_timeout(self.resume_test_event.wait())

        expected_executed_quote_amount = Decimal(str(
            complete_fill["size"])) * Decimal(str(complete_fill["price"]))
        expected_partial_event_fee += Decimal(
            complete_fill["taker_fee_rate"]) * expected_executed_quote_amount

        self.assertEqual(expected_partial_event_fee, order.fee_paid)

        self.assertEqual(2, len(self.order_filled_logger.event_log))
        fill_event: OrderFilledEvent = self.order_filled_logger.event_log[1]
        self.assertEqual(Decimal("0.001"), fill_event.trade_fee.percent)
        self.assertEqual([], fill_event.trade_fee.flat_fees)

        # The order should be marked as complete only when the "done" event arrives, not with the fill event
        self.assertFalse(
            self._is_logged(
                "INFO",
                f"The market buy order {order.client_order_id} has completed according to Coinbase Pro user stream."
            ))

        self.assertEqual(0, len(self.buy_order_completed_logger.event_log))
예제 #2
0
class TestCoinbaseProExchange(unittest.TestCase):
    # logging.Level required to receive logs from the exchange
    level = 0

    @classmethod
    def setUpClass(cls) -> None:
        super().setUpClass()
        cls.ev_loop = asyncio.get_event_loop()
        cls.base_asset = "COINALPHA"
        cls.quote_asset = "HBOT"
        cls.trading_pair = f"{cls.base_asset}-{cls.quote_asset}"
        cls.ex_trading_pair = f"{cls.base_asset}_{cls.quote_asset}"
        cls.api_key = "someKey"
        cls.api_secret = "shht"
        cls.api_passphrase = "somePhrase"

    def setUp(self) -> None:
        super().setUp()
        self.log_records = []
        self.mocking_assistant = NetworkMockingAssistant()
        self.async_tasks: List[asyncio.Task] = []
        self.client_config_map = ClientConfigAdapter(ClientConfigMap())

        self.exchange = CoinbaseProExchange(
            client_config_map=self.client_config_map,
            coinbase_pro_api_key=self.api_key,
            coinbase_pro_secret_key=self.api_secret,
            coinbase_pro_passphrase=self.api_passphrase,
            trading_pairs=[self.trading_pair])
        self.event_listener = EventLogger()

        self.exchange.logger().setLevel(1)
        self.exchange.logger().addHandler(self)

    def tearDown(self) -> None:
        for task in self.async_tasks:
            task.cancel()
        super().tearDown()

    def handle(self, record):
        self.log_records.append(record)

    def _is_logged(self, log_level: str, message: str) -> bool:
        return any(
            record.levelname == log_level and record.getMessage() == message
            for record in self.log_records)

    def async_run_with_timeout(self, coroutine: Awaitable, timeout: int = 1):
        ret = self.ev_loop.run_until_complete(
            asyncio.wait_for(coroutine, timeout))
        return ret

    def simulate_trading_rules_initialization(self, mock_api):
        url = f"{CONSTANTS.REST_URL}{CONSTANTS.PRODUCTS_PATH_URL}"
        alt_pair = "BTC-USDT"
        resp = self.get_products_response_mock(alt_pair)
        mock_api.get(url, body=json.dumps(resp))

        self.async_run_with_timeout(self.exchange._update_trading_rules())

    def simulate_execute_buy_order(self, mock_api, order_id):
        url = f"{CONSTANTS.REST_URL}{CONSTANTS.ORDERS_PATH_URL}"
        regex_url = re.compile(f"^{url}".replace(".",
                                                 r"\.").replace("?", r"\?"))
        resp = self.get_orders_response_mock(order_id)
        mock_api.post(regex_url, body=json.dumps(resp))

        self.async_run_with_timeout(
            self.exchange.execute_sell(
                order_id=order_id,
                trading_pair=self.trading_pair,
                amount=Decimal("1"),
                order_type=OrderType.LIMIT,
                price=Decimal("2"),
            ))

    def get_account_mock(self, base_balance: float, base_available: float,
                         quote_balance: float, quote_available: float) -> List:
        account_mock = [{
            "id": "7fd0abc0-e5ad-4cbb-8d54-f2b3f43364da",
            "currency": self.base_asset,
            "balance": str(base_balance),
            "available": str(base_available),
            "hold": "0.0000000000000000",
            "profile_id": "8058d771-2d88-4f0f-ab6e-299c153d4308",
            "trading_enabled": True
        }, {
            "id": "7fd0abc0-e5ad-4cbb-8d54-f2b3f43364da",
            "currency": self.quote_asset,
            "balance": str(quote_balance),
            "available": str(quote_available),
            "hold": "0.0000000000000000",
            "profile_id": "8058d771-2d88-4f0f-ab6e-299c153d4308",
            "trading_enabled": True
        }]
        return account_mock

    def get_products_response_mock(self, other_pair: str) -> List:
        products_mock = [{
            "id": self.trading_pair,
            "base_currency": self.base_asset,
            "quote_currency": self.quote_asset,
            "base_min_size": "0.00100000",
            "base_max_size": "280.00000000",
            "quote_increment": "0.01000000",
            "base_increment": "0.00000001",
            "display_name": f"{self.base_asset}/{self.quote_asset}",
            "min_market_funds": "10",
            "max_market_funds": "1000000",
            "margin_enabled": False,
            "post_only": False,
            "limit_only": False,
            "cancel_only": False,
            "status": "online",
            "status_message": "",
            "auction_mode": True,
        }, {
            "id": other_pair,
            "base_currency": other_pair.split("-")[0],
            "quote_currency": other_pair.split("-")[1],
            "base_min_size": "0.00100000",
            "base_max_size": "280.00000000",
            "quote_increment": "0.01000000",
            "base_increment": "0.00000001",
            "display_name": other_pair.replace("-", "/"),
            "min_market_funds": "10",
            "max_market_funds": "1000000",
            "margin_enabled": False,
            "post_only": False,
            "limit_only": False,
            "cancel_only": False,
            "status": "online",
            "status_message": "",
            "auction_mode": True,
        }]
        return products_mock

    def get_orders_response_mock(self, order_id: str) -> Dict:
        orders_mock = {
            "id": order_id,
            "price": "10.00000000",
            "size": "1.00000000",
            "product_id": self.trading_pair,
            "profile_id": "8058d771-2d88-4f0f-ab6e-299c153d4308",
            "side": "buy",
            "type": "limit",
            "time_in_force": "GTC",
            "post_only": True,
            "created_at": "2020-03-11T20:48:46.622052Z",
            "fill_fees": "0.0000000000000000",
            "filled_size": "0.00000000",
            "executed_value": "0.0000000000000000",
            "status": "open",
            "settled": False
        }
        return orders_mock

    @aioresponses()
    def test_check_network_not_connected(self, mock_api):
        url = f"{CONSTANTS.REST_URL}{CONSTANTS.TIME_PATH_URL}"
        resp = ""
        mock_api.get(url, status=500, body=json.dumps(resp))

        ret = self.async_run_with_timeout(
            coroutine=self.exchange.check_network())

        self.assertEqual(ret, NetworkStatus.NOT_CONNECTED)

    @aioresponses()
    def test_check_network(self, mock_api):
        url = f"{CONSTANTS.REST_URL}{CONSTANTS.TIME_PATH_URL}"
        resp = {}
        mock_api.get(url, body=json.dumps(resp))

        ret = self.async_run_with_timeout(
            coroutine=self.exchange.check_network())

        self.assertEqual(ret, NetworkStatus.CONNECTED)

    @aioresponses()
    def test_update_fee_percentage(self, mock_api):
        url = f"{CONSTANTS.REST_URL}{CONSTANTS.FEES_PATH_URL}"
        resp = {
            "maker_fee_rate": "0.0050",
            "taker_fee_rate": "0.0050",
            "usd_volume": "43806.92"
        }
        mock_api.get(url, body=json.dumps(resp))

        self.async_run_with_timeout(self.exchange._update_fee_percentage())

        self.assertEqual(Decimal(resp["maker_fee_rate"]),
                         self.exchange.maker_fee_percentage)
        self.assertEqual(Decimal(resp["taker_fee_rate"]),
                         self.exchange.taker_fee_percentage)

    @aioresponses()
    def test_update_balances(self, mock_api):
        url = f"{CONSTANTS.REST_URL}{CONSTANTS.ACCOUNTS_PATH_URL}"
        resp = self.get_account_mock(
            base_balance=2,
            base_available=1,
            quote_balance=4,
            quote_available=3,
        )
        mock_api.get(url, body=json.dumps(resp))

        self.async_run_with_timeout(self.exchange._update_balances())

        expected_available_balances = {
            self.base_asset: Decimal("1"),
            self.quote_asset: Decimal("3")
        }
        self.assertEqual(expected_available_balances,
                         self.exchange.available_balances)
        expected_balances = {
            self.base_asset: Decimal("2"),
            self.quote_asset: Decimal("4")
        }
        self.assertEqual(expected_balances, self.exchange.get_all_balances())

    @aioresponses()
    def test_update_trading_rules(self, mock_api):
        url = f"{CONSTANTS.REST_URL}{CONSTANTS.PRODUCTS_PATH_URL}"
        alt_pair = "BTC-USDT"
        resp = self.get_products_response_mock(alt_pair)
        mock_api.get(url, body=json.dumps(resp))

        self.async_run_with_timeout(self.exchange._update_trading_rules())

        trading_rules = self.exchange.trading_rules

        self.assertEqual(2, len(trading_rules))
        self.assertIn(self.trading_pair, trading_rules)
        self.assertIn(alt_pair, trading_rules)
        self.assertIsInstance(trading_rules[self.trading_pair], TradingRule)
        self.assertIsInstance(trading_rules[alt_pair], TradingRule)

    @aioresponses()
    def test_execute_buy(self, mock_api):
        self.simulate_trading_rules_initialization(mock_api)

        some_order_id = "someID"
        url = f"{CONSTANTS.REST_URL}{CONSTANTS.ORDERS_PATH_URL}"
        regex_url = re.compile(f"^{url}".replace(".",
                                                 r"\.").replace("?", r"\?"))
        resp = self.get_orders_response_mock(some_order_id)
        mock_api.post(regex_url, body=json.dumps(resp))

        self.exchange.add_listener(MarketEvent.BuyOrderCreated,
                                   self.event_listener)
        self.exchange.add_listener(MarketEvent.OrderFilled,
                                   self.event_listener)

        self.async_run_with_timeout(
            self.exchange.execute_buy(
                order_id=some_order_id,
                trading_pair=self.trading_pair,
                amount=Decimal("1"),
                order_type=OrderType.LIMIT,
                price=Decimal("2"),
            ))

        self.assertEqual(1, len(self.event_listener.event_log))

        event = self.event_listener.event_log[0]

        self.assertIsInstance(event, BuyOrderCreatedEvent)
        self.assertEqual(some_order_id, event.order_id)
        self.assertIn(some_order_id, self.exchange.in_flight_orders)

    @aioresponses()
    def test_execute_buy_handles_errors(self, mock_api):
        self.simulate_trading_rules_initialization(mock_api)

        url = f"{CONSTANTS.REST_URL}{CONSTANTS.ORDERS_PATH_URL}"
        regex_url = re.compile(f"^{url}".replace(".",
                                                 r"\.").replace("?", r"\?"))
        mock_api.post(regex_url, exception=RuntimeError)

        self.exchange.add_listener(MarketEvent.BuyOrderCreated,
                                   self.event_listener)
        self.exchange.add_listener(MarketEvent.OrderFailure,
                                   self.event_listener)

        some_order_id = "someID"
        self.async_run_with_timeout(
            self.exchange.execute_buy(
                order_id=some_order_id,
                trading_pair=self.trading_pair,
                amount=Decimal("1"),
                order_type=OrderType.LIMIT,
                price=Decimal("2"),
            ))

        self.assertEqual(1, len(self.event_listener.event_log))

        event = self.event_listener.event_log[0]

        self.assertIsInstance(event, MarketOrderFailureEvent)
        self.assertEqual(some_order_id, event.order_id)
        self.assertNotIn(some_order_id, self.exchange.in_flight_orders)

    @aioresponses()
    def test_execute_sell(self, mock_api):
        self.simulate_trading_rules_initialization(mock_api)

        some_order_id = "someID"
        url = f"{CONSTANTS.REST_URL}{CONSTANTS.ORDERS_PATH_URL}"
        regex_url = re.compile(f"^{url}".replace(".",
                                                 r"\.").replace("?", r"\?"))
        resp = self.get_orders_response_mock(some_order_id)
        mock_api.post(regex_url, body=json.dumps(resp))

        self.exchange.add_listener(MarketEvent.SellOrderCreated,
                                   self.event_listener)
        self.exchange.add_listener(MarketEvent.OrderFilled,
                                   self.event_listener)

        self.async_run_with_timeout(
            self.exchange.execute_sell(
                order_id=some_order_id,
                trading_pair=self.trading_pair,
                amount=Decimal("1"),
                order_type=OrderType.LIMIT,
                price=Decimal("2"),
            ))

        self.assertEqual(1, len(self.event_listener.event_log))

        event = self.event_listener.event_log[0]

        self.assertIsInstance(event, SellOrderCreatedEvent)
        self.assertEqual(some_order_id, event.order_id)
        self.assertIn(some_order_id, self.exchange.in_flight_orders)

    @aioresponses()
    def test_execute_sell_handles_errors(self, mock_api):
        self.simulate_trading_rules_initialization(mock_api)

        url = f"{CONSTANTS.REST_URL}{CONSTANTS.ORDERS_PATH_URL}"
        regex_url = re.compile(f"^{url}".replace(".",
                                                 r"\.").replace("?", r"\?"))
        mock_api.post(regex_url, exception=RuntimeError)

        self.exchange.add_listener(MarketEvent.SellOrderCreated,
                                   self.event_listener)
        self.exchange.add_listener(MarketEvent.OrderFailure,
                                   self.event_listener)

        some_order_id = "someID"
        self.async_run_with_timeout(
            self.exchange.execute_sell(
                order_id=some_order_id,
                trading_pair=self.trading_pair,
                amount=Decimal("1"),
                order_type=OrderType.LIMIT,
                price=Decimal("2"),
            ))

        self.assertEqual(1, len(self.event_listener.event_log))

        event = self.event_listener.event_log[0]

        self.assertIsInstance(event, MarketOrderFailureEvent)
        self.assertEqual(some_order_id, event.order_id)
        self.assertNotIn(some_order_id, self.exchange.in_flight_orders)

    @aioresponses()
    def test_execute_cancel(self, mock_api):
        self.simulate_trading_rules_initialization(mock_api)
        some_order_id = "someID"
        self.simulate_execute_buy_order(mock_api, some_order_id)

        url = f"{CONSTANTS.REST_URL}{CONSTANTS.ORDERS_PATH_URL}/{some_order_id}"
        resp = some_order_id
        mock_api.delete(url, body=json.dumps(resp))

        self.exchange.add_listener(MarketEvent.OrderCancelled,
                                   self.event_listener)

        self.async_run_with_timeout(
            self.exchange.execute_cancel(self.trading_pair, some_order_id))

        self.assertEqual(1, len(self.event_listener.event_log))

        event = self.event_listener.event_log[0]

        self.assertIsInstance(event, OrderCancelledEvent)
        self.assertEqual(some_order_id, event.order_id)
        self.assertNotIn(some_order_id, self.exchange.in_flight_orders)

    @aioresponses()
    def test_execute_cancel_order_does_not_exist(self, mock_api):
        self.simulate_trading_rules_initialization(mock_api)
        some_order_id = "someID"
        self.simulate_execute_buy_order(mock_api, some_order_id)

        url = f"{CONSTANTS.REST_URL}{CONSTANTS.ORDERS_PATH_URL}/{some_order_id}"
        mock_api.delete(url, exception=IOError("order not found"))

        self.exchange.add_listener(MarketEvent.OrderCancelled,
                                   self.event_listener)

        self.async_run_with_timeout(
            self.exchange.execute_cancel(self.trading_pair, some_order_id))

        self.assertEqual(1, len(self.event_listener.event_log))

        event = self.event_listener.event_log[0]

        self.assertIsInstance(event, OrderCancelledEvent)
        self.assertEqual(some_order_id, event.order_id)
        self.assertNotIn(some_order_id, self.exchange.in_flight_orders)

    @aioresponses()
    def test_get_order(self, mock_api):
        self.simulate_trading_rules_initialization(mock_api)
        some_order_id = "someID"
        self.simulate_execute_buy_order(mock_api, some_order_id)

        url = f"{CONSTANTS.REST_URL}{CONSTANTS.ORDERS_PATH_URL}/{some_order_id}"
        resp = self.get_orders_response_mock(some_order_id)
        mock_api.get(url, body=json.dumps(resp))