diff --git a/bin/group-balance-wallet b/bin/group-balance-wallet index dea7f0a..c3a67b2 100755 --- a/bin/group-balance-wallet +++ b/bin/group-balance-wallet @@ -20,7 +20,7 @@ parser = argparse.ArgumentParser( description="Balance the value of tokens in a Mango Markets group to specific values or percentages.") mango.ContextBuilder.add_command_line_parameters(parser) mango.Wallet.add_command_line_parameters(parser) -parser.add_argument("--target", type=str, action="append", required=True, +parser.add_argument("--target", type=mango.parse_target_balance, action="append", required=True, help="token symbol plus target value or percentage, separated by a colon (e.g. 'ETH:2.5')") parser.add_argument("--action-threshold", type=Decimal, default=Decimal("0.01"), help="fraction of total wallet value a trade must be above to be carried out") @@ -45,8 +45,7 @@ try: group = mango.Group.load(context) tokens = [token_info.token for token_info in group.tokens if token_info is not None] - balance_parser = mango.TargetBalanceParser(tokens) - targets = list(map(balance_parser.parse, args.target)) + targets = args.target logging.info(f"Targets: {targets}") # TODO - fetch prices when available for V3. diff --git a/bin/liquidator b/bin/liquidator index e906372..f5d43e0 100755 --- a/bin/liquidator +++ b/bin/liquidator @@ -26,7 +26,7 @@ parser.add_argument("--throttle-reload-to-seconds", type=Decimal, default=Decima help="minimum number of seconds between each full margin account reload loop (including time taken processing accounts)") parser.add_argument("--throttle-ripe-update-to-seconds", type=Decimal, default=Decimal(5), help="minimum number of seconds between each ripe update loop (including time taken processing accounts)") -parser.add_argument("--target", type=str, action="append", +parser.add_argument("--target", type=mango.parse_target_balance, action="append", help="token symbol plus target value or percentage, separated by a colon (e.g. 'ETH:2.5')") parser.add_argument("--action-threshold", type=Decimal, default=Decimal("0.01"), help="fraction of total wallet value a trade must be above to be carried out") @@ -127,8 +127,7 @@ try: if args.dry_run or (args.target is None) or (len(args.target) == 0): wallet_balancer: mango.WalletBalancer = mango.NullWalletBalancer() else: - balance_parser = mango.TargetBalanceParser(tokens) - targets = list(map(balance_parser.parse, args.target)) + targets = args.target trade_executor = mango.ImmediateTradeExecutor(context, wallet, None, adjustment_factor) wallet_balancer = mango.LiveWalletBalancer( context, wallet, group, trade_executor, action_threshold, tokens, targets) diff --git a/bin/sweep b/bin/sweep index e92653d..c09a257 100755 --- a/bin/sweep +++ b/bin/sweep @@ -14,12 +14,22 @@ sys.path.insert(0, os.path.abspath( os.path.join(os.path.dirname(__file__), ".."))) import mango # nopep8 + +def resolve_quantity(token: mango.Token, current_quantity: Decimal, target_quantity: mango.TargetBalance, price: Decimal): + current_value: Decimal = current_quantity * price + resolved_value_to_keep: mango.TokenValue = target_quantity.resolve(token, price, current_value) + return resolved_value_to_keep + + parser = argparse.ArgumentParser(description="Sells all base tokens for quote on a market.") mango.ContextBuilder.add_command_line_parameters(parser) mango.Wallet.add_command_line_parameters(parser) -parser.add_argument("--market", type=str, required=True, help="market symbol to sweep (e.g. ETH/USDC)") +parser.add_argument("--target-quantity", type=mango.parse_target_balance, required=True, + help="target quantity to maintain - token symbol plus target value or percentage, separated by a colon (e.g. 'ETH:2.5')") parser.add_argument("--max-slippage", type=Decimal, default=Decimal("0.05"), help="maximum slippage allowed for the IOC order price") +parser.add_argument("--action-threshold", type=Decimal, default=Decimal("0.01"), + help="fraction of total wallet value a trade must be above to be carried out") parser.add_argument("--account-address", type=PublicKey, help="address of the specific account to use, if more than one available") parser.add_argument("--dry-run", action="store_true", default=False, @@ -34,7 +44,10 @@ wallet = mango.Wallet.from_command_line_parameters_or_raise(args) group = mango.Group.load(context, context.group_address) account = mango.Account.load_for_owner_by_address(context, wallet.address, group, args.account_address) -market_symbol = args.market.upper() +target_quantity: mango.TargetBalance = args.target_quantity +base = mango.TokenInfo.find_by_symbol(group.base_tokens, target_quantity.symbol) + +market_symbol = f"{base.token.symbol}/{group.shared_quote_token.token.symbol}" market: typing.Optional[mango.Market] = context.market_lookup.find_by_symbol(market_symbol) if market is None: raise Exception(f"Could not find market {market_symbol}") @@ -44,17 +57,39 @@ if not isinstance(loaded_market, mango.SpotMarket): raise Exception(f"Market {market_symbol} is not a spot market") basket_token: mango.AccountBasketBaseToken = account.find_basket_token(market.base) -quantity: Decimal = basket_token.net_value.value -if quantity <= 0: - print(f"No {basket_token.net_value.token.symbol} to sweep.") +logging.info(f"Value in basket:{basket_token}") + +market_operations: mango.MarketOperations = mango.create_market_operations(context, wallet, account, market, False) +orders: typing.Sequence[mango.Order] = market_operations.load_orders() +top_bid: Decimal = max([order.price for order in orders if order.side == mango.Side.BUY]) +top_ask: Decimal = min([order.price for order in orders if order.side == mango.Side.SELL]) +logging.info(f"Spread is {top_bid:,.8f} / {top_ask:,.8f}") + +resolved_value_to_keep = resolve_quantity(base.token, basket_token.net_value.value, target_quantity, top_bid) +quantity: Decimal = basket_token.net_value.value - resolved_value_to_keep.value +if quantity < 0: + quantity = 0 - quantity + side: mango.Side = mango.Side.BUY + slippage_factor: Decimal = Decimal(1) + args.max_slippage + price: Decimal = top_ask * slippage_factor else: - market_operations: mango.MarketOperations = mango.create_market_operations(context, wallet, account, market, False) - orders: typing.Sequence[mango.Order] = market_operations.load_orders() - top_bid: Decimal = max([order.price for order in orders if order.side == mango.Side.BUY]) - decrease_factor: Decimal = Decimal(1) - args.max_slippage - price: Decimal = top_bid * decrease_factor - order: mango.Order = mango.Order.from_basic_info( - mango.Side.SELL, price, basket_token.net_value.value, mango.OrderType.IOC) + side = mango.Side.SELL + slippage_factor = Decimal(1) - args.max_slippage + price = top_bid * slippage_factor + +logging.info(f"Resolved value to order {quantity:,.8f}") +logging.info(f"Worst acceptable price: {price:,.8f}") + +token_price: mango.TokenValue = mango.TokenValue(basket_token.net_value.token, price) +quantity_change: mango.TokenValue = mango.TokenValue(basket_token.net_value.token, quantity) + +worthwhile_checker = mango.FilterSmallChanges(args.action_threshold, [basket_token.net_value], [token_price]) +worth_doing: bool = worthwhile_checker.allow(quantity_change) + +if not worth_doing: + print(f"Balance change {quantity_change} is below threshold for change: {args.action_threshold}") +else: + order: mango.Order = mango.Order.from_basic_info(side, price, quantity, mango.OrderType.IOC) if args.dry_run: print("Dry run: not completing order", order) else: diff --git a/mango/__init__.py b/mango/__init__.py index d104ea1..cc670af 100644 --- a/mango/__init__.py +++ b/mango/__init__.py @@ -77,7 +77,7 @@ from .tradeexecutor import TradeExecutor, NullTradeExecutor, ImmediateTradeExecu from .transactionscout import TransactionScout, fetch_all_recent_transaction_signatures, mango_instruction_from_response from .version import Version from .wallet import Wallet -from .walletbalancer import TargetBalance, FixedTargetBalance, PercentageTargetBalance, TargetBalanceParser, sort_changes_for_trades, calculate_required_balance_changes, FilterSmallChanges, WalletBalancer, NullWalletBalancer, LiveWalletBalancer +from .walletbalancer import TargetBalance, FixedTargetBalance, PercentageTargetBalance, parse_target_balance, sort_changes_for_trades, calculate_required_balance_changes, FilterSmallChanges, WalletBalancer, NullWalletBalancer, LiveWalletBalancer from .watcher import Watcher, ManualUpdateWatcher, LamdaUpdateWatcher from .watchers import build_group_watcher, build_account_watcher, build_cache_watcher, build_spot_open_orders_watcher, build_serum_open_orders_watcher, build_perp_open_orders_watcher, build_price_watcher, build_serum_inventory_watcher, build_perp_orderbook_side_watcher, build_serum_orderbook_side_watcher from .websocketsubscription import WebSocketSubscription, WebSocketProgramSubscription, WebSocketAccountSubscription, WebSocketLogSubscription, WebSocketSubscriptionManager, IndividualWebSocketSubscriptionManager, SharedWebSocketSubscriptionManager diff --git a/mango/spotmarketoperations.py b/mango/spotmarketoperations.py index 7cbe379..fc27361 100644 --- a/mango/spotmarketoperations.py +++ b/mango/spotmarketoperations.py @@ -61,9 +61,8 @@ class SpotMarketOperations(MarketOperations): def place_order(self, order: Order) -> Order: client_id: int = self.context.random_client_id() signers: CombinableInstructions = CombinableInstructions.from_wallet(self.wallet) - order_with_client_id: Order = Order(id=0, client_id=client_id, side=order.side, price=order.price, - quantity=order.quantity, owner=self.open_orders_address or SYSTEM_PROGRAM_ADDRESS, - order_type=order.order_type) + order_with_client_id: Order = order.with_client_id(client_id).with_owner( + self.open_orders_address or SYSTEM_PROGRAM_ADDRESS) self.logger.info(f"Placing {self.spot_market.symbol} order {order}.") place: CombinableInstructions = self.market_instruction_builder.build_place_order_instructions( order_with_client_id) diff --git a/mango/walletbalancer.py b/mango/walletbalancer.py index 65f1fab..d2a184a 100644 --- a/mango/walletbalancer.py +++ b/mango/walletbalancer.py @@ -67,14 +67,12 @@ from .wallet import Wallet # # This is the abstract base class for our target balances, to allow them to be treated polymorphically. # - - class TargetBalance(metaclass=abc.ABCMeta): - def __init__(self, token: Token): - self.token = token + def __init__(self, symbol: str): + self.symbol = symbol.upper() @abc.abstractmethod - def resolve(self, current_price: Decimal, total_value: Decimal) -> TokenValue: + def resolve(self, token: Token, current_price: Decimal, total_value: Decimal) -> TokenValue: raise NotImplementedError("TargetBalance.resolve() is not implemented on the base type.") def __repr__(self) -> str: @@ -85,17 +83,16 @@ class TargetBalance(metaclass=abc.ABCMeta): # # This is the simple case, where the `FixedTargetBalance` object contains enough information on its own to build the resolved `TokenValue` object. # - class FixedTargetBalance(TargetBalance): - def __init__(self, token: Token, value: Decimal): - super().__init__(token) + def __init__(self, symbol: str, value: Decimal): + super().__init__(symbol) self.value = value - def resolve(self, current_price: Decimal, total_value: Decimal) -> TokenValue: - return TokenValue(self.token, self.value) + def resolve(self, token: Token, current_price: Decimal, total_value: Decimal) -> TokenValue: + return TokenValue(token, self.value) def __str__(self) -> str: - return f"""Β« FixedTargetBalance [{self.value} {self.token.name}] Β»""" + return f"""Β« π™΅πš’πš‘πšŽπšπšƒπšŠπš›πšπšŽπšπ™±πšŠπš•πšŠπš—πšŒπšŽ [{self.value} {self.symbol}] Β»""" # # πŸ₯­ PercentageTargetBalance @@ -110,56 +107,51 @@ class FixedTargetBalance(TargetBalance): # > # > _target balance_ is _wallet fraction_ divided by _token price_ # - class PercentageTargetBalance(TargetBalance): - def __init__(self, token: Token, target_percentage: Decimal): - super().__init__(token) + def __init__(self, symbol: str, target_percentage: Decimal): + super().__init__(symbol) self.target_fraction = target_percentage / 100 - def resolve(self, current_price: Decimal, total_value: Decimal) -> TokenValue: + def resolve(self, token: Token, current_price: Decimal, total_value: Decimal) -> TokenValue: target_value = total_value * self.target_fraction target_size = target_value / current_price - return TokenValue(self.token, target_size) + return TokenValue(token, target_size) def __str__(self) -> str: - return f"""Β« PercentageTargetBalance [{self.target_fraction * 100}% {self.token.name}] Β»""" + return f"""Β« π™ΏπšŽπš›πšŒπšŽπš—πšπšŠπšπšŽπšƒπšŠπš›πšπšŽπšπ™±πšŠπš•πšŠπš—πšŒπšŽ [{self.target_fraction * 100}% {self.symbol}] Β»""" -# # πŸ₯­ TargetBalanceParser class +# #Β πŸ₯­ parse_target_balance function # -# The `TargetBalanceParser` takes a string like "BTC:0.2" or "ETH:20%" and returns the appropriate TargetBalance object. -# -# This has a lot of manual error handling because it's likely the error messages will be seen by people and so we want to be as clear as we can what specifically is wrong. +# `argparse` handler for `TargetBalance` parsing. Can be used like: +# parser.add_argument("--target", type=mango.parse_target_balance, action="append", required=True, +# help="token symbol plus target value or percentage, separated by a colon (e.g. 'ETH:2.5')") # +def parse_target_balance(to_parse: str) -> TargetBalance: + try: + symbol, value = to_parse.split(":") + except Exception as exception: + raise Exception(f"Could not parse target balance '{to_parse}'") from exception -class TargetBalanceParser: - def __init__(self, tokens: typing.Sequence[Token]): - self.tokens = tokens + # The value we have may be an int (like 27), a fraction (like 0.1) or a percentage + # (like 25%). In all cases we want the number as a number, but we also want to know if + # we have a percent or not + values = value.split("%") + numeric_value_string = values[0] + try: + numeric_value = Decimal(numeric_value_string) + except Exception as exception: + raise Exception( + f"Could not parse '{numeric_value_string}' as a decimal number. It should be formatted as a decimal number, e.g. '2.345', with no surrounding spaces.") from exception - def parse(self, to_parse: str) -> TargetBalance: - try: - token_name, value = to_parse.split(":") - except Exception as exception: - raise Exception(f"Could not parse target balance '{to_parse}'") from exception + if len(values) > 2: + raise Exception( + f"Could not parse '{value}' as a decimal percentage. It should be formatted as a decimal number followed by a percentage sign, e.g. '30%', with no surrounding spaces.") - token = Token.find_by_symbol(self.tokens, token_name) - - # The value we have may be an int (like 27), a fraction (like 0.1) or a percentage - # (like 25%). In all cases we want the number as a number, but we also want to know if - # we have a percent or not - values = value.split("%") - numeric_value_string = values[0] - try: - numeric_value = Decimal(numeric_value_string) - except Exception as exception: - raise Exception( - f"Could not parse '{numeric_value_string}' as a decimal number. It should be formatted as a decimal number, e.g. '2.345', with no surrounding spaces.") from exception - - if len(values) > 1: - raise ValueError( - f"Error parsing '{value}'. Percentage targets could lead to over-rebalancing (due to token value changes rather than liquidations) and so are no longer supported.") - - return FixedTargetBalance(token, numeric_value) + if len(values) == 1: + return FixedTargetBalance(symbol, numeric_value) + else: + return PercentageTargetBalance(symbol, numeric_value) # #Β πŸ₯­ sort_changes_for_trades function @@ -172,7 +164,6 @@ class TargetBalanceParser: # really care that much as long as we have SELLs before BUYs. (We could, later, take price # into account for this sorting but we don't need to now so we don't.) # - def sort_changes_for_trades(changes: typing.Sequence[TokenValue]) -> typing.Sequence[TokenValue]: return sorted(changes, key=lambda change: change.value) @@ -181,8 +172,6 @@ def sort_changes_for_trades(changes: typing.Sequence[TokenValue]) -> typing.Sequ # # Takes a list of current balances, and a list of desired balances, and returns the list of changes required to get us to the desired balances. # - - def calculate_required_balance_changes(current_balances: typing.Sequence[TokenValue], desired_balances: typing.Sequence[TokenValue]) -> typing.Sequence[TokenValue]: changes: typing.List[TokenValue] = [] for desired in desired_balances: @@ -205,8 +194,6 @@ def calculate_required_balance_changes(current_balances: typing.Sequence[TokenVa # of 10 in another token. Normalising values to our wallet balance makes these changes # easier to reason about. # - - class FilterSmallChanges: def __init__(self, action_threshold: Decimal, balances: typing.Sequence[TokenValue], prices: typing.Sequence[TokenValue]): self.logger: logging.Logger = logging.getLogger(self.__class__.__name__) @@ -252,7 +239,6 @@ class FilterSmallChanges: # # This is the abstract class which defines the interface. # - class WalletBalancer(metaclass=abc.ABCMeta): @abc.abstractmethod def balance(self, prices: typing.Sequence[TokenValue]): @@ -264,8 +250,6 @@ class WalletBalancer(metaclass=abc.ABCMeta): # This is the 'empty', 'no-op', 'dry run' wallet balancer which doesn't do anything but # which can be plugged into algorithms that may want balancing logic. # - - class NullWalletBalancer(WalletBalancer): def balance(self, prices: typing.Sequence[TokenValue]): pass @@ -275,7 +259,6 @@ class NullWalletBalancer(WalletBalancer): # # This is the high-level class that does much of the work. # - class LiveWalletBalancer(WalletBalancer): def __init__(self, context: Context, wallet: Wallet, group: Group, trade_executor: TradeExecutor, action_threshold: Decimal, tokens: typing.Sequence[Token], target_balances: typing.Sequence[TargetBalance]): self.logger: logging.Logger = logging.getLogger(self.__class__.__name__) @@ -302,8 +285,8 @@ class LiveWalletBalancer(WalletBalancer): self.logger.info(f"Starting balances: {padding}{balances_report(current_balances)} - total: {total_value}") resolved_targets: typing.List[TokenValue] = [] for target in self.target_balances: - price = TokenValue.find_by_token(prices, target.token) - resolved_targets += [target.resolve(price.value, total_value)] + price = TokenValue.find_by_symbol(prices, target.symbol) + resolved_targets += [target.resolve(price.token, price.value, total_value)] balance_changes = calculate_required_balance_changes(current_balances, resolved_targets) self.logger.info(f"Full balance changes: {padding}{balances_report(balance_changes)}") diff --git a/tests/test_walletbalancer.py b/tests/test_walletbalancer.py index 79e4ea0..5ed5a68 100644 --- a/tests/test_walletbalancer.py +++ b/tests/test_walletbalancer.py @@ -1,5 +1,3 @@ -import pytest - from .context import mango from .fakes import fake_token @@ -27,30 +25,21 @@ def test_target_balance_constructor(): def test_fixed_target_balance_constructor(): token = fake_token() value = Decimal(23) - actual = mango.FixedTargetBalance(token, value) + actual = mango.FixedTargetBalance(token.symbol, value) assert actual is not None - assert actual.token == token + assert actual.symbol == token.symbol assert actual.value == value def test_percentage_target_balance_constructor(): token = fake_token() value = Decimal(5) - actual = mango.PercentageTargetBalance(token, value) + actual = mango.PercentageTargetBalance(token.symbol, value) assert actual is not None - assert actual.token == token + assert actual.symbol == token.symbol assert actual.target_fraction == Decimal("0.05") # Calculated as a fraction instead of a percentage. -def test_target_balance_parser_constructor(): - token1 = fake_token() - token2 = fake_token() - tokens = [token1, token2] - actual = mango.TargetBalanceParser(tokens) - assert actual is not None - assert actual.tokens == tokens - - def test_calculate_required_balance_changes(): current_balances = [ mango.TokenValue(ETH_TOKEN, Decimal("0.5")), @@ -72,27 +61,32 @@ def test_calculate_required_balance_changes(): def test_percentage_target_balance(): token = fake_token() - percentage_parsed_balance_change = mango.PercentageTargetBalance(token, Decimal(33)) - assert(percentage_parsed_balance_change.token == token) + percentage_parsed_balance_change = mango.PercentageTargetBalance(token.symbol, Decimal(33)) + assert(percentage_parsed_balance_change.symbol == token.symbol) current_token_price = Decimal(2000) # It's $2,000 per TOKEN current_account_value = Decimal(10000) # We hold $10,000 in total across all assets in our account. - resolved_parsed_balance_change = percentage_parsed_balance_change.resolve( - current_token_price, current_account_value) + resolved_parsed_balance_change = percentage_parsed_balance_change.resolve(token, + current_token_price, + current_account_value) assert(resolved_parsed_balance_change.token == token) # 33% of $10,000 is $3,300 # $3,300 spent on TOKEN gives us 1.65 TOKEN assert(resolved_parsed_balance_change.value == Decimal("1.65")) -def test_target_balance_parser(): - parser = mango.TargetBalanceParser([ETH_TOKEN, BTC_TOKEN, USDT_TOKEN]) - with pytest.raises(ValueError): - parser.parse("eth:10%") +def test_target_balance_parser_fixedvalue(): + parsed = mango.parse_target_balance("eth:70") + assert isinstance(parsed, mango.FixedTargetBalance) + assert parsed.symbol == "ETH" + assert parsed.value == Decimal(70) - parsed_fixed = parser.parse("eth:70") - assert(parsed_fixed.token == ETH_TOKEN) - assert(parsed_fixed.value == Decimal(70)) + +def test_target_balance_parser_percentagevalue(): + parsed = mango.parse_target_balance("btc:10%") + assert isinstance(parsed, mango.PercentageTargetBalance) + assert parsed.symbol == "BTC" + assert parsed.target_fraction == Decimal("0.1") def test_filter_small_changes_constructor():