diff --git a/bin/redeem-pnl b/bin/redeem-pnl new file mode 100755 index 0000000..34c0da7 --- /dev/null +++ b/bin/redeem-pnl @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 + +import argparse +import os +import os.path +import sys + +from solana.publickey import PublicKey + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) +import mango # nopep8 + + +parser = argparse.ArgumentParser(description="redeems PnL for a Mango account") +mango.ContextBuilder.add_command_line_parameters(parser) +mango.Wallet.add_command_line_parameters(parser) +parser.add_argument( + "--market", type=str, help="perp market symbol to redeem PnL (e.g. ETH-PERP)" +) +parser.add_argument( + "--all", + action="store_true", + default=False, + help="redeem all MNGO in all perp markets in the account", +) +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, + help="runs as read-only and does not perform any transactions", +) +parser.add_argument( + "--wait", + action="store_true", + default=False, + help="wait until the transactions are confirmed", +) +args: argparse.Namespace = mango.parse_args(parser) + +if (not args.all) and (args.market is None): + raise Exception( + "Must specify either an individual market (using --market) or use --all for all markets" + ) + +with mango.ContextBuilder.from_command_line_parameters(args) as context: + wallet = mango.Wallet.from_command_line_parameters_or_raise(args) + + group = mango.Group.load(context, context.group_address) + cache = mango.Cache.load(context, group.cache) + account = mango.Account.load_for_owner_by_address( + context, wallet.address, group, args.account_address + ) + + if args.all: + signatures = account.redeem_all_perp_pnl(context, wallet, group, cache) + else: + group_slots = [ + gs + for gs in group.slots + if mango.Market.symbols_match(gs.perp_market_symbol, args.market) + ] + if len(group_slots) != 1: + raise Exception( + f"Could not find perp market slot with symbol '{args.market}'" + ) + + group_slot = group_slots[0] + slot = account.slots[group_slot.index] + perp_market_cache = group.perp_market_cache_from_cache( + cache, slot.base_instrument + ) + if perp_market_cache is None: + raise Exception( + f"Could not find perp market cache for {slot.base_instrument.symbol}" + ) + + price = group.token_price_from_cache(cache, slot.base_instrument) + signatures = account.redeem_pnl_for_perp_market( + context, wallet, group, slot, perp_market_cache, price + ) + + if args.wait: + mango.output("Waiting on transaction signatures:") + mango.output(mango.indent_collection_as_str(signatures, 1)) + results = mango.WebSocketTransactionMonitor.wait_for_all( + context.client.cluster_ws_url, signatures + ) + mango.output("Transaction results:") + mango.output(mango.indent_collection_as_str(results, 1)) + else: + mango.output("Transaction signatures:") + mango.output(mango.indent_collection_as_str(signatures, 1)) diff --git a/mango/__init__.py b/mango/__init__.py index 4c56730..99a324b 100644 --- a/mango/__init__.py +++ b/mango/__init__.py @@ -105,6 +105,12 @@ from .instructions import ( from .instructions import ( build_mango_set_referrer_memory_instructions as build_mango_set_referrer_memory_instructions, ) +from .instructions import ( + build_mango_settle_fees_instructions as build_mango_settle_fees_instructions, +) +from .instructions import ( + build_mango_settle_pnl_instructions as build_mango_settle_pnl_instructions, +) from .instructions import ( build_mango_update_funding_instructions as build_mango_update_funding_instructions, ) diff --git a/mango/account.py b/mango/account.py index 40de2fe..e15a362 100644 --- a/mango/account.py +++ b/mango/account.py @@ -30,6 +30,8 @@ from .encoding import encode_key from .group import Group, GroupSlot, GroupSlotPerpMarket from .instructions import ( build_mango_deposit_instructions, + build_mango_settle_fees_instructions, + build_mango_settle_pnl_instructions, build_mango_withdraw_instructions, ) from .instrumentvalue import InstrumentValue @@ -39,6 +41,7 @@ from .observables import Disposable from .openorders import OpenOrders from .orders import Side from .perpaccount import PerpAccount +from .perpmarketdetails import PerpMarketDetails from .perpopenorders import PerpOpenOrders from .placedorder import PlacedOrder from .tokens import Instrument, Token @@ -167,6 +170,15 @@ class AccountSlot: def raw_net_value(self) -> Decimal: return self.raw_deposit - self.raw_borrow + def pnl( + self, + perp_market_cache: PerpMarketCache, + price: InstrumentValue, + ) -> Decimal: + if self.perp_account is None: + return Decimal(0) + return self.perp_account.pnl(perp_market_cache, price) + def __str__(self) -> str: perp_account: str = "None" if self.perp_account is not None: @@ -697,6 +709,201 @@ class Account(AddressableAccount): all_instructions = signers + create_ata + withdraw return all_instructions.execute(context) + def redeem_all_perp_pnl( + self, + context: Context, + wallet: Wallet, + group: Group, + cache: Cache, + ) -> typing.Sequence[str]: + all_accounts = Account.load_all(context, group) + self._logger.debug(f"Fetched {len(all_accounts)} Mango Accounts.") + + instructions = CombinableInstructions.from_wallet(wallet) + for slot in self.slots: + if (slot.perp_account is not None) and (not slot.perp_account.empty): + self._logger.debug( + f"Redeeming perp account for {slot.base_instrument.symbol}" + ) + price = group.token_price_from_cache(cache, slot.base_instrument) + perp_market_cache = group.perp_market_cache_from_cache( + cache, slot.base_instrument + ) + if perp_market_cache is None: + raise Exception( + f"Could not load perp market cache for {slot.base_instrument.symbol} despite having a perp account for it." + ) + + instructions += self.build_redeem_pnl_instructions_for_market( + context, group, all_accounts, slot, perp_market_cache, price + ) + else: + self._logger.debug(f"No perp account for {slot.base_instrument.symbol}") + + self._logger.debug( + f"Have {len(instructions.instructions)} instructions to send for all perp markets." + ) + + return instructions.execute(context) + + def redeem_pnl_for_perp_market( + self, + context: Context, + wallet: Wallet, + group: Group, + slot: AccountSlot, + perp_market_cache: PerpMarketCache, + price: InstrumentValue, + ) -> typing.Sequence[str]: + group_slot = group.slots[slot.index] + if group_slot.perp_market is None: + return [] + + if slot.perp_account is None: + return [] + + pnl = slot.perp_account.pnl(perp_market_cache, price) + if pnl == 0: + # No PnL to redeem + return [] + + self._logger.debug( + f"Trying to settle {pnl:,.8f} {group_slot.quote_token_bank.token.symbol}." + ) + + instructions = CombinableInstructions.from_wallet(wallet) + + all_accounts = Account.load_all(context, group) + self._logger.debug(f"Fetched {len(all_accounts)} Mango Accounts.") + + instructions += self.build_redeem_pnl_instructions_for_market( + context, group, all_accounts, slot, perp_market_cache, price + ) + + return instructions.execute(context) + + def build_redeem_pnl_instructions_for_market( + self, + context: Context, + group: Group, + all_accounts: typing.Sequence["Account"], + slot: AccountSlot, + perp_market_cache: PerpMarketCache, + price: InstrumentValue, + ) -> CombinableInstructions: + group_slot = group.slots[slot.index] + if group_slot.perp_market is None: + self._logger.debug(f"No perp market for {slot.base_instrument.symbol}") + return CombinableInstructions.empty() + + if slot.perp_account is None: + self._logger.debug(f"No perp account for {slot.base_instrument.symbol}") + return CombinableInstructions.empty() + + pnl = slot.perp_account.pnl(perp_market_cache, price) + if pnl == 0: + # No PnL to redeem + return CombinableInstructions.empty() + + self._logger.debug( + f"Trying to settle {pnl:,.8f} {group_slot.quote_token_bank.token.symbol} on {group_slot.perp_market_symbol}." + ) + + pnl_is_negative: bool = False + if pnl < 0: + pnl_is_negative = True + + quote_root_bank = group.shared_quote.ensure_root_bank(context) + + instructions = CombinableInstructions.empty() + + # If negative, build settle fees instruction + if pnl_is_negative: + perp_market = PerpMarketDetails.load( + context, group_slot.perp_market.address, group + ) + quote_node_bank = quote_root_bank.pick_node_bank(context) + self._logger.debug("Adding SettleFees instruction.") + instructions += build_mango_settle_fees_instructions( + context, + group, + perp_market, + self, + quote_root_bank, + quote_node_bank, + ) + + all_accounts_except_self = filter( + lambda acc: acc.address != self.address, + all_accounts, + ) + + # Filter out all accounts with zero or same-signed PnL + if pnl_is_negative: + filtered = list( + filter( + lambda acc: acc.slots[slot.index].pnl(perp_market_cache, price) > 0, + all_accounts_except_self, + ) + ) + else: + filtered = list( + filter( + lambda acc: acc.slots[slot.index].pnl(perp_market_cache, price) < 0, + all_accounts_except_self, + ) + ) + self._logger.debug( + f"Removed irrelevent accounts - now have {len(filtered)} Mango Accounts." + ) + + # Now we have a filtered list, sort it + sorted_accounts = sorted( + filtered, + key=lambda acc: acc.slots[slot.index].pnl(perp_market_cache, price), + reverse=pnl_is_negative, + ) + + # Add 5% to our collection so we're sure we're sending enough accounts even if + # the price has shifted. (This doesn't affect the redemption value.) + target_pnl = (pnl * Decimal("1.01")).copy_abs() + + # Loop over top N accounts until we have redeemed enough or we run out of accounts. + collected = Decimal(0) + for other in sorted_accounts: + other_perp_account = other.slots[slot.index].perp_account + if other_perp_account is not None: + other_pnl = other_perp_account.pnl(perp_market_cache, price) + + # Build instruction + instructions += build_mango_settle_pnl_instructions( + context, + group, + group_slot, + self, + other, + quote_root_bank, + ) + + collected += other_pnl + self._logger.debug( + f"Collected {collected:,.8f} out of {target_pnl:,.8f} - added {other_pnl:,.8f} from {other.address}" + ) + + # Add 1% to our collection so we're sure we're sending enough accounts even if + # the price has shifted. (This doesn't affect the redemption value.) + if collected.copy_abs() > target_pnl: + self._logger.debug( + f"Done. Have collected {collected:,.8f} to redeem against." + ) + break + + self._logger.debug( + f"Have {len(instructions.instructions)} instructions to send for {group_slot.perp_market_symbol}." + ) + + return instructions + def slot_by_instrument_or_none( self, instrument: Instrument ) -> typing.Optional[AccountSlot]: diff --git a/mango/instructions.py b/mango/instructions.py index 7d029f8..9e25d62 100644 --- a/mango/instructions.py +++ b/mango/instructions.py @@ -108,6 +108,7 @@ class IGroupSlot(typing.Protocol): class IGroup(typing.Protocol): cache: PublicKey signer_key: PublicKey + fees_vault: PublicKey shared_quote: TokenBank @property @@ -1712,3 +1713,81 @@ def build_mango_update_funding_instructions( data=layouts.UPDATE_FUNDING.build({}), ) return CombinableInstructions(signers=[], instructions=[instruction]) + + +def build_mango_settle_fees_instructions( + context: Context, + group: IGroup, + perp_market_details: PerpMarketDetails, + account: IAccount, + root_bank: RootBank, + node_bank: NodeBank, +) -> CombinableInstructions: + # /// Take an account that has losses in the selected perp market to account for fees_accrued + # /// + # /// Accounts expected: 10 + # /// 0. `[]` mango_group_ai - MangoGroup + # /// 1. `[]` mango_cache_ai - MangoCache + # /// 2. `[writable]` perp_market_ai - PerpMarket + # /// 3. `[writable]` mango_account_ai - MangoAccount + # /// 4. `[]` root_bank_ai - RootBank + # /// 5. `[writable]` node_bank_ai - NodeBank + # /// 6. `[writable]` bank_vault_ai - ? + # /// 7. `[writable]` fees_vault_ai - fee vault owned by mango DAO token governance + # /// 8. `[]` signer_ai - Group Signer Account + # /// 9. `[]` token_prog_ai - Token Program Account + instruction = TransactionInstruction( + keys=[ + AccountMeta(is_signer=False, is_writable=False, pubkey=group.address), + AccountMeta(is_signer=False, is_writable=False, pubkey=group.cache), + AccountMeta( + is_signer=False, is_writable=True, pubkey=perp_market_details.address + ), + AccountMeta(is_signer=False, is_writable=True, pubkey=account.address), + AccountMeta(is_signer=False, is_writable=False, pubkey=root_bank.address), + AccountMeta(is_signer=False, is_writable=True, pubkey=node_bank.address), + AccountMeta(is_signer=False, is_writable=True, pubkey=node_bank.vault), + AccountMeta(is_signer=False, is_writable=True, pubkey=group.fees_vault), + AccountMeta(is_signer=False, is_writable=False, pubkey=group.signer_key), + AccountMeta(is_signer=False, is_writable=False, pubkey=TOKEN_PROGRAM_ID), + ], + program_id=context.mango_program_address, + data=layouts.SETTLE_FEES.build({}), + ) + return CombinableInstructions(signers=[], instructions=[instruction]) + + +def build_mango_settle_pnl_instructions( + context: Context, + group: IGroup, + group_slot: IGroupSlot, + account_a: IAccount, + account_b: IAccount, + root_bank: RootBank, +) -> CombinableInstructions: + # /// Take two MangoAccounts and settle profits and losses between them for a perp market + # /// + # /// Accounts expected (6): + # const keys = [ + # { isSigner: false, isWritable: false, pubkey: mangoGroupPk }, + # { isSigner: false, isWritable: true, pubkey: mangoAccountAPk }, + # { isSigner: false, isWritable: true, pubkey: mangoAccountBPk }, + # { isSigner: false, isWritable: false, pubkey: mangoCachePk }, + # { isSigner: false, isWritable: false, pubkey: rootBankPk }, + # { isSigner: false, isWritable: true, pubkey: nodeBankPk }, + # ]; + instruction = TransactionInstruction( + keys=[ + AccountMeta(is_signer=False, is_writable=False, pubkey=group.address), + AccountMeta(is_signer=False, is_writable=True, pubkey=account_a.address), + AccountMeta(is_signer=False, is_writable=True, pubkey=account_b.address), + AccountMeta(is_signer=False, is_writable=False, pubkey=group.cache), + AccountMeta(is_signer=False, is_writable=False, pubkey=root_bank.address), + AccountMeta( + is_signer=False, is_writable=True, pubkey=root_bank.node_banks[0] + ), + ], + program_id=context.mango_program_address, + data=layouts.SETTLE_PNL.build({"market_index": group_slot.index}), + ) + return CombinableInstructions(signers=[], instructions=[instruction]) diff --git a/mango/layouts/layouts.py b/mango/layouts/layouts.py index 49281dd..0ba858e 100644 --- a/mango/layouts/layouts.py +++ b/mango/layouts/layouts.py @@ -1584,6 +1584,32 @@ UPDATE_ROOT_BANK = construct.Struct( "variant" / construct.Const(21, construct.BytesInteger(4, swapped=True)), ) +# /// Take two MangoAccounts and settle profits and losses between them for a perp market +# /// +# /// Accounts expected (6): +SETTLE_PNL = construct.Struct( + "variant" / construct.Const(22, construct.BytesInteger(4, swapped=True)), + "market_index" / DecimalAdapter(), +) + + +# /// Take an account that has losses in the selected perp market to account for fees_accrued +# /// +# /// Accounts expected: 10 +# /// 0. `[]` mango_group_ai - MangoGroup +# /// 1. `[]` mango_cache_ai - MangoCache +# /// 2. `[writable]` perp_market_ai - PerpMarket +# /// 3. `[writable]` mango_account_ai - MangoAccount +# /// 4. `[]` root_bank_ai - RootBank +# /// 5. `[writable]` node_bank_ai - NodeBank +# /// 6. `[writable]` bank_vault_ai - ? +# /// 7. `[writable]` fees_vault_ai - fee vault owned by mango DAO token governance +# /// 8. `[]` signer_ai - Group Signer Account +# /// 9. `[]` token_prog_ai - Token Program Account +SETTLE_FEES = construct.Struct( + "variant" / construct.Const(29, construct.BytesInteger(4, swapped=True)) +) + # /// Redeem the mngo_accrued in a PerpAccount for MNGO in MangoAccount deposits # /// @@ -1830,14 +1856,14 @@ InstructionParsersByVariant = { 19: SETTLE_FUNDS, # SETTLE_FUNDS, 20: CANCEL_SPOT_ORDER, # CANCEL_SPOT_ORDER, 21: UPDATE_ROOT_BANK, # UPDATE_ROOT_BANK, - 22: UNSPECIFIED, # SETTLE_PNL, + 22: SETTLE_PNL, # SETTLE_PNL, 23: UNSPECIFIED, # SETTLE_BORROW, 24: UNSPECIFIED, # FORCE_CANCEL_SPOT_ORDERS, 25: UNSPECIFIED, # FORCE_CANCEL_PERP_ORDERS, 26: UNSPECIFIED, # LIQUIDATE_TOKEN_AND_TOKEN, 27: UNSPECIFIED, # LIQUIDATE_TOKEN_AND_PERP, 28: UNSPECIFIED, # LIQUIDATE_PERP_MARKET, - 29: UNSPECIFIED, # SETTLE_FEES, + 29: SETTLE_FEES, # SETTLE_FEES, 30: UNSPECIFIED, # RESOLVE_PERP_BANKRUPTCY, 31: UNSPECIFIED, # RESOLVE_TOKEN_BANKRUPTCY, 32: UNSPECIFIED, # INIT_SPOT_OPEN_ORDERS,