# # ⚠ 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 construct import logging import time import typing from decimal import Decimal from solana.publickey import PublicKey from solana.rpc.commitment import Single from solana.rpc.types import MemcmpOpts from .accountinfo import AccountInfo from .addressableaccount import AddressableAccount from .balancesheet import BalanceSheet from .constants import SYSTEM_PROGRAM_ADDRESS from .context import Context from .encoding import encode_int, encode_key from .group import Group from .layouts import layouts from .mangoaccountflags import MangoAccountFlags from .openorders import OpenOrders from .token import Token from .tokenvalue import TokenValue from .version import Version # # 🥭 MarginAccount class # class MarginAccount(AddressableAccount): def __init__(self, account_info: AccountInfo, version: Version, account_flags: MangoAccountFlags, has_borrows: bool, mango_group: PublicKey, owner: PublicKey, deposits: typing.List[TokenValue], borrows: typing.List[TokenValue], open_orders: typing.List[PublicKey]): super().__init__(account_info) self.version: Version = version self.account_flags: MangoAccountFlags = account_flags self.has_borrows: bool = has_borrows self.mango_group: PublicKey = mango_group self.owner: PublicKey = owner self.deposits: typing.List[TokenValue] = deposits self.borrows: typing.List[TokenValue] = borrows self.open_orders: typing.List[PublicKey] = open_orders self.open_orders_accounts: typing.List[typing.Optional[OpenOrders]] = [None] * len(open_orders) @staticmethod def from_layout(layout: construct.Struct, account_info: AccountInfo, version: Version, group: Group) -> "MarginAccount": if group.address != layout.mango_group: raise Exception( f"Margin account belongs to Group ID '{group.address}', not Group ID '{layout.mango_group}'") if version == Version.V1: # This is an old-style margin account, with no borrows flag has_borrows = False else: # This is a new-style margin account where we can depend on the presence of the borrows flag has_borrows = bool(layout.has_borrows) account_flags: MangoAccountFlags = MangoAccountFlags.from_layout(layout.account_flags) deposits: typing.List[TokenValue] = [] for index, deposit in enumerate(layout.deposits): token = group.basket_tokens[index].token rebased_deposit = deposit * group.basket_tokens[index].index.deposit.value token_value = TokenValue(token, rebased_deposit) deposits += [token_value] borrows: typing.List[TokenValue] = [] for index, borrow in enumerate(layout.borrows): token = group.basket_tokens[index].token rebased_borrow = borrow * group.basket_tokens[index].index.borrow.value token_value = TokenValue(token, rebased_borrow) borrows += [token_value] return MarginAccount(account_info, version, account_flags, has_borrows, layout.mango_group, layout.owner, deposits, borrows, list(layout.open_orders)) @staticmethod def parse(account_info: AccountInfo, group: Group) -> "MarginAccount": data = account_info.data if len(data) == layouts.MARGIN_ACCOUNT_V1.sizeof(): layout = layouts.MARGIN_ACCOUNT_V1.parse(data) version: Version = Version.V1 elif len(data) == layouts.MARGIN_ACCOUNT_V2.sizeof(): version = Version.V2 layout = layouts.MARGIN_ACCOUNT_V2.parse(data) else: raise Exception( f"Data length ({len(data)}) does not match expected size ({layouts.MARGIN_ACCOUNT_V1.sizeof()} or {layouts.MARGIN_ACCOUNT_V2.sizeof()})") return MarginAccount.from_layout(layout, account_info, version, group) @staticmethod def load(context: Context, margin_account_address: PublicKey, group: Group) -> "MarginAccount": account_info = AccountInfo.load(context, margin_account_address) if account_info is None: raise Exception(f"MarginAccount account not found at address '{margin_account_address}'") margin_account = MarginAccount.parse(account_info, group) margin_account.load_open_orders_accounts(context, group) return margin_account @staticmethod def load_all_for_group(context: Context, program_id: PublicKey, group: Group) -> typing.List["MarginAccount"]: filters = [ MemcmpOpts( offset=layouts.MANGO_ACCOUNT_FLAGS.sizeof(), # mango_group is just after the MangoAccountFlags, which is the first entry bytes=encode_key(group.address) ) ] if group.version == Version.V1: parser = layouts.MARGIN_ACCOUNT_V1 else: parser = layouts.MARGIN_ACCOUNT_V2 response = context.client.get_program_accounts( program_id, data_size=parser.sizeof(), memcmp_opts=filters, commitment=Single, encoding="base64") margin_accounts = [] for margin_account_data in response["result"]: address = PublicKey(margin_account_data["pubkey"]) account = AccountInfo._from_response_values(margin_account_data["account"], address) margin_account = MarginAccount.parse(account, group) margin_accounts += [margin_account] return margin_accounts @staticmethod def load_all_for_group_with_open_orders(context: Context, program_id: PublicKey, group: Group) -> typing.List["MarginAccount"]: margin_accounts = MarginAccount.load_all_for_group(context, program_id, group) open_orders = OpenOrders.load_raw_open_orders_account_infos(context, group) for margin_account in margin_accounts: margin_account.install_open_orders_accounts(group, open_orders) return margin_accounts @staticmethod def load_all_for_owner(context: Context, owner: PublicKey, group: typing.Optional[Group] = None) -> typing.List["MarginAccount"]: if group is None: group = Group.load(context) # mango_group is just after the MangoAccountFlags, which is the first entry. mango_group_offset = layouts.MANGO_ACCOUNT_FLAGS.sizeof() # owner is just after mango_group in the layout, and it's a PublicKey which is 32 bytes. owner_offset = mango_group_offset + 32 filters = [ MemcmpOpts( offset=mango_group_offset, bytes=encode_key(group.address) ), MemcmpOpts( offset=owner_offset, bytes=encode_key(owner) ) ] response = context.client.get_program_accounts( context.program_id, memcmp_opts=filters, commitment=Single, encoding="base64") margin_accounts = [] for margin_account_data in response["result"]: address = PublicKey(margin_account_data["pubkey"]) account = AccountInfo._from_response_values(margin_account_data["account"], address) margin_account = MarginAccount.parse(account, group) margin_account.load_open_orders_accounts(context, group) margin_accounts += [margin_account] return margin_accounts @classmethod def filter_out_unripe(cls, margin_accounts: typing.List["MarginAccount"], group: Group, prices: typing.List[TokenValue]) -> typing.List["MarginAccount"]: logger: logging.Logger = logging.getLogger(cls.__name__) nonzero: typing.List[MarginAccountMetadata] = [] for margin_account in margin_accounts: balance_sheet = margin_account.get_balance_sheet_totals(group, prices) if balance_sheet.collateral_ratio > 0: balances = margin_account.get_intrinsic_balances(group) nonzero += [MarginAccountMetadata(margin_account, balance_sheet, balances)] logger.info(f"Of those {len(margin_accounts)}, {len(nonzero)} have a nonzero collateral ratio.") ripe_metadata = filter(lambda mam: mam.balance_sheet.collateral_ratio <= group.init_coll_ratio, nonzero) ripe_accounts = list(map(lambda mam: mam.margin_account, ripe_metadata)) logger.info(f"Of those {len(nonzero)}, {len(ripe_accounts)} are ripe 🥭.") return ripe_accounts def load_open_orders_accounts(self, context: Context, group: Group) -> None: for index, oo in enumerate(self.open_orders): key = oo if key is not None: self.open_orders_accounts[index] = OpenOrders.load( context, key, group.basket_tokens[index].token.decimals, group.shared_quote_token.token.decimals) def install_open_orders_accounts(self, group: Group, all_open_orders_by_address: typing.Dict[str, AccountInfo]) -> None: for index, oo in enumerate(self.open_orders): key = str(oo) if key in all_open_orders_by_address: open_orders_account_info = all_open_orders_by_address[key] open_orders = OpenOrders.parse(open_orders_account_info, group.basket_tokens[index].token.decimals, group.shared_quote_token.token.decimals) self.open_orders_accounts[index] = open_orders def get_intrinsic_balance_sheets(self, group: Group) -> typing.List[BalanceSheet]: settled_assets: typing.List[Decimal] = [Decimal(0)] * len(group.basket_tokens) liabilities: typing.List[Decimal] = [Decimal(0)] * len(group.basket_tokens) for index, token in enumerate(group.basket_tokens): settled_assets[index] = token.index.deposit.value * self.deposits[index].value liabilities[index] = token.index.borrow.value * self.borrows[index].value unsettled_assets: typing.List[Decimal] = [Decimal(0)] * len(group.basket_tokens) for index, open_orders_account in enumerate(self.open_orders_accounts): if open_orders_account is not None: unsettled_assets[index] += open_orders_account.base_token_total unsettled_assets[-1] += open_orders_account.quote_token_total balance_sheets: typing.List[BalanceSheet] = [] for index, token in enumerate(group.basket_tokens): balance_sheets += [BalanceSheet(token.token, liabilities[index], settled_assets[index], unsettled_assets[index])] return balance_sheets def get_priced_balance_sheets(self, group: Group, prices: typing.List[TokenValue]) -> typing.List[BalanceSheet]: priced: typing.List[BalanceSheet] = [] balance_sheets = self.get_intrinsic_balance_sheets(group) for balance_sheet in balance_sheets: price = TokenValue.find_by_token(prices, balance_sheet.token) liabilities = balance_sheet.liabilities * price.value settled_assets = balance_sheet.settled_assets * price.value unsettled_assets = balance_sheet.unsettled_assets * price.value priced += [BalanceSheet( price.token, price.token.round(liabilities), price.token.round(settled_assets), price.token.round(unsettled_assets) )] return priced def get_balance_sheet_totals(self, group: Group, prices: typing.List[TokenValue]) -> BalanceSheet: liabilities = Decimal(0) settled_assets = Decimal(0) unsettled_assets = Decimal(0) balance_sheets = self.get_priced_balance_sheets(group, prices) for balance_sheet in balance_sheets: if balance_sheet is not None: liabilities += balance_sheet.liabilities settled_assets += balance_sheet.settled_assets unsettled_assets += balance_sheet.unsettled_assets # A BalanceSheet must have a token - it's a pain to make it a typing.Optional[Token]. # So in this one case, we produce a 'fake' token whose symbol is a summary of all token # symbols that went into it. # # If this becomes more painful than typing.Optional[Token], we can go with making # Token optional. summary_name = "-".join([bal.token.name for bal in balance_sheets]) summary_token = Token(summary_name, f"{summary_name} Summary", SYSTEM_PROGRAM_ADDRESS, Decimal(0)) return BalanceSheet(summary_token, liabilities, settled_assets, unsettled_assets) def get_intrinsic_balances(self, group: Group) -> typing.List[TokenValue]: balance_sheets = self.get_intrinsic_balance_sheets(group) balances: typing.List[TokenValue] = [] for index, balance_sheet in enumerate(balance_sheets): if balance_sheet.token is None: raise Exception(f"Intrinsic balance sheet with index [{index}] has no token.") balances += [TokenValue(balance_sheet.token, balance_sheet.value)] return balances # The old way of fetching ripe margin accounts was to fetch them all then inspect them to see # if they were ripe. That was a big performance problem - fetching all groups was quite a penalty. # # This is still how it's done in load_ripe_v1(). # # The newer mechanism is to look for the has_borrows flag in the ManrginAccount. That should # mean fewer MarginAccounts need to be fetched. # # This newer method is implemented in load_ripe_v2() @staticmethod def load_ripe(context: Context, group: Group) -> typing.List["MarginAccount"]: if group.version == Version.V1: return MarginAccount._load_ripe_v1(context, group) else: return MarginAccount._load_ripe_v2(context, group) @classmethod def _load_ripe_v1(cls, context: Context, group: Group) -> typing.List["MarginAccount"]: started_at = time.time() logger: logging.Logger = logging.getLogger(cls.__name__) margin_accounts = MarginAccount.load_all_for_group_with_open_orders(context, context.program_id, group) logger.info(f"Fetched {len(margin_accounts)} V1 margin accounts to process.") prices = group.fetch_token_prices(context) ripe_accounts = MarginAccount.filter_out_unripe(margin_accounts, group, prices) time_taken = time.time() - started_at logger.info(f"Loading ripe 🥭 accounts complete. Time taken: {time_taken:.2f} seconds.") return ripe_accounts @classmethod def _load_ripe_v2(cls, context: Context, group: Group) -> typing.List["MarginAccount"]: started_at = time.time() logger: logging.Logger = logging.getLogger(cls.__name__) filters = [ # 'has_borrows' offset is: 8 + 32 + 32 + (5 * 16) + (5 * 16) + (4 * 32) + 1 # = 361 MemcmpOpts( offset=361, bytes=encode_int(1) ), MemcmpOpts( offset=layouts.MANGO_ACCOUNT_FLAGS.sizeof(), # mango_group is just after the MangoAccountFlags, which is the first entry bytes=encode_key(group.address) ) ] data_size = layouts.MARGIN_ACCOUNT_V2.sizeof() response = context.client.get_program_accounts( context.program_id, data_size=data_size, memcmp_opts=filters, commitment=Single, encoding="base64") result = context.unwrap_or_raise_exception(response) margin_accounts = [] open_orders_addresses = [] for margin_account_data in result: address = PublicKey(margin_account_data["pubkey"]) account = AccountInfo._from_response_values(margin_account_data["account"], address) margin_account = MarginAccount.parse(account, group) open_orders_addresses += margin_account.open_orders margin_accounts += [margin_account] logger.info(f"Fetched {len(margin_accounts)} V2 margin accounts to process.") # It looks like this will be more efficient - just specify only the addresses we # need, and install them. # # Unfortunately there's a limit of 100 for the getMultipleAccounts() RPC call, # and doing it repeatedly requires some pauses because of rate limits. # # It's quicker (so far) to bring back every openorders account for the group. # # open_orders_addresses = [oo for oo in open_orders_addresses if oo is not None] # open_orders_account_infos = AccountInfo.load_multiple(self.context, open_orders_addresses) # open_orders_account_infos_by_address = {key: value for key, value in [(str(account_info.address), account_info) for account_info in open_orders_account_infos]} # for margin_account in margin_accounts: # margin_account.install_open_orders_accounts(self, open_orders_account_infos_by_address) # This just fetches every openorder account for the group. open_orders = OpenOrders.load_raw_open_orders_account_infos(context, group) logger.info(f"Fetched {len(open_orders)} openorders accounts.") for margin_account in margin_accounts: margin_account.install_open_orders_accounts(group, open_orders) prices = group.fetch_token_prices(context) ripe_accounts = MarginAccount.filter_out_unripe(margin_accounts, group, prices) time_taken = time.time() - started_at logger.info(f"Loading ripe 🥭 accounts complete. Time taken: {time_taken:.2f} seconds.") return ripe_accounts def __str__(self) -> str: deposits = "\n ".join([f"{item}" for item in self.deposits]) borrows = "\n ".join([f"{item:}" for item in self.borrows]) if all(oo is None for oo in self.open_orders_accounts): open_orders = f"{self.open_orders}" else: open_orders_unindented = f"{self.open_orders_accounts}" open_orders = open_orders_unindented.replace("\n", "\n ") return f"""« MarginAccount: {self.address} Flags: {self.account_flags} Has Borrows: {self.has_borrows} Owner: {self.owner} Mango Group: {self.mango_group} Deposits: {deposits} Borrows: {borrows} Mango Open Orders: {open_orders} »""" # ## MarginAccountMetadata class class MarginAccountMetadata: def __init__(self, margin_account: MarginAccount, balance_sheet: BalanceSheet, balances: typing.List[TokenValue]): self.logger: logging.Logger = logging.getLogger(self.__class__.__name__) self.margin_account = margin_account self.balance_sheet = balance_sheet self.balances = balances @property def assets(self): return self.balance_sheet.assets @property def liabilities(self): return self.balance_sheet.liabilities @property def collateral_ratio(self): return self.balance_sheet.collateral_ratio