Repository: hummingbot/hummingbot
Pull request: feat/ implement balance limit command
**Before submitting this PR, please make sure**:
- [x] Your code builds clean without any errors or warnings
- [x] You are using approved title ("feat/", "fix/", "docs/", "refactor/", etc)


**Optional**:
- Related Github issue:
#1443 
- Related Clubhouse Story:
https://app.clubhouse.io/coinalpha/story/9682/implement-a-limit-for-the-amount-of-assets-the-hummingbot-client

**A description of the changes proposed in the pull request**:

Additions/changes were made to the `balance` command. Below are the list of changes:

**Changes to existing command(s):**
- `balance`:
Displays your asset balances and **asset limits** across all connected exchanges.

**Additional command(s):**
- `balance limit [<exchange> <asset> <amount>]`: 
Configures the asset limit on the specified `exchange` and `asset`. 
By default would display the asset limits across all exchanges.
Optional Argument(s):
  - `exchange` - _Required_ - Name of Exchange
  - `asset` - _Required_ - Asset token(as specified in respective exchange)
  - `amount` - _Required_ - Amount of asset token to limit

Problem statement:
[Feature] Allow limiting the amount of assets HB can access in an exchange/wallet
**Feature Suggestion**
There should be an ability to limit the amount of assets HB can access/use in an exchange or wallet. The asset symbol and the limit size in asset units should be configurable.

**Impact**
It can be a good security measure to avoid unexpected and undesirable losses. Along with that, it is just convenient for a user who doesn't wish to remove the 'untouchable' amounts from the exchange/wallet before starting HB.

**Additional context**
None

Hints from issue discussion:
The closes function that I could think of is Hummingbot feature called [kill-switch](https://docs.hummingbot.io/utilities/kill-switch/). Automatically stops the bot when it reaches a certain performance threshold, which can be either positive or negative.

But this could be a good new feature.
I like this feature. Should be useful for users running bots on many different trading pairs with shared assets.

Commit message: Merge branch 'development' into feat/limit-asset-access

Patch to review:
diff --git a/hummingbot/client/command/balance_command.py b/hummingbot/client/command/balance_command.py
index 0de9a2b419f..efe2441efd8 100644
--- a/hummingbot/client/command/balance_command.py
+++ b/hummingbot/client/command/balance_command.py
@@ -1,6 +1,12 @@
+from hummingbot.client.settings import (
+    GLOBAL_CONFIG_PATH,
+)
 from hummingbot.user.user_balances import UserBalances
 from hummingbot.core.utils.async_utils import safe_ensure_future
 from hummingbot.client.config.global_config_map import global_config_map
+from hummingbot.client.config.config_helpers import (
+    save_to_yml
+)
 from hummingbot.market.celo.celo_cli import CeloCLI
 import pandas as pd
 from decimal import Decimal
@@ -8,17 +14,70 @@
 if TYPE_CHECKING:
     from hummingbot.client.hummingbot_application import HummingbotApplication
 
+OPTIONS = [
+    "limit",
+]
+
+OPTION_HELP = {
+    "limit": "balance limit [exchange] [ASSET] [AMOUNT]",
+}
+
+OPTION_DESCRIPTION = {
+    "limit": "Configure the asset limits for specified exchange",
+}
+
+LIMIT_GLOBAL_CONFIG = "balance_asset_limit"
+
 
 class BalanceCommand:
-    def balance(self):
-        safe_ensure_future(self.show_balances())
+    def balance(self,
+                option: str = None,
+                exchange: str = None,
+                asset: str = None,
+                amount: str = None,
+                ):
+        self.app.clear_input()
+        if option is None:
+            safe_ensure_future(self.show_balances())
+
+        elif option in OPTIONS:
+            config_map = global_config_map
+            file_path = GLOBAL_CONFIG_PATH
+            if option == "limit":
+
+                config_var = config_map[LIMIT_GLOBAL_CONFIG]
+                if exchange is None and asset is None and amount is None:
+                    safe_ensure_future(self.show_asset_limits())
+                    return
+
+                if asset is not None and amount is not None and exchange is not None:
+                    exchange_limit_conf = config_var.value[exchange]
+                    asset = asset.upper()
+                    self._notify(f"Limit for {asset} token, set to {amount}")
+                    exchange_limit_conf[asset] = amount
+                    return
+
+                self._notify("Error with command arguments. See command details below.")
+                safe_ensure_future(self.list_options())
+                return
+
+            save_to_yml(file_path, config_map)
+
+    async def list_options(self):
+        row = []
+        self._notify(f"List of Option(s) of Balance command\n")
+        for option in OPTIONS:
+            row.append(f"{option}: {OPTION_DESCRIPTION[option]}")
+            row.append(f"   e.g. {OPTION_HELP[option]}")
+        self._notify("\n".join(row))
 
     async def show_balances(self):
         self._notify("Updating balances, please wait...")
         all_ex_bals = await UserBalances.instance().all_balances_all_exchanges()
+        all_ex_limits = global_config_map[LIMIT_GLOBAL_CONFIG].value
         for exchange, bals in all_ex_bals.items():
             self._notify(f"\n{exchange}:")
-            df = await self.exchange_balances_df(bals)
+            df = await self.exchange_balances_df(bals, all_ex_limits[exchange])
             if df.empty:
                 self._notify("You have no balance on this exchange.")
             else:
@@ -45,15 +104,17 @@ async def show_balances(self):
             self._notify("\n".join(lines))
 
     async def exchange_balances_df(self,  # type: HummingbotApplication
-                                   ex_bals: Dict[str, Decimal]):
+                                   exchange_balances: Dict[str, Decimal],
+                                   exchange_limits: Dict[str, str] = None):
         rows = []
