sweep command now takes a target balance.

This commit is contained in:
Geoff Taylor 2021-09-06 21:38:12 +01:00
parent 0297fda92c
commit b4406a13d9
7 changed files with 115 additions and 106 deletions

View File

@ -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.

View File

@ -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)

View File

@ -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:

View File

@ -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

View File

@ -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)

View File

@ -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)}")

View File

@ -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():