mango-explorer/mango/walletbalancer.py

523 lines
20 KiB
Python
Raw Normal View History

# # ⚠ Warning
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
# LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
# NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
#
# [🥭 Mango Markets](https://mango.markets/) support is available at:
# [Docs](https://docs.mango.markets/)
# [Discord](https://discord.gg/67jySBhxrg)
# [Twitter](https://twitter.com/mangomarkets)
# [Github](https://github.com/blockworks-foundation)
# [Email](mailto:hello@blockworks.foundation)
import abc
import logging
import typing
from decimal import Decimal
from .account import Account
from .context import Context
2021-10-04 10:27:07 -07:00
from .group import Group
from .marketoperations import MarketOperations
from .porcelain import operations
from .tokens import Instrument, Token
from .instrumentvalue import InstrumentValue
from .wallet import Wallet
# # 🥭 WalletBalancer
#
# This notebook deals with balancing a wallet after processing liquidations, so that it has
# appropriate funds for the next liquidation.
#
# We want to be able to maintain liquidity in our wallet. For instance if there are a lot of
# ETH shorts being liquidated, we'll need to supply ETH, but what do we do when we run out
# of ETH and there are still liquidations to perform?
#
# We 'balance' our wallet tokens, buying or selling or swapping them as required.
#
# # 🥭 Target Balances
#
# To be able to maintain the right balance of tokens, we need to know what the right
# balance is. Different people have different opinions, and we don't all have the same
# value in our liquidator accounts, so we need a way to allow whoever is running the
# liquidator to specify what the target token balances should be.
#
# There are two possible approaches to specifying the target value:
# * A 'fixed' value, like 10 ETH
# * A 'percentage' value, like 20% ETH
#
# Percentage is trickier, because to come up with the actual target we need to take into
# account the wallet value and the current price of the target token.
#
# The way this all hangs together is:
# * A parser parses string values (probably from a command-line) into `TargetBalance`
# objects.
# * There are two types of `TargetBalance` objects - `FixedTargetBalance` and
# `PercentageTargetBalance`.
# * To get the actual `InstrumentValue` for balancing, the `TargetBalance` must be 'resolved'
# by calling `resolve()` with the appropriate token price and wallet value.
#
# # 🥭 TargetBalance class
#
# 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, symbol: str) -> None:
self.symbol = symbol
@abc.abstractmethod
def resolve(
self, instrument: Instrument, current_price: Decimal, total_value: Decimal
) -> InstrumentValue:
raise NotImplementedError(
"TargetBalance.resolve() is not implemented on the base type."
)
def __repr__(self) -> str:
return f"{self}"
# # 🥭 FixedTargetBalance class
#
# This is the simple case, where the `FixedTargetBalance` object contains enough information on its own to build the resolved `InstrumentValue` object.
#
class FixedTargetBalance(TargetBalance):
def __init__(self, symbol: str, value: Decimal) -> None:
super().__init__(symbol)
self.value = value
def resolve(
self, instrument: Instrument, current_price: Decimal, total_value: Decimal
) -> InstrumentValue:
return InstrumentValue(instrument, self.value)
def __str__(self) -> str:
return f"""« FixedTargetBalance [{self.value} {self.symbol}] »"""
# # 🥭 PercentageTargetBalance
#
# This is the more complex case, where the target is a percentage of the total wallet
# balance.
#
# So, to actually calculate the right target, we need to know the total wallet balance and
# the current price. Once we have those the calculation is just:
# >
# > _wallet fraction_ is _percentage_ of _wallet value_
# >
# > _target balance_ is _wallet fraction_ divided by _token price_
#
class PercentageTargetBalance(TargetBalance):
def __init__(self, symbol: str, target_percentage: Decimal) -> None:
super().__init__(symbol)
self.target_fraction = target_percentage / 100
def resolve(
self, instrument: Instrument, current_price: Decimal, total_value: Decimal
) -> InstrumentValue:
target_value = total_value * self.target_fraction
target_size = target_value / current_price
return InstrumentValue(instrument, target_size)
def __str__(self) -> str:
return f"""« PercentageTargetBalance [{self.target_fraction * 100}% {self.symbol}] »"""
# # 🥭 parse_target_balance function
#
# `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
# 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) > 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."
)
if len(values) == 1:
return FixedTargetBalance(symbol, numeric_value)
else:
return PercentageTargetBalance(symbol, numeric_value)
# # 🥭 parse_fixed_target_balance function
#
# `argparse` handler for `TargetBalance` parsing. Can only be used for `FixedTargetBalance`s - will raise an
# error if a `PercentageTargetBalance` is attempted. This is useful for circumstances where percentage
# balance targets aren't allowed.
#
# Can be used like:
# parser.add_argument("--target", type=mango.parse_fixed_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_fixed_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
# The value we have may be an int (like 27)or a fraction (like 0.1). In all cases we want the number
# as a number, but we also want to know if we have a percent or not so we can raise an exception.
values = value.split("%")
if len(values) > 1:
raise Exception(
f"Could not parse '{value}' as a decimal target. (Percentage targets are not allowed in this context.)"
)
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
return FixedTargetBalance(symbol, numeric_value)
# # 🥭 sort_changes_for_trades function
#
# It's important to process SELLs first, so we have enough funds in the quote balance for the
# BUYs.
#
# It looks like this function takes size into account, but it doesn't really - 2 ETH is
# smaller than 1 BTC (for now?) but the value 2 will be treated as bigger than 1. We don't
# 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[InstrumentValue],
) -> typing.Sequence[InstrumentValue]:
return sorted(changes, key=lambda change: change.value)
# # 🥭 calculate_required_balance_changes function
#
# 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[InstrumentValue],
desired_balances: typing.Sequence[InstrumentValue],
) -> typing.Sequence[InstrumentValue]:
changes: typing.List[InstrumentValue] = []
for desired in desired_balances:
current = InstrumentValue.find_by_token(current_balances, desired.token)
change = InstrumentValue(desired.token, desired.value - current.value)
changes += [change]
return changes
# # 🥭 FilterSmallChanges class
#
# Allows us to filter out changes that aren't worth the effort.
#
# For instance, if our desired balance requires changing less than 1% of our total balance,
# it may not be worth bothering with right not.
#
# Calculations are based on the total wallet balance, rather than the magnitude of the
# change per-token, because a change of 0.01 of one token may be worth more than a change
# 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[InstrumentValue],
prices: typing.Sequence[InstrumentValue],
) -> None:
self._logger: logging.Logger = logging.getLogger(self.__class__.__name__)
self.prices: typing.Dict[str, InstrumentValue] = {}
total = Decimal(0)
for balance in balances:
price = InstrumentValue.find_by_token(prices, balance.token)
self.prices[price.token.symbol] = price
total += price.value * balance.value
self.total_balance = total
self.action_threshold_value = total * action_threshold
self._logger.info(
f"Wallet total balance of {total:,.8f} gives action threshold: {self.action_threshold_value:,.8f}"
)
def allow(self, token_value: InstrumentValue) -> bool:
price = self.prices[token_value.token.symbol]
value = price.value * token_value.value
absolute_value = value.copy_abs()
result = absolute_value > self.action_threshold_value
self._logger.info(
f"Worth doing? {result}. {token_value.token.name} trade is worth: {absolute_value:,.8f}, threshold is: {self.action_threshold_value:,.8f}."
)
return result
# # 🥭 WalletBalancers
#
# We want two types of this class:
# * 'null' implementation that adheres to the interface but doesn't do anything, and
# * 'live' implementations that actually do the balancing.
#
# This allows us to have code that implements logic including wallet balancing, without
# having to worry about whether the user wants to re-balance or not - we can just plug
# in the 'null' variant and the logic all still works.
#
# To have this work we define an abstract base class `WalletBalancer` which defines the
# interface, then a `NullWalletBalancer` which adheres to this interface but doesn't
# perform any action, and finally the real `LiveWalletBalancer` which can perform the
# balancing action.
#
# # 🥭 WalletBalancer class
#
# This is the abstract class which defines the interface.
#
class WalletBalancer(metaclass=abc.ABCMeta):
def __init__(self) -> None:
self._logger: logging.Logger = logging.getLogger(self.__class__.__name__)
@abc.abstractmethod
def balance(
self, context: Context, prices: typing.Sequence[InstrumentValue]
) -> None:
raise NotImplementedError(
"WalletBalancer.balance() is not implemented on the base type."
)
# # 🥭 NullWalletBalancer class
#
# 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 __init__(self) -> None:
super().__init__()
def balance(
self, context: Context, prices: typing.Sequence[InstrumentValue]
) -> None:
pass
# # 🥭 LiveWalletBalancer class
#
# This is the high-level class that does much of the work.
#
class LiveWalletBalancer(WalletBalancer):
def __init__(
self,
context: Context,
wallet: Wallet,
account: Account,
quote_token: Token,
targets: typing.Sequence[TargetBalance],
action_threshold: Decimal,
max_slippage: Decimal,
dry_run: bool,
) -> None:
super().__init__()
self.context: Context = context
self.wallet: Wallet = wallet
self.account: Account = account
self.quote_token: Token = quote_token
self.targets: typing.Sequence[TargetBalance] = targets
self.action_threshold: Decimal = action_threshold
self.max_slippage: Decimal = max_slippage
self.dry_run: bool = dry_run
def balance(
self, context: Context, prices: typing.Sequence[InstrumentValue]
) -> None:
padding = "\n "
def balances_report(balances: typing.Sequence[InstrumentValue]) -> str:
return padding.join(list([f"{bal}" for bal in balances]))
tokens: typing.List[Token] = []
for target_balance in self.targets:
token = context.instrument_lookup.find_by_symbol(target_balance.symbol)
if token is None:
raise Exception(
f"Could not find details of token {target_balance.symbol}."
)
tokens += [Token.ensure(token)]
tokens += [self.quote_token]
balances = self._fetch_balances(context, tokens)
total_value = Decimal(0)
for bal in balances:
price = InstrumentValue.find_by_token(prices, bal.token)
value = bal.value * price.value
total_value += value
self._logger.info(f"Starting balances: {padding}{balances_report(balances)}")
total_token_value: InstrumentValue = InstrumentValue(
self.quote_token, total_value
)
self._logger.info(f"Total: {total_token_value}")
resolved_targets: typing.List[InstrumentValue] = []
for target in self.targets:
price = InstrumentValue.find_by_symbol(prices, target.symbol)
resolved_targets += [target.resolve(price.token, price.value, total_value)]
balance_changes = calculate_required_balance_changes(balances, resolved_targets)
self._logger.info(
f"Desired balance changes: {padding}{balances_report(balance_changes)}"
)
dont_bother = FilterSmallChanges(self.action_threshold, balances, prices)
filtered_changes = list(filter(dont_bother.allow, balance_changes))
self._logger.info(
f"Filtered balance changes: {padding}{balances_report(filtered_changes)}"
)
if len(filtered_changes) == 0:
self._logger.info("No balance changes to make.")
return
sorted_changes = sort_changes_for_trades(filtered_changes)
self._make_changes(sorted_changes)
updated_balances = self._fetch_balances(context, tokens)
self._logger.info(
f"Finishing balances: {padding}{balances_report(updated_balances)}"
)
def _make_changes(self, balance_changes: typing.Sequence[InstrumentValue]) -> None:
quote = self.quote_token.symbol
for change in balance_changes:
market_symbol = f"serum:{change.token.symbol}/{quote}"
ops: MarketOperations = operations(
self.context, self.wallet, self.account, market_symbol, self.dry_run
)
if change.value < 0:
ops.market_sell(change.value.copy_abs(), self.max_slippage)
else:
ops.market_buy(change.value.copy_abs(), self.max_slippage)
def _fetch_balances(
self, context: Context, tokens: typing.Sequence[Token]
) -> typing.Sequence[InstrumentValue]:
balances: typing.List[InstrumentValue] = []
for token in tokens:
balance = InstrumentValue.fetch_total_value(
context, self.wallet.address, token
)
balances += [balance]
return balances
# # 🥭 LiveAccountBalancer class
#
# This is the high-level class that does much of the work.
#
class LiveAccountBalancer(WalletBalancer):
def __init__(
self,
context: Context,
wallet: Wallet,
account: Account,
group: Group,
targets: typing.Sequence[TargetBalance],
action_threshold: Decimal,
max_slippage: Decimal,
dry_run: bool,
) -> None:
super().__init__()
self.context: Context = context
self.wallet: Wallet = wallet
self.account: Account = account
2021-10-04 10:27:07 -07:00
self.group: Group = group
self.targets: typing.Sequence[TargetBalance] = targets
self.action_threshold: Decimal = action_threshold
self.max_slippage: Decimal = max_slippage
self.dry_run: bool = dry_run
def balance(
self, context: Context, prices: typing.Sequence[InstrumentValue]
) -> None:
padding = "\n "
def balances_report(balances: typing.Sequence[InstrumentValue]) -> str:
return padding.join(list([f"{bal}" for bal in balances]))
balances = [basket_token.net_value for basket_token in self.account.base_slots]
total_value = Decimal(0)
for bal in balances:
price = InstrumentValue.find_by_token(prices, bal.token)
value = bal.value * price.value
total_value += value
self._logger.info(f"Starting balances: {padding}{balances_report(balances)}")
quote_token: Token = self.account.shared_quote_token
total_token_value: InstrumentValue = InstrumentValue(quote_token, total_value)
self._logger.info(f"Total: {total_token_value}")
resolved_targets: typing.List[InstrumentValue] = []
for target in self.targets:
price = InstrumentValue.find_by_symbol(prices, target.symbol)
resolved_targets += [target.resolve(price.token, price.value, total_value)]
balance_changes = calculate_required_balance_changes(balances, resolved_targets)
self._logger.info(
f"Desired balance changes: {padding}{balances_report(balance_changes)}"
)
dont_bother = FilterSmallChanges(self.action_threshold, balances, prices)
filtered_changes = list(filter(dont_bother.allow, balance_changes))
self._logger.info(
f"Worthwhile balance changes: {padding}{balances_report(filtered_changes)}"
)
if len(filtered_changes) == 0:
self._logger.info("No balance changes to make.")
return
sorted_changes = sort_changes_for_trades(filtered_changes)
self._make_changes(sorted_changes)
updated_account: Account = Account.load(
context, self.account.address, self.group
)
updated_balances = [
basket_token.net_value for basket_token in updated_account.base_slots
]
self._logger.info(
f"Finishing balances: {padding}{balances_report(updated_balances)}"
)
def _make_changes(self, balance_changes: typing.Sequence[InstrumentValue]) -> None:
quote = self.account.shared_quote_token.symbol
for change in balance_changes:
market_symbol = f"spot:{change.token.symbol}/{quote}"
ops: MarketOperations = operations(
self.context, self.wallet, self.account, market_symbol, self.dry_run
)
if change.value < 0:
ops.market_sell(change.value.copy_abs(), self.max_slippage)
else:
ops.market_buy(change.value.copy_abs(), self.max_slippage)