-        for token, bal in ex_bals.items():
-            if bal == 0:
+        for token, bal in exchange_balances.items():
+            limit = Decimal(exchange_limits.get(token, 0))
+            if bal == 0 and limit == 0:
                 continue
             token = token.upper()
-            rows.append({"asset": token.upper(), "amount": round(bal, 4)})
-        df = pd.DataFrame(data=rows, columns=["asset", "amount"])
-        df.sort_values(by=["asset"], inplace=True)
+            rows.append({"Asset": token.upper(), "Amount": round(bal, 4), "Limit": round(limit, 4)})
+        df = pd.DataFrame(data=rows, columns=["Asset", "Amount", "Limit"])
+        df.sort_values(by=["Asset"], inplace=True)
         return df
 
     async def celo_balances_df(self,  # type: HummingbotApplication
@@ -74,3 +135,32 @@ async def ethereum_balances_df(self,  # type: HummingbotApplication
         df = pd.DataFrame(data=rows, columns=["asset", "amount"])
         df.sort_values(by=["asset"], inplace=True)
         return df
+
+    async def asset_limits_df(self,
+                              asset_limit_conf: Dict[str, str]):
+        rows = []
+        for token, amount in asset_limit_conf.items():
+            rows.append({"Asset": token, "Limit": round(Decimal(amount), 4)})
+
+        df = pd.DataFrame(data=rows, columns=["Asset", "Limit"])
+        df.sort_values(by=["Asset"], inplace=True)
+        return df
+
+    async def show_asset_limits(self):
+        self._notify(f"Balance Limits per exchange...")
+        config_var = global_config_map[LIMIT_GLOBAL_CONFIG]
+        exchange_limit_conf: Dict[str, Dict[str, str]] = config_var.value
+
+        for exchange, asset_limit_config in exchange_limit_conf.items():
+            if asset_limit_config is None:
+                continue
+
+            self._notify(f"\n{exchange}")
+            df = await self.asset_limits_df(asset_limit_config)
+            if df.empty:
+                self._notify("You have no limits on this exchange.")
+            else:
+                lines = ["    " + line for line in df.to_string(index=False).split("\n")]
+                self._notify("\n".join(lines))
+        self._notify("\n")
+        return
diff --git a/hummingbot/client/config/global_config_map.py b/hummingbot/client/config/global_config_map.py
index 486b8d01fbc..aed67ce2292 100644
--- a/hummingbot/client/config/global_config_map.py
+++ b/hummingbot/client/config/global_config_map.py
@@ -391,4 +391,10 @@ def validate_script_file_path(file_path: str) -> Optional[bool]:
                   type_str="str",
                   required_if=lambda: global_config_map["script_enabled"].value,
                   validator=validate_script_file_path),
+    "balance_asset_limit":
+        ConfigVar(key="balance_asset_limit",
+                  prompt="Use the `balance limit` command"
+                         "e.g. balance limit [ASSET] [AMOUNT]",
+                  required_if=lambda: False,
+                  type_str="json"),
 }
diff --git a/hummingbot/client/ui/completer.py b/hummingbot/client/ui/completer.py
index 543c0b62bb7..23db129b596 100644
--- a/hummingbot/client/ui/completer.py
+++ b/hummingbot/client/ui/completer.py
@@ -124,6 +124,11 @@ def _complete_subcommand(self, document: Document) -> bool:
         index: int = text_before_cursor.index(' ')
         return text_before_cursor[0:index] in self.parser.commands
 
