From 6da8b6f09fc494429df9ef2e6d87de6e05458dec Mon Sep 17 00:00:00 2001 From: mlguys Date: Thu, 21 Dec 2023 11:43:18 +0700 Subject: [PATCH 1/6] Added min max amount config and check balance before creating position --- hummingbot/strategy/uniswap_v3_lp/start.py | 6 ++-- .../strategy/uniswap_v3_lp/uniswap_v3_lp.py | 34 +++++++++++++------ .../uniswap_v3_lp/uniswap_v3_lp_config_map.py | 10 ++++-- .../conf_uniswap_v3_lp_strategy_TEMPLATE.yml | 3 +- 4 files changed, 38 insertions(+), 15 deletions(-) diff --git a/hummingbot/strategy/uniswap_v3_lp/start.py b/hummingbot/strategy/uniswap_v3_lp/start.py index 14079ecbc6..ec90066be3 100644 --- a/hummingbot/strategy/uniswap_v3_lp/start.py +++ b/hummingbot/strategy/uniswap_v3_lp/start.py @@ -10,7 +10,8 @@ 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 + min_amount = c_map.get("min_amount").value + max_amount = c_map.get("max_amount").value min_profitability = c_map.get("min_profitability").value self._initialize_markets([(connector, [pair])]) @@ -21,5 +22,6 @@ def start(self): self.strategy = UniswapV3LpStrategy(market_info, fee_tier, price_spread, - amount, + min_amount, + max_amount, min_profitability) diff --git a/hummingbot/strategy/uniswap_v3_lp/uniswap_v3_lp.py b/hummingbot/strategy/uniswap_v3_lp/uniswap_v3_lp.py index 6aaa67bab3..a1e86b8320 100644 --- a/hummingbot/strategy/uniswap_v3_lp/uniswap_v3_lp.py +++ b/hummingbot/strategy/uniswap_v3_lp/uniswap_v3_lp.py @@ -28,14 +28,16 @@ def __init__(self, market_info: MarketTradingPairTuple, fee_tier: str, price_spread: Decimal, - amount: Decimal, + min_amount: Decimal, + max_amount: Decimal, min_profitability: Decimal, status_report_interval: float = 900): 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._min_profitability = min_profitability self._ev_loop = asyncio.get_event_loop() @@ -65,11 +67,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) @@ -120,7 +124,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."]) @@ -155,7 +160,7 @@ 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) + await self.execute_proposal(lower_price, upper_price) self.close_matured_positions() def any_active_position(self, current_price: Decimal): @@ -186,22 +191,30 @@ async def propose_position_boundary(self): 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() + base_balance = self._market_info.market.get_available_balance(self.base_asset) quote_balance = self._market_info.market.get_available_balance(self.quote_asset) if base_balance + quote_balance == s_decimal_0: self.log_with_clock(logging.INFO, "Both balances exhausted. Add more assets.") + elif base_balance < self._min_amount: + self.log_with_clock(logging.INFO, + f"Base balance exhausted. Available: {base_balance}, required: {self._min_amount}") + elif quote_balance < (self._min_amount * self._last_price): + self.log_with_clock(logging.INFO, + f"Quote balance exhausted. Available: {quote_balance}, required: {(self._min_amount * self._last_price)}") else: self.log_with_clock(logging.INFO, f"Creating new position over {lower_price} to {upper_price} price range.") self._market_info.market.add_liquidity(self.trading_pair, - min(base_balance, self._amount), - min(quote_balance, (self._amount * self._last_price)), + min(base_balance, self._max_amount), + min(quote_balance, (self._max_amount * self._last_price)), lower_price, upper_price, self._fee_tier) @@ -212,7 +225,8 @@ def close_matured_positions(self): """ 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 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"Unclaimed base fee: {position.unclaimed_fee_0}, unclaimed quote fee: {position.unclaimed_fee_1}") diff --git a/hummingbot/strategy/uniswap_v3_lp/uniswap_v3_lp_config_map.py b/hummingbot/strategy/uniswap_v3_lp/uniswap_v3_lp_config_map.py index 1148605dac..a61806e97f 100644 --- a/hummingbot/strategy/uniswap_v3_lp/uniswap_v3_lp_config_map.py +++ b/hummingbot/strategy/uniswap_v3_lp/uniswap_v3_lp_config_map.py @@ -71,12 +71,18 @@ 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", + "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) >>>", diff --git a/hummingbot/templates/conf_uniswap_v3_lp_strategy_TEMPLATE.yml b/hummingbot/templates/conf_uniswap_v3_lp_strategy_TEMPLATE.yml index ece047fcd3..2f0e356125 100644 --- a/hummingbot/templates/conf_uniswap_v3_lp_strategy_TEMPLATE.yml +++ b/hummingbot/templates/conf_uniswap_v3_lp_strategy_TEMPLATE.yml @@ -18,7 +18,8 @@ fee_tier: null price_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 From 8fc83053bc0cd51008f8bd48bc43da78c74937bd Mon Sep 17 00:00:00 2001 From: mlguys Date: Thu, 21 Dec 2023 11:57:07 +0700 Subject: [PATCH 2/6] added comment --- hummingbot/strategy/uniswap_v3_lp/uniswap_v3_lp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hummingbot/strategy/uniswap_v3_lp/uniswap_v3_lp.py b/hummingbot/strategy/uniswap_v3_lp/uniswap_v3_lp.py index a1e86b8320..634beb3ce4 100644 --- a/hummingbot/strategy/uniswap_v3_lp/uniswap_v3_lp.py +++ b/hummingbot/strategy/uniswap_v3_lp/uniswap_v3_lp.py @@ -197,7 +197,7 @@ async def execute_proposal(self, lower_price: Decimal, upper_price: Decimal): :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() + 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) From c625de561dcdb87c13f540486f7d2501e1ed1041 Mon Sep 17 00:00:00 2001 From: mlguys Date: Thu, 21 Dec 2023 13:55:45 +0700 Subject: [PATCH 3/6] updated test cases --- .../uniswap_v3_lp/test_uniswap_v3_lp.py | 20 +++++++++++++++---- .../uniswap_v3_lp/test_uniswap_v3_lp_start.py | 6 ++++-- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/test/hummingbot/strategy/uniswap_v3_lp/test_uniswap_v3_lp.py b/test/hummingbot/strategy/uniswap_v3_lp/test_uniswap_v3_lp.py index edeaced6c6..fb75ace986 100644 --- a/test/hummingbot/strategy/uniswap_v3_lp/test_uniswap_v3_lp.py +++ b/test/hummingbot/strategy/uniswap_v3_lp/test_uniswap_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 UniswapV3LpUnitTest(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/uniswap_v3_lp/test_uniswap_v3_lp_start.py b/test/hummingbot/strategy/uniswap_v3_lp/test_uniswap_v3_lp_start.py index 2f4c77efc5..a18c3ea48b 100644 --- a/test/hummingbot/strategy/uniswap_v3_lp/test_uniswap_v3_lp_start.py +++ b/test/hummingbot/strategy/uniswap_v3_lp/test_uniswap_v3_lp_start.py @@ -21,7 +21,8 @@ def setUp(self) -> None: uniswap_v3_lp_config_map.get("market").value = "ETH-USDT" uniswap_v3_lp_config_map.get("fee_tier").value = "LOW" uniswap_v3_lp_config_map.get("price_spread").value = Decimal("1") - uniswap_v3_lp_config_map.get("amount").value = Decimal("1") + uniswap_v3_lp_config_map.get("max_amount").value = Decimal("10") + uniswap_v3_lp_config_map.get("min_amount").value = Decimal("1") uniswap_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.uniswap_v3_lp.uniswap_v3_lp.UniswapV3LpStrategy.add_markets') def test_uniswap_v3_lp_strategy_creation(self, mock): uniswap_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")) From 26272fe943d8e42af79a8717a4d10dce932ba5ad Mon Sep 17 00:00:00 2001 From: mlguys Date: Fri, 22 Dec 2023 01:01:50 +0700 Subject: [PATCH 4/6] Added: - Improved handling out of range positions - Fixed creating ZERO_LIQUIDITY position --- hummingbot/strategy/amm_v3_lp/amm_v3_lp.py | 66 +++++++++++++++++----- 1 file changed, 52 insertions(+), 14 deletions(-) diff --git a/hummingbot/strategy/amm_v3_lp/amm_v3_lp.py b/hummingbot/strategy/amm_v3_lp/amm_v3_lp.py index 27ba529e84..b41e170213 100644 --- a/hummingbot/strategy/amm_v3_lp/amm_v3_lp.py +++ b/hummingbot/strategy/amm_v3_lp/amm_v3_lp.py @@ -97,7 +97,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"{self.get_position_status_by_price_range(self._last_price, position, Decimal(0.5))}", ]) return pd.DataFrame(data=data, columns=columns) @@ -163,13 +163,14 @@ async def main(self): await self.execute_proposal(lower_price, upper_price) self.close_matured_positions() - def any_active_position(self, current_price: Decimal): + 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 ((position.lower_price * (Decimal("1") - (buffer_spread / 100))) <= current_price <= + (position.upper_price * (Decimal("1") + (buffer_spread / 100)))): return True return False @@ -184,7 +185,8 @@ 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), + Decimal("0.5")): # 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)) @@ -197,26 +199,35 @@ async def execute_proposal(self, lower_price: Decimal, upper_price: Decimal): :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 + 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 + + # 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 + proposed_upper_price = self._last_price * (Decimal("1") - Decimal("0.1") / 100) + + # 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 + proposed_lower_price = self._last_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.") - elif base_balance < self._min_amount: - self.log_with_clock(logging.INFO, - f"Base balance exhausted. Available: {base_balance}, required: {self._min_amount}") - elif quote_balance < (self._min_amount * self._last_price): - self.log_with_clock(logging.INFO, - f"Quote balance exhausted. Available: {quote_balance}, required: {(self._min_amount * self._last_price)}") 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._max_amount), min(quote_balance, (self._max_amount * self._last_price)), - lower_price, - upper_price, + proposed_lower_price, + proposed_upper_price, self._fee_tier) def close_matured_positions(self): @@ -228,9 +239,36 @@ def close_matured_positions(self): 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) + else: + self.close_out_of_buffered_range_position(position, Decimal("0.5")) + + def close_out_of_buffered_range_position(self, position: any, buffer_spread: Decimal = None): + """ + This closes out-of-range positions that are too far from last price. + """ + if self._last_price <= ( + position.lower_price * (Decimal("1") - (buffer_spread / 100))) or self._last_price >= ( + position.upper_price * (Decimal("1") + (buffer_spread / 100))): # 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}") + self._market_info.market.remove_liquidity(self.trading_pair, position.token_id) + + def get_position_status_by_price_range(self, last_price: Decimal, position: any, buffer_spread: Decimal = None): + """ + 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 position.lower_price <= last_price <= position.upper_price: + return "[In range]" + elif ((position.lower_price * (Decimal("1") - (buffer_spread / 100))) <= last_price <= + (position.upper_price * (Decimal("1") + (buffer_spread / 100)))): + return "[In buffered range]" + else: + return "[Out of range]" def stop(self, clock: Clock): if self._main_task is not None: From e1e00da759e8fc44fc9545308f1bb34af937fc44 Mon Sep 17 00:00:00 2001 From: mlguys Date: Fri, 22 Dec 2023 11:55:13 +0700 Subject: [PATCH 5/6] Added: - Refactor logic for checking if price is in range - Increase poll interval to prevent rejection from rcp node - New "buffer_spread" config to keep position active when price goes out of range and not matured yet --- .../gateway/amm_lp/gateway_evm_amm_lp.py | 2 +- hummingbot/strategy/amm_v3_lp/amm_v3_lp.py | 72 ++++++++++++------- .../amm_v3_lp/amm_v3_lp_config_map.py | 7 ++ hummingbot/strategy/amm_v3_lp/start.py | 2 + .../conf_amm_v3_lp_strategy_TEMPLATE.yml | 3 + 5 files changed, 60 insertions(+), 26 deletions(-) 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..99764d9723 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+)") diff --git a/hummingbot/strategy/amm_v3_lp/amm_v3_lp.py b/hummingbot/strategy/amm_v3_lp/amm_v3_lp.py index b41e170213..6ea56aedeb 100644 --- a/hummingbot/strategy/amm_v3_lp/amm_v3_lp.py +++ b/hummingbot/strategy/amm_v3_lp/amm_v3_lp.py @@ -15,6 +15,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,6 +54,7 @@ def __init__(self, market_info: MarketTradingPairTuple, fee_tier: str, price_spread: Decimal, + buffer_spread: Decimal, min_amount: Decimal, max_amount: Decimal, min_profitability: Decimal, @@ -38,6 +65,7 @@ def __init__(self, self._price_spread = price_spread 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() @@ -97,7 +125,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)}", - f"{self.get_position_status_by_price_range(self._last_price, position, Decimal(0.5))}", + 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) @@ -169,8 +197,7 @@ def any_active_position(self, current_price: Decimal, buffer_spread: Decimal = D :return: True/False """ for position in self.active_positions: - if ((position.lower_price * (Decimal("1") - (buffer_spread / 100))) <= current_price <= - (position.upper_price * (Decimal("1") + (buffer_spread / 100)))): + if is_price_in_range(current_price, position.lower_price, position.upper_price, buffer_spread): return True return False @@ -186,7 +213,7 @@ async def propose_position_boundary(self): if current_price != s_decimal_0: self._last_price = current_price if not self.any_active_position(Decimal(current_price), - Decimal("0.5")): # only set prices if there's no active position + 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)) @@ -206,16 +233,25 @@ async def execute_proposal(self, lower_price: Decimal, upper_price: Decimal): 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 - proposed_upper_price = self._last_price * (Decimal("1") - Decimal("0.1") / 100) + # 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 - proposed_lower_price = self._last_price * (Decimal("1") + Decimal("0.1") / 100) + # 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, @@ -235,7 +271,8 @@ def close_matured_positions(self): This closes out-of-range positions that have more than the min profitability. """ for position in self.active_positions: - if self._last_price <= position.lower_price or self._last_price >= position.upper_price: # out-of-range + 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, @@ -243,33 +280,18 @@ def close_matured_positions(self): 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) else: - self.close_out_of_buffered_range_position(position, Decimal("0.5")) + self.close_out_of_buffered_range_position(position) - def close_out_of_buffered_range_position(self, position: any, buffer_spread: Decimal = None): + def close_out_of_buffered_range_position(self, position: any): """ This closes out-of-range positions that are too far from last price. """ - if self._last_price <= ( - position.lower_price * (Decimal("1") - (buffer_spread / 100))) or self._last_price >= ( - position.upper_price * (Decimal("1") + (buffer_spread / 100))): # out-of-range + if not is_price_in_range(self._last_price, position, 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}") self._market_info.market.remove_liquidity(self.trading_pair, position.token_id) - def get_position_status_by_price_range(self, last_price: Decimal, position: any, buffer_spread: Decimal = None): - """ - 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 position.lower_price <= last_price <= position.upper_price: - return "[In range]" - elif ((position.lower_price * (Decimal("1") - (buffer_spread / 100))) <= last_price <= - (position.upper_price * (Decimal("1") + (buffer_spread / 100)))): - return "[In buffered range]" - else: - return "[Out of range]" - def stop(self, clock: Clock): if self._main_task is not None: self._main_task.cancel() 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 2c0c13b582..3774a4614f 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 @@ -71,6 +71,13 @@ def market_prompt() -> str: validator=lambda v: validate_decimal(v, Decimal("0"), inclusive=False), default=Decimal("1"), prompt_on_new=True), + "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. >>>", diff --git a/hummingbot/strategy/amm_v3_lp/start.py b/hummingbot/strategy/amm_v3_lp/start.py index b317ee6eac..5054530f3d 100644 --- a/hummingbot/strategy/amm_v3_lp/start.py +++ b/hummingbot/strategy/amm_v3_lp/start.py @@ -10,6 +10,7 @@ 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") + 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 @@ -22,6 +23,7 @@ def start(self): self.strategy = AmmV3LpStrategy(market_info, fee_tier, price_spread, + buffer_spread, min_amount, max_amount, min_profitability) diff --git a/hummingbot/templates/conf_amm_v3_lp_strategy_TEMPLATE.yml b/hummingbot/templates/conf_amm_v3_lp_strategy_TEMPLATE.yml index 7f0416ad69..188ab815ac 100644 --- a/hummingbot/templates/conf_amm_v3_lp_strategy_TEMPLATE.yml +++ b/hummingbot/templates/conf_amm_v3_lp_strategy_TEMPLATE.yml @@ -17,6 +17,9 @@ 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 max_amount: null min_amount: null From da898feaa1df48aa5c37fa90abbcc0cf6a9b07ff Mon Sep 17 00:00:00 2001 From: mlguys Date: Tue, 23 Jan 2024 18:39:48 +0700 Subject: [PATCH 6/6] Add status report interval and + rebalancing to AMM LP strategy --- .../gateway/amm_lp/gateway_evm_amm_lp.py | 6 + hummingbot/strategy/amm_v3_lp/amm_v3_lp.py | 150 ++++++++++++++++-- .../amm_v3_lp/amm_v3_lp_config_map.py | 11 +- hummingbot/strategy/amm_v3_lp/start.py | 4 +- .../conf_amm_v3_lp_strategy_TEMPLATE.yml | 3 + 5 files changed, 157 insertions(+), 17 deletions(-) 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 99764d9723..e1ed47853e 100644 --- a/hummingbot/connector/gateway/amm_lp/gateway_evm_amm_lp.py +++ b/hummingbot/connector/gateway/amm_lp/gateway_evm_amm_lp.py @@ -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 6ea56aedeb..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 @@ -58,7 +63,7 @@ def __init__(self, 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 @@ -77,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 @@ -181,15 +190,21 @@ 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: - await self.execute_proposal(lower_price, upper_price) - self.close_matured_positions() + position_closed = await self.close_matured_positions() + + 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: """ @@ -245,7 +260,7 @@ async def execute_proposal(self, lower_price: Decimal, upper_price: Decimal): 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: + 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) @@ -266,10 +281,11 @@ async def execute_proposal(self, lower_price: Decimal, upper_price: Decimal): 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 not is_price_in_range(self._last_price, position.lower_price, position.upper_price, Decimal("0")): # out-of-range @@ -278,19 +294,125 @@ def close_matured_positions(self): self.log_with_clock(logging.INFO, 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: - self.close_out_of_buffered_range_position(position) + closed_position = await self.close_out_of_buffered_range_position(position) - def close_out_of_buffered_range_position(self, position: any): + 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, self._buffer_spread): # out-of-range + 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}") - 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) + 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 3774a4614f..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, @@ -86,7 +86,7 @@ def market_prompt() -> str: 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="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"), @@ -97,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 5054530f3d..65e5e0b8c2 100644 --- a/hummingbot/strategy/amm_v3_lp/start.py +++ b/hummingbot/strategy/amm_v3_lp/start.py @@ -14,6 +14,7 @@ def start(self): 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("-") @@ -26,4 +27,5 @@ def start(self): buffer_spread, min_amount, max_amount, - min_profitability) + 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 188ab815ac..89d837e76b 100644 --- a/hummingbot/templates/conf_amm_v3_lp_strategy_TEMPLATE.yml +++ b/hummingbot/templates/conf_amm_v3_lp_strategy_TEMPLATE.yml @@ -26,3 +26,6 @@ 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