diff --git a/hummingbot/connector/gateway/amm_lp/gateway_evm_amm_lp.py b/hummingbot/connector/gateway/amm_lp/gateway_evm_amm_lp.py index fde42c9d21..e1ed47853e 100644 --- a/hummingbot/connector/gateway/amm_lp/gateway_evm_amm_lp.py +++ b/hummingbot/connector/gateway/amm_lp/gateway_evm_amm_lp.py @@ -52,7 +52,7 @@ class GatewayEVMAMMLP(ConnectorBase): """ API_CALL_TIMEOUT = 10.0 - POLL_INTERVAL = 1.0 + POLL_INTERVAL = 10.0 UPDATE_BALANCE_INTERVAL = 30.0 APPROVAL_ORDER_ID_PATTERN = re.compile(r"approve-(\w+)-(\w+)") @@ -1256,3 +1256,9 @@ def tracking_states(self) -> Dict[str, any]: def _get_gateway_instance(self) -> GatewayHttpClient: gateway_instance = GatewayHttpClient.get_instance(self._client_config) return gateway_instance + + def set_pool_interval(self, interval: int): + self.POLL_INTERVAL = interval + + def set_update_balance_interval(self, interval: int): + self.UPDATE_BALANCE_INTERVAL = interval diff --git a/hummingbot/strategy/amm_v3_lp/amm_v3_lp.py b/hummingbot/strategy/amm_v3_lp/amm_v3_lp.py index 6a11bd2b47..25bfcb5691 100644 --- a/hummingbot/strategy/amm_v3_lp/amm_v3_lp.py +++ b/hummingbot/strategy/amm_v3_lp/amm_v3_lp.py @@ -1,11 +1,16 @@ import asyncio import logging +import time from decimal import Decimal import pandas as pd from hummingbot.client.performance import PerformanceMetrics +from hummingbot.connector.gateway.amm_lp.gateway_evm_amm_lp import GatewayEVMAMMLP +from hummingbot.connector.gateway.amm_lp.gateway_in_flight_lp_order import GatewayInFlightLPOrder from hummingbot.core.clock import Clock +from hummingbot.core.event.events import TradeType +from hummingbot.core.gateway.gateway_http_client import GatewayHttpClient from hummingbot.core.utils.async_utils import safe_ensure_future from hummingbot.logger import HummingbotLogger from hummingbot.strategy.market_trading_pair_tuple import MarketTradingPairTuple @@ -15,6 +20,32 @@ s_decimal_0 = Decimal("0") +def get_position_status_by_price_range(price: Decimal, lower_price: Decimal, upper_price: Decimal, + buffer_spread: Decimal = Decimal("0")): + """ + This returns the status of a position based on last price and buffer spread. + There are 3 possible statuses: In range, In buffered range, Out of range. + """ + if lower_price <= price <= upper_price: + return "[In range]" + elif is_price_in_range(price, lower_price, upper_price, buffer_spread): + return "[In buffered range]" + else: + return "[Out of range]" + + +def is_price_in_range(price: Decimal, lower_price: Decimal, upper_price: Decimal, + buffer_spread: Decimal = Decimal("0")): + """ + Check if price is in range of lower and upper price with buffer spread. + """ + if ((lower_price * (Decimal("1") - buffer_spread)) <= price <= + (upper_price * (Decimal("1") + buffer_spread))): + return True + else: + return False + + class AmmV3LpStrategy(StrategyPyBase): @classmethod @@ -28,14 +59,18 @@ def __init__(self, market_info: MarketTradingPairTuple, fee_tier: str, price_spread: Decimal, - amount: Decimal, + buffer_spread: Decimal, + min_amount: Decimal, + max_amount: Decimal, min_profitability: Decimal, - status_report_interval: float = 900): + status_report_interval: int = 10): super().__init__() self._market_info = market_info self._fee_tier = fee_tier self._price_spread = price_spread - self._amount = amount + self._min_amount = min_amount + self._max_amount = max_amount + self._buffer_spread = buffer_spread self._min_profitability = min_profitability self._ev_loop = asyncio.get_event_loop() @@ -47,6 +82,10 @@ def __init__(self, self._main_task = None self._fetch_prices_task = None + self._connector: GatewayEVMAMMLP = self._market_info.market + self._connector.set_update_balance_interval(status_report_interval) + self._connector.set_pool_interval(status_report_interval) + @property def connector_name(self): return self._market_info.market.display_name @@ -65,11 +104,13 @@ def trading_pair(self): @property def active_positions(self): - return [pos for pos in self._market_info.market.amm_lp_orders if pos.is_nft and pos.trading_pair == self.trading_pair] + return [pos for pos in self._market_info.market.amm_lp_orders if + pos.is_nft and pos.trading_pair == self.trading_pair] @property def active_orders(self): - return [pos for pos in self._market_info.market.amm_lp_orders if not pos.is_nft and pos.trading_pair == self.trading_pair] + return [pos for pos in self._market_info.market.amm_lp_orders if + not pos.is_nft and pos.trading_pair == self.trading_pair] async def get_pool_price(self, update_volatility: bool = False) -> float: prices = await self._market_info.market.get_price(self.trading_pair, self._fee_tier) @@ -93,7 +134,7 @@ def active_positions_df(self) -> pd.DataFrame: f"{PerformanceMetrics.smart_round(position.amount_1, 8)}", f"{PerformanceMetrics.smart_round(position.unclaimed_fee_0, 8)} / " f"{PerformanceMetrics.smart_round(position.unclaimed_fee_1, 8)}", - "[In range]" if self._last_price >= position.adjusted_lower_price and self._last_price <= position.adjusted_upper_price else "[Out of range]" + f"{get_position_status_by_price_range(self._last_price, position.adjusted_lower_price, position.adjusted_upper_price, self._buffer_spread)}", ]) return pd.DataFrame(data=data, columns=columns) @@ -120,7 +161,8 @@ async def format_status(self) -> str: # See if there're any active positions. if len(self.active_positions) > 0: pos_info_df = self.active_positions_df() - lines.extend(["", " Positions:"] + [" " + line for line in pos_info_df.to_string(index=False).split("\n")]) + lines.extend( + ["", " Positions:"] + [" " + line for line in pos_info_df.to_string(index=False).split("\n")]) else: lines.extend(["", " No active positions."]) @@ -148,23 +190,29 @@ def tick(self, timestamp: float): else: self.logger().info(f"{self.connector_name} connector is ready. Trading started.") - if self._main_task is None or self._main_task.done(): - self._main_task = safe_ensure_future(self.main()) + if time.time() - self._last_timestamp > self._status_report_interval: + if self._main_task is None or self._main_task.done(): + self._main_task = safe_ensure_future(self.main()) + self._last_timestamp = time.time() async def main(self): if len(self.active_orders) == 0: # this ensures that there'll always be one lp order per time - lower_price, upper_price = await self.propose_position_boundary() - if lower_price + upper_price != s_decimal_0: - self.execute_proposal(lower_price, upper_price) - self.close_matured_positions() + position_closed = await self.close_matured_positions() - def any_active_position(self, current_price: Decimal): + if position_closed: + await self.reset_balance() + + lower_price, upper_price = await self.propose_position_boundary() + if lower_price + upper_price != s_decimal_0: + await self.execute_proposal(lower_price, upper_price) + + def any_active_position(self, current_price: Decimal, buffer_spread: Decimal = Decimal("0")) -> bool: """ We use this to know if any existing position is in-range. :return: True/False """ for position in self.active_positions: - if current_price >= position.lower_price and current_price <= position.upper_price: + if is_price_in_range(current_price, position.lower_price, position.upper_price, buffer_spread): return True return False @@ -179,44 +227,192 @@ async def propose_position_boundary(self): if current_price != s_decimal_0: self._last_price = current_price - if not self.any_active_position(current_price): # only set prices if there's no active position + if not self.any_active_position(Decimal(current_price), + self._buffer_spread): # only set prices if there's no active position half_spread = self._price_spread / Decimal("2") lower_price = (current_price * (Decimal("1") - half_spread)) upper_price = (current_price * (Decimal("1") + half_spread)) lower_price = max(s_decimal_0, lower_price) return lower_price, upper_price - def execute_proposal(self, lower_price: Decimal, upper_price: Decimal): + async def execute_proposal(self, lower_price: Decimal, upper_price: Decimal): """ This execute proposal generated earlier by propose_position_boundary function. :param lower_price: lower price for position to be created :param upper_price: upper price for position to be created """ + await self._market_info.market._update_balances() # this is to ensure that we have the latest balances + base_balance = self._market_info.market.get_available_balance(self.base_asset) quote_balance = self._market_info.market.get_available_balance(self.quote_asset) + + proposed_lower_price = lower_price + proposed_upper_price = upper_price + current_price = Decimal(await self.get_pool_price()) + + # Make sure we don't create position with too little amount of base asset + if base_balance < self._min_amount: + base_balance = s_decimal_0 + # Add 0.1% gap from current price to make sure we don't use any base balance in new position + # NOTE: Please make sure buffer_spread is bigger than 0.1% to avoid the position being closed immediately + proposed_upper_price = current_price * (Decimal("1") - Decimal("0.1") / 100) + # We use the whole price spread for lower bound + proposed_lower_price = current_price * (Decimal("1") - self._price_spread) + + # Make sure we don't create position with too little amount of quote asset + if (quote_balance / self._last_price) < self._min_amount: + quote_balance = s_decimal_0 + # We use the whole price spread for upper bound + proposed_upper_price = current_price * (Decimal("1") + self._price_spread) + # Add 0.1% gap from current price to make sure we don't use any quote balance in new position + # NOTE: Please make sure buffer_spread is bigger than 0.1% to avoid the position being closed immediately + proposed_lower_price = current_price * (Decimal("1") + Decimal("0.1") / 100) + if base_balance + quote_balance == s_decimal_0: self.log_with_clock(logging.INFO, "Both balances exhausted. Add more assets.") else: self.log_with_clock(logging.INFO, f"Creating new position over {lower_price} to {upper_price} price range.") + self.log_with_clock(logging.INFO, f"Base balance: {base_balance}, quote balance: {quote_balance}") self._market_info.market.add_liquidity(self.trading_pair, - min(base_balance, self._amount), - min(quote_balance, (self._amount * self._last_price)), - lower_price, - upper_price, + min(base_balance, self._max_amount), + min(quote_balance, (self._max_amount * self._last_price)), + proposed_lower_price, + proposed_upper_price, self._fee_tier) - def close_matured_positions(self): + async def close_matured_positions(self) -> bool: """ This closes out-of-range positions that have more than the min profitability. """ + closed_position = False for position in self.active_positions: - if self._last_price <= position.lower_price or self._last_price >= position.upper_price: # out-of-range - if position.unclaimed_fee_0 + (position.unclaimed_fee_1 / self._last_price) > self._min_profitability: # matured + if not is_price_in_range(self._last_price, position.lower_price, position.upper_price, + Decimal("0")): # out-of-range + if position.unclaimed_fee_0 + ( + position.unclaimed_fee_1 / self._last_price) > self._min_profitability: # matured self.log_with_clock(logging.INFO, - f"Closing position with Id {position.token_id}." + f"Closing position with Id {position.token_id} (Matured position)." f"Unclaimed base fee: {position.unclaimed_fee_0}, unclaimed quote fee: {position.unclaimed_fee_1}") - self._market_info.market.remove_liquidity(self.trading_pair, position.token_id) + await self._market_info.market.remove_liquidity(self.trading_pair, position.token_id) + closed_position = True + else: + closed_position = await self.close_out_of_buffered_range_position(position) + + if len(self.active_positions) == 0: + self.log_with_clock(logging.INFO, + "Closing matured positions completed. No active positions left.") + closed_position = True + + return closed_position + + async def close_out_of_buffered_range_position(self, position: GatewayInFlightLPOrder) -> bool: + """ + This closes out-of-range positions that are too far from last price. + """ + if not is_price_in_range(self._last_price, position.lower_price, position.upper_price, self._buffer_spread): # out-of-range + self.log_with_clock(logging.INFO, + f"Closing position with Id {position.token_id} (Out of range)." + f"Unclaimed base fee: {position.unclaimed_fee_0}, unclaimed quote fee: {position.unclaimed_fee_1}") + await self._market_info.market.remove_liquidity(self.trading_pair, position.token_id) + return True + + return False + + async def reset_balance(self): + """ + This resets the balance of the wallet to create new lp position. + """ + await self._market_info.market._update_balances() + base_balance = self._market_info.market.get_available_balance(self.base_asset) + quote_balance = self._market_info.market.get_available_balance(self.quote_asset) + current_price = Decimal(await self.get_pool_price()) + + if (base_balance > self._min_amount) and ((quote_balance / current_price) > self._min_amount): + self.log_with_clock(logging.INFO, + "Both balances are above minimum amount. No need to reset balance.") + return + + base, quote = list(self.trading_pair)[0].split("-") + chain = self._connector.chain + network = self._connector.network + connector = "uniswap" + total_balance_in_base: Decimal = base_balance + (quote_balance / current_price) + base_balance_needed: Decimal = total_balance_in_base / Decimal("2") + balance_diff_in_base: Decimal = base_balance_needed - base_balance + + if (base_balance_needed < self._min_amount): + self.log_with_clock(logging.INFO, + "Total balance is below minimum amount. No need to reset balance.") + return + + slippage_buffer = Decimal("0.01") + + if balance_diff_in_base > s_decimal_0: + self.log_with_clock(logging.INFO, + f"Resetting balance. Adding {abs(balance_diff_in_base)} {base} to wallet.") + trade_side = TradeType.BUY + else: + self.log_with_clock(logging.INFO, + f"Resetting balance. Adding {abs(balance_diff_in_base)} {quote} to wallet.") + trade_side = TradeType.SELL + + # add slippage buffer to current price + if trade_side == TradeType.BUY: + price = current_price * (Decimal("1") + slippage_buffer) + else: + price = current_price * (Decimal("1") - slippage_buffer) + self.logger().info(f"Swap Limit Price: {price}") + + # execute swap + self.logger().info(f"POST /amm/trade [ connector: {connector}, base: {base}, quote: {quote}, amount: {abs(balance_diff_in_base)}, side: {trade_side}, price: {price} ]") + trade_date = await GatewayHttpClient.get_instance().amm_trade( + chain, + network, + connector, + self._connector.address, + base, + quote, + trade_side, + abs(balance_diff_in_base), + price + ) + + await self.poll_transaction(chain, network, trade_date['txHash']) + await self.get_balance(chain, network, self._connector.address, base, quote) + + # fetch and print balance of base and quote tokens + async def get_balance(self, chain, network, address, base, quote): + self.logger().info(f"POST /network/balance [ address: {address}, base: {base}, quote: {quote} ]") + balance_data = await GatewayHttpClient.get_instance().get_balances( + chain, + network, + address, + [base, quote] + ) + self.logger().info(f"Balances for {address}: {balance_data['balances']}") + + # continuously poll for transaction until confirmed + async def poll_transaction(self, chain, network, tx_hash): + pending: bool = True + while pending is True: + self.logger().info(f"POST /network/poll [ txHash: {tx_hash} ]") + poll_data = await GatewayHttpClient.get_instance().get_transaction_status( + chain, + network, + tx_hash + ) + transaction_status = poll_data.get("txStatus") + if transaction_status == 1: + self.logger().info(f"Trade with transaction hash {tx_hash} has been executed successfully.") + pending = False + elif transaction_status in [-1, 0, 2]: + self.logger().info(f"Trade is pending confirmation, Transaction hash: {tx_hash}") + await asyncio.sleep(2) + else: + self.logger().info(f"Unknown txStatus: {transaction_status}") + self.logger().info(f"{poll_data}") + pending = False def stop(self, clock: Clock): if self._main_task is not None: diff --git a/hummingbot/strategy/amm_v3_lp/amm_v3_lp_config_map.py b/hummingbot/strategy/amm_v3_lp/amm_v3_lp_config_map.py index ee38a9ca62..566585303d 100644 --- a/hummingbot/strategy/amm_v3_lp/amm_v3_lp_config_map.py +++ b/hummingbot/strategy/amm_v3_lp/amm_v3_lp_config_map.py @@ -1,6 +1,6 @@ from decimal import Decimal -from hummingbot.client.config.config_validators import validate_decimal, validate_market_trading_pair +from hummingbot.client.config.config_validators import validate_decimal, validate_int, validate_market_trading_pair from hummingbot.client.config.config_var import ConfigVar from hummingbot.client.settings import ( AllConnectorSettings, @@ -71,12 +71,25 @@ def market_prompt() -> str: validator=lambda v: validate_decimal(v, Decimal("0"), inclusive=False), default=Decimal("1"), prompt_on_new=True), - "amount": ConfigVar( - key="amount", + "buffer_spread": ConfigVar( + key="buffer_spread", + prompt="How far from the position price range do you want to keep the position active? (Enter 1 to indicate 1%) >>> ", + type_str="decimal", + validator=lambda v: validate_decimal(v, Decimal("0"), inclusive=False), + default=Decimal("0.5"), + prompt_on_new=True), + "max_amount": ConfigVar( + key="max_amount", prompt="Enter the maximum value(in terms of base asset) to use for providing liquidity. >>>", prompt_on_new=True, validator=lambda v: validate_decimal(v, Decimal("0"), inclusive=False), type_str="decimal"), + "min_amount": ConfigVar( + key="min_amount", + prompt="Enter the minimum value (in terms of base asset) to use for providing liquidity. >>>", + prompt_on_new=True, + validator=lambda v: validate_decimal(v, Decimal("0"), inclusive=False), + type_str="decimal"), "min_profitability": ConfigVar( key="min_profitability", prompt="What is the minimum unclaimed fees an out of range position must have before it is closed? (in terms of base asset) >>>", @@ -84,4 +97,11 @@ def market_prompt() -> str: validator=lambda v: validate_decimal(v, Decimal("0"), inclusive=False), default=Decimal("1"), type_str="decimal"), + "status_report_interval": ConfigVar( + key="status_report_interval", + prompt="How often should the bot get market updates from gateway? (in seconds) >>>", + prompt_on_new=True, + validator=lambda v: validate_int(v, 1, inclusive=True), + default=10, + type_str="int"), } diff --git a/hummingbot/strategy/amm_v3_lp/start.py b/hummingbot/strategy/amm_v3_lp/start.py index bf8ae9b742..65e5e0b8c2 100644 --- a/hummingbot/strategy/amm_v3_lp/start.py +++ b/hummingbot/strategy/amm_v3_lp/start.py @@ -10,8 +10,11 @@ def start(self): pair = c_map.get("market").value fee_tier = c_map.get("fee_tier").value price_spread = c_map.get("price_spread").value / Decimal("100") - amount = c_map.get("amount").value + buffer_spread = c_map.get("buffer_spread").value / Decimal("100") + min_amount = c_map.get("min_amount").value + max_amount = c_map.get("max_amount").value min_profitability = c_map.get("min_profitability").value + status_report_interval = c_map.get("status_report_interval").value self._initialize_markets([(connector, [pair])]) base, quote = pair.split("-") @@ -21,5 +24,8 @@ def start(self): self.strategy = AmmV3LpStrategy(market_info, fee_tier, price_spread, - amount, - min_profitability) + buffer_spread, + min_amount, + max_amount, + min_profitability, + status_report_interval) diff --git a/hummingbot/templates/conf_amm_v3_lp_strategy_TEMPLATE.yml b/hummingbot/templates/conf_amm_v3_lp_strategy_TEMPLATE.yml index 38ff87eae3..89d837e76b 100644 --- a/hummingbot/templates/conf_amm_v3_lp_strategy_TEMPLATE.yml +++ b/hummingbot/templates/conf_amm_v3_lp_strategy_TEMPLATE.yml @@ -17,8 +17,15 @@ fee_tier: null # The spread between lower price to the upper price price_spread: null +# The buffer to keep position active when price is moving out of range +buffer_spread: null + # The amount of token (liquidity) provided to the pool -amount: null +max_amount: null +min_amount: null # The minimum profit required before positions can be adjusted min_profitability: null + +# How often should the bot get market updates from gateway? +status_report_interval: null \ No newline at end of file diff --git a/test/hummingbot/strategy/amm_v3_lp/test_amm_v3_lp.py b/test/hummingbot/strategy/amm_v3_lp/test_amm_v3_lp.py index 59f56dd4ab..e1a5cb7c0c 100644 --- a/test/hummingbot/strategy/amm_v3_lp/test_amm_v3_lp.py +++ b/test/hummingbot/strategy/amm_v3_lp/test_amm_v3_lp.py @@ -137,13 +137,16 @@ async def check_network(self) -> NetworkStatus: async def cancel_outdated_orders(self, _: int) -> List: return [] + async def _update_balances(self): + pass + class AmmV3LpUnitTest(unittest.TestCase): def setUp(self): self.clock: Clock = Clock(ClockMode.REALTIME) self.stack: contextlib.ExitStack = contextlib.ExitStack() self.lp: MockAMMLP = MockAMMLP("onion") - self.lp.set_balance(BASE_ASSET, 500) + self.lp.set_balance(BASE_ASSET, 5) self.lp.set_balance(QUOTE_ASSET, 500) self.market_info = MarketTradingPairTuple(self.lp, TRADING_PAIR, BASE_ASSET, QUOTE_ASSET) @@ -154,7 +157,8 @@ def setUp(self): self.market_info, "LOW", Decimal("0.2"), - Decimal("1"), + Decimal("10"), + Decimal("100"), Decimal("10"), ) self.clock.add_iterator(self.lp) @@ -193,19 +197,27 @@ async def test_format_status(self): Assets: Exchange Asset Total Balance Available Balance - 0 onion HBOT 500 500 + 0 onion HBOT 5 5 1 onion USDT 500 500""" current_status = await self.strategy.format_status() print(current_status) self.assertTrue(expected_status in current_status) @async_test(loop=ev_loop) - async def test_any_active_position(self): + async def test_any_active_position_when_below_min_amount(self): + await asyncio.sleep(2) + self.assertFalse(self.strategy.any_active_position(Decimal("1"))) + + @async_test(loop=ev_loop) + async def test_any_active_position_when_above_min_amount(self): + await asyncio.sleep(2) + self.lp.set_balance(BASE_ASSET, 500) await asyncio.sleep(2) self.assertTrue(self.strategy.any_active_position(Decimal("1"))) @async_test(loop=ev_loop) async def test_positions_are_created_with_price(self): + self.lp.set_balance(BASE_ASSET, 500) await asyncio.sleep(2) self.assertEqual(len(self.strategy.active_positions), 1) self.lp.set_price(TRADING_PAIR, 2) diff --git a/test/hummingbot/strategy/amm_v3_lp/test_amm_v3_lp_start.py b/test/hummingbot/strategy/amm_v3_lp/test_amm_v3_lp_start.py index fc9cba15e7..89e2d7bf08 100644 --- a/test/hummingbot/strategy/amm_v3_lp/test_amm_v3_lp_start.py +++ b/test/hummingbot/strategy/amm_v3_lp/test_amm_v3_lp_start.py @@ -21,7 +21,8 @@ def setUp(self) -> None: amm_v3_lp_config_map.get("market").value = "ETH-USDT" amm_v3_lp_config_map.get("fee_tier").value = "LOW" amm_v3_lp_config_map.get("price_spread").value = Decimal("1") - amm_v3_lp_config_map.get("amount").value = Decimal("1") + amm_v3_lp_config_map.get("max_amount").value = Decimal("10") + amm_v3_lp_config_map.get("min_amount").value = Decimal("1") amm_v3_lp_config_map.get("min_profitability").value = Decimal("10") def _initialize_market_assets(self, market, trading_pairs): @@ -42,5 +43,6 @@ def error(self, message, exc_info): @unittest.mock.patch('hummingbot.strategy.amm_v3_lp.amm_v3_lp.AmmV3LpStrategy.add_markets') def test_amm_v3_lp_strategy_creation(self, mock): amm_v3_lp_start.start(self) - self.assertEqual(self.strategy._amount, Decimal(1)) + self.assertEqual(self.strategy._max_amount, Decimal(10)) + self.assertEqual(self.strategy._min_amount, Decimal(1)) self.assertEqual(self.strategy._min_profitability, Decimal("10"))