+    def _complete_balance_limit_exchanges(self, document: Document):
+        text_before_cursor: str = document.text_before_cursor
+        command_args = text_before_cursor.split(" ")
+        return len(command_args) == 3 and command_args[0] == "balance" and command_args[1] == "limit"
+
     def get_completions(self, document: Document, complete_event: CompleteEvent):
         """
         Get completions for the current scope. This is the defining function for the completer
@@ -174,6 +179,10 @@ def get_completions(self, document: Document, complete_event: CompleteEvent):
             for c in self._option_completer.get_completions(document, complete_event):
                 yield c
 
+        elif self._complete_balance_limit_exchanges(document):
+            for c in self._connect_exchange_completer.get_completions(document, complete_event):
+                yield c
+
         else:
             text_before_cursor: str = document.text_before_cursor
             try:
diff --git a/hummingbot/client/ui/parser.py b/hummingbot/client/ui/parser.py
index 87e32620b50..89e3760a241 100644
--- a/hummingbot/client/ui/parser.py
+++ b/hummingbot/client/ui/parser.py
@@ -2,6 +2,8 @@
 from typing import (
     List,
 )
+
+from client.settings import EXCHANGES
 from hummingbot.client.errors import ArgumentParserError
 from hummingbot.client.command.connect_command import OPTIONS as CONNECT_OPTIONS
 
@@ -55,7 +57,11 @@ def load_parser(hummingbot) -> ThrowingArgumentParser:
     help_parser.add_argument("command", nargs="?", default="all", help="Enter ")
     help_parser.set_defaults(func=hummingbot.help)
 
-    balance_parser = subparsers.add_parser("balance", help="Display your asset balances across all connected exchanges")
+    balance_parser = subparsers.add_parser("balance", help=f"Display your asset balances across all connected exchanges")
+    balance_parser.add_argument("option", nargs="?", choices=["limit"], default=None, help="Option for balance configuration")
+    balance_parser.add_argument("exchange", nargs="?", choices=EXCHANGES, default=None, help="Name of Exchange")
+    balance_parser.add_argument("asset", nargs="?", default=None, help="Name of Asset Token")
+    balance_parser.add_argument("amount", nargs="?", default=None, help="Amount to limit")
     balance_parser.set_defaults(func=hummingbot.balance)
 
     config_parser = subparsers.add_parser("config", help="Display the current bot's configuration")
diff --git a/hummingbot/market/market_base.pxd b/hummingbot/market/market_base.pxd
index 574be8aff04..e006080e2f1 100755
--- a/hummingbot/market/market_base.pxd
+++ b/hummingbot/market/market_base.pxd
@@ -13,6 +13,7 @@ cdef class MarketBase(NetworkIterator):
         EventLogger _event_logger
         dict _account_available_balances
         dict _account_balances
+        dict _asset_limit
         bint _trading_required
         object _order_book_tracker
 
diff --git a/hummingbot/market/market_base.pyx b/hummingbot/market/market_base.pyx
index 416fd6bcec3..3159c01d0a6 100755
--- a/hummingbot/market/market_base.pyx
+++ b/hummingbot/market/market_base.pyx
@@ -55,6 +55,7 @@ cdef class MarketBase(NetworkIterator):
 
         self._account_balances = {}  # Dict[asset_name:str, Decimal]
         self._account_available_balances = {}  # Dict[asset_name:str, Decimal]
+        self._asset_limit = {}  # Dict[asset_name: str, Decimal]
         self._order_book_tracker = None
 
     @staticmethod
diff --git a/hummingbot/templates/conf_global_TEMPLATE.yml b/hummingbot/templates/conf_global_TEMPLATE.yml
index 498e853eb17..02a6f3ba7fc 100644
--- a/hummingbot/templates/conf_global_TEMPLATE.yml
+++ b/hummingbot/templates/conf_global_TEMPLATE.yml
@@ -74,6 +74,20 @@ paper_trade_account_balance:
 - - DAI
   - 1000
 
+# Balance Limit Configurations
+balance_asset_limit:
+    bamboo_relay:
+    binance:
+    coinbase_pro:
+    huobi:
+    liquid:
+    radar_relay:
+    dolomite:
+    bittrex:
+    kucoin:
+    eterbase:
+    kraken:
+
 # Telegram integration
 telegram_enabled: false
 telegram_token: null


Code review comments:
diff_hunk: @@ -391,4 +391,10 @@ def validate_script_file_path(file_path: str) -> Optional[bool]:
                   type_str="str",
                   required_if=lambda: global_config_map["script_enabled"].value,
                   validator=validate_script_file_path),
+    "balance_asset_limit":
+        ConfigVar(key="balance_asset_limit",
+                  prompt="Use the `balance limit` command"
+                         "e.g. balance limit [ASSET] [AMOUNT]",
path: hummingbot/client/config/global_config_map.py
text: @user1:
missing [exchange] here