class AmmArbUnitTest(unittest.TestCase): @classmethod def setUpClass(cls): cls.ev_loop = asyncio.get_event_loop() cls.clock: Clock = Clock(ClockMode.REALTIME) cls.stack: contextlib.ExitStack = contextlib.ExitStack() cls._clock = cls.stack.enter_context(cls.clock) cls._patcher = unittest.mock.patch( "hummingbot.strategy.amm_arb.data_types.estimate_fee") cls._url_mock = cls._patcher.start() cls._url_mock.return_value = TradeFee(percent=0, flat_fees=[]) @classmethod def tearDownClass(cls) -> None: cls.stack.close() cls._patcher.stop() def setUp(self): self.amm_1: MockAMM = MockAMM("onion") self.amm_1.set_balance(base_asset, 500) self.amm_1.set_balance(quote_asset, 500) self.market_info_1 = MarketTradingPairTuple(self.amm_1, trading_pair, base_asset, quote_asset) self.amm_2: MockAMM = MockAMM("garlic") self.amm_2.set_balance(base_asset, 500) self.amm_2.set_balance(quote_asset, 500) self.market_info_2 = MarketTradingPairTuple(self.amm_2, trading_pair, base_asset, quote_asset) self.strategy = AmmArbStrategy() self.strategy.init_params( self.market_info_1, self.market_info_2, min_profitability=Decimal("0.01"), order_amount=Decimal("1"), market_1_slippage_buffer=Decimal("0.001"), market_2_slippage_buffer=Decimal("0.002"), ) self.clock.add_iterator(self.amm_1) self.clock.add_iterator(self.amm_2) self.clock.add_iterator(self.strategy) self.market_order_fill_logger: EventLogger = EventLogger() self.amm_1.add_listener(MarketEvent.OrderFilled, self.market_order_fill_logger) self.amm_2.add_listener(MarketEvent.OrderFilled, self.market_order_fill_logger) def test_arbitrage_not_profitable(self): self.amm_1.set_prices(trading_pair, True, 101) self.amm_1.set_prices(trading_pair, False, 100) self.amm_2.set_prices(trading_pair, True, 101) self.amm_2.set_prices(trading_pair, False, 100) self.ev_loop.run_until_complete(asyncio.sleep(2)) taker_orders = self.strategy.tracked_limit_orders + self.strategy.tracked_market_orders self.assertTrue(len(taker_orders) == 0) def test_arb_buy_amm_1_sell_amm_2(self): asyncio.ensure_future(self.clock.run()) self.amm_1.set_prices(trading_pair, True, 101) self.amm_1.set_prices(trading_pair, False, 100) self.amm_2.set_prices(trading_pair, True, 105) self.amm_2.set_prices(trading_pair, False, 104) self.ev_loop.run_until_complete(asyncio.sleep(1.5)) placed_orders = self.strategy.tracked_limit_orders amm_1_order = [ order for market, order in placed_orders if market == self.amm_1 ][0] amm_2_order = [ order for market, order in placed_orders if market == self.amm_2 ][0] self.assertTrue(len(placed_orders) == 2) # Check if the order is created as intended self.assertEqual(Decimal("1"), amm_1_order.quantity) self.assertEqual(True, amm_1_order.is_buy) # The order price has to account for slippage_buffer exp_price = self.amm_1.quantize_order_price( trading_pair, Decimal("101") * Decimal("1.001")) self.assertEqual(exp_price, amm_1_order.price) self.assertEqual(trading_pair, amm_1_order.trading_pair) self.assertEqual(Decimal("1"), amm_2_order.quantity) self.assertEqual(False, amm_2_order.is_buy) exp_price = self.amm_1.quantize_order_price( trading_pair, Decimal("104") * (Decimal("1") - Decimal("0.002"))) self.assertEqual(exp_price, amm_2_order.price) self.assertEqual(trading_pair, amm_2_order.trading_pair) # There are outstanding orders, the strategy is not ready to take on new arb self.assertFalse(self.strategy.ready_for_new_arb_trades()) self.ev_loop.run_until_complete(asyncio.sleep(2)) placed_orders = self.strategy.tracked_limit_orders new_amm_1_order = [ order for market, order in placed_orders if market == self.amm_1 ][0] new_amm_2_order = [ order for market, order in placed_orders if market == self.amm_2 ][0] # Check if orders remain the same self.assertEqual(amm_1_order.client_order_id, new_amm_1_order.client_order_id) self.assertEqual(amm_2_order.client_order_id, new_amm_2_order.client_order_id) def test_arb_buy_amm_2_sell_amm_1(self): asyncio.ensure_future(self.clock.run()) self.amm_1.set_prices(trading_pair, True, 105) self.amm_1.set_prices(trading_pair, False, 104) self.amm_2.set_prices(trading_pair, True, 101) self.amm_2.set_prices(trading_pair, False, 100) self.ev_loop.run_until_complete(asyncio.sleep(1.5)) placed_orders = self.strategy.tracked_limit_orders amm_1_order = [ order for market, order in placed_orders if market == self.amm_1 ][0] amm_2_order = [ order for market, order in placed_orders if market == self.amm_2 ][0] self.assertTrue(len(placed_orders) == 2) self.assertEqual(Decimal("1"), amm_1_order.quantity) self.assertEqual(False, amm_1_order.is_buy) exp_price = self.amm_1.quantize_order_price( trading_pair, Decimal("104") * (Decimal("1") - Decimal("0.001"))) self.assertEqual(exp_price, amm_1_order.price) self.assertEqual(trading_pair, amm_1_order.trading_pair) self.assertEqual(Decimal("1"), amm_2_order.quantity) self.assertEqual(True, amm_2_order.is_buy) exp_price = self.amm_1.quantize_order_price( trading_pair, Decimal("101") * (Decimal("1") + Decimal("0.002"))) self.assertEqual(exp_price, amm_2_order.price) self.assertEqual(trading_pair, amm_2_order.trading_pair) def test_insufficient_balance(self): self.amm_1.set_prices(trading_pair, True, 105) self.amm_1.set_prices(trading_pair, False, 104) self.amm_2.set_prices(trading_pair, True, 101) self.amm_2.set_prices(trading_pair, False, 100) # set base_asset to below order_amount, so not enough to sell on amm_1 self.amm_1.set_balance(base_asset, 0.5) asyncio.ensure_future(self.clock.run()) self.ev_loop.run_until_complete(asyncio.sleep(1.5)) placed_orders = self.strategy.tracked_limit_orders self.assertTrue(len(placed_orders) == 0) self.amm_1.set_balance(base_asset, 10) # set quote balance to 0 on amm_2, so not enough to buy self.amm_2.set_balance(quote_asset, 0) asyncio.ensure_future(self.clock.run()) self.ev_loop.run_until_complete(asyncio.sleep(1.5)) placed_orders = self.strategy.tracked_limit_orders self.assertTrue(len(placed_orders) == 0) @staticmethod def trigger_order_complete(is_buy: bool, connector: ConnectorBase, amount: Decimal, price: Decimal, order_id: str): # This function triggers order complete event for our mock connector, this is to simulate scenarios more # precisely taker orders are fully filled. event_tag = MarketEvent.BuyOrderCompleted if is_buy else MarketEvent.SellOrderCompleted event_class = BuyOrderCompletedEvent if is_buy else SellOrderCompletedEvent connector.trigger_event( event_tag, event_class(connector.current_timestamp, order_id, base_asset, quote_asset, quote_asset, amount, amount * price, Decimal("0"), OrderType.LIMIT)) def test_non_concurrent_orders_submission(self): # On non concurrent orders submission, the second leg of the arb trade has to wait for the first leg order gets # filled. self.strategy = AmmArbStrategy() self.strategy.init_params(self.market_info_1, self.market_info_2, min_profitability=Decimal("0.01"), order_amount=Decimal("1"), concurrent_orders_submission=False) self.clock.add_iterator(self.strategy) asyncio.ensure_future(self.clock.run()) self.amm_1.set_prices(trading_pair, True, 101) self.amm_1.set_prices(trading_pair, False, 100) self.amm_2.set_prices(trading_pair, True, 105) self.amm_2.set_prices(trading_pair, False, 104) self.ev_loop.run_until_complete(asyncio.sleep(1.5)) placed_orders = self.strategy.tracked_limit_orders self.assertEqual(1, len(placed_orders)) # Only one order submitted at this point, the one from amm_1 amm_1_order = [ order for market, order in placed_orders if market == self.amm_1 ][0] amm_2_orders = [ order for market, order in placed_orders if market == self.amm_2 ] self.assertEqual(0, len(amm_2_orders)) self.assertEqual(True, amm_1_order.is_buy) self.trigger_order_complete(True, self.amm_1, amm_1_order.quantity, amm_1_order.price, amm_1_order.client_order_id) # After the first leg order completed, the second one is now submitted. self.ev_loop.run_until_complete(asyncio.sleep(1.5)) placed_orders = self.strategy.tracked_limit_orders amm_2_orders = [ order for market, order in placed_orders if market == self.amm_2 ] self.assertEqual(1, len(amm_2_orders)) amm_2_order = amm_2_orders[0] self.assertEqual(False, amm_2_order.is_buy) self.trigger_order_complete(False, self.amm_2, amm_2_order.quantity, amm_2_order.price, amm_2_order.client_order_id) self.ev_loop.run_until_complete(asyncio.sleep(1.5)) placed_orders = self.strategy.tracked_limit_orders new_amm_1_order = [ order for market, order in placed_orders if market == self.amm_1 ][0] # Check if new order is submitted when arb opportunity still presents self.assertNotEqual(amm_1_order.client_order_id, new_amm_1_order.client_order_id) def test_format_status(self): self.amm_1.set_prices(trading_pair, True, 101) self.amm_1.set_prices(trading_pair, False, 100) self.amm_2.set_prices(trading_pair, True, 105) self.amm_2.set_prices(trading_pair, False, 104) first_side = ArbProposalSide(self.market_info_1, True, Decimal(101), Decimal(100), Decimal(50)) second_side = ArbProposalSide(self.market_info_2, False, Decimal(105), Decimal(104), Decimal(50)) self.strategy._arb_proposals = [ArbProposal(first_side, second_side)] expected_status = ( " Markets:\n" " Exchange Market Sell Price Buy Price Mid Price\n" " onion HBOT-USDT 100.00000000 101.00000000 100.50000000\n" " garlic HBOT-USDT 104.00000000 105.00000000 104.50000000\n\n" " Assets:\n" " Exchange Asset Total Balance Available Balance\n" " 0 onion HBOT 500 500\n" " 1 onion USDT 500 500\n" " 2 garlic HBOT 500 500\n" " 3 garlic USDT 500 500\n\n" " Profitability:\n" " buy at onion, sell at garlic: 3.96%\n\n" " Quotes Rates (fixed rates)\n" " Quotes pair Rate\n" " 0 USDT-USDT 1") current_status = self.ev_loop.run_until_complete( self.strategy.format_status()) self.assertTrue(expected_status in current_status)
class AmmArbUnitTest(unittest.TestCase): def setUp(self): self.clock: Clock = Clock(ClockMode.REALTIME) self.stack: contextlib.ExitStack = contextlib.ExitStack() self.amm_1: MockAMM = MockAMM( name="onion", client_config_map=ClientConfigAdapter(ClientConfigMap())) self.amm_1.set_balance(BASE_ASSET, 500) self.amm_1.set_balance(QUOTE_ASSET, 500) self.market_info_1 = MarketTradingPairTuple(self.amm_1, TRADING_PAIR, BASE_ASSET, QUOTE_ASSET) self.amm_2: MockAMM = MockAMM( name="garlic", client_config_map=ClientConfigAdapter(ClientConfigMap())) self.amm_2.set_balance(BASE_ASSET, 500) self.amm_2.set_balance(QUOTE_ASSET, 500) self.market_info_2 = MarketTradingPairTuple(self.amm_2, TRADING_PAIR, BASE_ASSET, QUOTE_ASSET) # Set some default prices. self.amm_1.set_prices(TRADING_PAIR, True, 101) self.amm_1.set_prices(TRADING_PAIR, False, 100) self.amm_2.set_prices(TRADING_PAIR, True, 105) self.amm_2.set_prices(TRADING_PAIR, False, 104) self.strategy = AmmArbStrategy() self.strategy.init_params( self.market_info_1, self.market_info_2, min_profitability=Decimal("0.01"), order_amount=Decimal("1"), market_1_slippage_buffer=Decimal("0.001"), market_2_slippage_buffer=Decimal("0.002"), ) self.rate_source: FixedRateSource = FixedRateSource() self.strategy.rate_source = self.rate_source self.clock.add_iterator(self.amm_1) self.clock.add_iterator(self.amm_2) self.clock.add_iterator(self.strategy) self.market_order_fill_logger: EventLogger = EventLogger() self.amm_1.add_listener(MarketEvent.OrderFilled, self.market_order_fill_logger) self.amm_2.add_listener(MarketEvent.OrderFilled, self.market_order_fill_logger) self.rate_source.add_rate("ETH-USDT", Decimal(3000)) self.stack.enter_context(self.clock) self.stack.enter_context(patch( "hummingbot.client.config.trade_fee_schema_loader.TradeFeeSchemaLoader.configured_schema_for_exchange", return_value=TradeFeeSchema() )) self.clock_task: asyncio.Task = safe_ensure_future(self.clock.run()) def tearDown(self) -> None: self.stack.close() self.clock_task.cancel() try: ev_loop.run_until_complete(self.clock_task) except asyncio.CancelledError: pass @async_test(loop=ev_loop) async def test_arbitrage_not_profitable(self): self.amm_1.set_prices(TRADING_PAIR, True, 101) self.amm_1.set_prices(TRADING_PAIR, False, 100) self.amm_2.set_prices(TRADING_PAIR, True, 101) self.amm_2.set_prices(TRADING_PAIR, False, 100) await asyncio.sleep(2) taker_orders = self.strategy.tracked_limit_orders + self.strategy.tracked_market_orders self.assertTrue(len(taker_orders) == 0) @async_test(loop=ev_loop) async def test_arb_buy_amm_1_sell_amm_2(self): self.amm_1.set_prices(TRADING_PAIR, True, 101) self.amm_1.set_prices(TRADING_PAIR, False, 100) self.amm_2.set_prices(TRADING_PAIR, True, 105) self.amm_2.set_prices(TRADING_PAIR, False, 104) await asyncio.sleep(1.5) placed_orders = self.strategy.tracked_limit_orders amm_1_order = [order for market, order in placed_orders if market == self.amm_1][0] amm_2_order = [order for market, order in placed_orders if market == self.amm_2][0] self.assertTrue(len(placed_orders) == 2) # Check if the order is created as intended self.assertEqual(Decimal("1"), amm_1_order.quantity) self.assertEqual(True, amm_1_order.is_buy) # The order price has to account for slippage_buffer exp_price = self.amm_1.quantize_order_price(TRADING_PAIR, Decimal("101") * Decimal("1.001")) self.assertEqual(exp_price, amm_1_order.price) self.assertEqual(TRADING_PAIR, amm_1_order.trading_pair) self.assertEqual(Decimal("1"), amm_2_order.quantity) self.assertEqual(False, amm_2_order.is_buy) exp_price = self.amm_1.quantize_order_price(TRADING_PAIR, Decimal("104") * (Decimal("1") - Decimal("0.002"))) self.assertEqual(exp_price, amm_2_order.price) self.assertEqual(TRADING_PAIR, amm_2_order.trading_pair) # There are outstanding orders, the strategy is not ready to take on new arb self.assertFalse(self.strategy.ready_for_new_arb_trades()) await asyncio.sleep(2) placed_orders = self.strategy.tracked_limit_orders new_amm_1_order = [order for market, order in placed_orders if market == self.amm_1][0] new_amm_2_order = [order for market, order in placed_orders if market == self.amm_2][0] # Check if orders remain the same self.assertEqual(amm_1_order.client_order_id, new_amm_1_order.client_order_id) self.assertEqual(amm_2_order.client_order_id, new_amm_2_order.client_order_id) @async_test(loop=ev_loop) async def test_arb_buy_amm_2_sell_amm_1(self): self.amm_1.set_prices(TRADING_PAIR, True, 105) self.amm_1.set_prices(TRADING_PAIR, False, 104) self.amm_2.set_prices(TRADING_PAIR, True, 101) self.amm_2.set_prices(TRADING_PAIR, False, 100) await asyncio.sleep(1.5) placed_orders = self.strategy.tracked_limit_orders amm_1_order = [order for market, order in placed_orders if market == self.amm_1][0] amm_2_order = [order for market, order in placed_orders if market == self.amm_2][0] self.assertTrue(len(placed_orders) == 2) self.assertEqual(Decimal("1"), amm_1_order.quantity) self.assertEqual(False, amm_1_order.is_buy) exp_price = self.amm_1.quantize_order_price(TRADING_PAIR, Decimal("104") * (Decimal("1") - Decimal("0.001"))) self.assertEqual(exp_price, amm_1_order.price) self.assertEqual(TRADING_PAIR, amm_1_order.trading_pair) self.assertEqual(Decimal("1"), amm_2_order.quantity) self.assertEqual(True, amm_2_order.is_buy) exp_price = self.amm_1.quantize_order_price(TRADING_PAIR, Decimal("101") * (Decimal("1") + Decimal("0.002"))) self.assertEqual(exp_price, amm_2_order.price) self.assertEqual(TRADING_PAIR, amm_2_order.trading_pair) @async_test(loop=ev_loop) async def test_insufficient_balance(self): self.amm_1.set_prices(TRADING_PAIR, True, 105) self.amm_1.set_prices(TRADING_PAIR, False, 104) self.amm_2.set_prices(TRADING_PAIR, True, 101) self.amm_2.set_prices(TRADING_PAIR, False, 100) # set base_asset to below order_amount, so not enough to sell on amm_1 self.amm_1.set_balance(BASE_ASSET, 0.5) await asyncio.sleep(1.5) placed_orders = self.strategy.tracked_limit_orders self.assertTrue(len(placed_orders) == 0) self.amm_1.set_balance(BASE_ASSET, 10) # set quote balance to 0 on amm_2, so not enough to buy self.amm_2.set_balance(QUOTE_ASSET, 0) await asyncio.sleep(1.5) placed_orders = self.strategy.tracked_limit_orders self.assertTrue(len(placed_orders) == 0) @staticmethod def trigger_order_complete(is_buy: bool, connector: ConnectorBase, amount: Decimal, price: Decimal, order_id: str): # This function triggers order complete event for our mock connector, this is to simulate scenarios more # precisely taker orders are fully filled. event_tag = MarketEvent.BuyOrderCompleted if is_buy else MarketEvent.SellOrderCompleted event_class = BuyOrderCompletedEvent if is_buy else SellOrderCompletedEvent connector.trigger_event(event_tag, event_class(connector.current_timestamp, order_id, BASE_ASSET, QUOTE_ASSET, amount, amount * price, OrderType.LIMIT)) @async_test(loop=ev_loop) async def test_non_concurrent_orders_submission(self): # On non concurrent orders submission, the second leg of the arb trade has to wait for the first leg order gets # filled. self.strategy = AmmArbStrategy() self.strategy.init_params( self.market_info_1, self.market_info_2, min_profitability=Decimal("0.01"), order_amount=Decimal("1"), concurrent_orders_submission=False ) self.strategy.rate_source = self.rate_source self.clock.add_iterator(self.strategy) await asyncio.sleep(1.5) placed_orders = self.strategy.tracked_limit_orders self.assertEqual(1, len(placed_orders)) # Only one order submitted at this point, the one from amm_1 amm_1_order = [order for market, order in placed_orders if market == self.amm_1][0] amm_2_orders = [order for market, order in placed_orders if market == self.amm_2] self.assertEqual(0, len(amm_2_orders)) self.assertEqual(True, amm_1_order.is_buy) self.trigger_order_complete(True, self.amm_1, amm_1_order.quantity, amm_1_order.price, amm_1_order.client_order_id) # After the first leg order completed, the second one is now submitted. await asyncio.sleep(1.5) placed_orders = self.strategy.tracked_limit_orders amm_2_orders = [order for market, order in placed_orders if market == self.amm_2] self.assertEqual(1, len(amm_2_orders)) amm_2_order = amm_2_orders[0] self.assertEqual(False, amm_2_order.is_buy) self.trigger_order_complete(False, self.amm_2, amm_2_order.quantity, amm_2_order.price, amm_2_order.client_order_id) await asyncio.sleep(1.5) placed_orders = self.strategy.tracked_limit_orders new_amm_1_order = [order for market, order in placed_orders if market == self.amm_1][0] # Check if new order is submitted when arb opportunity still presents self.assertNotEqual(amm_1_order.client_order_id, new_amm_1_order.client_order_id) @async_test(loop=ev_loop) async def test_format_status(self): first_side = ArbProposalSide( self.market_info_1, True, Decimal(101), Decimal(100), Decimal(50), [] ) second_side = ArbProposalSide( self.market_info_2, False, Decimal(105), Decimal(104), Decimal(50), [] ) self.strategy._all_arb_proposals = [ArbProposal(first_side, second_side)] expected_status = """ Markets: Exchange Market Sell Price Buy Price Mid Price onion HBOT-USDT 100.00000000 101.00000000 100.50000000 garlic HBOT-USDT 104.00000000 105.00000000 104.50000000 Network Fees: Exchange Gas Fees onion 0 ETH garlic 0 ETH Assets: Exchange Asset Total Balance Available Balance 0 onion HBOT 500 500 1 onion USDT 500 500 2 garlic HBOT 500 500 3 garlic USDT 500 500 Profitability: buy at onion, sell at garlic: 3.96% Quotes Rates (fixed rates) Quotes pair Rate 0 USDT-USDT 1""" current_status = await self.strategy.format_status() self.assertTrue(expected_status in current_status) @async_test(loop=ev_loop) async def test_arb_not_profitable_from_gas_prices(self): self.amm_1.set_prices(TRADING_PAIR, True, 101) self.amm_1.set_prices(TRADING_PAIR, False, 100) self.amm_2.set_prices(TRADING_PAIR, True, 110) self.amm_2.set_prices(TRADING_PAIR, False, 109) self.amm_1.network_transaction_fee = TokenAmount("ETH", Decimal("0.01")) await asyncio.sleep(2) taker_orders = self.strategy.tracked_limit_orders + self.strategy.tracked_market_orders self.assertTrue(len(taker_orders) == 0) @async_test(loop=ev_loop) async def test_arb_profitable_after_gas_prices(self): self.amm_1.set_prices(TRADING_PAIR, True, 101) self.amm_1.set_prices(TRADING_PAIR, False, 100) self.amm_2.set_prices(TRADING_PAIR, True, 105) self.amm_2.set_prices(TRADING_PAIR, False, 104) self.amm_1.network_transaction_fee = TokenAmount("ETH", Decimal("0.0002")) await asyncio.sleep(2) placed_orders = self.strategy.tracked_limit_orders + self.strategy.tracked_market_orders self.assertEqual(2, len(placed_orders)) @async_test(loop=ev_loop) @unittest.mock.patch("hummingbot.strategy.amm_arb.amm_arb.AmmArbStrategy.apply_gateway_transaction_cancel_interval") async def test_apply_cancel_interval(self, patched_func: unittest.mock.AsyncMock): await asyncio.sleep(2) patched_func.assert_awaited() @async_test(loop=ev_loop) @unittest.mock.patch("hummingbot.strategy.amm_arb.amm_arb.AmmArbStrategy.is_gateway_market", return_value=True) @unittest.mock.patch.object(MockAMM, "cancel_outdated_orders") async def test_cancel_outdated_orders( self, cancel_outdated_orders_func: unittest.mock.AsyncMock, _: unittest.mock.Mock ): await asyncio.sleep(2) cancel_outdated_orders_func.assert_awaited() @async_test(loop=ev_loop) async def test_set_order_failed(self): self.amm_1.set_prices(TRADING_PAIR, True, 101) self.amm_1.set_prices(TRADING_PAIR, False, 100) self.amm_2.set_prices(TRADING_PAIR, True, 105) self.amm_2.set_prices(TRADING_PAIR, False, 104) self.amm_1.network_transaction_fee = TokenAmount("ETH", Decimal("0.0002")) await asyncio.sleep(2) new_amm_1_order = [order for market, order in self.strategy.tracked_limit_orders if market == self.amm_1][0] self.assertEqual(2, len(self.strategy.tracked_limit_orders)) self.strategy.set_order_failed(new_amm_1_order.client_order_id) self.assertEqual(2, len(self.strategy.tracked_limit_orders)) @async_test(loop=ev_loop) async def test_market_ready(self): self.amm_1.ready = False await asyncio.sleep(10) self.assertFalse(self.strategy._all_markets_ready)