Moved library code to .py files instead of notebooks.

* This is a big change that touched most files in the project.
* Library code is now an actual package, in /mango.
* Pure .py files used for shared code - easier to edit/debug, and should ease move to installable package later.
* Removed many notebooks. The remaining notebooks are useful 'display'/'show' notebooks for investigating Mango objects.
* More tests! The test story is now much improved, but more unit tests are still needed.
* There's now a Makefile for project operations.
This commit is contained in:
Geoff Taylor 2021-06-07 15:10:18 +01:00
parent de0144f43a
commit 5e59d8a7e3
129 changed files with 10341 additions and 9705 deletions

7
.flake8 Normal file
View File

@ -0,0 +1,7 @@
[flake8]
ignore = D203
exclude = .git,__pycache__,.ipynb_checkpoints,docs
per-file-ignores =
# imported but unused
__init__.py: F401,
tests/context.py: F401

20
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,20 @@
{
"telemetry.enableCrashReporter": false,
"python.testing.pytestArgs": [
"tests"
],
"python.testing.unittestEnabled": false,
"python.testing.nosetestsEnabled": false,
"python.testing.pytestEnabled": true,
"python.autoComplete.addBrackets": true,
"python.formatting.autopep8Args": [
"--max-line-length",
"120"
],
"shebang.associations": [
{
"pattern": "^#!/usr/bin/env pyston3$",
"language": "python"
}
]
}

View File

@ -1,398 +0,0 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "prime-slovenia",
"metadata": {},
"source": [
"# ⚠ Warning\n",
"\n",
"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.\n",
"\n",
"[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gl/OpinionatedGeek%2Fmango-explorer/HEAD?filepath=AccountLiquidator.ipynb) _🏃 To run this notebook press the ⏩ icon in the toolbar above._\n",
"\n",
"[🥭 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)"
]
},
{
"cell_type": "markdown",
"id": "declared-thing",
"metadata": {},
"source": [
"# 🥭 AccountLiquidator\n",
"\n",
"An `AccountLiquidator` liquidates a `MarginAccount`, if possible.\n",
"\n",
"The follows the common pattern of having an abstract base class that defines the interface external code should use, along with a 'null' implementation and at least one full implementation.\n",
"\n",
"The idea is that preparing code can choose whether to use the null implementation (in the case of a 'dry run' for instance) or the full implementation, but the code that defines the algorithm - which actually calls the `AccountLiquidator` - doesn't have to care about this choice."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "living-watch",
"metadata": {
"jupyter": {
"source_hidden": true
}
},
"outputs": [],
"source": [
"import abc\n",
"import datetime\n",
"import logging\n",
"import typing\n",
"\n",
"from solana.transaction import Transaction\n",
"\n",
"from BaseModel import Group, LiquidationEvent, MarginAccount, MarginAccountMetadata, TokenValue\n",
"from Context import Context\n",
"from Instructions import ForceCancelOrdersInstructionBuilder, InstructionBuilder, LiquidateInstructionBuilder\n",
"from Observables import EventSource\n",
"from TransactionScout import TransactionScout\n",
"from Wallet import Wallet\n"
]
},
{
"cell_type": "markdown",
"id": "preliminary-guinea",
"metadata": {},
"source": [
"## 💧 AccountLiquidator class\n",
"\n",
"This abstract base class defines the interface to account liquidators, which in this case is just the `liquidate()` method.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "directed-intersection",
"metadata": {},
"outputs": [],
"source": [
"class AccountLiquidator(metaclass=abc.ABCMeta):\n",
" def __init__(self):\n",
" self.logger: logging.Logger = logging.getLogger(self.__class__.__name__)\n",
"\n",
" @abc.abstractmethod\n",
" def prepare_instructions(self, group: Group, margin_account: MarginAccount, prices: typing.List[TokenValue]) -> typing.List[InstructionBuilder]:\n",
" raise NotImplementedError(\"AccountLiquidator.prepare_instructions() is not implemented on the base type.\")\n",
"\n",
" @abc.abstractmethod\n",
" def liquidate(self, group: Group, margin_account: MarginAccount, prices: typing.List[TokenValue]) -> typing.Optional[str]:\n",
" raise NotImplementedError(\"AccountLiquidator.liquidate() is not implemented on the base type.\")\n"
]
},
{
"cell_type": "markdown",
"id": "inside-speed",
"metadata": {},
"source": [
"## 🌬️ NullAccountLiquidator class\n",
"\n",
"A 'null', 'no-op', 'dry run' implementation of the `AccountLiquidator` class."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "generic-circuit",
"metadata": {},
"outputs": [],
"source": [
"class NullAccountLiquidator(AccountLiquidator):\n",
" def __init__(self):\n",
" super().__init__()\n",
"\n",
" def prepare_instructions(self, group: Group, margin_account: MarginAccount, prices: typing.List[TokenValue]) -> typing.List[InstructionBuilder]:\n",
" return []\n",
"\n",
" def liquidate(self, group: Group, margin_account: MarginAccount, prices: typing.List[TokenValue]) -> typing.Optional[str]:\n",
" self.logger.info(f\"Skipping liquidation of margin account [{margin_account.address}]\")\n",
" return None\n"
]
},
{
"cell_type": "markdown",
"id": "apparent-metallic",
"metadata": {},
"source": [
"## 💧 ActualAccountLiquidator class\n",
"\n",
"This full implementation takes a `MarginAccount` and liquidates it.\n",
"\n",
"It can also serve as a base class for further derivation. Derived classes may override `prepare_instructions()` to extend the liquidation process (for example to cancel outstanding orders before liquidating)."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "hired-definition",
"metadata": {},
"outputs": [],
"source": [
"class ActualAccountLiquidator(AccountLiquidator):\n",
" def __init__(self, context: Context, wallet: Wallet):\n",
" super().__init__()\n",
" self.logger: logging.Logger = logging.getLogger(self.__class__.__name__)\n",
" self.context = context\n",
" self.wallet = wallet\n",
"\n",
" def prepare_instructions(self, group: Group, margin_account: MarginAccount, prices: typing.List[TokenValue]) -> typing.List[InstructionBuilder]:\n",
" liquidate_instructions: typing.List[InstructionBuilder] = []\n",
" liquidate_instruction = LiquidateInstructionBuilder.from_margin_account_and_market(self.context, group, self.wallet, margin_account, prices)\n",
" if liquidate_instruction is not None:\n",
" liquidate_instructions += [liquidate_instruction]\n",
"\n",
" return liquidate_instructions\n",
"\n",
" def liquidate(self, group: Group, margin_account: MarginAccount, prices: typing.List[TokenValue]) -> typing.Optional[str]:\n",
" instruction_builders = self.prepare_instructions(group, margin_account, prices)\n",
"\n",
" if len(instruction_builders) == 0:\n",
" return None\n",
"\n",
" transaction = Transaction()\n",
" for builder in instruction_builders:\n",
" transaction.add(builder.build())\n",
"\n",
" for instruction in transaction.instructions:\n",
" self.logger.debug(\"INSTRUCTION\")\n",
" self.logger.debug(\" Keys:\")\n",
" for key in instruction.keys:\n",
" self.logger.debug(\" \", f\"{key.pubkey}\".ljust(45), f\"{key.is_signer}\".ljust(6), f\"{key.is_writable}\".ljust(6))\n",
" self.logger.debug(\" Data:\", \" \".join(f\"{x:02x}\" for x in instruction.data))\n",
" self.logger.debug(\" Program ID:\", instruction.program_id)\n",
"\n",
" transaction_response = self.context.client.send_transaction(transaction, self.wallet.account)\n",
" transaction_id = self.context.unwrap_transaction_id_or_raise_exception(transaction_response)\n",
" return transaction_id\n",
"\n"
]
},
{
"cell_type": "markdown",
"id": "miniature-graduation",
"metadata": {},
"source": [
"# 🌪️ ForceCancelOrdersAccountLiquidator class\n",
"\n",
"When liquidating an account, it's a good idea to ensure it has no open orders that could lock funds. This is why Mango allows a liquidator to force-close orders on a liquidatable account.\n",
"\n",
"`ForceCancelOrdersAccountLiquidator` overrides `prepare_instructions()` to inject any necessary force-cancel instructions before the `PartialLiquidate` instruction.\n",
"\n",
"This is not always necessary. For example, if the liquidator is partially-liquidating a large account, then perhaps only the first partial-liquidate needs to check and force-close orders, and subsequent partial liquidations can skip this step as an optimisation.\n",
"\n",
"The separation of the regular `AccountLiquidator` and the `ForceCancelOrdersAccountLiquidator` classes allows the caller to determine which process is used."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "treated-relaxation",
"metadata": {},
"outputs": [],
"source": [
"class ForceCancelOrdersAccountLiquidator(ActualAccountLiquidator):\n",
" def __init__(self, context: Context, wallet: Wallet):\n",
" super().__init__(context, wallet)\n",
"\n",
" def prepare_instructions(self, group: Group, margin_account: MarginAccount, prices: typing.List[TokenValue]) -> typing.List[InstructionBuilder]:\n",
" force_cancel_orders_instructions: typing.List[InstructionBuilder] = []\n",
" for index, market_metadata in enumerate(group.markets):\n",
" open_orders = margin_account.open_orders_accounts[index]\n",
" if open_orders is not None:\n",
" market = market_metadata.fetch_market(self.context)\n",
" orders = market.load_orders_for_owner(margin_account.owner)\n",
" order_count = len(orders)\n",
" if order_count > 0:\n",
" force_cancel_orders_instructions += ForceCancelOrdersInstructionBuilder.multiple_instructions_from_margin_account_and_market(self.context, group, self.wallet, margin_account, market_metadata, order_count)\n",
"\n",
" all_instructions = force_cancel_orders_instructions + super().prepare_instructions(group, margin_account, prices)\n",
"\n",
" return all_instructions\n"
]
},
{
"cell_type": "markdown",
"id": "neural-botswana",
"metadata": {},
"source": [
"# 📝 ReportingAccountLiquidator class\n",
"\n",
"This class takes a regular `AccountLiquidator` and wraps its `liquidate()` call in some useful reporting."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "enclosed-twenty",
"metadata": {},
"outputs": [],
"source": [
"class ReportingAccountLiquidator(AccountLiquidator):\n",
" def __init__(self, inner: AccountLiquidator, context: Context, wallet: Wallet, liquidations_publisher: EventSource[LiquidationEvent], liquidator_name: str):\n",
" super().__init__()\n",
" self.logger: logging.Logger = logging.getLogger(self.__class__.__name__)\n",
" self.inner: AccountLiquidator = inner\n",
" self.context: Context = context\n",
" self.wallet: Wallet = wallet\n",
" self.liquidations_publisher: EventSource[LiquidationEvent] = liquidations_publisher\n",
" self.liquidator_name: str = liquidator_name\n",
"\n",
" def prepare_instructions(self, group: Group, margin_account: MarginAccount, prices: typing.List[TokenValue]) -> typing.List[InstructionBuilder]:\n",
" return self.inner.prepare_instructions(group, margin_account, prices)\n",
"\n",
" def liquidate(self, group: Group, margin_account: MarginAccount, prices: typing.List[TokenValue]) -> typing.Optional[str]:\n",
" balance_sheet = margin_account.get_balance_sheet_totals(group, prices)\n",
" balances = margin_account.get_intrinsic_balances(group)\n",
" mam = MarginAccountMetadata(margin_account, balance_sheet, balances)\n",
"\n",
" balances_before = group.fetch_balances(self.wallet.address)\n",
" self.logger.info(\"Wallet balances before:\")\n",
" TokenValue.report(self.logger.info, balances_before)\n",
"\n",
" self.logger.info(f\"Margin account balances before:\\n{mam.balances}\")\n",
" self.logger.info(f\"Liquidating margin account: {mam.margin_account}\\n{mam.balance_sheet}\")\n",
" transaction_id = self.inner.liquidate(group, mam.margin_account, prices)\n",
" if transaction_id is None:\n",
" self.logger.info(\"No transaction sent.\")\n",
" else:\n",
" self.logger.info(f\"Transaction ID: {transaction_id} - waiting for confirmation...\")\n",
"\n",
" response = self.context.wait_for_confirmation(transaction_id)\n",
" if response is None:\n",
" self.logger.warning(f\"Could not process 'after' liquidation stage - no data for transaction {transaction_id}\")\n",
" return transaction_id\n",
"\n",
" transaction_scout = TransactionScout.from_transaction_response(self.context, response)\n",
"\n",
" group_after = Group.load(self.context)\n",
" margin_account_after_liquidation = MarginAccount.load(self.context, mam.margin_account.address, group_after)\n",
" intrinsic_balances_after = margin_account_after_liquidation.get_intrinsic_balances(group_after)\n",
" self.logger.info(f\"Margin account balances after: {intrinsic_balances_after}\")\n",
"\n",
" self.logger.info(\"Wallet Balances After:\")\n",
" balances_after = group_after.fetch_balances(self.wallet.address)\n",
" TokenValue.report(self.logger.info, balances_after)\n",
"\n",
" liquidation_event = LiquidationEvent(datetime.datetime.now(),\n",
" self.liquidator_name,\n",
" self.context.group_name,\n",
" transaction_scout.succeeded,\n",
" transaction_id,\n",
" self.wallet.address,\n",
" margin_account_after_liquidation.address,\n",
" balances_before,\n",
" balances_after)\n",
"\n",
" self.logger.info(\"Wallet Balances Changes:\")\n",
" changes = TokenValue.changes(balances_before, balances_after)\n",
" TokenValue.report(self.logger.info, changes)\n",
"\n",
" self.liquidations_publisher.publish(liquidation_event)\n",
"\n",
" return transaction_id\n"
]
},
{
"cell_type": "markdown",
"id": "incorporated-enemy",
"metadata": {},
"source": [
"# 🏃 Running"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "expressed-banks",
"metadata": {},
"outputs": [],
"source": [
"if __name__ == \"__main__\":\n",
" logging.getLogger().setLevel(logging.INFO)\n",
"\n",
" from Context import default_context\n",
" from Wallet import default_wallet\n",
"\n",
" if default_wallet is None:\n",
" print(\"No default wallet file available.\")\n",
" else:\n",
" group = Group.load(default_context)\n",
" prices = group.fetch_token_prices()\n",
" margin_accounts = MarginAccount.load_all_for_owner(default_context, default_wallet.address, group)\n",
" for margin_account in margin_accounts:\n",
" account_liquidator = ActualAccountLiquidator(default_context, default_wallet)\n",
" print(account_liquidator.prepare_instructions(group, margin_account, prices))\n",
"\n",
" force_cancel_orders_account_liquidator = ForceCancelOrdersAccountLiquidator(default_context, default_wallet)\n",
" print(force_cancel_orders_account_liquidator.prepare_instructions(group, margin_account, prices))\n"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.8.6"
},
"toc": {
"base_numbering": 1,
"nav_menu": {},
"number_sections": true,
"sideBar": true,
"skip_h1_title": false,
"title_cell": "Table of Contents",
"title_sidebar": "Contents",
"toc_cell": false,
"toc_position": {},
"toc_section_display": true,
"toc_window_display": true
},
"varInspector": {
"cols": {
"lenName": 16,
"lenType": 16,
"lenVar": 40
},
"kernels_config": {
"python": {
"delete_cmd_postfix": "",
"delete_cmd_prefix": "del ",
"library": "var_list.py",
"varRefreshCmd": "print(var_dic_list())"
},
"r": {
"delete_cmd_postfix": ") ",
"delete_cmd_prefix": "rm(",
"library": "var_list.r",
"varRefreshCmd": "cat(var_dic_list()) "
}
},
"types_to_exclude": [
"module",
"function",
"builtin_function_or_method",
"instance",
"_Feature"
],
"window_display": false
}
},
"nbformat": 4,
"nbformat_minor": 5
}

View File

@ -1,332 +0,0 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "wrapped-caribbean",
"metadata": {},
"source": [
"# ⚠ Warning\n",
"\n",
"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.\n",
"\n",
"[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gl/OpinionatedGeek%2Fmango-explorer/HEAD?filepath=AccountScout.ipynb) _🏃 To run this notebook press the ⏩ icon in the toolbar above._\n",
"\n",
"[🥭 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)"
]
},
{
"cell_type": "markdown",
"id": "british-suicide",
"metadata": {},
"source": [
"# 🥭 AccountScout\n",
"\n",
"If you want to run this code to check a user account, skip down to the **Running** section below and follow the instructions there.\n",
"\n",
"(A 'user account' here is the root SOL account for a user - the one you have the private key for, and the one that owns sub-accounts.)\n"
]
},
{
"cell_type": "markdown",
"id": "wrong-number",
"metadata": {},
"source": [
"## Required Accounts\n",
"\n",
"Mango Markets code expects some accounts to be present, and if they're not present some actions can fail.\n",
"\n",
"From [Daffy on Discord](https://discord.com/channels/791995070613159966/820390560085835786/834024719958147073):\n",
"\n",
"> You need an open orders account for each of the spot markets Mango. And you need a token account for each of the tokens.\n",
"\n",
"This notebook (and the `AccountScout` class) can be used to check the required accounts are present and maybe in future set up all the required accounts.\n",
"\n",
"(There's no reason not to write the code to fix problems and create any missing accounts. It just hasn't been done yet.)\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "bound-rebound",
"metadata": {
"jupyter": {
"source_hidden": true
}
},
"outputs": [],
"source": [
"import logging\n",
"import typing\n",
"\n",
"from solana.publickey import PublicKey\n",
"\n",
"from BaseModel import AccountInfo, Group, MarginAccount, OpenOrders, TokenAccount\n",
"from Constants import SYSTEM_PROGRAM_ADDRESS\n",
"from Context import Context\n",
"from Wallet import Wallet\n"
]
},
{
"cell_type": "markdown",
"id": "induced-frame",
"metadata": {},
"source": [
"# ScoutReport class\n",
"\n",
"The `ScoutReport` class is built up by the `AccountScout` to report errors, warnings and details pertaining to a user account."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "structural-stroke",
"metadata": {},
"outputs": [],
"source": [
"class ScoutReport:\n",
" def __init__(self, address: PublicKey):\n",
" self.address = address\n",
" self.errors: typing.List[str] = []\n",
" self.warnings: typing.List[str] = []\n",
" self.details: typing.List[str] = []\n",
"\n",
" @property\n",
" def has_errors(self) -> bool:\n",
" return len(self.errors) > 0\n",
"\n",
" @property\n",
" def has_warnings(self) -> bool:\n",
" return len(self.warnings) > 0\n",
"\n",
" def add_error(self, error) -> None:\n",
" self.errors += [error]\n",
"\n",
" def add_warning(self, warning) -> None:\n",
" self.warnings += [warning]\n",
"\n",
" def add_detail(self, detail) -> None:\n",
" self.details += [detail]\n",
"\n",
" def __str__(self) -> str:\n",
" def _pad(text_list: typing.List[str]) -> str:\n",
" if len(text_list) == 0:\n",
" return \"None\"\n",
" padding = \"\\n \"\n",
" return padding.join(map(lambda text: text.replace(\"\\n\", padding), text_list))\n",
"\n",
" error_text = _pad(self.errors)\n",
" warning_text = _pad(self.warnings)\n",
" detail_text = _pad(self.details)\n",
" if len(self.errors) > 0 or len(self.warnings) > 0:\n",
" summary = f\"Found {len(self.errors)} error(s) and {len(self.warnings)} warning(s).\"\n",
" else:\n",
" summary = \"No problems found\"\n",
"\n",
" return f\"\"\"« ScoutReport [{self.address}]:\n",
" Summary:\n",
" {summary}\n",
"\n",
" Errors:\n",
" {error_text}\n",
"\n",
" Warnings:\n",
" {warning_text}\n",
"\n",
" Details:\n",
" {detail_text}\n",
"»\"\"\"\n",
"\n",
" def __repr__(self) -> str:\n",
" return f\"{self}\"\n"
]
},
{
"cell_type": "markdown",
"id": "universal-sheep",
"metadata": {},
"source": [
"# AccountScout class\n",
"\n",
"The `AccountScout` class aims to run various checks against a user account to make sure it is in a position to run the liquidator.\n",
"\n",
"Passing all checks here with no errors will be a precondition on liquidator startup."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "perceived-trout",
"metadata": {},
"outputs": [],
"source": [
"class AccountScout:\n",
" def __init__(self):\n",
" pass\n",
"\n",
" def require_account_prepared_for_group(self, context: Context, group: Group, account_address: PublicKey) -> None:\n",
" report = self.verify_account_prepared_for_group(context, group, account_address)\n",
" if report.has_errors:\n",
" raise Exception(f\"Account '{account_address}' is not prepared for group '{group.address}':\\n\\n{report}\")\n",
"\n",
" def verify_account_prepared_for_group(self, context: Context, group: Group, account_address: PublicKey) -> ScoutReport:\n",
" report = ScoutReport(account_address)\n",
"\n",
" # First of all, the account must actually exist. If it doesn't, just return early.\n",
" root_account = AccountInfo.load(context, account_address)\n",
" if root_account is None:\n",
" report.add_error(f\"Root account '{account_address}' does not exist.\")\n",
" return report\n",
"\n",
" # For this to be a user/wallet account, it must be owned by 11111111111111111111111111111111.\n",
" if root_account.owner != SYSTEM_PROGRAM_ADDRESS:\n",
" report.add_error(f\"Account '{account_address}' is not a root user account.\")\n",
" return report\n",
"\n",
" # Must have token accounts for each of the tokens in the group's basket.\n",
" for basket_token in group.basket_tokens:\n",
" token_accounts = TokenAccount.fetch_all_for_owner_and_token(context, account_address, basket_token.token)\n",
" if len(token_accounts) == 0:\n",
" report.add_error(f\"Account '{account_address}' has no account for token '{basket_token.token.name}', mint '{basket_token.token.mint}'.\")\n",
" else:\n",
" report.add_detail(f\"Account '{account_address}' has {len(token_accounts)} {basket_token.token.name} token accounts with mint '{basket_token.token.mint}'.\")\n",
"\n",
" # Should have an open orders account for each market in the group. (Only required for re-balancing via Serum, which isn't implemented here yet.)\n",
" for market in group.markets:\n",
" open_orders = OpenOrders.load_for_market_and_owner(context, market.address, account_address, context.dex_program_id, market.base.token.decimals, market.quote.token.decimals)\n",
" if len(open_orders) == 0:\n",
" report.add_warning(f\"No Serum open orders account for market '{market.base.token.name}/{market.quote.token.name}' [{market.address}]'.\")\n",
" else:\n",
" for open_orders_account in open_orders:\n",
" report.add_detail(f\"Serum open orders account for market '{market.base.token.name}/{market.quote.token.name}': {open_orders_account}\")\n",
"\n",
" # May have one or more Mango Markets margin account, but it's optional for liquidating\n",
" margin_accounts = MarginAccount.load_all_for_owner(context, account_address, group)\n",
" if len(margin_accounts) == 0:\n",
" report.add_detail(f\"Account '{account_address}' has no Mango Markets margin accounts.\")\n",
" else:\n",
" for margin_account in margin_accounts:\n",
" report.add_detail(f\"Margin account: {margin_account}\")\n",
"\n",
" return report\n",
"\n",
" # It would be good to be able to fix an account automatically, which should\n",
" # be possible if a Wallet is passed.\n",
" def prepare_wallet_for_group(self, wallet: Wallet, group: Group) -> ScoutReport:\n",
" report = ScoutReport(wallet.address)\n",
" report.add_error(\"AccountScout can't yet prepare wallets.\")\n",
" return report\n"
]
},
{
"cell_type": "markdown",
"id": "substantial-blues",
"metadata": {},
"source": [
"# 🏃 Running\n",
"\n",
"You can run the following cell to check any user account to make sure it has the proper sub-accounts set up and available.\n",
"\n",
"Enter the public key of the account you want to verify in the value for `ACCOUNT_TO_VERIFY` in the box below, between the double-quote marks. Then run the notebook by choosing 'Run > Run All Cells' from the notebook menu at the top of the page."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "humanitarian-warning",
"metadata": {},
"outputs": [],
"source": [
"ACCOUNT_TO_VERIFY = \"\""
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "neural-beaver",
"metadata": {},
"outputs": [],
"source": [
"if __name__ == \"__main__\":\n",
" logging.getLogger().setLevel(logging.INFO)\n",
"\n",
" if ACCOUNT_TO_VERIFY == \"\":\n",
" raise Exception(\"No account to look up - try setting the variable ACCOUNT_TO_LOOK_UP to an account public key.\")\n",
"\n",
" from Context import default_context\n",
"\n",
" print(\"Context:\", default_context)\n",
"\n",
" root_account_key = PublicKey(ACCOUNT_TO_VERIFY)\n",
" group = Group.load(default_context)\n",
"\n",
" scout = AccountScout()\n",
" report = scout.verify_account_prepared_for_group(default_context, group, root_account_key)\n",
"\n",
" print(report)\n"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.8.6"
},
"toc": {
"base_numbering": 1,
"nav_menu": {},
"number_sections": true,
"sideBar": true,
"skip_h1_title": false,
"title_cell": "Table of Contents",
"title_sidebar": "Contents",
"toc_cell": false,
"toc_position": {},
"toc_section_display": true,
"toc_window_display": true
},
"varInspector": {
"cols": {
"lenName": 16,
"lenType": 16,
"lenVar": 40
},
"kernels_config": {
"python": {
"delete_cmd_postfix": "",
"delete_cmd_prefix": "del ",
"library": "var_list.py",
"varRefreshCmd": "print(var_dic_list())"
},
"r": {
"delete_cmd_postfix": ") ",
"delete_cmd_prefix": "rm(",
"library": "var_list.r",
"varRefreshCmd": "cat(var_dic_list()) "
}
},
"types_to_exclude": [
"module",
"function",
"builtin_function_or_method",
"instance",
"_Feature"
],
"window_display": false
}
},
"nbformat": 4,
"nbformat_minor": 5
}

File diff suppressed because it is too large Load Diff

View File

@ -1,311 +0,0 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "successful-ordinary",
"metadata": {},
"source": [
"# ⚠ Warning\n",
"\n",
"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.\n",
"\n",
"[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gl/OpinionatedGeek%2Fmango-explorer/HEAD?filepath=Constants.ipynb) _🏃 To run this notebook press the ⏩ icon in the toolbar above._\n",
"\n",
"[🥭 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)"
]
},
{
"cell_type": "markdown",
"id": "living-league",
"metadata": {},
"source": [
"# 🥭 Constants\n",
"\n",
"This notebook contains some hard-coded values, all kept in one place, as well as the mechanism for loading the Mango `ids.json` file."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "spatial-korean",
"metadata": {
"jupyter": {
"source_hidden": true
}
},
"outputs": [],
"source": [
"import decimal\n",
"import json\n",
"import logging\n",
"\n",
"from solana.publickey import PublicKey\n"
]
},
{
"cell_type": "markdown",
"id": "constitutional-former",
"metadata": {},
"source": [
"## SYSTEM_PROGRAM_ADDRESS\n",
"\n",
"The Solana system program address is always 11111111111111111111111111111111."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "undefined-majority",
"metadata": {},
"outputs": [],
"source": [
"SYSTEM_PROGRAM_ADDRESS = PublicKey(\"11111111111111111111111111111111\")"
]
},
{
"cell_type": "markdown",
"id": "liberal-hamilton",
"metadata": {},
"source": [
"## SOL_MINT_ADDRESS\n",
"\n",
"The fake mint address of the SOL token. **Note:** Wrapped SOL has a different mint address - it is So11111111111111111111111111111111111111112."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "accepting-circumstances",
"metadata": {},
"outputs": [],
"source": [
"SOL_MINT_ADDRESS = PublicKey(\"So11111111111111111111111111111111111111111\")"
]
},
{
"cell_type": "markdown",
"id": "sacred-valve",
"metadata": {},
"source": [
"## SOL_DECIMALS\n",
"\n",
"The number of decimal places used to convert Lamports into SOLs."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "female-customs",
"metadata": {},
"outputs": [],
"source": [
"SOL_DECIMALS = decimal.Decimal(9)"
]
},
{
"cell_type": "markdown",
"id": "excess-tyler",
"metadata": {},
"source": [
"## SOL_DECIMAL_DIVISOR decimal\n",
"\n",
"The divisor to use to turn an integer value of SOLs from an account's `balance` into a value with the correct number of decimal places."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "divine-concord",
"metadata": {},
"outputs": [],
"source": [
"SOL_DECIMAL_DIVISOR = decimal.Decimal(10 ** SOL_DECIMALS)"
]
},
{
"cell_type": "markdown",
"id": "western-removal",
"metadata": {},
"source": [
"## NUM_TOKENS\n",
"\n",
"This is currently hard-coded to 3."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "hungry-disco",
"metadata": {},
"outputs": [],
"source": [
"NUM_TOKENS = 3\n"
]
},
{
"cell_type": "markdown",
"id": "abroad-woman",
"metadata": {},
"source": [
"## NUM_MARKETS\n",
"\n",
"There is one fewer market than tokens."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "flush-wages",
"metadata": {},
"outputs": [],
"source": [
"NUM_MARKETS = NUM_TOKENS - 1\n"
]
},
{
"cell_type": "markdown",
"id": "pleasant-convergence",
"metadata": {},
"source": [
"# WARNING_DISCLAIMER_TEXT\n",
"\n",
"This is the warning text that is output on each run of a command."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "residential-roots",
"metadata": {},
"outputs": [],
"source": [
"WARNING_DISCLAIMER_TEXT = \"\"\"\n",
"⚠ WARNING ⚠\n",
"\n",
"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.\n",
"\n",
" 🥭 Mango Markets: https://mango.markets\n",
" 📄 Documentation: https://docs.mango.markets/\n",
" 💬 Discord: https://discord.gg/67jySBhxrg\n",
" 🐦 Twitter: https://twitter.com/mangomarkets\n",
" 🚧 Github: https://github.com/blockworks-foundation\n",
" 📧 Email: mailto:hello@blockworks.foundation\n",
"\"\"\""
]
},
{
"cell_type": "markdown",
"id": "surrounded-magnet",
"metadata": {},
"source": [
"## MangoConstants\n",
"\n",
"Load all Mango Market's constants from its own `ids.json` file (retrieved from [GitHub](https://raw.githubusercontent.com/blockworks-foundation/mango-client-ts/main/src/ids.json)."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "radical-submission",
"metadata": {},
"outputs": [],
"source": [
"with open(\"ids.json\") as json_file:\n",
" MangoConstants = json.load(json_file)\n"
]
},
{
"cell_type": "markdown",
"id": "dutch-tension",
"metadata": {},
"source": [
"# 🏃 Running\n",
"\n",
"As a simple harness, just try to access some things and print them out to make sure we have loaded properly."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "settled-clock",
"metadata": {},
"outputs": [],
"source": [
"if __name__ == \"__main__\":\n",
" logging.getLogger().setLevel(logging.INFO)\n",
"\n",
" print(\"System program address:\", SYSTEM_PROGRAM_ADDRESS)\n",
" print(\"SOL decimal divisor:\", SOL_DECIMAL_DIVISOR)\n",
" print(\"Number of tokens:\", NUM_TOKENS)\n",
" print(\"Number of markets:\", NUM_MARKETS)\n",
" mango_group = MangoConstants[\"mainnet-beta\"]\n",
" print(f\"Mango program ID: {mango_group['mango_program_id']}\")\n",
" for oracle in mango_group[\"oracles\"]:\n",
" print(f\"Oracle [{oracle}]: {mango_group['oracles'][oracle]}\")\n"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.8.6"
},
"toc": {
"base_numbering": 1,
"nav_menu": {},
"number_sections": true,
"sideBar": true,
"skip_h1_title": false,
"title_cell": "Table of Contents",
"title_sidebar": "Contents",
"toc_cell": false,
"toc_position": {},
"toc_section_display": true,
"toc_window_display": true
},
"varInspector": {
"cols": {
"lenName": 16,
"lenType": 16,
"lenVar": 40
},
"kernels_config": {
"python": {
"delete_cmd_postfix": "",
"delete_cmd_prefix": "del ",
"library": "var_list.py",
"varRefreshCmd": "print(var_dic_list())"
},
"r": {
"delete_cmd_postfix": ") ",
"delete_cmd_prefix": "rm(",
"library": "var_list.r",
"varRefreshCmd": "cat(var_dic_list()) "
}
},
"types_to_exclude": [
"module",
"function",
"builtin_function_or_method",
"instance",
"_Feature"
],
"window_display": false
}
},
"nbformat": 4,
"nbformat_minor": 5
}

View File

@ -1,426 +0,0 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "affecting-slovakia",
"metadata": {},
"source": [
"# ⚠ Warning\n",
"\n",
"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.\n",
"\n",
"[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gl/OpinionatedGeek%2Fmango-explorer/HEAD?filepath=Context.ipynb) _🏃 To run this notebook press the ⏩ icon in the toolbar above._\n",
"\n",
"[🥭 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)"
]
},
{
"cell_type": "markdown",
"id": "senior-indianapolis",
"metadata": {},
"source": [
"# 🥭 Context\n",
"\n",
"This notebook contains a `Context` object to manage Solana connection configuration and Mango groups."
]
},
{
"cell_type": "markdown",
"id": "genetic-chancellor",
"metadata": {},
"source": [
"## Environment Variables\n",
"\n",
"It's possible to override the values in the `Context` variables provided. This can be easier than creating the `Context` in code or introducing dependencies and configuration.\n",
"\n",
"The following environment variables are read:\n",
"* CLUSTER (defaults to: mainnet-beta)\n",
"* CLUSTER_URL (defaults to URL for RPC server for CLUSTER defined in `ids.json`)\n",
"* GROUP_NAME (defaults to: BTC_ETH_USDT)\n"
]
},
{
"cell_type": "markdown",
"id": "light-coupon",
"metadata": {},
"source": [
"## Provided Configured Objects\n",
"\n",
"This notebook provides 3 `Context` objects, already configured and ready to use.\n",
"* default_context (uses the environment variables specified above and `ids.json` file for configuration)\n",
"* solana_context (uses the environment variables specified above and `ids.json` file for configuration but explicitly sets the RPC server to be [Solana's mainnet RPC server](https://api.mainnet-beta.solana.com))\n",
"* serum_context (uses the environment variables specified above and `ids.json` file for configuration but explicitly sets the RPC server to be [Project Serum's mainnet RPC server](https://solana-api.projectserum.com))\n",
"\n",
"Where notebooks depend on `default_context`, you can change this behaviour by adding an import line like:\n",
"```\n",
"from Context import solana_context as default_context\n",
"```\n",
"This can be useful if one of the RPC servers starts behaving oddly."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "visible-burst",
"metadata": {
"jupyter": {
"source_hidden": true
}
},
"outputs": [],
"source": [
"import logging\n",
"import os\n",
"import random\n",
"import time\n",
"import typing\n",
"\n",
"from decimal import Decimal\n",
"from solana.publickey import PublicKey\n",
"from solana.rpc.api import Client\n",
"from solana.rpc.types import MemcmpOpts, RPCError, RPCResponse\n",
"from solana.rpc.commitment import Commitment, Single\n",
"\n",
"from Constants import MangoConstants, SOL_DECIMAL_DIVISOR\n"
]
},
{
"cell_type": "markdown",
"id": "bigger-wheel",
"metadata": {},
"source": [
"## Context class"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "blank-cancellation",
"metadata": {},
"outputs": [],
"source": [
"class Context:\n",
" def __init__(self, cluster: str, cluster_url: str, program_id: PublicKey, dex_program_id: PublicKey,\n",
" group_name: str, group_id: PublicKey):\n",
" self.logger: logging.Logger = logging.getLogger(self.__class__.__name__)\n",
" self.cluster: str = cluster\n",
" self.cluster_url: str = cluster_url\n",
" self.client: Client = Client(cluster_url)\n",
" self.program_id: PublicKey = program_id\n",
" self.dex_program_id: PublicKey = dex_program_id\n",
" self.group_name: str = group_name\n",
" self.group_id: PublicKey = group_id\n",
" self.commitment: Commitment = Single\n",
" self.encoding: str = \"base64\"\n",
"\n",
" # kangda said in Discord: https://discord.com/channels/791995070613159966/836239696467591186/847816026245693451\n",
" # \"I think you are better off doing 4,8,16,20,30\"\n",
" self.retry_pauses = [Decimal(4), Decimal(8), Decimal(16), Decimal(20), Decimal(30)]\n",
"\n",
" def fetch_sol_balance(self, account_public_key: PublicKey) -> Decimal:\n",
" result = self.client.get_balance(account_public_key, commitment=self.commitment)\n",
" value = Decimal(result[\"result\"][\"value\"])\n",
" return value / SOL_DECIMAL_DIVISOR\n",
"\n",
" def fetch_program_accounts_for_owner(self, program_id: PublicKey, owner: PublicKey):\n",
" memcmp_opts = [\n",
" MemcmpOpts(offset=40, bytes=str(owner)),\n",
" ]\n",
"\n",
" return self.client.get_program_accounts(program_id, memcmp_opts=memcmp_opts, commitment=self.commitment, encoding=self.encoding)\n",
"\n",
" def unwrap_or_raise_exception(self, response: RPCResponse) -> typing.Any:\n",
" if \"error\" in response:\n",
" if response[\"error\"] is str:\n",
" message: str = typing.cast(str, response[\"error\"])\n",
" code: int = -1\n",
" else:\n",
" error: RPCError = typing.cast(RPCError, response[\"error\"])\n",
" message = error[\"message\"]\n",
" code = error[\"code\"]\n",
" raise Exception(f\"Error response from server: '{message}', code: {code}\")\n",
"\n",
" return response[\"result\"]\n",
"\n",
" def unwrap_transaction_id_or_raise_exception(self, response: RPCResponse) -> str:\n",
" return typing.cast(str, self.unwrap_or_raise_exception(response))\n",
"\n",
" def random_client_id(self) -> int:\n",
" # 9223372036854775807 is sys.maxsize for 64-bit systems, with a bit_length of 63.\n",
" # We explicitly want to use a max of 64-bits though, so we use the number instead of\n",
" # sys.maxsize, which could be lower on 32-bit systems or higher on 128-bit systems.\n",
" return random.randrange(9223372036854775807)\n",
"\n",
" @staticmethod\n",
" def _lookup_name_by_address(address: PublicKey, collection: typing.Dict[str, str]) -> typing.Optional[str]:\n",
" address_string = str(address)\n",
" for stored_name, stored_address in collection.items():\n",
" if stored_address == address_string:\n",
" return stored_name\n",
" return None\n",
"\n",
" @staticmethod\n",
" def _lookup_address_by_name(name: str, collection: typing.Dict[str, str]) -> typing.Optional[PublicKey]:\n",
" for stored_name, stored_address in collection.items():\n",
" if stored_name == name:\n",
" return PublicKey(stored_address)\n",
" return None\n",
"\n",
" def lookup_group_name(self, group_address: PublicKey) -> str:\n",
" for name, values in MangoConstants[self.cluster][\"mango_groups\"].items():\n",
" if values[\"mango_group_pk\"] == str(group_address):\n",
" return name\n",
" return \"« Unknown Group »\"\n",
"\n",
" def lookup_oracle_name(self, token_address: PublicKey) -> str:\n",
" return Context._lookup_name_by_address(token_address, MangoConstants[self.cluster][\"oracles\"]) or \"« Unknown Oracle »\"\n",
"\n",
" def wait_for_confirmation(self, transaction_id: str, max_wait_in_seconds: int = 60) -> typing.Optional[typing.Dict]:\n",
" self.logger.info(f\"Waiting up to {max_wait_in_seconds} seconds for {transaction_id}.\")\n",
" for wait in range(0, max_wait_in_seconds):\n",
" time.sleep(1)\n",
" confirmed = default_context.client.get_confirmed_transaction(transaction_id)\n",
" if confirmed[\"result\"] is not None:\n",
" self.logger.info(f\"Confirmed after {wait} seconds.\")\n",
" return confirmed[\"result\"]\n",
" self.logger.info(f\"Timed out after {wait} seconds waiting on transaction {transaction_id}.\")\n",
" return None\n",
"\n",
" def new_from_cluster(self, cluster: str) -> \"Context\":\n",
" cluster_url = MangoConstants[\"cluster_urls\"][cluster]\n",
" program_id = PublicKey(MangoConstants[cluster][\"mango_program_id\"])\n",
" dex_program_id = PublicKey(MangoConstants[cluster][\"dex_program_id\"])\n",
" group_id = PublicKey(MangoConstants[cluster][\"mango_groups\"][self.group_name][\"mango_group_pk\"])\n",
"\n",
" return Context(cluster, cluster_url, program_id, dex_program_id, self.group_name, group_id)\n",
"\n",
" def new_from_group_name(self, group_name: str) -> \"Context\":\n",
" group_id = PublicKey(MangoConstants[self.cluster][\"mango_groups\"][group_name][\"mango_group_pk\"])\n",
"\n",
" return Context(self.cluster, self.cluster_url, self.program_id, self.dex_program_id, group_name, group_id)\n",
"\n",
" def new_from_group_id(self, group_id: PublicKey) -> \"Context\":\n",
" actual_group_name = \"« Unknown Group »\"\n",
" group_id_str = str(group_id)\n",
" for group_name in MangoConstants[self.cluster][\"mango_groups\"]:\n",
" if MangoConstants[self.cluster][\"mango_groups\"][group_name][\"mango_group_pk\"] == group_id_str:\n",
" actual_group_name = group_name\n",
" break\n",
"\n",
" return Context(self.cluster, self.cluster_url, self.program_id, self.dex_program_id, actual_group_name, group_id)\n",
"\n",
" def new_from_cluster_url(self, cluster_url: str) -> \"Context\":\n",
" return Context(self.cluster, cluster_url, self.program_id, self.dex_program_id, self.group_name, self.group_id)\n",
"\n",
" @staticmethod\n",
" def from_command_line(cluster: str, cluster_url: str, program_id: PublicKey,\n",
" dex_program_id: PublicKey, group_name: str,\n",
" group_id: PublicKey) -> \"Context\":\n",
" # Here we should have values for all our parameters (because they'll either be specified\n",
" # on the command-line or will be the default_* value) but we may be in the situation where\n",
" # a group name is specified but not a group ID, and in that case we want to look up the\n",
" # group ID.\n",
" #\n",
" # In that situation, the group_name will not be default_group_name but the group_id will\n",
" # still be default_group_id. In that situation we want to override what we were passed\n",
" # as the group_id.\n",
" if (group_name != default_group_name) and (group_id == default_group_id):\n",
" group_id = PublicKey(MangoConstants[cluster][\"mango_groups\"][group_name][\"mango_group_pk\"])\n",
"\n",
" return Context(cluster, cluster_url, program_id, dex_program_id, group_name, group_id)\n",
"\n",
" @staticmethod\n",
" def from_cluster_and_group_name(cluster: str, group_name: str) -> \"Context\":\n",
" cluster_url = MangoConstants[\"cluster_urls\"][cluster]\n",
" program_id = PublicKey(MangoConstants[cluster][\"mango_program_id\"])\n",
" dex_program_id = PublicKey(MangoConstants[cluster][\"dex_program_id\"])\n",
" group_id = PublicKey(MangoConstants[cluster][\"mango_groups\"][group_name][\"mango_group_pk\"])\n",
"\n",
" return Context(cluster, cluster_url, program_id, dex_program_id, group_name, group_id)\n",
"\n",
" def __str__(self) -> str:\n",
" return f\"\"\"« Context:\n",
" Cluster: {self.cluster}\n",
" Cluster URL: {self.cluster_url}\n",
" Program ID: {self.program_id}\n",
" DEX Program ID: {self.dex_program_id}\n",
" Group Name: {self.group_name}\n",
" Group ID: {self.group_id}\n",
"»\"\"\"\n",
"\n",
" def __repr__(self) -> str:\n",
" return f\"{self}\"\n"
]
},
{
"cell_type": "markdown",
"id": "standard-genealogy",
"metadata": {},
"source": [
"## default_context object\n",
"\n",
"A default `Context` object that connects to mainnet, to save having to create one all over the place. This `Context` uses the default values in the `ids.json` file, overridden by environment variables if they're set."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "lucky-proceeding",
"metadata": {},
"outputs": [],
"source": [
"default_cluster = os.environ.get(\"CLUSTER\") or \"mainnet-beta\"\n",
"default_cluster_url = os.environ.get(\"CLUSTER_URL\") or MangoConstants[\"cluster_urls\"][default_cluster]\n",
"\n",
"default_program_id = PublicKey(MangoConstants[default_cluster][\"mango_program_id\"])\n",
"default_dex_program_id = PublicKey(MangoConstants[default_cluster][\"dex_program_id\"])\n",
"\n",
"default_group_name = os.environ.get(\"GROUP_NAME\") or \"BTC_ETH_USDT\"\n",
"default_group_id = PublicKey(MangoConstants[default_cluster][\"mango_groups\"][default_group_name][\"mango_group_pk\"])\n",
"\n",
"default_context = Context(default_cluster, default_cluster_url, default_program_id,\n",
" default_dex_program_id, default_group_name, default_group_id)"
]
},
{
"cell_type": "markdown",
"id": "necessary-forwarding",
"metadata": {},
"source": [
"## solana_context object\n",
"\n",
"A `Context` object that connects to mainnet using Solana's own https://api.mainnet-beta.solana.com server. Apart from the RPC server URL, this `Context` uses the default values in the `ids.json` file, overridden by environment variables if they're set."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "monetary-prime",
"metadata": {},
"outputs": [],
"source": [
"solana_cluster_url = \"https://api.mainnet-beta.solana.com\"\n",
"\n",
"solana_context = Context(default_cluster, solana_cluster_url, default_program_id,\n",
" default_dex_program_id, default_group_name, default_group_id)"
]
},
{
"cell_type": "markdown",
"id": "extra-crack",
"metadata": {},
"source": [
"## serum_context object\n",
"\n",
"A `Context` object that connects to mainnet using Serum's own https://solana-api.projectserum.com server. Apart from the RPC server URL, this `Context` uses the default values in the `ids.json` file, overridden by environment variables if they're set."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "documentary-butterfly",
"metadata": {},
"outputs": [],
"source": [
"serum_cluster_url = \"https://solana-api.projectserum.com\"\n",
"\n",
"serum_context = Context(default_cluster, serum_cluster_url, default_program_id,\n",
" default_dex_program_id, default_group_name, default_group_id)"
]
},
{
"cell_type": "markdown",
"id": "incident-background",
"metadata": {},
"source": [
"# 🏃 Running\n",
"\n",
"If running interactively, just print out the default Context object."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "rough-defense",
"metadata": {},
"outputs": [],
"source": [
"if __name__ == \"__main__\":\n",
" logging.getLogger().setLevel(logging.INFO)\n",
"\n",
" print(default_context)\n",
"\n",
" # Fill out your account address between the quotes below\n",
" MY_ACCOUNT_ADDRESS = \"\"\n",
" # Don't edit anything beyond here.\n",
"\n",
" if MY_ACCOUNT_ADDRESS != \"\":\n",
" account_key = PublicKey(MY_ACCOUNT_ADDRESS)\n",
" print(\"SOL balance:\", default_context.fetch_sol_balance(account_key))\n"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.8.6"
},
"toc": {
"base_numbering": 1,
"nav_menu": {},
"number_sections": true,
"sideBar": true,
"skip_h1_title": false,
"title_cell": "Table of Contents",
"title_sidebar": "Contents",
"toc_cell": false,
"toc_position": {},
"toc_section_display": true,
"toc_window_display": true
},
"varInspector": {
"cols": {
"lenName": 16,
"lenType": 16,
"lenVar": 40
},
"kernels_config": {
"python": {
"delete_cmd_postfix": "",
"delete_cmd_prefix": "del ",
"library": "var_list.py",
"varRefreshCmd": "print(var_dic_list())"
},
"r": {
"delete_cmd_postfix": ") ",
"delete_cmd_prefix": "rm(",
"library": "var_list.r",
"varRefreshCmd": "cat(var_dic_list()) "
}
},
"types_to_exclude": [
"module",
"function",
"builtin_function_or_method",
"instance",
"_Feature"
],
"window_display": false
}
},
"nbformat": 4,
"nbformat_minor": 5
}

View File

@ -1,231 +0,0 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "dominant-algeria",
"metadata": {},
"source": [
"# ⚠ Warning\n",
"\n",
"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.\n",
"\n",
"[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gl/OpinionatedGeek%2Fmango-explorer/HEAD?filepath=Decoder.ipynb) _🏃 To run this notebook press the ⏩ icon in the toolbar above._\n",
"\n",
"[🥭 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)"
]
},
{
"cell_type": "markdown",
"id": "fresh-northeast",
"metadata": {},
"source": [
"# 🥭 Decoder\n",
"\n",
"This notebook contains some useful functions for decoding base64 and base58 data."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "further-translation",
"metadata": {
"jupyter": {
"source_hidden": true
}
},
"outputs": [],
"source": [
"import base64\n",
"import base58\n",
"import logging\n",
"import typing\n",
"\n",
"from solana.publickey import PublicKey\n"
]
},
{
"cell_type": "markdown",
"id": "individual-vulnerability",
"metadata": {},
"source": [
"## decode_binary() function\n",
"\n",
"A Solana binary data structure may come back as an array with the base64 or base58 encoded data, and a text moniker saying which encoding was used.\n",
"\n",
"For example:\n",
"```\n",
"['AwAAAAAAAACCaOmpoURMK6XHelGTaFawcuQ/78/15LAemWI8jrt3SRKLy2R9i60eclDjuDS8+p/ZhvTUd9G7uQVOYCsR6+BhmqGCiO6EPYP2PQkf/VRTvw7JjXvIjPFJy06QR1Cq1WfTonHl0OjCkyEf60SD07+MFJu5pVWNFGGEO/8AiAYfduaKdnFTaZEHPcK5Eq72WWHeHg2yIbBF09kyeOhlCJwOoG8O5SgpPV8QOA64ZNV4aKroFfADg6kEy/wWCdp3fv2B8WJgAAAAANVfH3HGtjwAAQAAAAAAAADr8cwFi9UOAAEAAAAAAAAAgfFiYAAAAABo3Dbz0L0oAAEAAAAAAAAAr8K+TvCjCwABAAAAAAAAAIHxYmAAAAAA49t5tVNZhwABAAAAAAAAAAmPtcB1zC8AAQAAAAAAAABIBGiCcyaEZdNhrTyeqUY692vOzzPdHaxAxguht3JQGlkzjtd05dX9LENHkl2z1XvUbTNKZlweypNRetmH0lmQ9VYQAHqylxZVK65gEg85g27YuSyvOBZAjJyRmYU9KdCO1D+4ehdPu9dQB1yI1uh75wShdAaFn2o4qrMYwq3SQQEAAAAAAAAAAiH1PPJKAuh6oGiE35aGhUQhFi/bxgKOudpFv8HEHNCFDy1uAqR6+CTQmradxC1wyyjL+iSft+5XudJWwSdi72NJGmyK96x7Obj/AgAAAAB8RjOEdJow6r9LMhIAAAAAGkNK4CXHh5M2st7PnwAAAE33lx1h8hPFD04AAAAAAAA8YRV3Oa309B2wGwAAAAAAOIlOLmkr6+r605n+AQAAAACgmZmZmZkZAQAAAAAAAAAAMDMzMzMzMwEAAAAAAAAA25D1XcAtRzSuuyx3U+X7aE9vM1EJySU9KprgL0LMJ/vat9+SEEUZuga7O5tTUrcMDYWDg+LYaAWhSQiN2fYk7aCGAQAAAAAAgIQeAAAAAAAA8gUqAQAAAAYGBgICAAAA', 'base64']\n",
"```\n",
"Alternatively, it may just be a base58-encoded string.\n",
"\n",
"`decode_binary()` decodes the data properly based on which encoding was used."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "thermal-aircraft",
"metadata": {},
"outputs": [],
"source": [
"def decode_binary(encoded: typing.List) -> bytes:\n",
" if isinstance(encoded, str):\n",
" return base58.b58decode(encoded)\n",
" elif encoded[1] == \"base64\":\n",
" return base64.b64decode(encoded[0])\n",
" else:\n",
" return base58.b58decode(encoded[0])\n"
]
},
{
"cell_type": "markdown",
"id": "humanitarian-difference",
"metadata": {},
"source": [
"## encode_binary() function\n",
"\n",
"Inverse of `decode_binary()`, this takes a binary list and encodes it (using base 64), then returns the encoded string and the string \"base64\" in an array.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "simple-clearance",
"metadata": {},
"outputs": [],
"source": [
"def encode_binary(decoded: bytes) -> typing.List:\n",
" return [base64.b64encode(decoded), \"base64\"]\n"
]
},
{
"cell_type": "markdown",
"id": "covered-amount",
"metadata": {},
"source": [
"## encode_key() function\n",
"\n",
"Encodes a `PublicKey` in the proper way for RPC calls."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "concrete-fossil",
"metadata": {},
"outputs": [],
"source": [
"def encode_key(key: PublicKey) -> str:\n",
" return str(key)\n"
]
},
{
"cell_type": "markdown",
"id": "level-kinase",
"metadata": {},
"source": [
"## encode_int() function\n",
"\n",
"Encodes an `int` in the proper way for RPC calls."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "pediatric-finger",
"metadata": {},
"outputs": [],
"source": [
"def encode_int(value: int) -> str:\n",
" return base58.b58encode_int(value).decode('ascii')\n"
]
},
{
"cell_type": "markdown",
"id": "intelligent-watson",
"metadata": {},
"source": [
"# 🏃 Running\n",
"\n",
"A simple harness to run the above code.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "banned-profession",
"metadata": {},
"outputs": [],
"source": [
"if __name__ == \"__main__\":\n",
" logging.getLogger().setLevel(logging.INFO)\n",
"\n",
" data = decode_binary(['AwAAAAAAAACCaOmpoURMK6XHelGTaFawcuQ/78/15LAemWI8jrt3SRKLy2R9i60eclDjuDS8+p/ZhvTUd9G7uQVOYCsR6+BhmqGCiO6EPYP2PQkf/VRTvw7JjXvIjPFJy06QR1Cq1WfTonHl0OjCkyEf60SD07+MFJu5pVWNFGGEO/8AiAYfduaKdnFTaZEHPcK5Eq72WWHeHg2yIbBF09kyeOhlCJwOoG8O5SgpPV8QOA64ZNV4aKroFfADg6kEy/wWCdp3fv2B8WJgAAAAANVfH3HGtjwAAQAAAAAAAADr8cwFi9UOAAEAAAAAAAAAgfFiYAAAAABo3Dbz0L0oAAEAAAAAAAAAr8K+TvCjCwABAAAAAAAAAIHxYmAAAAAA49t5tVNZhwABAAAAAAAAAAmPtcB1zC8AAQAAAAAAAABIBGiCcyaEZdNhrTyeqUY692vOzzPdHaxAxguht3JQGlkzjtd05dX9LENHkl2z1XvUbTNKZlweypNRetmH0lmQ9VYQAHqylxZVK65gEg85g27YuSyvOBZAjJyRmYU9KdCO1D+4ehdPu9dQB1yI1uh75wShdAaFn2o4qrMYwq3SQQEAAAAAAAAAAiH1PPJKAuh6oGiE35aGhUQhFi/bxgKOudpFv8HEHNCFDy1uAqR6+CTQmradxC1wyyjL+iSft+5XudJWwSdi72NJGmyK96x7Obj/AgAAAAB8RjOEdJow6r9LMhIAAAAAGkNK4CXHh5M2st7PnwAAAE33lx1h8hPFD04AAAAAAAA8YRV3Oa309B2wGwAAAAAAOIlOLmkr6+r605n+AQAAAACgmZmZmZkZAQAAAAAAAAAAMDMzMzMzMwEAAAAAAAAA25D1XcAtRzSuuyx3U+X7aE9vM1EJySU9KprgL0LMJ/vat9+SEEUZuga7O5tTUrcMDYWDg+LYaAWhSQiN2fYk7aCGAQAAAAAAgIQeAAAAAAAA8gUqAQAAAAYGBgICAAAA', 'base64'])\n",
" print(f\"Data length (should be 744): {len(data)}\")\n"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.8.6"
},
"toc": {
"base_numbering": 1,
"nav_menu": {},
"number_sections": true,
"sideBar": true,
"skip_h1_title": false,
"title_cell": "Table of Contents",
"title_sidebar": "Contents",
"toc_cell": false,
"toc_position": {},
"toc_section_display": true,
"toc_window_display": true
},
"varInspector": {
"cols": {
"lenName": 16,
"lenType": 16,
"lenVar": 40
},
"kernels_config": {
"python": {
"delete_cmd_postfix": "",
"delete_cmd_prefix": "del ",
"library": "var_list.py",
"varRefreshCmd": "print(var_dic_list())"
},
"r": {
"delete_cmd_postfix": ") ",
"delete_cmd_prefix": "rm(",
"library": "var_list.r",
"varRefreshCmd": "cat(var_dic_list()) "
}
},
"types_to_exclude": [
"module",
"function",
"builtin_function_or_method",
"instance",
"_Feature"
],
"window_display": false
}
},
"nbformat": 4,
"nbformat_minor": 5
}

View File

@ -1,614 +0,0 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "numeric-sheep",
"metadata": {},
"source": [
"# ⚠ Warning\n",
"\n",
"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.\n",
"\n",
"[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gl/OpinionatedGeek%2Fmango-explorer/HEAD?filepath=Liquidation.ipynb) _🏃 To run this notebook press the ⏩ icon in the toolbar above._\n",
"\n",
"[🥭 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)"
]
},
{
"cell_type": "markdown",
"id": "lesser-small",
"metadata": {},
"source": [
"# 🥭 Instructions\n",
"\n",
"This notebook contains the low-level `InstructionBuilder`s that build the raw instructions to send to Solana."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "african-picking",
"metadata": {
"jupyter": {
"source_hidden": true
}
},
"outputs": [],
"source": [
"import abc\n",
"import logging\n",
"import struct\n",
"import typing\n",
"\n",
"from decimal import Decimal\n",
"from pyserum.market import Market\n",
"from solana.publickey import PublicKey\n",
"from solana.transaction import AccountMeta, TransactionInstruction\n",
"from solana.sysvar import SYSVAR_CLOCK_PUBKEY\n",
"from spl.token.constants import TOKEN_PROGRAM_ID\n",
"\n",
"from BaseModel import BasketToken, Group, MarginAccount, MarketMetadata, TokenAccount, TokenValue\n",
"from Context import Context\n",
"from Layouts import FORCE_CANCEL_ORDERS, PARTIAL_LIQUIDATE\n",
"from Wallet import Wallet\n"
]
},
{
"cell_type": "markdown",
"id": "trained-cartridge",
"metadata": {},
"source": [
"# InstructionBuilder class\n",
"\n",
"An abstract base class for all our `InstructionBuilder`s."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "apparent-plane",
"metadata": {},
"outputs": [],
"source": [
"class InstructionBuilder(metaclass=abc.ABCMeta):\n",
" def __init__(self, context: Context):\n",
" self.logger: logging.Logger = logging.getLogger(self.__class__.__name__)\n",
" self.context = context\n",
"\n",
" @abc.abstractmethod\n",
" def build(self) -> TransactionInstruction:\n",
" raise NotImplementedError(\"InstructionBuilder.build() is not implemented on the base class.\")\n",
"\n",
" def __repr__(self) -> str:\n",
" return f\"{self}\"\n"
]
},
{
"cell_type": "markdown",
"id": "daily-dependence",
"metadata": {},
"source": [
"# ForceCancelOrdersInstructionBuilder class"
]
},
{
"cell_type": "markdown",
"id": "ceramic-football",
"metadata": {},
"source": [
"## Rust Interface\n",
"\n",
"This is what the `force_cancel_orders` instruction looks like in the [Mango Rust](https://github.com/blockworks-foundation/mango/blob/master/program/src/instruction.rs) code:\n",
"```\n",
"pub fn force_cancel_orders(\n",
" program_id: &Pubkey,\n",
" mango_group_pk: &Pubkey,\n",
" liqor_pk: &Pubkey,\n",
" liqee_margin_account_acc: &Pubkey,\n",
" base_vault_pk: &Pubkey,\n",
" quote_vault_pk: &Pubkey,\n",
" spot_market_pk: &Pubkey,\n",
" bids_pk: &Pubkey,\n",
" asks_pk: &Pubkey,\n",
" signer_pk: &Pubkey,\n",
" dex_event_queue_pk: &Pubkey,\n",
" dex_base_pk: &Pubkey,\n",
" dex_quote_pk: &Pubkey,\n",
" dex_signer_pk: &Pubkey,\n",
" dex_prog_id: &Pubkey,\n",
" open_orders_pks: &[Pubkey],\n",
" oracle_pks: &[Pubkey],\n",
" limit: u8\n",
") -> Result<Instruction, ProgramError>\n",
"```"
]
},
{
"cell_type": "markdown",
"id": "empirical-ultimate",
"metadata": {},
"source": [
"## Client API call\n",
"\n",
"This is how it is built using the Mango Markets client API:\n",
"```\n",
" const keys = [\n",
" { isSigner: false, isWritable: true, pubkey: mangoGroup },\n",
" { isSigner: true, isWritable: false, pubkey: liqor },\n",
" { isSigner: false, isWritable: true, pubkey: liqeeMarginAccount },\n",
" { isSigner: false, isWritable: true, pubkey: baseVault },\n",
" { isSigner: false, isWritable: true, pubkey: quoteVault },\n",
" { isSigner: false, isWritable: true, pubkey: spotMarket },\n",
" { isSigner: false, isWritable: true, pubkey: bids },\n",
" { isSigner: false, isWritable: true, pubkey: asks },\n",
" { isSigner: false, isWritable: false, pubkey: signerKey },\n",
" { isSigner: false, isWritable: true, pubkey: dexEventQueue },\n",
" { isSigner: false, isWritable: true, pubkey: dexBaseVault },\n",
" { isSigner: false, isWritable: true, pubkey: dexQuoteVault },\n",
" { isSigner: false, isWritable: false, pubkey: dexSigner },\n",
" { isSigner: false, isWritable: false, pubkey: TOKEN_PROGRAM_ID },\n",
" { isSigner: false, isWritable: false, pubkey: dexProgramId },\n",
" { isSigner: false, isWritable: false, pubkey: SYSVAR_CLOCK_PUBKEY },\n",
" ...openOrders.map((pubkey) => ({\n",
" isSigner: false,\n",
" isWritable: true,\n",
" pubkey,\n",
" })),\n",
" ...oracles.map((pubkey) => ({\n",
" isSigner: false,\n",
" isWritable: false,\n",
" pubkey,\n",
" })),\n",
" ];\n",
"\n",
" const data = encodeMangoInstruction({ ForceCancelOrders: { limit } });\n",
" return new TransactionInstruction({ keys, data, programId });\n",
"```"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "eligible-madison",
"metadata": {},
"outputs": [],
"source": [
"class ForceCancelOrdersInstructionBuilder(InstructionBuilder):\n",
" # We can create up to a maximum of max_instructions instructions. I'm not sure of the reason \n",
" # for this threshold but it's what's in the original liquidator source code and I'm assuming\n",
" # it's there for a good reason.\n",
" max_instructions: int = 10\n",
"\n",
" # We cancel up to max_cancels_per_instruction orders with each instruction.\n",
" max_cancels_per_instruction: int = 5\n",
"\n",
" def __init__(self, context: Context, group: Group, wallet: Wallet, margin_account: MarginAccount, market_metadata: MarketMetadata, market: Market, oracles: typing.List[PublicKey], dex_signer: PublicKey):\n",
" super().__init__(context)\n",
" self.group = group\n",
" self.wallet = wallet\n",
" self.margin_account = margin_account\n",
" self.market_metadata = market_metadata\n",
" self.market = market\n",
" self.oracles = oracles\n",
" self.dex_signer = dex_signer\n",
"\n",
" def build(self) -> TransactionInstruction:\n",
" transaction = TransactionInstruction(\n",
" keys=[\n",
" AccountMeta(is_signer=False, is_writable=True, pubkey=self.group.address),\n",
" AccountMeta(is_signer=True, is_writable=False, pubkey=self.wallet.address),\n",
" AccountMeta(is_signer=False, is_writable=True, pubkey=self.margin_account.address),\n",
" AccountMeta(is_signer=False, is_writable=True, pubkey=self.market_metadata.base.vault),\n",
" AccountMeta(is_signer=False, is_writable=True, pubkey=self.market_metadata.quote.vault),\n",
" AccountMeta(is_signer=False, is_writable=True, pubkey=self.market_metadata.spot.address),\n",
" AccountMeta(is_signer=False, is_writable=True, pubkey=self.market.state.bids()),\n",
" AccountMeta(is_signer=False, is_writable=True, pubkey=self.market.state.asks()),\n",
" AccountMeta(is_signer=False, is_writable=False, pubkey=self.group.signer_key),\n",
" AccountMeta(is_signer=False, is_writable=True, pubkey=self.market.state.event_queue()),\n",
" AccountMeta(is_signer=False, is_writable=True, pubkey=self.market.state.base_vault()),\n",
" AccountMeta(is_signer=False, is_writable=True, pubkey=self.market.state.quote_vault()),\n",
" AccountMeta(is_signer=False, is_writable=False, pubkey=self.dex_signer),\n",
" AccountMeta(is_signer=False, is_writable=False, pubkey=TOKEN_PROGRAM_ID),\n",
" AccountMeta(is_signer=False, is_writable=False, pubkey=self.context.dex_program_id),\n",
" AccountMeta(is_signer=False, is_writable=False, pubkey=SYSVAR_CLOCK_PUBKEY),\n",
" *list([AccountMeta(is_signer=False, is_writable=True, pubkey=oo_address) for oo_address in self.margin_account.open_orders]),\n",
" *list([AccountMeta(is_signer=False, is_writable=False, pubkey=oracle_address) for oracle_address in self.oracles])\n",
" ],\n",
" program_id=self.context.program_id,\n",
" data=FORCE_CANCEL_ORDERS.build({\"limit\": ForceCancelOrdersInstructionBuilder.max_cancels_per_instruction})\n",
" )\n",
" self.logger.debug(f\"Built transaction: {transaction}\")\n",
" return transaction\n",
"\n",
" @staticmethod\n",
" def from_margin_account_and_market(context: Context, group: Group, wallet: Wallet, margin_account: MarginAccount, market_metadata: MarketMetadata) -> \"ForceCancelOrdersInstructionBuilder\":\n",
" market = market_metadata.fetch_market(context)\n",
" nonce = struct.pack(\"<Q\", market.state.vault_signer_nonce())\n",
" dex_signer = PublicKey.create_program_address([bytes(market_metadata.spot.address), nonce], context.dex_program_id)\n",
" oracles = list([mkt.oracle for mkt in group.markets])\n",
"\n",
" return ForceCancelOrdersInstructionBuilder(context, group, wallet, margin_account, market_metadata, market, oracles, dex_signer)\n",
"\n",
" @classmethod\n",
" def multiple_instructions_from_margin_account_and_market(cls, context: Context, group: Group, wallet: Wallet, margin_account: MarginAccount, market_metadata: MarketMetadata, at_least_this_many_cancellations: int) -> typing.List[\"ForceCancelOrdersInstructionBuilder\"]:\n",
" logger: logging.Logger = logging.getLogger(cls.__name__)\n",
"\n",
" # We cancel up to max_cancels_per_instruction orders with each instruction, but if\n",
" # we have more than cancel_limit we create more instructions (each handling up to\n",
" # 5 orders)\n",
" calculated_instruction_count = int(at_least_this_many_cancellations / ForceCancelOrdersInstructionBuilder.max_cancels_per_instruction) + 1\n",
"\n",
" # We create a maximum of max_instructions instructions.\n",
" instruction_count = min(calculated_instruction_count, ForceCancelOrdersInstructionBuilder.max_instructions)\n",
"\n",
" instructions: typing.List[ForceCancelOrdersInstructionBuilder] = []\n",
" for counter in range(instruction_count):\n",
" instructions += [ForceCancelOrdersInstructionBuilder.from_margin_account_and_market(context, group, wallet, margin_account, market_metadata)]\n",
"\n",
" logger.debug(f\"Built {len(instructions)} transaction(s).\")\n",
"\n",
" return instructions\n",
"\n",
" def __str__(self) -> str:\n",
" # Print the members out using the Rust parameter order and names.\n",
" return f\"\"\"« ForceCancelOrdersInstructionBuilder:\n",
" program_id: &Pubkey: {self.context.program_id},\n",
" mango_group_pk: &Pubkey: {self.group.address},\n",
" liqor_pk: &Pubkey: {self.wallet.address},\n",
" liqee_margin_account_acc: &Pubkey: {self.margin_account.address},\n",
" base_vault_pk: &Pubkey: {self.market_metadata.base.vault},\n",
" quote_vault_pk: &Pubkey: {self.market_metadata.quote.vault},\n",
" spot_market_pk: &Pubkey: {self.market_metadata.spot.address},\n",
" bids_pk: &Pubkey: {self.market.state.bids()},\n",
" asks_pk: &Pubkey: {self.market.state.asks()},\n",
" signer_pk: &Pubkey: {self.group.signer_key},\n",
" dex_event_queue_pk: &Pubkey: {self.market.state.event_queue()},\n",
" dex_base_pk: &Pubkey: {self.market.state.base_vault()},\n",
" dex_quote_pk: &Pubkey: {self.market.state.quote_vault()},\n",
" dex_signer_pk: &Pubkey: {self.dex_signer},\n",
" dex_prog_id: &Pubkey: {self.context.dex_program_id},\n",
" open_orders_pks: &[Pubkey]: {self.margin_account.open_orders},\n",
" oracle_pks: &[Pubkey]: {self.oracles},\n",
" limit: u8: {ForceCancelOrdersInstructionBuilder.max_cancels_per_instruction}\n",
"»\"\"\"\n"
]
},
{
"cell_type": "markdown",
"id": "expanded-separate",
"metadata": {},
"source": [
"# LiquidateInstructionBuilder class\n",
"\n",
"This is the `Instruction` we send to Solana to perform the (partial) liquidation.\n",
"\n",
"We take care to pass the proper high-level parameters to the `LiquidateInstructionBuilder` constructor so that `build_transaction()` is straightforward. That tends to push complexities to `from_margin_account_and_market()` though.\n"
]
},
{
"cell_type": "markdown",
"id": "stupid-arrest",
"metadata": {},
"source": [
"## Rust Interface\n",
"\n",
"This is what the `partial_liquidate` instruction looks like in the [Mango Rust](https://github.com/blockworks-foundation/mango/blob/master/program/src/instruction.rs) code:\n",
"```\n",
"/// Take over a MarginAccount that is below init_coll_ratio by depositing funds\n",
"///\n",
"/// Accounts expected by this instruction (10 + 2 * NUM_MARKETS):\n",
"///\n",
"/// 0. `[writable]` mango_group_acc - MangoGroup that this margin account is for\n",
"/// 1. `[signer]` liqor_acc - liquidator's solana account\n",
"/// 2. `[writable]` liqor_in_token_acc - liquidator's token account to deposit\n",
"/// 3. `[writable]` liqor_out_token_acc - liquidator's token account to withdraw into\n",
"/// 4. `[writable]` liqee_margin_account_acc - MarginAccount of liquidatee\n",
"/// 5. `[writable]` in_vault_acc - Mango vault of in_token\n",
"/// 6. `[writable]` out_vault_acc - Mango vault of out_token\n",
"/// 7. `[]` signer_acc\n",
"/// 8. `[]` token_prog_acc - Token program id\n",
"/// 9. `[]` clock_acc - Clock sysvar account\n",
"/// 10..10+NUM_MARKETS `[]` open_orders_accs - open orders for each of the spot market\n",
"/// 10+NUM_MARKETS..10+2*NUM_MARKETS `[]`\n",
"/// oracle_accs - flux aggregator feed accounts\n",
"```\n",
"\n",
"```\n",
"pub fn partial_liquidate(\n",
" program_id: &Pubkey,\n",
" mango_group_pk: &Pubkey,\n",
" liqor_pk: &Pubkey,\n",
" liqor_in_token_pk: &Pubkey,\n",
" liqor_out_token_pk: &Pubkey,\n",
" liqee_margin_account_acc: &Pubkey,\n",
" in_vault_pk: &Pubkey,\n",
" out_vault_pk: &Pubkey,\n",
" signer_pk: &Pubkey,\n",
" open_orders_pks: &[Pubkey],\n",
" oracle_pks: &[Pubkey],\n",
" max_deposit: u64\n",
") -> Result<Instruction, ProgramError>\n",
"```\n"
]
},
{
"cell_type": "markdown",
"id": "identical-november",
"metadata": {},
"source": [
"## Client API call\n",
"\n",
"This is how it is built using the Mango Markets client API:\n",
"```\n",
" const keys = [\n",
" { isSigner: false, isWritable: true, pubkey: mangoGroup },\n",
" { isSigner: true, isWritable: false, pubkey: liqor },\n",
" { isSigner: false, isWritable: true, pubkey: liqorInTokenWallet },\n",
" { isSigner: false, isWritable: true, pubkey: liqorOutTokenWallet },\n",
" { isSigner: false, isWritable: true, pubkey: liqeeMarginAccount },\n",
" { isSigner: false, isWritable: true, pubkey: inTokenVault },\n",
" { isSigner: false, isWritable: true, pubkey: outTokenVault },\n",
" { isSigner: false, isWritable: false, pubkey: signerKey },\n",
" { isSigner: false, isWritable: false, pubkey: TOKEN_PROGRAM_ID },\n",
" { isSigner: false, isWritable: false, pubkey: SYSVAR_CLOCK_PUBKEY },\n",
" ...openOrders.map((pubkey) => ({\n",
" isSigner: false,\n",
" isWritable: false,\n",
" pubkey,\n",
" })),\n",
" ...oracles.map((pubkey) => ({\n",
" isSigner: false,\n",
" isWritable: false,\n",
" pubkey,\n",
" })),\n",
" ];\n",
" const data = encodeMangoInstruction({ PartialLiquidate: { maxDeposit } });\n",
"\n",
" return new TransactionInstruction({ keys, data, programId });\n",
"```"
]
},
{
"cell_type": "markdown",
"id": "logical-burning",
"metadata": {},
"source": [
"## from_margin_account_and_market() function\n",
"\n",
"`from_margin_account_and_market()` merits a bit of explaining.\n",
"\n",
"`from_margin_account_and_market()` takes (among other things) a `Wallet` and a `MarginAccount`. The idea is that the `MarginAccount` has some assets in one token, and some liabilities in some different token.\n",
"\n",
"To liquidate the account, we want to:\n",
"* supply tokens from the `Wallet` in the token currency that has the greatest liability value in the `MarginAccount`\n",
"* receive tokens in the `Wallet` in the token currency that has the greatest asset value in the `MarginAccount`\n",
"\n",
"So we calculate the token currencies from the largest liabilities and assets in the `MarginAccount`, but we use those token types to get the correct `Wallet` accounts.\n",
"* `input_token` is the `BasketToken` of the currency the `Wallet` is _paying_ and the `MarginAccount` is _receiving_ to pay off its largest liability.\n",
"* `output_token` is the `BasketToken` of the currency the `Wallet` is _receiving_ and the `MarginAccount` is _paying_ from its largest asset.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "pending-services",
"metadata": {},
"outputs": [],
"source": [
"class LiquidateInstructionBuilder(InstructionBuilder):\n",
" def __init__(self, context: Context, group: Group, wallet: Wallet, margin_account: MarginAccount, oracles: typing.List[PublicKey], input_token: BasketToken, output_token: BasketToken, wallet_input_token_account: TokenAccount, wallet_output_token_account: TokenAccount, maximum_input_amount: Decimal):\n",
" super().__init__(context)\n",
" self.group: Group = group\n",
" self.wallet: Wallet = wallet\n",
" self.margin_account: MarginAccount = margin_account\n",
" self.oracles: typing.List[PublicKey] = oracles\n",
" self.input_token: BasketToken = input_token\n",
" self.output_token: BasketToken = output_token\n",
" self.wallet_input_token_account: TokenAccount = wallet_input_token_account\n",
" self.wallet_output_token_account: TokenAccount = wallet_output_token_account\n",
" self.maximum_input_amount: Decimal = maximum_input_amount\n",
"\n",
" def build(self) -> TransactionInstruction:\n",
" transaction = TransactionInstruction(\n",
" keys=[\n",
" AccountMeta(is_signer=False, is_writable=True, pubkey=self.group.address),\n",
" AccountMeta(is_signer=True, is_writable=False, pubkey=self.wallet.address),\n",
" AccountMeta(is_signer=False, is_writable=True, pubkey=self.wallet_input_token_account.address),\n",
" AccountMeta(is_signer=False, is_writable=True, pubkey=self.wallet_output_token_account.address),\n",
" AccountMeta(is_signer=False, is_writable=True, pubkey=self.margin_account.address),\n",
" AccountMeta(is_signer=False, is_writable=True, pubkey=self.input_token.vault),\n",
" AccountMeta(is_signer=False, is_writable=True, pubkey=self.output_token.vault),\n",
" AccountMeta(is_signer=False, is_writable=False, pubkey=self.group.signer_key),\n",
" AccountMeta(is_signer=False, is_writable=False, pubkey=TOKEN_PROGRAM_ID),\n",
" AccountMeta(is_signer=False, is_writable=False, pubkey=SYSVAR_CLOCK_PUBKEY),\n",
" *list([AccountMeta(is_signer=False, is_writable=True, pubkey=oo_address) for oo_address in self.margin_account.open_orders]),\n",
" *list([AccountMeta(is_signer=False, is_writable=False, pubkey=oracle_address) for oracle_address in self.oracles])\n",
" ],\n",
" program_id=self.context.program_id,\n",
" data=PARTIAL_LIQUIDATE.build({\"max_deposit\": int(self.maximum_input_amount)})\n",
" )\n",
" self.logger.debug(f\"Built transaction: {transaction}\")\n",
" return transaction\n",
"\n",
" @classmethod\n",
" def from_margin_account_and_market(cls, context: Context, group: Group, wallet: Wallet, margin_account: MarginAccount, prices: typing.List[TokenValue]) -> typing.Optional[\"LiquidateInstructionBuilder\"]:\n",
" logger: logging.Logger = logging.getLogger(cls.__name__)\n",
"\n",
" oracles = list([mkt.oracle for mkt in group.markets])\n",
"\n",
" balance_sheets = margin_account.get_priced_balance_sheets(group, prices)\n",
"\n",
" sorted_by_assets = sorted(balance_sheets, key=lambda sheet: sheet.assets, reverse=True)\n",
" sorted_by_liabilities = sorted(balance_sheets, key=lambda sheet: sheet.liabilities, reverse=True)\n",
"\n",
" most_assets = sorted_by_assets[0]\n",
" most_liabilities = sorted_by_liabilities[0]\n",
" if most_assets.token == most_liabilities.token:\n",
" # If there's a weirdness where the account with the biggest assets is also the one\n",
" # with the biggest liabilities, pick the next-best one by assets.\n",
" logger.info(f\"Switching asset token from {most_assets.token.name} to {sorted_by_assets[1].token.name} because {most_liabilities.token.name} is the token with most liabilities.\")\n",
" most_assets = sorted_by_assets[1]\n",
"\n",
" logger.info(f\"Most assets: {most_assets}\")\n",
" logger.info(f\"Most liabilities: {most_liabilities}\")\n",
"\n",
" most_assets_basket_token = BasketToken.find_by_token(group.basket_tokens, most_assets.token)\n",
" most_liabilities_basket_token = BasketToken.find_by_token(group.basket_tokens, most_liabilities.token)\n",
" logger.info(f\"Most assets basket token: {most_assets_basket_token}\")\n",
" logger.info(f\"Most liabilities basket token: {most_liabilities_basket_token}\")\n",
"\n",
" if most_assets.value == Decimal(0):\n",
" logger.warning(f\"Margin account {margin_account.address} has no assets to take.\")\n",
" return None\n",
"\n",
" if most_liabilities.value == Decimal(0):\n",
" logger.warning(f\"Margin account {margin_account.address} has no liabilities to fund.\")\n",
" return None\n",
"\n",
" wallet_input_token_account = TokenAccount.fetch_largest_for_owner_and_token(context, wallet.address, most_liabilities.token)\n",
" if wallet_input_token_account is None:\n",
" raise Exception(f\"Could not load wallet input token account for mint '{most_liabilities.token.mint}'\")\n",
"\n",
" if wallet_input_token_account.amount == Decimal(0):\n",
" logger.warning(f\"Wallet token account {wallet_input_token_account.address} has no tokens to send that could fund a liquidation.\")\n",
" return None\n",
"\n",
" wallet_output_token_account = TokenAccount.fetch_largest_for_owner_and_token(context, wallet.address, most_assets.token)\n",
" if wallet_output_token_account is None:\n",
" raise Exception(f\"Could not load wallet output token account for mint '{most_assets.token.mint}'\")\n",
"\n",
" return LiquidateInstructionBuilder(context, group, wallet, margin_account, oracles,\n",
" most_liabilities_basket_token, most_assets_basket_token,\n",
" wallet_input_token_account,\n",
" wallet_output_token_account,\n",
" wallet_input_token_account.amount)\n",
"\n",
" def __str__(self) -> str:\n",
" # Print the members out using the Rust parameter order and names.\n",
" return f\"\"\"« LiquidateInstructionBuilder:\n",
" program_id: &Pubkey: {self.context.program_id},\n",
" mango_group_pk: &Pubkey: {self.group.address},\n",
" liqor_pk: &Pubkey: {self.wallet.address},\n",
" liqor_in_token_pk: &Pubkey: {self.wallet_input_token_account.address},\n",
" liqor_out_token_pk: &Pubkey: {self.wallet_output_token_account.address},\n",
" liqee_margin_account_acc: &Pubkey: {self.margin_account.address},\n",
" in_vault_pk: &Pubkey: {self.input_token.vault},\n",
" out_vault_pk: &Pubkey: {self.output_token.vault},\n",
" signer_pk: &Pubkey: {self.group.signer_key},\n",
" open_orders_pks: &[Pubkey]: {self.margin_account.open_orders},\n",
" oracle_pks: &[Pubkey]: {self.oracles},\n",
" max_deposit: u64: : {self.maximum_input_amount}\n",
"»\"\"\"\n"
]
},
{
"cell_type": "markdown",
"id": "retired-bundle",
"metadata": {},
"source": [
"# 🏃 Running"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "wrong-rebel",
"metadata": {},
"outputs": [],
"source": [
"if __name__ == \"__main__\":\n",
" logging.getLogger().setLevel(logging.INFO)\n",
"\n",
" from Wallet import default_wallet\n",
" if default_wallet is None:\n",
" print(\"No default wallet file available.\")\n",
" else:\n",
" # Build and print a ForceCancelOrdersInstructionBuilder and LiquidateInstructionBuilder\n",
" # for the loaded Wallet and the margin account for the loaded wallet.\n",
" #\n",
" # It doesn't make a lot of sense to do this in real life, but it should load and show\n",
" # the proper values when printing.\n",
" from Context import default_context\n",
"\n",
" group = Group.load(default_context)\n",
" my_margin_accounts = MarginAccount.load_all_for_owner(default_context, default_wallet.address, group)\n",
" margin_account = my_margin_accounts[0]\n",
" market_metadata = group.markets[0]\n",
"\n",
" force_cancel = ForceCancelOrdersInstructionBuilder.from_margin_account_and_market(default_context, group, default_wallet, margin_account, market_metadata)\n",
" force_cancel_instruction = force_cancel.build()\n",
" print(\"ForceCancelOrdersInstruction\", force_cancel_instruction, \"Data:\", \" \".join(f\"{x:02x}\" for x in force_cancel_instruction.data))\n",
"\n",
" prices = group.fetch_token_prices()\n",
"\n",
" liquidate = LiquidateInstructionBuilder.from_margin_account_and_market(default_context, group, default_wallet, margin_account, prices)\n",
" if liquidate is not None:\n",
" liquidate_instruction = liquidate.build()\n",
" print(\"LiquidateInstruction\", liquidate_instruction, \"Data:\", \" \".join(f\"{x:02x}\" for x in liquidate_instruction.data))\n"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.8.6"
},
"toc": {
"base_numbering": 1,
"nav_menu": {},
"number_sections": true,
"sideBar": true,
"skip_h1_title": false,
"title_cell": "Table of Contents",
"title_sidebar": "Contents",
"toc_cell": false,
"toc_position": {},
"toc_section_display": true,
"toc_window_display": true
},
"varInspector": {
"cols": {
"lenName": 16,
"lenType": 16,
"lenVar": 40
},
"kernels_config": {
"python": {
"delete_cmd_postfix": "",
"delete_cmd_prefix": "del ",
"library": "var_list.py",
"varRefreshCmd": "print(var_dic_list())"
},
"r": {
"delete_cmd_postfix": ") ",
"delete_cmd_prefix": "rm(",
"library": "var_list.r",
"varRefreshCmd": "cat(var_dic_list()) "
}
},
"types_to_exclude": [
"module",
"function",
"builtin_function_or_method",
"instance",
"_Feature"
],
"window_display": false
}
},
"nbformat": 4,
"nbformat_minor": 5
}

View File

@ -2,7 +2,7 @@
"cells": [
{
"cell_type": "markdown",
"id": "lucky-arlington",
"id": "alpine-terrain",
"metadata": {},
"source": [
"# ⚠ Warning\n",
@ -16,7 +16,7 @@
},
{
"cell_type": "markdown",
"id": "wired-eclipse",
"id": "compressed-contractor",
"metadata": {},
"source": [
"# 🥭 Layouts\n",
@ -35,12 +35,8 @@
{
"cell_type": "code",
"execution_count": null,
"id": "bigger-origin",
"metadata": {
"jupyter": {
"source_hidden": true
}
},
"id": "developing-indonesia",
"metadata": {},
"outputs": [],
"source": [
"import construct\n",
@ -54,7 +50,7 @@
},
{
"cell_type": "markdown",
"id": "military-turkish",
"id": "fifteen-plymouth",
"metadata": {},
"source": [
"# Adapters\n",
@ -64,7 +60,7 @@
},
{
"cell_type": "markdown",
"id": "baking-behavior",
"id": "decent-sunglasses",
"metadata": {},
"source": [
"## DecimalAdapter class\n",
@ -75,7 +71,7 @@
{
"cell_type": "code",
"execution_count": null,
"id": "supreme-center",
"id": "acute-treasure",
"metadata": {},
"outputs": [],
"source": [
@ -93,7 +89,7 @@
},
{
"cell_type": "markdown",
"id": "interesting-chosen",
"id": "boxed-grant",
"metadata": {},
"source": [
"## FloatAdapter class\n",
@ -115,7 +111,7 @@
{
"cell_type": "code",
"execution_count": null,
"id": "orange-academy",
"id": "japanese-consumption",
"metadata": {},
"outputs": [],
"source": [
@ -142,7 +138,7 @@
},
{
"cell_type": "markdown",
"id": "selective-scholar",
"id": "chinese-soundtrack",
"metadata": {},
"source": [
"## PublicKeyAdapter\n",
@ -153,7 +149,7 @@
{
"cell_type": "code",
"execution_count": null,
"id": "tracked-tunnel",
"id": "controlling-mother",
"metadata": {},
"outputs": [],
"source": [
@ -172,7 +168,7 @@
},
{
"cell_type": "markdown",
"id": "reliable-international",
"id": "endangered-scanning",
"metadata": {},
"source": [
"## DatetimeAdapter\n",
@ -183,7 +179,7 @@
{
"cell_type": "code",
"execution_count": null,
"id": "connected-potato",
"id": "deluxe-range",
"metadata": {},
"outputs": [],
"source": [
@ -200,7 +196,7 @@
},
{
"cell_type": "markdown",
"id": "studied-findings",
"id": "enormous-fundamentals",
"metadata": {},
"source": [
"# Layout Structs"
@ -208,7 +204,7 @@
},
{
"cell_type": "markdown",
"id": "handed-uruguay",
"id": "hollow-peace",
"metadata": {},
"source": [
"## SERUM_ACCOUNT_FLAGS\n",
@ -235,7 +231,7 @@
{
"cell_type": "code",
"execution_count": null,
"id": "different-slope",
"id": "minor-pierre",
"metadata": {},
"outputs": [],
"source": [
@ -256,7 +252,7 @@
},
{
"cell_type": "markdown",
"id": "revised-clinic",
"id": "premier-raise",
"metadata": {},
"source": [
"## MANGO_ACCOUNT_FLAGS\n",
@ -281,7 +277,7 @@
{
"cell_type": "code",
"execution_count": null,
"id": "molecular-still",
"id": "special-medicare",
"metadata": {},
"outputs": [],
"source": [
@ -298,7 +294,7 @@
},
{
"cell_type": "markdown",
"id": "saved-might",
"id": "narrow-collection",
"metadata": {},
"source": [
"## INDEX\n",
@ -318,7 +314,7 @@
{
"cell_type": "code",
"execution_count": null,
"id": "breathing-metabolism",
"id": "operational-implementation",
"metadata": {},
"outputs": [],
"source": [
@ -331,7 +327,7 @@
},
{
"cell_type": "markdown",
"id": "interested-advancement",
"id": "looking-clarity",
"metadata": {},
"source": [
"## AGGREGATOR_CONFIG\n",
@ -367,7 +363,7 @@
{
"cell_type": "code",
"execution_count": null,
"id": "concrete-rainbow",
"id": "intermediate-insured",
"metadata": {},
"outputs": [],
"source": [
@ -384,7 +380,7 @@
},
{
"cell_type": "markdown",
"id": "fresh-expression",
"id": "impressed-jackson",
"metadata": {},
"source": [
"## ROUND\n",
@ -403,7 +399,7 @@
{
"cell_type": "code",
"execution_count": null,
"id": "ultimate-multimedia",
"id": "excellent-sunset",
"metadata": {},
"outputs": [],
"source": [
@ -416,7 +412,7 @@
},
{
"cell_type": "markdown",
"id": "dying-prescription",
"id": "dress-champion",
"metadata": {},
"source": [
"## ANSWER\n",
@ -436,7 +432,7 @@
{
"cell_type": "code",
"execution_count": null,
"id": "surgical-indie",
"id": "short-sewing",
"metadata": {},
"outputs": [],
"source": [
@ -450,7 +446,7 @@
},
{
"cell_type": "markdown",
"id": "complete-reproduction",
"id": "blind-weight",
"metadata": {},
"source": [
"## AGGREGATOR\n",
@ -477,7 +473,7 @@
{
"cell_type": "code",
"execution_count": null,
"id": "progressive-multiple",
"id": "verbal-subscriber",
"metadata": {},
"outputs": [],
"source": [
@ -494,7 +490,7 @@
},
{
"cell_type": "markdown",
"id": "revolutionary-toilet",
"id": "black-bacteria",
"metadata": {},
"source": [
"## GROUP_V1\n",
@ -544,7 +540,7 @@
{
"cell_type": "code",
"execution_count": null,
"id": "billion-headline",
"id": "changing-story",
"metadata": {},
"outputs": [],
"source": [
@ -576,7 +572,7 @@
},
{
"cell_type": "markdown",
"id": "different-phrase",
"id": "wicked-scotland",
"metadata": {},
"source": [
"## GROUP_V2\n",
@ -629,7 +625,7 @@
{
"cell_type": "code",
"execution_count": null,
"id": "requested-norfolk",
"id": "headed-windsor",
"metadata": {},
"outputs": [],
"source": [
@ -661,7 +657,7 @@
},
{
"cell_type": "markdown",
"id": "curious-atmosphere",
"id": "surgical-proceeding",
"metadata": {},
"source": [
"## TOKEN_ACCOUNT"
@ -670,7 +666,7 @@
{
"cell_type": "code",
"execution_count": null,
"id": "solar-victorian",
"id": "least-replication",
"metadata": {},
"outputs": [],
"source": [
@ -684,7 +680,7 @@
},
{
"cell_type": "markdown",
"id": "meaning-moses",
"id": "upper-persian",
"metadata": {},
"source": [
"## OPEN_ORDERS\n",
@ -695,7 +691,7 @@
{
"cell_type": "code",
"execution_count": null,
"id": "structured-jumping",
"id": "supposed-purpose",
"metadata": {},
"outputs": [],
"source": [
@ -719,7 +715,7 @@
},
{
"cell_type": "markdown",
"id": "intense-packaging",
"id": "fatty-dublin",
"metadata": {},
"source": [
"## MARGIN_ACCOUNT_V1\n",
@ -751,7 +747,7 @@
{
"cell_type": "code",
"execution_count": null,
"id": "refined-asset",
"id": "centered-metadata",
"metadata": {},
"outputs": [],
"source": [
@ -771,7 +767,7 @@
},
{
"cell_type": "markdown",
"id": "brave-fluid",
"id": "enabling-london",
"metadata": {},
"source": [
"## MARGIN_ACCOUNT_V2\n",
@ -803,7 +799,7 @@
{
"cell_type": "code",
"execution_count": null,
"id": "forbidden-italy",
"id": "palestinian-startup",
"metadata": {},
"outputs": [],
"source": [
@ -824,7 +820,7 @@
},
{
"cell_type": "markdown",
"id": "buried-mills",
"id": "scenic-honey",
"metadata": {},
"source": [
"## build_margin_account_parser_for_num_tokens() function\n",
@ -835,7 +831,7 @@
{
"cell_type": "code",
"execution_count": null,
"id": "danish-mainland",
"id": "dominican-wales",
"metadata": {},
"outputs": [],
"source": [
@ -855,7 +851,7 @@
},
{
"cell_type": "markdown",
"id": "dietary-certification",
"id": "certified-sympathy",
"metadata": {},
"source": [
"## build_margin_account_parser_for_length() function\n",
@ -868,7 +864,7 @@
{
"cell_type": "code",
"execution_count": null,
"id": "capable-compiler",
"id": "received-humanity",
"metadata": {},
"outputs": [],
"source": [
@ -886,7 +882,7 @@
},
{
"cell_type": "markdown",
"id": "dried-batch",
"id": "appointed-tennis",
"metadata": {},
"source": [
"# Instruction Structs"
@ -894,7 +890,7 @@
},
{
"cell_type": "markdown",
"id": "neutral-ambassador",
"id": "hollywood-import",
"metadata": {},
"source": [
"## MANGO_INSTRUCTION_VARIANT_FINDER\n",
@ -907,7 +903,7 @@
{
"cell_type": "code",
"execution_count": null,
"id": "sized-banana",
"id": "apparent-offer",
"metadata": {},
"outputs": [],
"source": [
@ -918,7 +914,7 @@
},
{
"cell_type": "markdown",
"id": "floating-share",
"id": "superior-version",
"metadata": {},
"source": [
"## Variant 0: INIT_MANGO_GROUP\n",
@ -939,7 +935,7 @@
{
"cell_type": "code",
"execution_count": null,
"id": "moved-catering",
"id": "russian-study",
"metadata": {},
"outputs": [],
"source": [
@ -954,7 +950,7 @@
},
{
"cell_type": "markdown",
"id": "assumed-custom",
"id": "weighted-neighbor",
"metadata": {},
"source": [
"## Variant 1: INIT_MARGIN_ACCOUNT\n",
@ -970,7 +966,7 @@
{
"cell_type": "code",
"execution_count": null,
"id": "stainless-exception",
"id": "certain-baker",
"metadata": {},
"outputs": [],
"source": [
@ -981,7 +977,7 @@
},
{
"cell_type": "markdown",
"id": "developing-mother",
"id": "second-bottom",
"metadata": {},
"source": [
"## Variant 2: DEPOSIT\n",
@ -999,7 +995,7 @@
{
"cell_type": "code",
"execution_count": null,
"id": "variable-frontier",
"id": "royal-repository",
"metadata": {},
"outputs": [],
"source": [
@ -1011,7 +1007,7 @@
},
{
"cell_type": "markdown",
"id": "seasonal-cuisine",
"id": "communist-private",
"metadata": {},
"source": [
"## Variant 3: WITHDRAW\n",
@ -1029,7 +1025,7 @@
{
"cell_type": "code",
"execution_count": null,
"id": "exempt-private",
"id": "spread-spencer",
"metadata": {},
"outputs": [],
"source": [
@ -1041,7 +1037,7 @@
},
{
"cell_type": "markdown",
"id": "spiritual-visit",
"id": "valid-representation",
"metadata": {},
"source": [
"## Variant 4: BORROW\n",
@ -1060,7 +1056,7 @@
{
"cell_type": "code",
"execution_count": null,
"id": "civilian-handy",
"id": "impossible-logging",
"metadata": {},
"outputs": [],
"source": [
@ -1073,7 +1069,7 @@
},
{
"cell_type": "markdown",
"id": "duplicate-lighting",
"id": "former-transparency",
"metadata": {},
"source": [
"## Variant 5: SETTLE_BORROW\n",
@ -1092,7 +1088,7 @@
{
"cell_type": "code",
"execution_count": null,
"id": "realistic-paint",
"id": "sudden-bikini",
"metadata": {},
"outputs": [],
"source": [
@ -1105,7 +1101,7 @@
},
{
"cell_type": "markdown",
"id": "surprising-uncertainty",
"id": "measured-diving",
"metadata": {},
"source": [
"## Variant 6: LIQUIDATE\n",
@ -1124,7 +1120,7 @@
{
"cell_type": "code",
"execution_count": null,
"id": "tested-grant",
"id": "public-relay",
"metadata": {},
"outputs": [],
"source": [
@ -1137,7 +1133,7 @@
},
{
"cell_type": "markdown",
"id": "bibliographic-shanghai",
"id": "limited-hygiene",
"metadata": {},
"source": [
"## Variant 7: DEPOSIT_SRM\n",
@ -1159,7 +1155,7 @@
{
"cell_type": "code",
"execution_count": null,
"id": "administrative-semiconductor",
"id": "apart-religion",
"metadata": {},
"outputs": [],
"source": [
@ -1171,7 +1167,7 @@
},
{
"cell_type": "markdown",
"id": "acting-louis",
"id": "departmental-context",
"metadata": {},
"source": [
"## Variant 8: WITHDRAW_SRM\n",
@ -1189,7 +1185,7 @@
{
"cell_type": "code",
"execution_count": null,
"id": "transparent-heath",
"id": "mexican-budapest",
"metadata": {},
"outputs": [],
"source": [
@ -1201,7 +1197,7 @@
},
{
"cell_type": "markdown",
"id": "digital-skill",
"id": "incomplete-wound",
"metadata": {},
"source": [
"## Variant 9: PLACE_ORDER\n",
@ -1219,7 +1215,7 @@
{
"cell_type": "code",
"execution_count": null,
"id": "powered-professor",
"id": "expanded-resolution",
"metadata": {},
"outputs": [],
"source": [
@ -1231,7 +1227,7 @@
},
{
"cell_type": "markdown",
"id": "accessory-disability",
"id": "nearby-defensive",
"metadata": {},
"source": [
"## Variant 10: SETTLE_FUNDS\n",
@ -1247,7 +1243,7 @@
{
"cell_type": "code",
"execution_count": null,
"id": "metallic-opposition",
"id": "packed-crowd",
"metadata": {},
"outputs": [],
"source": [
@ -1258,7 +1254,7 @@
},
{
"cell_type": "markdown",
"id": "applicable-penny",
"id": "periodic-arkansas",
"metadata": {},
"source": [
"## Variant 11: CANCEL_ORDER\n",
@ -1276,7 +1272,7 @@
{
"cell_type": "code",
"execution_count": null,
"id": "curious-utility",
"id": "accepted-reduction",
"metadata": {},
"outputs": [],
"source": [
@ -1288,7 +1284,7 @@
},
{
"cell_type": "markdown",
"id": "certain-strike",
"id": "empty-matter",
"metadata": {},
"source": [
"## Variant 12: CANCEL_ORDER_BY_CLIENT_ID\n",
@ -1306,7 +1302,7 @@
{
"cell_type": "code",
"execution_count": null,
"id": "suspended-edition",
"id": "recent-gardening",
"metadata": {},
"outputs": [],
"source": [
@ -1318,7 +1314,7 @@
},
{
"cell_type": "markdown",
"id": "middle-cherry",
"id": "dirty-decimal",
"metadata": {},
"source": [
"## Variant 13: CHANGE_BORROW_LIMIT\n",
@ -1339,7 +1335,7 @@
{
"cell_type": "code",
"execution_count": null,
"id": "single-nature",
"id": "ethical-mason",
"metadata": {},
"outputs": [],
"source": [
@ -1352,7 +1348,7 @@
},
{
"cell_type": "markdown",
"id": "divided-colorado",
"id": "offensive-tract",
"metadata": {},
"source": [
"## Variant 14: PLACE_AND_SETTLE\n",
@ -1370,7 +1366,7 @@
{
"cell_type": "code",
"execution_count": null,
"id": "green-addiction",
"id": "steady-plaza",
"metadata": {},
"outputs": [],
"source": [
@ -1382,7 +1378,7 @@
},
{
"cell_type": "markdown",
"id": "genetic-chile",
"id": "dense-dinner",
"metadata": {},
"source": [
"## Variant 15: FORCE_CANCEL_ORDERS\n",
@ -1403,7 +1399,7 @@
{
"cell_type": "code",
"execution_count": null,
"id": "confident-paradise",
"id": "stopped-relay",
"metadata": {},
"outputs": [],
"source": [
@ -1415,7 +1411,7 @@
},
{
"cell_type": "markdown",
"id": "short-keeping",
"id": "provincial-literacy",
"metadata": {},
"source": [
"## Variant 16: PARTIAL_LIQUIDATE\n",
@ -1435,7 +1431,7 @@
{
"cell_type": "code",
"execution_count": null,
"id": "mediterranean-stake",
"id": "artistic-hardwood",
"metadata": {},
"outputs": [],
"source": [
@ -1447,7 +1443,7 @@
},
{
"cell_type": "markdown",
"id": "acceptable-founder",
"id": "announced-interval",
"metadata": {},
"source": [
"## InstructionParsersByVariant dictionary\n",
@ -1458,7 +1454,7 @@
{
"cell_type": "code",
"execution_count": null,
"id": "funny-traffic",
"id": "macro-proportion",
"metadata": {},
"outputs": [],
"source": [
@ -1485,7 +1481,7 @@
},
{
"cell_type": "markdown",
"id": "perceived-money",
"id": "foreign-provincial",
"metadata": {},
"source": [
"# 🏃 Running"
@ -1494,7 +1490,7 @@
{
"cell_type": "code",
"execution_count": null,
"id": "covered-ukraine",
"id": "european-adventure",
"metadata": {},
"outputs": [],
"source": [

View File

@ -40,11 +40,9 @@
"outputs": [],
"source": [
"import logging\n",
"import mango\n",
"\n",
"from solana.publickey import PublicKey\n",
"\n",
"from BaseModel import Group, MarginAccount, TokenValue\n",
"from AccountLiquidator import ForceCancelOrdersAccountLiquidator\n"
"from solana.publickey import PublicKey\n"
]
},
{
@ -153,22 +151,22 @@
" if MARGIN_ACCOUNT_TO_LIQUIDATE == \"\":\n",
" raise Exception(\"No margin account to liquidate - try setting the variable MARGIN_ACCOUNT_TO_LIQUIDATE to a margin account public key.\")\n",
"\n",
" from Context import default_context\n",
" from Wallet import default_wallet\n",
" context = mango.default_context\n",
" wallet = mango.default_wallet\n",
"\n",
" if default_wallet is None:\n",
" if wallet is None:\n",
" print(\"No default wallet file available.\")\n",
" else:\n",
" print(\"Wallet Balances Before:\")\n",
" group = Group.load(default_context)\n",
" balances_before = group.fetch_balances(default_wallet.address)\n",
" TokenValue.report(print, balances_before)\n",
" group = mango.Group.load(context)\n",
" balances_before = group.fetch_balances(wallet.address)\n",
" mango.TokenValue.report(print, balances_before)\n",
"\n",
" prices = group.fetch_token_prices()\n",
" margin_account = MarginAccount.load(default_context, PublicKey(MARGIN_ACCOUNT_TO_LIQUIDATE), group)\n",
" margin_account = mango.MarginAccount.load(context, PublicKey(MARGIN_ACCOUNT_TO_LIQUIDATE), group)\n",
" intrinsic_balance_sheets_before = margin_account.get_intrinsic_balance_sheets(group)\n",
" print(\"Margin Account Before:\", intrinsic_balance_sheets_before)\n",
" liquidator = ForceCancelOrdersAccountLiquidator(default_context, default_wallet)\n",
" liquidator = mango.ForceCancelOrdersAccountLiquidator(context, wallet)\n",
" transaction_id = liquidator.liquidate(group, margin_account, prices)\n",
" if transaction_id is None:\n",
" print(\"No transaction sent.\")\n",
@ -176,27 +174,26 @@
" print(\"Transaction ID:\", transaction_id)\n",
" print(\"Waiting for confirmation...\")\n",
"\n",
" default_context.wait_for_confirmation(transaction_id)\n",
" context.wait_for_confirmation(transaction_id)\n",
"\n",
" group_after = Group.load(default_context)\n",
" margin_account_after_liquidation = MarginAccount.load(default_context, PublicKey(MARGIN_ACCOUNT_TO_LIQUIDATE), group_after)\n",
" group_after = mango.Group.load(context)\n",
" margin_account_after_liquidation = mango.MarginAccount.load(context, PublicKey(MARGIN_ACCOUNT_TO_LIQUIDATE), group_after)\n",
" intrinsic_balance_sheets_after = margin_account_after_liquidation.get_intrinsic_balance_sheets(group_after)\n",
" print(\"Margin Account After:\", intrinsic_balance_sheets_after)\n",
" print(\"Wallet Balances After:\")\n",
" balances_after = group_after.fetch_balances(default_wallet.address)\n",
" TokenValue.report(print, balances_after)\n",
" balances_after = group_after.fetch_balances(wallet.address)\n",
" mango.TokenValue.report(print, balances_after)\n",
"\n",
" print(\"Wallet Balances Changes:\")\n",
" changes = TokenValue.changes(balances_before, balances_after)\n",
" TokenValue.report(print, changes)\n"
" changes = mango.TokenValue.changes(balances_before, balances_after)\n",
" mango.TokenValue.report(print, changes)\n"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
"name": "python39464bit39b046140da4445497cec6b3acc85e88",
"display_name": "Python 3.9.4 64-bit"
},
"language_info": {
"codemirror_mode": {
@ -208,7 +205,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.8.6"
"version": "3.9.4"
},
"toc": {
"base_numbering": 1,
@ -251,8 +248,13 @@
"_Feature"
],
"window_display": false
},
"metadata": {
"interpreter": {
"hash": "ac2eaa0ea0ebeafcc7822e65e46aa9d4f966f30b695406963e145ea4a91cd4fc"
}
}
},
"nbformat": 4,
"nbformat_minor": 5
}
}

View File

@ -1,279 +0,0 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "regulation-package",
"metadata": {},
"source": [
"# ⚠ Warning\n",
"\n",
"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.\n",
"\n",
"[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gl/OpinionatedGeek%2Fmango-explorer/HEAD?filepath=LiquidationProcessor.ipynb) _🏃 To run this notebook press the ⏩ icon in the toolbar above._\n",
"\n",
"[🥭 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)"
]
},
{
"cell_type": "markdown",
"id": "hearing-solid",
"metadata": {},
"source": [
"# 🥭 Liquidation Processor\n",
"\n",
"This notebook contains a liquidator that makes heavy use of [RX Observables](https://rxpy.readthedocs.io/en/latest/reference_observable.html).\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "handed-siemens",
"metadata": {
"jupyter": {
"source_hidden": true
}
},
"outputs": [],
"source": [
"import logging\n",
"import rx\n",
"import rx.operators as ops\n",
"import time\n",
"import typing\n",
"\n",
"from AccountLiquidator import AccountLiquidator\n",
"from BaseModel import Group, LiquidationEvent, MarginAccount, MarginAccountMetadata, TokenValue\n",
"from Context import Context\n",
"from Observables import EventSource\n",
"from Retrier import retry_context\n",
"from WalletBalancer import NullWalletBalancer, WalletBalancer\n"
]
},
{
"cell_type": "markdown",
"id": "elementary-boutique",
"metadata": {},
"source": [
"# 💧 LiquidationProcessor class\n",
"\n",
"An `AccountLiquidator` liquidates a `MarginAccount`. A `LiquidationProcessor` processes a list of `MarginAccount`s, determines if they're liquidatable, and calls an `AccountLiquidator` to do the work."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "through-butter",
"metadata": {},
"outputs": [],
"source": [
"class LiquidationProcessor:\n",
" def __init__(self, context: Context, account_liquidator: AccountLiquidator, wallet_balancer: WalletBalancer, worthwhile_threshold: float = 0.01):\n",
" self.logger: logging.Logger = logging.getLogger(self.__class__.__name__)\n",
" self.context: Context = context\n",
" self.account_liquidator: AccountLiquidator = account_liquidator\n",
" self.wallet_balancer: WalletBalancer = wallet_balancer\n",
" self.worthwhile_threshold: float = worthwhile_threshold\n",
" self.liquidations: EventSource[LiquidationEvent] = EventSource[LiquidationEvent]()\n",
" self.ripe_accounts: typing.Optional[typing.List[MarginAccount]] = None\n",
"\n",
" def update_margin_accounts(self, ripe_margin_accounts: typing.List[MarginAccount]):\n",
" self.logger.info(f\"Received {len(ripe_margin_accounts)} ripe 🥭 margin accounts to process.\")\n",
" self.ripe_accounts = ripe_margin_accounts\n",
"\n",
" def update_prices(self, group, prices):\n",
" started_at = time.time()\n",
"\n",
" if self.ripe_accounts is None:\n",
" self.logger.info(\"Ripe accounts is None - skipping\")\n",
" return\n",
"\n",
" self.logger.info(f\"Running on {len(self.ripe_accounts)} ripe accounts.\")\n",
" updated: typing.List[MarginAccountMetadata] = []\n",
" for margin_account in self.ripe_accounts:\n",
" balance_sheet = margin_account.get_balance_sheet_totals(group, prices)\n",
" balances = margin_account.get_intrinsic_balances(group)\n",
" updated += [MarginAccountMetadata(margin_account, balance_sheet, balances)]\n",
"\n",
" liquidatable = list(filter(lambda mam: mam.balance_sheet.collateral_ratio <= group.maint_coll_ratio, updated))\n",
" self.logger.info(f\"Of those {len(updated)}, {len(liquidatable)} are liquidatable.\")\n",
"\n",
" above_water = list(filter(lambda mam: mam.collateral_ratio > 1, liquidatable))\n",
" self.logger.info(f\"Of those {len(liquidatable)} liquidatable margin accounts, {len(above_water)} are 'above water' margin accounts with assets greater than their liabilities.\")\n",
"\n",
" worthwhile = list(filter(lambda mam: mam.assets - mam.liabilities > self.worthwhile_threshold, above_water))\n",
" self.logger.info(f\"Of those {len(above_water)} above water margin accounts, {len(worthwhile)} are worthwhile margin accounts with more than ${self.worthwhile_threshold} net assets.\")\n",
"\n",
" self._liquidate_all(group, prices, worthwhile)\n",
"\n",
" time_taken = time.time() - started_at\n",
" self.logger.info(f\"Check of all ripe 🥭 accounts complete. Time taken: {time_taken:.2f} seconds.\")\n",
"\n",
" def _liquidate_all(self, group: Group, prices: typing.List[TokenValue], to_liquidate: typing.List[MarginAccountMetadata]):\n",
" to_process = to_liquidate\n",
" while len(to_process) > 0:\n",
" highest_first = sorted(to_process, key=lambda mam: mam.assets - mam.liabilities, reverse=True)\n",
" highest = highest_first[0]\n",
" try:\n",
" self.account_liquidator.liquidate(group, highest.margin_account, prices)\n",
" self.wallet_balancer.balance(prices)\n",
"\n",
" updated_margin_account = MarginAccount.load(self.context, highest.margin_account.address, group)\n",
" balance_sheet = updated_margin_account.get_balance_sheet_totals(group, prices)\n",
" balances = updated_margin_account.get_intrinsic_balances(group)\n",
" updated_mam = MarginAccountMetadata(updated_margin_account, balance_sheet, balances)\n",
" if updated_mam.assets - updated_mam.liabilities > self.worthwhile_threshold:\n",
" self.logger.info(f\"Margin account {updated_margin_account.address} has been drained and is no longer worthwhile.\")\n",
" else:\n",
" self.logger.info(f\"Margin account {updated_margin_account.address} is still worthwhile - putting it back on list.\")\n",
" to_process += [updated_mam]\n",
" except Exception as exception:\n",
" self.logger.error(f\"Failed to liquidate account '{highest.margin_account.address}' - {exception}\")\n",
" finally:\n",
" # highest should always be in to_process, but we're outside the try-except block\n",
" # so let's be a little paranoid about it.\n",
" if highest in to_process:\n",
" to_process.remove(highest)\n"
]
},
{
"cell_type": "markdown",
"id": "assigned-apache",
"metadata": {},
"source": [
"# 🏃 Running\n",
"\n",
"A quick example to show how to plug observables into the `LiquidationProcessor`."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "seasonal-singapore",
"metadata": {},
"outputs": [],
"source": [
"if __name__ == \"__main__\":\n",
" from AccountLiquidator import NullAccountLiquidator\n",
" from Context import default_context\n",
" from Observables import create_backpressure_skipping_observer, log_subscription_error\n",
" from Wallet import default_wallet\n",
"\n",
" from rx.scheduler import ThreadPoolScheduler\n",
"\n",
" logging.getLogger().setLevel(logging.INFO)\n",
"\n",
" if default_wallet is None:\n",
" raise Exception(\"No wallet\")\n",
"\n",
" pool_scheduler = ThreadPoolScheduler(2)\n",
"\n",
" def fetch_prices(context):\n",
" def _fetch_prices(_):\n",
" with retry_context(\"Price Fetch\",\n",
" lambda: Group.load_with_prices(context),\n",
" context.retry_pauses) as retrier:\n",
" return retrier.run()\n",
"\n",
" return _fetch_prices\n",
"\n",
" def fetch_margin_accounts(context):\n",
" group = Group.load(context)\n",
"\n",
" def _fetch_margin_accounts(_):\n",
" with retry_context(\"Margin Account Fetch\",\n",
" group.load_ripe_margin_accounts,\n",
" context.retry_pauses) as retrier:\n",
" return retrier.run()\n",
" return _fetch_margin_accounts\n",
"\n",
" liquidation_processor = LiquidationProcessor(default_context, NullAccountLiquidator(), NullWalletBalancer())\n",
"\n",
" print(\"Starting margin account fetcher subscription\")\n",
" margin_account_interval = 60\n",
" margin_account_subscription = rx.interval(margin_account_interval).pipe(\n",
" ops.subscribe_on(pool_scheduler),\n",
" ops.start_with(-1),\n",
" ops.map(fetch_margin_accounts(default_context)),\n",
" ).subscribe(create_backpressure_skipping_observer(on_next=liquidation_processor.update_margin_accounts, on_error=log_subscription_error))\n",
"\n",
" print(\"Starting price fetcher subscription\")\n",
" price_interval = 2\n",
" price_subscription = rx.interval(price_interval).pipe(\n",
" ops.subscribe_on(pool_scheduler),\n",
" ops.map(fetch_prices(default_context))\n",
" ).subscribe(create_backpressure_skipping_observer(on_next=lambda piped: liquidation_processor.update_prices(piped[0], piped[1]), on_error=log_subscription_error))\n",
"\n",
" print(\"Subscriptions created - now just running\")\n",
"\n",
" time.sleep(120)\n",
" print(\"Disposing\")\n",
" price_subscription.dispose()\n",
" margin_account_subscription.dispose()\n",
" print(\"Disposed\")\n"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.8.6"
},
"toc": {
"base_numbering": 1,
"nav_menu": {},
"number_sections": true,
"sideBar": true,
"skip_h1_title": false,
"title_cell": "Table of Contents",
"title_sidebar": "Contents",
"toc_cell": false,
"toc_position": {},
"toc_section_display": true,
"toc_window_display": true
},
"varInspector": {
"cols": {
"lenName": 16,
"lenType": 16,
"lenVar": 40
},
"kernels_config": {
"python": {
"delete_cmd_postfix": "",
"delete_cmd_prefix": "del ",
"library": "var_list.py",
"varRefreshCmd": "print(var_dic_list())"
},
"r": {
"delete_cmd_postfix": ") ",
"delete_cmd_prefix": "rm(",
"library": "var_list.r",
"varRefreshCmd": "cat(var_dic_list()) "
}
},
"types_to_exclude": [
"module",
"function",
"builtin_function_or_method",
"instance",
"_Feature"
],
"window_display": false
}
},
"nbformat": 4,
"nbformat_minor": 5
}

52
Makefile Normal file
View File

@ -0,0 +1,52 @@
.EXPORT_ALL_VARIABLES:
commands := $(wildcard bin/*)
setup: ## Install all the build and lint dependencies
pip install -r requirements.txt
upgrade: ## Upgrade all the build and lint dependencies
pip install --upgrade -r requirements.txt
test: ## Run all the tests
pytest -rP tests
#cover: test ## Run all the tests and opens the coverage report
# TODO: Coverage
mypy:
rm -rf .tmplintdir .mypy_cache
mkdir .tmplintdir
for file in bin/* ; do \
cp $${file} .tmplintdir/$${file##*/}.py ; \
done
-mypy mango .tmplintdir
rm -rf .tmplintdir
flake8:
flake8 --extend-ignore E402,E501,E722,W291,W391 . bin/*
lint: flake8 mypy
ci: lint test ## Run all the tests and code checks
docker-build:
docker build . -t opinionatedgeek/mango-explorer:latest
docker-push:
docker push opinionatedgeek/mango-explorer:latest
docker: docker-build docker-push
docker-experimental-build:
docker build . -t opinionatedgeek/mango-explorer:experimental
docker-experimental-push:
docker push opinionatedgeek/mango-explorer:experimental
docker-experimental: docker-experimental-build docker-experimental-push
# Absolutely awesome: http://marmelab.com/blog/2016/02/29/auto-documented-makefile.html
help:
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
.DEFAULT_GOAL := help

View File

@ -1,598 +0,0 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "ordinary-duplicate",
"metadata": {},
"source": [
"# ⚠ Warning\n",
"\n",
"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.\n",
"\n",
"[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gl/OpinionatedGeek%2Fmango-explorer/HEAD?filepath=Notification.ipynb) _🏃 To run this notebook press the ⏩ icon in the toolbar above._\n",
"\n",
"[🥭 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)"
]
},
{
"cell_type": "markdown",
"id": "sound-earthquake",
"metadata": {},
"source": [
"# 🥭 Notification\n",
"\n",
"This notebook contains code to send arbitrary notifications.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "tamil-carpet",
"metadata": {
"jupyter": {
"source_hidden": true
}
},
"outputs": [],
"source": [
"import abc\n",
"import csv\n",
"import logging\n",
"import os.path\n",
"import requests\n",
"import typing\n",
"\n",
"from urllib.parse import unquote\n",
"\n",
"from BaseModel import LiquidationEvent\n"
]
},
{
"cell_type": "markdown",
"id": "brave-coordinate",
"metadata": {},
"source": [
"# NotificationTarget class\n",
"\n",
"This base class is the root of the different notification mechanisms.\n",
"\n",
"Derived classes should override `send_notification()` to implement their own sending logic.\n",
"\n",
"Derived classes should not override `send()` since that is the interface outside classes call and it's used to ensure `NotificationTarget`s don't throw an exception when sending."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "weekly-financing",
"metadata": {},
"outputs": [],
"source": [
"class NotificationTarget(metaclass=abc.ABCMeta):\n",
" def __init__(self):\n",
" self.logger: logging.Logger = logging.getLogger(self.__class__.__name__)\n",
"\n",
" def send(self, item: typing.Any) -> None:\n",
" try:\n",
" self.send_notification(item)\n",
" except Exception as exception:\n",
" self.logger.error(f\"Error sending {item} - {self} - {exception}\")\n",
"\n",
" @abc.abstractmethod\n",
" def send_notification(self, item: typing.Any) -> None:\n",
" raise NotImplementedError(\"NotificationTarget.send() is not implemented on the base type.\")\n",
"\n",
" def __repr__(self) -> str:\n",
" return f\"{self}\"\n"
]
},
{
"cell_type": "markdown",
"id": "negative-madagascar",
"metadata": {},
"source": [
"# TelegramNotificationTarget class\n",
"\n",
"The `TelegramNotificationTarget` sends messages to Telegram.\n",
"\n",
"The format for the telegram notification is:\n",
"1. The word 'telegram'\n",
"2. A colon ':'\n",
"3. The chat ID\n",
"4. An '@' symbol\n",
"5. The bot token\n",
"\n",
"For example:\n",
"```\n",
"telegram:<CHAT-ID>@<BOT-TOKEN>\n",
"```\n",
"\n",
"The [Telegram instructions to create a bot](https://core.telegram.org/bots#creating-a-new-bot) show you how to create the bot token."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "binary-export",
"metadata": {},
"outputs": [],
"source": [
"class TelegramNotificationTarget(NotificationTarget):\n",
" def __init__(self, address):\n",
" super().__init__()\n",
" chat_id, bot_id = address.split(\"@\", 1)\n",
" self.chat_id = chat_id\n",
" self.bot_id = bot_id\n",
"\n",
" def send_notification(self, item: typing.Any) -> None:\n",
" payload = {\"disable_notification\": True, \"chat_id\": self.chat_id, \"text\": str(item)}\n",
" url = f\"https://api.telegram.org/bot{self.bot_id}/sendMessage\"\n",
" headers = {\"Content-Type\": \"application/json\"}\n",
" requests.post(url, json=payload, headers=headers)\n",
"\n",
" def __str__(self) -> str:\n",
" return f\"Telegram chat ID: {self.chat_id}\"\n"
]
},
{
"cell_type": "markdown",
"id": "whole-design",
"metadata": {},
"source": [
"# DiscordNotificationTarget class\n",
"\n",
"The `DiscordNotificationTarget` sends messages to Discord.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "naughty-disney",
"metadata": {},
"outputs": [],
"source": [
"class DiscordNotificationTarget(NotificationTarget):\n",
" def __init__(self, address):\n",
" super().__init__()\n",
" self.address = address\n",
"\n",
" def send_notification(self, item: typing.Any) -> None:\n",
" payload = {\n",
" \"content\": str(item)\n",
" }\n",
" url = self.address\n",
" headers = {\"Content-Type\": \"application/json\"}\n",
" requests.post(url, json=payload, headers=headers)\n",
"\n",
" def __str__(self) -> str:\n",
" return \"Discord webhook\"\n"
]
},
{
"cell_type": "markdown",
"id": "finite-caribbean",
"metadata": {},
"source": [
"# MailjetNotificationTarget class\n",
"\n",
"The `MailjetNotificationTarget` sends an email through [Mailjet](https://mailjet.com).\n",
"\n",
"In order to pass everything in to the notifier as a single string (needed to stop command-line parameters form getting messy), `MailjetNotificationTarget` requires a compound string, separated by colons.\n",
"```\n",
"mailjet:<MAILJET-API-KEY>:<MAILJET-API-SECRET>:FROM-NAME:FROM-ADDRESS:TO-NAME:TO-ADDRESS\n",
"\n",
"```\n",
"Individual components are URL-encoded (so, for example, spaces are replaces with %20, colons are replaced with %3A).\n",
"\n",
"* `<MAILJET-API-KEY>` and `<MAILJET-API-SECRET>` are from your [Mailjet](https://mailjet.com) account.\n",
"* `FROM-NAME` and `TO-NAME` are just text fields that are used as descriptors in the email messages.\n",
"* `FROM-ADDRESS` is the address the email appears to come from. This must be validated with [Mailjet](https://mailjet.com).\n",
"* `TO-ADDRESS` is the destination address - the email account to which the email is being sent.\n",
"\n",
"Mailjet provides a client library, but really we don't need or want more dependencies. This code just replicates the `curl` way of doing things:\n",
"```\n",
"curl -s \\\n",
"\t-X POST \\\n",
"\t--user \"$MJ_APIKEY_PUBLIC:$MJ_APIKEY_PRIVATE\" \\\n",
"\thttps://api.mailjet.com/v3.1/send \\\n",
"\t-H 'Content-Type: application/json' \\\n",
"\t-d '{\n",
" \"SandboxMode\":\"true\",\n",
" \"Messages\":[\n",
" {\n",
" \"From\":[\n",
" {\n",
" \"Email\":\"pilot@mailjet.com\",\n",
" \"Name\":\"Your Mailjet Pilot\"\n",
" }\n",
" ],\n",
" \"HTMLPart\":\"<h3>Dear passenger, welcome to Mailjet!</h3><br />May the delivery force be with you!\",\n",
" \"Subject\":\"Your email flight plan!\",\n",
" \"TextPart\":\"Dear passenger, welcome to Mailjet! May the delivery force be with you!\",\n",
" \"To\":[\n",
" {\n",
" \"Email\":\"passenger@mailjet.com\",\n",
" \"Name\":\"Passenger 1\"\n",
" }\n",
" ]\n",
" }\n",
" ]\n",
"\t}'\n",
"```"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "daily-accreditation",
"metadata": {},
"outputs": [],
"source": [
"class MailjetNotificationTarget(NotificationTarget):\n",
" def __init__(self, encoded_parameters):\n",
" super().__init__()\n",
" self.address = \"https://api.mailjet.com/v3.1/send\"\n",
" api_key, api_secret, subject, from_name, from_address, to_name, to_address = encoded_parameters.split(\":\")\n",
" self.api_key: str = unquote(api_key)\n",
" self.api_secret: str = unquote(api_secret)\n",
" self.subject: str = unquote(subject)\n",
" self.from_name: str = unquote(from_name)\n",
" self.from_address: str = unquote(from_address)\n",
" self.to_name: str = unquote(to_name)\n",
" self.to_address: str = unquote(to_address)\n",
"\n",
" def send_notification(self, item: typing.Any) -> None:\n",
" payload = {\n",
" \"Messages\": [\n",
" {\n",
" \"From\": {\n",
" \"Email\": self.from_address,\n",
" \"Name\": self.from_name\n",
" },\n",
" \"Subject\": self.subject,\n",
" \"TextPart\": str(item),\n",
" \"To\": [\n",
" {\n",
" \"Email\": self.to_address,\n",
" \"Name\": self.to_name\n",
" }\n",
" ]\n",
" }\n",
" ]\n",
" }\n",
"\n",
" url = self.address\n",
" headers = {\"Content-Type\": \"application/json\"}\n",
" requests.post(url, json=payload, headers=headers, auth=(self.api_key, self.api_secret))\n",
"\n",
" def __str__(self) -> str:\n",
" return f\"Mailjet notifications to '{self.to_name}' '{self.to_address}' with subject '{self.subject}'\"\n"
]
},
{
"cell_type": "markdown",
"id": "physical-norfolk",
"metadata": {},
"source": [
"# CsvFileNotificationTarget class\n",
"\n",
"Outputs a liquidation event to CSV. Nothing is written if the item is not a `LiquidationEvent`.\n",
"\n",
"Headers for the CSV file should be:\n",
"```\n",
"\"Timestamp\",\"Liquidator Name\",\"Group\",\"Succeeded\",\"Signature\",\"Wallet\",\"Margin Account\",\"Token Changes\"\n",
"```\n",
"Token changes are listed as pairs of value plus symbol, so each token change adds two columns to the output. Token changes may arrive in different orders, so ordering of token changes is not guaranteed to be consistent from transaction to transaction.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "awful-airport",
"metadata": {},
"outputs": [],
"source": [
"class CsvFileNotificationTarget(NotificationTarget):\n",
" def __init__(self, filename):\n",
" super().__init__()\n",
" self.filename = filename\n",
"\n",
" def send_notification(self, item: typing.Any) -> None:\n",
" if isinstance(item, LiquidationEvent):\n",
" event: LiquidationEvent = item\n",
" if not os.path.isfile(self.filename) or os.path.getsize(self.filename) == 0:\n",
" with open(self.filename, \"w\") as empty_file:\n",
" empty_file.write('\"Timestamp\",\"Liquidator Name\",\"Group\",\"Succeeded\",\"Signature\",\"Wallet\",\"Margin Account\",\"Token Changes\"\\n')\n",
"\n",
" with open(self.filename, \"a\") as csvfile:\n",
" result = \"Succeeded\" if event.succeeded else \"Failed\"\n",
" row_data = [event.timestamp, event.liquidator_name, event.group_name, result, event.signature, event.wallet_address, event.margin_account_address]\n",
" for change in event.changes:\n",
" row_data += [f\"{change.value:.8f}\", change.token.name]\n",
" file_writer = csv.writer(csvfile, quoting=csv.QUOTE_MINIMAL)\n",
" file_writer.writerow(row_data)\n",
"\n",
" def __str__(self) -> str:\n",
" return f\"CSV notifications to file {self.filename}\"\n"
]
},
{
"cell_type": "markdown",
"id": "instrumental-adams",
"metadata": {},
"source": [
"# FilteringNotificationTarget class\n",
"\n",
"This class takes a `NotificationTarget` and a filter function, and only calls the `NotificationTarget` if the filter function returns `True` for the notification item."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "iraqi-dryer",
"metadata": {},
"outputs": [],
"source": [
"class FilteringNotificationTarget(NotificationTarget):\n",
" def __init__(self, inner_notifier: NotificationTarget, filter_func: typing.Callable[[typing.Any], bool]):\n",
" super().__init__()\n",
" self.filter_func = filter_func\n",
" self.inner_notifier: NotificationTarget = inner_notifier\n",
"\n",
" def send_notification(self, item: typing.Any) -> None:\n",
" if self.filter_func(item):\n",
" self.inner_notifier.send_notification(item)\n",
"\n",
" def __str__(self) -> str:\n",
" return f\"Filtering notification target for '{self.inner_notifier}'\"\n"
]
},
{
"cell_type": "markdown",
"id": "monthly-translator",
"metadata": {},
"source": [
"# parse_subscription_target() function\n",
"\n",
"`parse_subscription_target()` takes a parameter as a string and returns a notification target.\n",
"\n",
"This is most likely used when parsing command-line arguments - this function can be used in the `type` parameter of an `add_argument()` call."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "overall-camera",
"metadata": {},
"outputs": [],
"source": [
"def parse_subscription_target(target):\n",
" protocol, destination = target.split(\":\", 1)\n",
"\n",
" if protocol == \"telegram\":\n",
" return TelegramNotificationTarget(destination)\n",
" elif protocol == \"discord\":\n",
" return DiscordNotificationTarget(destination)\n",
" elif protocol == \"mailjet\":\n",
" return MailjetNotificationTarget(destination)\n",
" elif protocol == \"csvfile\":\n",
" return CsvFileNotificationTarget(destination)\n",
" else:\n",
" raise Exception(f\"Unknown protocol: {protocol}\")\n"
]
},
{
"cell_type": "markdown",
"id": "original-heating",
"metadata": {},
"source": [
"## NotificationHandler class\n",
"\n",
"A bridge between the worlds of notifications and logging. This allows any `NotificationTarget` to be plugged in to the `logging` subsystem to receive log messages and notify however it chooses."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "different-breach",
"metadata": {},
"outputs": [],
"source": [
"class NotificationHandler(logging.StreamHandler):\n",
" def __init__(self, target: NotificationTarget):\n",
" logging.StreamHandler.__init__(self)\n",
" self.target = target\n",
"\n",
" def emit(self, record):\n",
" message = self.format(record)\n",
" self.target.send_notification(message)\n"
]
},
{
"cell_type": "markdown",
"id": "entertaining-mobility",
"metadata": {},
"source": [
"# ✅ Testing"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "animal-player",
"metadata": {},
"outputs": [],
"source": [
"def _notebook_tests():\n",
" test_target = parse_subscription_target(\"telegram:chat@bot\")\n",
"\n",
" assert(test_target.chat_id == \"chat\")\n",
" assert(test_target.bot_id == \"bot\")\n",
"\n",
" mailjet_target_string = \"user:secret:subject:from%20name:from@address:to%20name%20with%20colon%3A:to@address\"\n",
" mailjet_target = MailjetNotificationTarget(mailjet_target_string)\n",
" assert(mailjet_target.api_key == \"user\")\n",
" assert(mailjet_target.api_secret == \"secret\")\n",
" assert(mailjet_target.subject == \"subject\")\n",
" assert(mailjet_target.from_name == \"from name\")\n",
" assert(mailjet_target.from_address == \"from@address\")\n",
" assert(mailjet_target.to_name == \"to name with colon:\")\n",
" assert(mailjet_target.to_address == \"to@address\")\n",
"\n",
" parse_subscription_target(\"telegram:012345678@9876543210:ABCDEFGHijklmnop-qrstuvwxyzABCDEFGH\")\n",
" parse_subscription_target(\"discord:https://discord.com/api/webhooks/012345678901234567/ABCDE_fghij-KLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMN\")\n",
" parse_subscription_target(\"mailjet:user:secret:subject:from%20name:from@address:to%20name%20with%20colon%3A:to@address\")\n",
" parse_subscription_target(\"csvfile:filename.csv\")\n",
"\n",
" class MockNotificationTarget(NotificationTarget):\n",
" def __init__(self):\n",
" super().__init__()\n",
" self.send_notification_called = False\n",
"\n",
" def send_notification(self, item: typing.Any) -> None:\n",
" self.send_notification_called = True\n",
"\n",
" mock = MockNotificationTarget()\n",
" filtering = FilteringNotificationTarget(mock, lambda x: x == \"yes\")\n",
" filtering.send(\"no\")\n",
" assert(not mock.send_notification_called)\n",
" filtering.send(\"yes\")\n",
" assert(mock.send_notification_called)\n",
"\n",
"\n",
"_notebook_tests()\n",
"del _notebook_tests"
]
},
{
"cell_type": "markdown",
"id": "after-definition",
"metadata": {},
"source": [
"# 🏃 Running\n",
"\n",
"A few quick examples to show how to use these functions"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "anticipated-destruction",
"metadata": {},
"outputs": [],
"source": [
"if __name__ == \"__main__\":\n",
" def _notebook_run():\n",
" log_level = logging.getLogger().level\n",
" try:\n",
" test_telegram_target = parse_subscription_target(\"telegram:012345678@9876543210:ABCDEFGHijklmnop-qrstuvwxyzABCDEFGH\")\n",
" print(test_telegram_target)\n",
"\n",
" test_discord_target = parse_subscription_target(\"discord:https://discord.com/api/webhooks/012345678901234567/ABCDE_fghij-KLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMN\")\n",
" print(test_discord_target)\n",
"\n",
" test_mailjet_target = parse_subscription_target(\"mailjet:user:secret:Subject%20text:from%20name:from@address:to%20name%20with%20colon%3A:to@address\")\n",
" print(test_mailjet_target)\n",
"\n",
" test_csv_target = parse_subscription_target(\"csvfile:liquidations.csv\")\n",
" print(test_csv_target)\n",
"\n",
" # These lines, if uncommented, will create and write to the file.\n",
" # import datetime\n",
" # from BaseModel import TokenLookup, TokenValue\n",
" # from Context import default_context\n",
" # from decimal import Decimal\n",
" # from Wallet import default_wallet\n",
" # token_lookup = TokenLookup.default_lookups()\n",
" # balances_before = [\n",
" # TokenValue(token_lookup.find_by_symbol(\"ETH\"), Decimal(1)),\n",
" # TokenValue(token_lookup.find_by_symbol(\"BTC\"), Decimal(\"0.1\")),\n",
" # TokenValue(token_lookup.find_by_symbol(\"USDT\"), Decimal(1000))\n",
" # ]\n",
" # balances_after = [\n",
" # TokenValue(token_lookup.find_by_symbol(\"ETH\"), Decimal(1)),\n",
" # TokenValue(token_lookup.find_by_symbol(\"BTC\"), Decimal(\"0.05\")),\n",
" # TokenValue(token_lookup.find_by_symbol(\"USDT\"), Decimal(2000))\n",
" # ]\n",
" # event = LiquidationEvent(datetime.datetime.now(),\n",
" # \"Liquidator Name\",\n",
" # \"GROUP_NAME\",\n",
" # True,\n",
" # \"SIGNATURE\",\n",
" # default_wallet.address,\n",
" # default_wallet.address,\n",
" # balances_before,\n",
" # balances_after)\n",
" # test_csv_target.send(event)\n",
" finally:\n",
" logging.getLogger().setLevel(log_level)\n",
"\n",
" _notebook_run()\n",
" del _notebook_run\n"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.8.6"
},
"toc": {
"base_numbering": 1,
"nav_menu": {},
"number_sections": true,
"sideBar": true,
"skip_h1_title": false,
"title_cell": "Table of Contents",
"title_sidebar": "Contents",
"toc_cell": false,
"toc_position": {},
"toc_section_display": true,
"toc_window_display": true
},
"varInspector": {
"cols": {
"lenName": 16,
"lenType": 16,
"lenVar": 40
},
"kernels_config": {
"python": {
"delete_cmd_postfix": "",
"delete_cmd_prefix": "del ",
"library": "var_list.py",
"varRefreshCmd": "print(var_dic_list())"
},
"r": {
"delete_cmd_postfix": ") ",
"delete_cmd_prefix": "rm(",
"library": "var_list.r",
"varRefreshCmd": "cat(var_dic_list()) "
}
},
"types_to_exclude": [
"module",
"function",
"builtin_function_or_method",
"instance",
"_Feature"
],
"window_display": false
}
},
"nbformat": 4,
"nbformat_minor": 5
}

View File

@ -1,481 +0,0 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "engaging-encyclopedia",
"metadata": {},
"source": [
"# ⚠ Warning\n",
"\n",
"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.\n",
"\n",
"[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gl/OpinionatedGeek%2Fmango-explorer/HEAD?filepath=Observables.ipynb) _🏃 To run this notebook press the ⏩ icon in the toolbar above._\n",
"\n",
"[🥭 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)"
]
},
{
"cell_type": "markdown",
"id": "innovative-airplane",
"metadata": {},
"source": [
"# 🥭 Observables\n",
"\n",
"This notebook contains some useful shared tools to work with [RX Observables](https://rxpy.readthedocs.io/en/latest/reference_observable.html).\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "prime-double",
"metadata": {
"jupyter": {
"source_hidden": true
}
},
"outputs": [],
"source": [
"import datetime\n",
"import logging\n",
"import rx\n",
"import rx.operators as ops\n",
"import typing\n",
"\n",
"from rxpy_backpressure import BackPressure\n"
]
},
{
"cell_type": "markdown",
"id": "radio-bangladesh",
"metadata": {},
"source": [
"# PrintingObserverSubscriber class\n",
"\n",
"This class can subscribe to an `Observable` and print out each item."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "resident-timothy",
"metadata": {},
"outputs": [],
"source": [
"class PrintingObserverSubscriber(rx.core.typing.Observer):\n",
" def __init__(self, report_no_output: bool) -> None:\n",
" super().__init__()\n",
" self.report_no_output = report_no_output\n",
"\n",
" def on_next(self, item: typing.Any) -> None:\n",
" self.report_no_output = False\n",
" print(item)\n",
"\n",
" def on_error(self, ex: Exception) -> None:\n",
" self.report_no_output = False\n",
" print(ex)\n",
"\n",
" def on_completed(self) -> None:\n",
" if self.report_no_output:\n",
" print(\"No items to show.\")\n"
]
},
{
"cell_type": "markdown",
"id": "roman-poker",
"metadata": {},
"source": [
"# TimestampedPrintingObserverSubscriber class\n",
"\n",
"Just like `PrintingObserverSubscriber` but it puts a timestamp on each printout."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "dominant-socket",
"metadata": {},
"outputs": [],
"source": [
"class TimestampedPrintingObserverSubscriber(PrintingObserverSubscriber):\n",
" def __init__(self, report_no_output: bool) -> None:\n",
" super().__init__(report_no_output)\n",
"\n",
" def on_next(self, item: typing.Any) -> None:\n",
" super().on_next(f\"{datetime.datetime.now()}: {item}\")\n"
]
},
{
"cell_type": "markdown",
"id": "dated-spring",
"metadata": {},
"source": [
"# CollectingObserverSubscriber class\n",
"\n",
"This class can subscribe to an `Observable` and collect each item."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "trying-substance",
"metadata": {},
"outputs": [],
"source": [
"class CollectingObserverSubscriber(rx.core.typing.Observer):\n",
" def __init__(self) -> None:\n",
" self.logger: logging.Logger = logging.getLogger(self.__class__.__name__)\n",
" self.collected: typing.List[typing.Any] = []\n",
"\n",
" def on_next(self, item: typing.Any) -> None:\n",
" self.collected += [item]\n",
"\n",
" def on_error(self, ex: Exception) -> None:\n",
" self.logger.error(f\"Received error: {ex}\")\n",
"\n",
" def on_completed(self) -> None:\n",
" pass\n"
]
},
{
"cell_type": "markdown",
"id": "actual-intervention",
"metadata": {},
"source": [
"# CaptureFirstItem class\n",
"\n",
"This captures the first item to pass through the pipeline, allowing it to be instpected later."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "prescribed-overhead",
"metadata": {},
"outputs": [],
"source": [
"class CaptureFirstItem:\n",
" def __init__(self):\n",
" self.captured: typing.Any = None\n",
" self.has_captured: bool = False\n",
"\n",
" def capture_if_first(self, item: typing.Any) -> typing.Any:\n",
" if not self.has_captured:\n",
" self.captured = item\n",
" self.has_captured = True\n",
"\n",
" return item\n"
]
},
{
"cell_type": "markdown",
"id": "written-newport",
"metadata": {},
"source": [
"# FunctionObserver\n",
"\n",
"This class takes functions for `on_next()`, `on_error()` and `on_completed()` and returns an `Observer` object.\n",
"\n",
"This is mostly for libraries (like `rxpy_backpressure`) that take observers but not their component functions."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "narrative-cambridge",
"metadata": {},
"outputs": [],
"source": [
"class FunctionObserver(rx.core.typing.Observer):\n",
" def __init__(self,\n",
" on_next: typing.Callable[[typing.Any], None],\n",
" on_error: typing.Callable[[Exception], None] = lambda _: None,\n",
" on_completed: typing.Callable[[], None] = lambda: None):\n",
" self.logger: logging.Logger = logging.getLogger(self.__class__.__name__)\n",
" self._on_next = on_next\n",
" self._on_error = on_error\n",
" self._on_completed = on_completed\n",
"\n",
" def on_next(self, value: typing.Any) -> None:\n",
" try:\n",
" self._on_next(value)\n",
" except Exception as exception:\n",
" self.logger.warning(f\"on_next callable raised exception: {exception}\")\n",
"\n",
" def on_error(self, error: Exception) -> None:\n",
" try:\n",
" self._on_error(error)\n",
" except Exception as exception:\n",
" self.logger.warning(f\"on_error callable raised exception: {exception}\")\n",
"\n",
" def on_completed(self) -> None:\n",
" try:\n",
" self._on_completed()\n",
" except Exception as exception:\n",
" self.logger.warning(f\"on_completed callable raised exception: {exception}\")\n"
]
},
{
"cell_type": "markdown",
"id": "universal-allowance",
"metadata": {},
"source": [
"# create_backpressure_skipping_observer function\n",
"\n",
"Creates an `Observer` that skips inputs if they are building up while a subscriber works.\n",
"\n",
"This is useful for situations that, say, poll every second but the operation can sometimes take multiple seconds to complete. In that case, the latest item will be immediately emitted and the in-between items skipped."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "contemporary-pepper",
"metadata": {},
"outputs": [],
"source": [
"def create_backpressure_skipping_observer(on_next: typing.Callable[[typing.Any], None], on_error: typing.Callable[[Exception], None] = lambda _: None, on_completed: typing.Callable[[], None] = lambda: None) -> rx.core.typing.Observer:\n",
" observer = FunctionObserver(on_next=on_next, on_error=on_error, on_completed=on_completed)\n",
" return BackPressure.LATEST(observer)\n"
]
},
{
"cell_type": "markdown",
"id": "waiting-projection",
"metadata": {},
"source": [
"# debug_print_item function\n",
"\n",
"This is a handy item that can be added to a pipeline to show what is being passed at that particular stage. For example, this shows how to print the item before and after filtering:\n",
"```\n",
"fetch().pipe(\n",
" ops.map(debug_print_item(\"Unfiltered:\")),\n",
" ops.filter(lambda item: item.something is not None),\n",
" ops.map(debug_print_item(\"Filtered:\")),\n",
" ops.filter(lambda item: item.something_else()),\n",
" ops.map(act_on_item)\n",
").subscribe(some_subscriber)\n",
"```"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "opposite-perth",
"metadata": {},
"outputs": [],
"source": [
"def debug_print_item(title: str) -> typing.Callable[[typing.Any], typing.Any]:\n",
" def _debug_print_item(item: typing.Any) -> typing.Any:\n",
" print(title, item)\n",
" return item\n",
" return _debug_print_item\n"
]
},
{
"cell_type": "markdown",
"id": "scientific-showcase",
"metadata": {},
"source": [
"# log_subscription_error function\n",
"\n",
"Logs subscription exceptions to the root logger.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "possible-worth",
"metadata": {},
"outputs": [],
"source": [
"def log_subscription_error(error: Exception) -> None:\n",
" logging.error(f\"Observable subscription error: {error}\")\n"
]
},
{
"cell_type": "markdown",
"id": "neural-allen",
"metadata": {},
"source": [
"# observable_pipeline_error_reporter function\n",
"\n",
"This intercepts and re-raises an exception, to help report on errors.\n",
"\n",
"RxPy pipelines are tricky to restart, so it's often easier to use the `ops.retry()` function in the pipeline. That just swallows the error though, so there's no way to know what was raised to cause the retry.\n",
"\n",
"Enter `observable_pipeline_error_reporter()`! Put it in a `catch` just before the `retry` and it should log the error properly.\n",
"\n",
"For example:\n",
"```\n",
"from rx import of, operators as ops\n",
"\n",
"def raise_on_every_third(item):\n",
" if (item % 3 == 0):\n",
" raise Exception(\"Divisible by 3\")\n",
" else:\n",
" return item\n",
"\n",
"sub1 = of(1, 2, 3, 4, 5, 6).pipe(\n",
" ops.map(lambda e : raise_on_every_third(e)),\n",
" ops.catch(observable_pipeline_error_reporter),\n",
" ops.retry(3)\n",
")\n",
"sub1.subscribe(lambda item: print(item), on_error = lambda error: print(f\"Error : {error}\"))\n",
"```"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "electoral-water",
"metadata": {},
"outputs": [],
"source": [
"def observable_pipeline_error_reporter(ex, _):\n",
" logging.error(f\"Intercepted error in observable pipeline: {ex}\")\n",
" raise ex\n"
]
},
{
"cell_type": "markdown",
"id": "satisfied-audio",
"metadata": {},
"source": [
"# Events\n",
"\n",
"A strongly(ish)-typed event source that can handle many subscribers."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "correct-medicaid",
"metadata": {},
"outputs": [],
"source": [
"TEventDatum = typing.TypeVar('TEventDatum')\n",
"\n",
"\n",
"class EventSource(rx.subject.Subject, typing.Generic[TEventDatum]):\n",
" def __init__(self) -> None:\n",
" super().__init__()\n",
" self.logger: logging.Logger = logging.getLogger(self.__class__.__name__)\n",
"\n",
" def on_next(self, event: TEventDatum) -> None:\n",
" super().on_next(event)\n",
"\n",
" def on_error(self, ex: Exception) -> None:\n",
" super().on_error(ex)\n",
"\n",
" def on_completed(self) -> None:\n",
" super().on_completed()\n",
"\n",
" def publish(self, event: TEventDatum) -> None:\n",
" try:\n",
" self.on_next(event)\n",
" except Exception as exception:\n",
" self.logger.warning(f\"Failed to publish event '{event}' - {exception}\")\n",
"\n",
" def dispose(self) -> None:\n",
" super().dispose()\n"
]
},
{
"cell_type": "markdown",
"id": "prostate-dinner",
"metadata": {},
"source": [
"# 🏃 Running\n",
"\n",
"A few quick examples to show how to use these functions"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "chubby-blind",
"metadata": {},
"outputs": [],
"source": [
"if __name__ == \"__main__\":\n",
" rx.from_([1, 2, 3, 4, 5]).subscribe(PrintingObserverSubscriber(False))\n",
" rx.from_([1, 2, 3, 4, 5]).pipe(\n",
" ops.filter(lambda item: (item % 2) == 0),\n",
" ).subscribe(PrintingObserverSubscriber(False))\n",
"\n",
" collector = CollectingObserverSubscriber()\n",
" rx.from_([\"a\", \"b\", \"c\"]).subscribe(collector)\n",
" print(collector.collected)\n",
"\n",
" rx.from_([1, 2, 3, 4, 5]).pipe(\n",
" ops.map(debug_print_item(\"Before even check:\")),\n",
" ops.filter(lambda item: (item % 2) == 0),\n",
" ops.map(debug_print_item(\"After even check:\")),\n",
" ).subscribe(PrintingObserverSubscriber(True))\n"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.8.6"
},
"toc": {
"base_numbering": 1,
"nav_menu": {},
"number_sections": true,
"sideBar": true,
"skip_h1_title": false,
"title_cell": "Table of Contents",
"title_sidebar": "Contents",
"toc_cell": false,
"toc_position": {},
"toc_section_display": true,
"toc_window_display": true
},
"varInspector": {
"cols": {
"lenName": 16,
"lenType": 16,
"lenVar": 40
},
"kernels_config": {
"python": {
"delete_cmd_postfix": "",
"delete_cmd_prefix": "del ",
"library": "var_list.py",
"varRefreshCmd": "print(var_dic_list())"
},
"r": {
"delete_cmd_postfix": ") ",
"delete_cmd_prefix": "rm(",
"library": "var_list.r",
"varRefreshCmd": "cat(var_dic_list()) "
}
},
"types_to_exclude": [
"module",
"function",
"builtin_function_or_method",
"instance",
"_Feature"
],
"window_display": false
}
},
"nbformat": 4,
"nbformat_minor": 5
}

View File

@ -40,11 +40,9 @@
"outputs": [],
"source": [
"import logging\n",
"import mango\n",
"import pandas as pd\n",
"import time\n",
"\n",
"from BaseModel import Group, MarginAccount, OpenOrders\n",
"from Context import default_context\n"
"import time\n"
]
},
{
@ -54,12 +52,11 @@
"metadata": {},
"outputs": [],
"source": [
"logging.getLogger().setLevel(logging.INFO)\n",
"\n",
"start_time = time.time()\n",
"context = mango.default_context\n",
"\n",
"print(\"Loading group...\")\n",
"group = Group.load(default_context)\n",
"group = mango.Group.load(context)\n",
"print(f\"Done. Time taken: {time.time() - start_time}\")\n",
"\n",
"print(\"Loading prices...\")\n",
@ -67,16 +64,7 @@
"print(f\"Done. Time taken: {time.time() - start_time}\")\n",
"\n",
"print(\"Loading margin accounts...\")\n",
"margin_accounts = MarginAccount.load_all_for_group(default_context, default_context.program_id, group)\n",
"print(f\"Done. Time taken: {time.time() - start_time}\")\n",
"\n",
"print(\"Loading open orders accounts...\")\n",
"all_open_orders = OpenOrders.load_raw_open_orders_account_infos(default_context, group)\n",
"print(f\"Done. Time taken: {time.time() - start_time}\")\n",
"\n",
"print(\"Installing open orders accounts...\")\n",
"for margin_account in margin_accounts:\n",
" margin_account.install_open_orders_accounts(group, all_open_orders)\n",
"margin_accounts = mango.MarginAccount.load_all_for_group_with_open_orders(context, context.program_id, group)\n",
"print(f\"Done. Time taken: {time.time() - start_time}\")\n",
"\n",
"print(\"Loading pandas dataframe...\")\n",
@ -272,9 +260,8 @@
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
"name": "python39464bit39b046140da4445497cec6b3acc85e88",
"display_name": "Python 3.9.4 64-bit"
},
"language_info": {
"codemirror_mode": {
@ -286,7 +273,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.8.6"
"version": "3.9.4"
},
"toc": {
"base_numbering": 1,
@ -329,8 +316,13 @@
"_Feature"
],
"window_display": false
},
"metadata": {
"interpreter": {
"hash": "ac2eaa0ea0ebeafcc7822e65e46aa9d4f966f30b695406963e145ea4a91cd4fc"
}
}
},
"nbformat": 4,
"nbformat_minor": 5
}
}

View File

@ -1,264 +0,0 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "continental-protein",
"metadata": {},
"source": [
"# ⚠ Warning\n",
"\n",
"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.\n",
"\n",
"[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gl/OpinionatedGeek%2Fmango-explorer/HEAD?filepath=Retrier.ipynb) _🏃 To run this notebook press the ⏩ icon in the toolbar above._\n",
"\n",
"[🥭 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)"
]
},
{
"cell_type": "markdown",
"id": "drawn-valve",
"metadata": {},
"source": [
"# 🥭 Retrier\n",
"\n",
"This notebook creates a 'retrier' context that can automatically retry failing functions."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "strategic-seating",
"metadata": {
"jupyter": {
"source_hidden": true
}
},
"outputs": [],
"source": [
"import logging\n",
"import typing\n",
"import requests.exceptions\n",
"import time\n",
"\n",
"from contextlib import contextmanager\n",
"from decimal import Decimal\n"
]
},
{
"cell_type": "markdown",
"id": "large-bosnia",
"metadata": {},
"source": [
"# RetryWithPauses class\n",
"\n",
"This class takes a function and a list of durations to pause after a failed call.\n",
"\n",
"If the function succeeds, the resultant value is returned.\n",
"\n",
"If the function fails by raising an exception, the call pauses for the duration at the head of the list, then the head of the list is moved and the function is retried.\n",
"\n",
"It is retried up to the number of entries in the list of delays. If they all fail, the last failing exception is re-raised.\n",
"\n",
"This can be particularly helpful in cases where rate limits prevent further processing.\n",
"\n",
"This class is best used in a `with...` block using the `retry_context()` function below."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "reported-muscle",
"metadata": {},
"outputs": [],
"source": [
"class RetryWithPauses:\n",
" def __init__(self, name: str, func: typing.Callable, pauses: typing.List[Decimal]) -> None:\n",
" self.logger: logging.Logger = logging.getLogger(self.__class__.__name__)\n",
" self.name: str = name\n",
" self.func: typing.Callable = func\n",
" self.pauses: typing.List[Decimal] = pauses\n",
"\n",
" def run(self, *args):\n",
" captured_exception: Exception = None\n",
" for sleep_time_on_error in self.pauses:\n",
" try:\n",
" return self.func(*args)\n",
" except requests.exceptions.HTTPError as exception:\n",
" captured_exception = exception\n",
" if exception.response is not None:\n",
" # \"You will see HTTP respose codes 429 for too many requests\n",
" # or 413 for too much bandwidth.\"\n",
" if exception.response.status_code == 413:\n",
" self.logger.info(f\"Retriable call [{self.name}] rate limited (too much bandwidth) with error '{exception}'.\")\n",
" elif exception.response.status_code == 429:\n",
" self.logger.info(f\"Retriable call [{self.name}] rate limited (too many requests) with error '{exception}'.\")\n",
" else:\n",
" self.logger.info(f\"Retriable call [{self.name}] failed with unexpected HTTP error '{exception}'.\")\n",
" else:\n",
" self.logger.info(f\"Retriable call [{self.name}] failed with unknown HTTP error '{exception}'.\")\n",
" except Exception as exception:\n",
" self.logger.info(f\"Retriable call failed [{self.name}] with error '{exception}'.\")\n",
" captured_exception = exception\n",
"\n",
" if sleep_time_on_error < 0:\n",
" self.logger.info(f\"No more retries for [{self.name}] - propagating exception.\")\n",
" raise captured_exception\n",
"\n",
" self.logger.info(f\"Will retry [{self.name}] call in {sleep_time_on_error} second(s).\")\n",
" time.sleep(float(sleep_time_on_error))\n",
"\n",
" self.logger.info(f\"End of retry loop for [{self.name}] - propagating exception.\")\n",
" raise captured_exception\n"
]
},
{
"cell_type": "markdown",
"id": "handled-channel",
"metadata": {},
"source": [
"# retry_context generator\n",
"\n",
"This is a bit of Python 'magic' to allow using the Retrier in a `with...` block.\n",
"\n",
"For example, this will call function `some_function(param1, param2)` up to `retry_count` times (7 in this case). It will only retry if the function throws an exception - the result of the first successful call is used to set the `result` variable:\n",
"```\n",
"pauses = [Decimal(1), Decimal(2), Decimal(4)]\n",
"with retry_context(\"Account Access\", some_function, pauses) as retrier:\n",
" result = retrier.run(param1, param2)\n",
"```"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "swedish-thermal",
"metadata": {},
"outputs": [],
"source": [
"@contextmanager\n",
"def retry_context(name: str, func: typing.Callable, pauses: typing.List[Decimal]) -> typing.Iterator[RetryWithPauses]:\n",
" yield RetryWithPauses(name, func, pauses)\n"
]
},
{
"cell_type": "markdown",
"id": "current-validation",
"metadata": {},
"source": [
"# 🏃 Running\n",
"\n",
"Run a failing method, retrying it 5 times, just to show how it works in practice."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "stopped-heavy",
"metadata": {},
"outputs": [],
"source": [
"if __name__ == \"__main__\":\n",
" logging.getLogger().setLevel(logging.INFO)\n",
"\n",
" def _raiser(value):\n",
" # All this does is raise an exception\n",
" raise Exception(f\"This is a test: {value}\")\n",
"\n",
" # NOTE! This will fail by design, with the exception message:\n",
" # \"Exception: This is a test: ignored parameter\"\n",
"# with retry_context(_raiser, 5) as retrier:\n",
"# response = retrier.run(\"ignored parameter\")\n",
"\n",
" import rx\n",
" import rx.operators as ops\n",
"\n",
" from decimal import Decimal\n",
" from rx.scheduler import ThreadPoolScheduler\n",
"\n",
" from BaseModel import AccountInfo\n",
" from Context import default_context\n",
" from Observables import PrintingObserverSubscriber\n",
" from solana.publickey import PublicKey\n",
"\n",
" def fetch_group_account(context, pauses):\n",
" def _fetch(_):\n",
" def _actual_fetcher():\n",
" return AccountInfo.load(context, PublicKey(\"7pVYhpKUHw88neQHxgExSH6cerMZ1Axx1ALQP9sxtvQV\"))\n",
" with retry_context(\"Group Account Loader\", _actual_fetcher, pauses) as retrier:\n",
" return retrier.run()\n",
" return _fetch\n",
"\n",
" default_context = default_context.new_from_cluster_url(\"https://api.rpcpool.com\")\n",
" pool_scheduler = ThreadPoolScheduler(10)\n",
" rx_subscription = rx.interval(0.01).pipe(\n",
" ops.subscribe_on(pool_scheduler),\n",
" ops.map(fetch_group_account(default_context, [Decimal(1), Decimal(2), Decimal(4), Decimal(8), Decimal(16), Decimal(32)])),\n",
" ops.take(100)\n",
" ).subscribe(PrintingObserverSubscriber(True))\n"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.8.6"
},
"toc": {
"base_numbering": 1,
"nav_menu": {},
"number_sections": true,
"sideBar": true,
"skip_h1_title": false,
"title_cell": "Table of Contents",
"title_sidebar": "Contents",
"toc_cell": false,
"toc_position": {},
"toc_section_display": true,
"toc_window_display": true
},
"varInspector": {
"cols": {
"lenName": 16,
"lenType": 16,
"lenVar": 40
},
"kernels_config": {
"python": {
"delete_cmd_postfix": "",
"delete_cmd_prefix": "del ",
"library": "var_list.py",
"varRefreshCmd": "print(var_dic_list())"
},
"r": {
"delete_cmd_postfix": ") ",
"delete_cmd_prefix": "rm(",
"library": "var_list.r",
"varRefreshCmd": "cat(var_dic_list()) "
}
},
"types_to_exclude": [
"module",
"function",
"builtin_function_or_method",
"instance",
"_Feature"
],
"window_display": false
}
},
"nbformat": 4,
"nbformat_minor": 5
}

View File

@ -2,7 +2,7 @@
"cells": [
{
"cell_type": "markdown",
"id": "impressed-compatibility",
"id": "convenient-namibia",
"metadata": {},
"source": [
"# ⚠ Warning\n",
@ -16,7 +16,7 @@
},
{
"cell_type": "markdown",
"id": "banner-grain",
"id": "super-height",
"metadata": {},
"source": [
"# 🥭 Show My Accounts\n",
@ -28,7 +28,7 @@
},
{
"cell_type": "markdown",
"id": "square-private",
"id": "public-logan",
"metadata": {},
"source": [
"## How To Use This Page\n",
@ -38,80 +38,190 @@
},
{
"cell_type": "code",
"execution_count": null,
"id": "spiritual-channel",
"metadata": {
"jupyter": {
"source_hidden": true
"execution_count": 1,
"id": "atmospheric-jaguar",
"metadata": {},
"outputs": [],
"source": [
"ACCOUNT_TO_LOOK_UP = \"4dgCXC4B6dnrcX3igFujgfzE1fqsEwA4JPcAdvVkysCj\""
]
},
{
"cell_type": "code",
"execution_count": 2,
"id": "injured-index",
"metadata": {},
"outputs": [
{
"output_type": "stream",
"name": "stdout",
"text": [
"Account: « AccountInfo [4dgCXC4B6dnrcX3igFujgfzE1fqsEwA4JPcAdvVkysCj]:\n",
" Owner: 11111111111111111111111111111111\n",
" Executable: False\n",
" Lamports: 5181046802\n",
" Rent Epoch: 188\n",
"»\n",
"« ScoutReport [4dgCXC4B6dnrcX3igFujgfzE1fqsEwA4JPcAdvVkysCj]:\n",
" Summary:\n",
" Found 0 error(s) and 1 warning(s).\n",
"\n",
" Errors:\n",
" None\n",
"\n",
" Warnings:\n",
" No Serum open orders account for market 'Wrapped Ethereum (Sollet)/USDT' [7dLVkUfBVfCGkFhSXDCq1ukM9usathSgS716t643iFGF]'.\n",
"\n",
" Details:\n",
" Account '4dgCXC4B6dnrcX3igFujgfzE1fqsEwA4JPcAdvVkysCj' has 1 Wrapped Bitcoin (Sollet) token accounts with mint '9n4nbM75f5Ui33ZbPYXn59EwSgE8CGsHtAeTH5YFeJ9E'.\n",
" Account '4dgCXC4B6dnrcX3igFujgfzE1fqsEwA4JPcAdvVkysCj' has 1 Wrapped Ethereum (Sollet) token accounts with mint '2FPyTwcZLUg1MDrwsyoP4D6s1tM7hAkHYRjkNb5w6Pxk'.\n",
" Account '4dgCXC4B6dnrcX3igFujgfzE1fqsEwA4JPcAdvVkysCj' has 1 USDT token accounts with mint 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB'.\n",
" Serum open orders account for market 'Wrapped Bitcoin (Sollet)/USDT': « OpenOrders:\n",
" Flags: « SerumAccountFlags: initialized | open_orders »\n",
" Program ID: 9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin\n",
" Address: E5izPLtf7BSN2ppAb3aoyTVATpbk9CYxjTgTDjW9oF2k\n",
" Market: C1EuT9VokAKLiW7i2ASnZUvxDoKuKkCpDDeNxAptuNe4\n",
" Owner: 4dgCXC4B6dnrcX3igFujgfzE1fqsEwA4JPcAdvVkysCj\n",
" Base Token: 0.00000000 of 0.00000000\n",
" Quote Token: 0.00000000 of 0.00000000\n",
" Referrer Rebate Accrued: 37053\n",
" Orders:\n",
" None\n",
" Client IDs:\n",
" None\n",
" »\n",
" Margin account: « MarginAccount: DyYvjvX6MenduvMVYyXYDpSCdv1Tuuyv7ZFG4EMjtvf8\n",
" Flags: « MangoAccountFlags: initialized | margin_account »\n",
" Has Borrows: False\n",
" Owner: 4dgCXC4B6dnrcX3igFujgfzE1fqsEwA4JPcAdvVkysCj\n",
" Mango Group: 7pVYhpKUHw88neQHxgExSH6cerMZ1Axx1ALQP9sxtvQV\n",
" Deposits: [0.00002955, 0.00003799, 9,998,188.61753728]\n",
" Borrows: [0.00000000, 0.00000000, 0.00000000]\n",
" Mango Open Orders: [« OpenOrders:\n",
" Flags: « SerumAccountFlags: initialized | open_orders »\n",
" Program ID: 9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin\n",
" Address: 2aGPAoyhMDYV1Ldeutu2mcExqWjPH67bKLMducoz7eEg\n",
" Market: C1EuT9VokAKLiW7i2ASnZUvxDoKuKkCpDDeNxAptuNe4\n",
" Owner: 6Lce22Pbnb1AkKyGfQArVAsRsHTP31PAhxLv4XaK8i1P\n",
" Base Token: 0.00000000 of 0.00000000\n",
" Quote Token: 0.00000000 of 0.00000000\n",
" Referrer Rebate Accrued: 0\n",
" Orders:\n",
" None\n",
" Client IDs:\n",
" None\n",
" », « OpenOrders:\n",
" Flags: « SerumAccountFlags: initialized | open_orders »\n",
" Program ID: 9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin\n",
" Address: H7zvXo9nAr3pqM8TC7xgktm9YtKV7RPn6phZS9Sr9y8d\n",
" Market: 7dLVkUfBVfCGkFhSXDCq1ukM9usathSgS716t643iFGF\n",
" Owner: 6Lce22Pbnb1AkKyGfQArVAsRsHTP31PAhxLv4XaK8i1P\n",
" Base Token: 0.00000000 of 0.00000000\n",
" Quote Token: 0.00000000 of 0.00000000\n",
" Referrer Rebate Accrued: 0\n",
" Orders:\n",
" None\n",
" Client IDs:\n",
" None\n",
" »]\n",
" »\n",
"»\n",
"Balances:\n",
"INFO:Group:Fetching prices complete. Time taken: 0.13 seconds.\n",
" 5.18104680 Pure SOL\n",
" 0.00000200 Wrapped Bitcoin (Sollet)\n",
" 0.00000000 Wrapped Ethereum (Sollet)\n",
" 0.00000000 USDT\n",
"Account has 1 margin account(s).\n",
"Margin account: « MarginAccount: DyYvjvX6MenduvMVYyXYDpSCdv1Tuuyv7ZFG4EMjtvf8\n",
" Flags: « MangoAccountFlags: initialized | margin_account »\n",
" Has Borrows: False\n",
" Owner: 4dgCXC4B6dnrcX3igFujgfzE1fqsEwA4JPcAdvVkysCj\n",
" Mango Group: 7pVYhpKUHw88neQHxgExSH6cerMZ1Axx1ALQP9sxtvQV\n",
" Deposits: [0.00002955, 0.00003799, 9,998,188.61753728]\n",
" Borrows: [0.00000000, 0.00000000, 0.00000000]\n",
" Mango Open Orders: [« OpenOrders:\n",
" Flags: « SerumAccountFlags: initialized | open_orders »\n",
" Program ID: 9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin\n",
" Address: 2aGPAoyhMDYV1Ldeutu2mcExqWjPH67bKLMducoz7eEg\n",
" Market: C1EuT9VokAKLiW7i2ASnZUvxDoKuKkCpDDeNxAptuNe4\n",
" Owner: 6Lce22Pbnb1AkKyGfQArVAsRsHTP31PAhxLv4XaK8i1P\n",
" Base Token: 0.00000000 of 0.00000000\n",
" Quote Token: 0.00000000 of 0.00000000\n",
" Referrer Rebate Accrued: 0\n",
" Orders:\n",
" None\n",
" Client IDs:\n",
" None\n",
" », « OpenOrders:\n",
" Flags: « SerumAccountFlags: initialized | open_orders »\n",
" Program ID: 9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin\n",
" Address: H7zvXo9nAr3pqM8TC7xgktm9YtKV7RPn6phZS9Sr9y8d\n",
" Market: 7dLVkUfBVfCGkFhSXDCq1ukM9usathSgS716t643iFGF\n",
" Owner: 6Lce22Pbnb1AkKyGfQArVAsRsHTP31PAhxLv4XaK8i1P\n",
" Base Token: 0.00000000 of 0.00000000\n",
" Quote Token: 0.00000000 of 0.00000000\n",
" Referrer Rebate Accrued: 0\n",
" Orders:\n",
" None\n",
" Client IDs:\n",
" None\n",
" »]\n",
"»\n",
"Balance sheet totals « BalanceSheet [Wrapped Bitcoin (Sollet)-Wrapped Ethereum (Sollet)-USDT Summary]:\n",
" Assets : 10.01349600\n",
" Settled Assets : 10.01349600\n",
" Unsettled Assets : 0.00000000\n",
" Liabilities : 0.00000000\n",
" Value : 10.01349600\n",
" Collateral Ratio : 0.00%\n",
"»\n",
"\n"
]
}
},
"outputs": [],
],
"source": [
"import logging\n",
"logging.getLogger().setLevel(logging.ERROR)\n",
"if __name__ == \"__main__\":\n",
" import base64\n",
" import mango\n",
" import solana.publickey as publickey\n",
"\n",
"from solana.publickey import PublicKey\n",
" if ACCOUNT_TO_LOOK_UP == \"\":\n",
" raise Exception(\"No account to look up - try setting the variable ACCOUNT_TO_LOOK_UP to an account public key.\")\n",
"\n",
"from AccountScout import AccountScout\n",
"from BaseModel import AccountInfo, Group, MarginAccount, TokenValue\n",
"from Constants import SYSTEM_PROGRAM_ADDRESS\n",
"from Context import default_context\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "anonymous-alpha",
"metadata": {},
"outputs": [],
"source": [
"ACCOUNT_TO_LOOK_UP = \"\""
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "measured-winning",
"metadata": {},
"outputs": [],
"source": [
"if ACCOUNT_TO_LOOK_UP == \"\":\n",
" raise Exception(\"No account to look up - try setting the variable ACCOUNT_TO_LOOK_UP to an account public key.\")\n",
" # print(\"Context:\", default_context)\n",
"\n",
"# print(\"Context:\", default_context)\n",
" root_account_key = publickey.PublicKey(ACCOUNT_TO_LOOK_UP)\n",
" root_account = mango.AccountInfo.load(mango.default_context, root_account_key)\n",
" if root_account is None:\n",
" raise Exception(f\"Account '{root_account_key}' could not be found.\")\n",
"\n",
"root_account_key = PublicKey(ACCOUNT_TO_LOOK_UP)\n",
"root_account = AccountInfo.load(default_context, root_account_key)\n",
"if root_account is None:\n",
" raise Exception(f\"Account '{root_account_key}' could not be found.\")\n",
" print(\"Account:\", root_account)\n",
" if root_account.owner != mango.SYSTEM_PROGRAM_ADDRESS:\n",
" raise Exception(f\"Account '{root_account_key}' is not a root user account.\")\n",
"\n",
"print(\"Account:\", root_account)\n",
"if root_account.owner != SYSTEM_PROGRAM_ADDRESS:\n",
" raise Exception(f\"Account '{root_account_key}' is not a root user account.\")\n",
" scout = mango.AccountScout()\n",
" group = mango.Group.load(mango.default_context)\n",
" scout_report = scout.verify_account_prepared_for_group(mango.default_context, group, root_account_key)\n",
" print(scout_report)\n",
"\n",
"scout = AccountScout()\n",
"group = Group.load(default_context)\n",
"scout_report = scout.verify_account_prepared_for_group(default_context, group, root_account_key)\n",
"print(scout_report)\n",
" print(\"Balances:\")\n",
" mango.TokenValue.report(print, group.fetch_balances(root_account_key))\n",
"\n",
"print(\"Balances:\")\n",
"TokenValue.report(print, group.fetch_balances(root_account_key))\n",
" prices = group.fetch_token_prices()\n",
"\n",
"prices = group.fetch_token_prices()\n",
"\n",
"margin_accounts = MarginAccount.load_all_for_owner(default_context, root_account_key, group)\n",
"print(f\"Account has {len(margin_accounts)} margin account(s).\")\n",
"for margin_account in margin_accounts:\n",
" print(\"Margin account:\", margin_account)\n",
" print(\"Balance sheet totals\", margin_account.get_balance_sheet_totals(group, prices))\n"
" margin_accounts = mango.MarginAccount.load_all_for_owner(mango.default_context, root_account_key, group)\n",
" print(f\"Account has {len(margin_accounts)} margin account(s).\")\n",
" for margin_account in margin_accounts:\n",
" print(\"Margin account:\", margin_account)\n",
" print(\"Balance sheet totals\", margin_account.get_balance_sheet_totals(group, prices))\n"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
"name": "python39464bit39b046140da4445497cec6b3acc85e88",
"display_name": "Python 3.9.4 64-bit"
},
"language_info": {
"codemirror_mode": {
@ -123,7 +233,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.8.6"
"version": "3.9.4"
},
"toc": {
"base_numbering": 1,
@ -166,8 +276,13 @@
"_Feature"
],
"window_display": false
},
"metadata": {
"interpreter": {
"hash": "ac2eaa0ea0ebeafcc7822e65e46aa9d4f966f30b695406963e145ea4a91cd4fc"
}
}
},
"nbformat": 4,
"nbformat_minor": 5
}
}

View File

@ -40,58 +40,523 @@
},
{
"cell_type": "code",
"execution_count": null,
"id": "determined-blair",
"metadata": {
"jupyter": {
"source_hidden": true
}
},
"outputs": [],
"source": [
"import logging\n",
"logging.getLogger().setLevel(logging.ERROR)\n",
"\n",
"from BaseModel import Group, MarginAccount\n",
"from Context import Context, default_context\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"execution_count": 1,
"id": "sufficient-parent",
"metadata": {},
"outputs": [],
"metadata": {
"tags": [
"outputPrepend"
]
},
"outputs": [
{
"output_type": "stream",
"name": "stdout",
"text": [
" Flags: « MangoAccountFlags: initialized | margin_account »\n",
" Has Borrows: False\n",
" Owner: 7Gw2zd6qH1fSWt4rujPGqKJE9uYDzFh7kRssQhFbk5L8\n",
" Mango Group: 7pVYhpKUHw88neQHxgExSH6cerMZ1Axx1ALQP9sxtvQV\n",
" Deposits: [0.99999949, 0.00000000, 1,808.31682879]\n",
" Borrows: [0.99996877, 0.00000000, 0.00000000]\n",
" Mango Open Orders: [« OpenOrders:\n",
" Flags: « SerumAccountFlags: initialized | open_orders »\n",
" Program ID: 9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin\n",
" Address: Ahf5spm5RKde7eh19KyhqLC9LVV1sJXkjYhoESjTKEDk\n",
" Market: C1EuT9VokAKLiW7i2ASnZUvxDoKuKkCpDDeNxAptuNe4\n",
" Owner: 6Lce22Pbnb1AkKyGfQArVAsRsHTP31PAhxLv4XaK8i1P\n",
" Base Token: 0.00000000 of 0.00000000\n",
" Quote Token: 0.56926100 of 0.56926100\n",
" Referrer Rebate Accrued: 3279\n",
" Orders:\n",
" None\n",
" Client IDs:\n",
" None\n",
" », None]\n",
"»\n",
"« MarginAccount: Eyjjz82y5TdTR5o1ZnQSfs3JFyraqAJPUGktWfyE2TfX\n",
" Flags: « MangoAccountFlags: initialized | margin_account »\n",
" Has Borrows: False\n",
" Owner: D6NTk2ZFQLQtKT6Kieb4sGKLuxrPHQpRc487RF8GGQL6\n",
" Mango Group: 7pVYhpKUHw88neQHxgExSH6cerMZ1Axx1ALQP9sxtvQV\n",
" Deposits: [0.00000000, 0.00000000, 864.20040498]\n",
" Borrows: [0.00000000, 0.00000000, 0.01667864]\n",
" Mango Open Orders: [None, « OpenOrders:\n",
" Flags: « SerumAccountFlags: initialized | open_orders »\n",
" Program ID: 9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin\n",
" Address: 2b7Hw4HVKzTwkwzFiDJB3S6JEyTArNeiYeP79XKfncU3\n",
" Market: 7dLVkUfBVfCGkFhSXDCq1ukM9usathSgS716t643iFGF\n",
" Owner: 6Lce22Pbnb1AkKyGfQArVAsRsHTP31PAhxLv4XaK8i1P\n",
" Base Token: 0.00000000 of 0.00000000\n",
" Quote Token: 0.00000000 of 0.00000000\n",
" Referrer Rebate Accrued: 5669\n",
" Orders:\n",
" None\n",
" Client IDs:\n",
" None\n",
" »]\n",
"»\n",
"« MarginAccount: 8Z5B3T9qPkt5i5QodWkhenWZSGueYMgLe2N926SUTqWd\n",
" Flags: « MangoAccountFlags: initialized | margin_account »\n",
" Has Borrows: False\n",
" Owner: C7WN4dN4xK3Dj6RWNf8Px9WNw6gN471TFQ7QgP9bgf6k\n",
" Mango Group: 7pVYhpKUHw88neQHxgExSH6cerMZ1Axx1ALQP9sxtvQV\n",
" Deposits: [0.00000000, 0.00000000, 2.17015300]\n",
" Borrows: [0.00000000, 0.00000000, 0.04401799]\n",
" Mango Open Orders: [« OpenOrders:\n",
" Flags: « SerumAccountFlags: initialized | open_orders »\n",
" Program ID: 9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin\n",
" Address: 6dLNT6PMKWosB7KyeyFGiDY6Awm9yCiBwdniJRiQHkpk\n",
" Market: C1EuT9VokAKLiW7i2ASnZUvxDoKuKkCpDDeNxAptuNe4\n",
" Owner: 6Lce22Pbnb1AkKyGfQArVAsRsHTP31PAhxLv4XaK8i1P\n",
" Base Token: 0.00000000 of 0.00000000\n",
" Quote Token: 0.00000000 of 0.00000000\n",
" Referrer Rebate Accrued: 16275\n",
" Orders:\n",
" None\n",
" Client IDs:\n",
" None\n",
" », None]\n",
"»\n",
"« MarginAccount: 3kDUTt318MB1XiVARkB6K1vdpKsMHkh5byHdyQm7ciav\n",
" Flags: « MangoAccountFlags: initialized | margin_account »\n",
" Has Borrows: False\n",
" Owner: 2sSAnruvmvrhucdfC12ZeSyMxc2S2YHBGeLukYkiTn6a\n",
" Mango Group: 7pVYhpKUHw88neQHxgExSH6cerMZ1Axx1ALQP9sxtvQV\n",
" Deposits: [0.00000000, 91.00001261, 1.09154931]\n",
" Borrows: [0.00000000, 0.00000000, 0.00000000]\n",
" Mango Open Orders: [None, « OpenOrders:\n",
" Flags: « SerumAccountFlags: initialized | open_orders »\n",
" Program ID: 9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin\n",
" Address: CevNXbGghxL7M8ALVvxHDgr5pd4ap4DgNEPCMrViQwLH\n",
" Market: 7dLVkUfBVfCGkFhSXDCq1ukM9usathSgS716t643iFGF\n",
" Owner: 6Lce22Pbnb1AkKyGfQArVAsRsHTP31PAhxLv4XaK8i1P\n",
" Base Token: 0.00000000 of 0.00000000\n",
" Quote Token: 0.00000000 of 0.00000000\n",
" Referrer Rebate Accrued: 2655\n",
" Orders:\n",
" None\n",
" Client IDs:\n",
" None\n",
" »]\n",
"»\n",
"« MarginAccount: Aaa1zANQXwpHgapbZDmoHzLckf9sxcWUyMre5TvDMJe4\n",
" Flags: « MangoAccountFlags: initialized | margin_account »\n",
" Has Borrows: False\n",
" Owner: 5GqJMdZ4JnDgEw2CFvz5LZLBBbyw6tf3CxCXQp4yYgV9\n",
" Mango Group: 7pVYhpKUHw88neQHxgExSH6cerMZ1Axx1ALQP9sxtvQV\n",
" Deposits: [0.00000000, 0.00000000, 6,182.22764382]\n",
" Borrows: [0.00000000, 0.00000000, 0.04700387]\n",
" Mango Open Orders: [« OpenOrders:\n",
" Flags: « SerumAccountFlags: initialized | open_orders »\n",
" Program ID: 9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin\n",
" Address: 35bB8YcALCLS9eo9h3aLeGYcSCdMR5VMvR7pR6cSrtJE\n",
" Market: C1EuT9VokAKLiW7i2ASnZUvxDoKuKkCpDDeNxAptuNe4\n",
" Owner: 6Lce22Pbnb1AkKyGfQArVAsRsHTP31PAhxLv4XaK8i1P\n",
" Base Token: 0.00000000 of 0.00000000\n",
" Quote Token: 0.00000000 of 0.00000000\n",
" Referrer Rebate Accrued: 1692\n",
" Orders:\n",
" None\n",
" Client IDs:\n",
" None\n",
" », None]\n",
"»\n",
"« MarginAccount: FMpWyg4Sym81s7EiAfUkkxecB5kRHhKTWGJAMpWRCBmi\n",
" Flags: « MangoAccountFlags: initialized | margin_account »\n",
" Has Borrows: False\n",
" Owner: 7VwtaXVEUKvSm9poocm8kmPDtfeahr36kRMKTNHhftsD\n",
" Mango Group: 7pVYhpKUHw88neQHxgExSH6cerMZ1Axx1ALQP9sxtvQV\n",
" Deposits: [0.00000000, 1,750,000.20154046, 0.00000000]\n",
" Borrows: [0.00000000, 0.00000000, 1,987,232,106.56844197]\n",
" Mango Open Orders: [None, None]\n",
"»\n",
"« MarginAccount: 61XPMp7sYe4QVY8P5Yc4FvATp9u1wKY3yumjcVwbnRPx\n",
" Flags: « MangoAccountFlags: initialized | margin_account »\n",
" Has Borrows: False\n",
" Owner: AZnKNSYvYG5Jt497rtNLfRizeUCSA1qevsoj1ryZmyXh\n",
" Mango Group: 7pVYhpKUHw88neQHxgExSH6cerMZ1Axx1ALQP9sxtvQV\n",
" Deposits: [8,802.29828567, 0.00000000, 0.00000000]\n",
" Borrows: [0.00000000, 0.00000000, 230,480,543.53984273]\n",
" Mango Open Orders: [None, None]\n",
"»\n",
"« MarginAccount: DFpP5ZMNApg8x9ukE7oVrQX4XV1KHPUDew2Jx8iCe4mJ\n",
" Flags: « MangoAccountFlags: initialized | margin_account »\n",
" Has Borrows: False\n",
" Owner: HSKcxEFAwE1QojQuf4cWYnesp1F9uHFKK9nByH2jy8j3\n",
" Mango Group: 7pVYhpKUHw88neQHxgExSH6cerMZ1Axx1ALQP9sxtvQV\n",
" Deposits: [0.00000000, 0.00000000, 958.15426080]\n",
" Borrows: [0.00000000, 0.00000000, 0.00000000]\n",
" Mango Open Orders: [None, « OpenOrders:\n",
" Flags: « SerumAccountFlags: initialized | open_orders »\n",
" Program ID: 9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin\n",
" Address: G9S4GdzaBW4L6Vx8eNuUbiByYt3Nz6TpE2WaV7pZ9sQL\n",
" Market: 7dLVkUfBVfCGkFhSXDCq1ukM9usathSgS716t643iFGF\n",
" Owner: 6Lce22Pbnb1AkKyGfQArVAsRsHTP31PAhxLv4XaK8i1P\n",
" Base Token: 0.00000000 of 0.00000000\n",
" Quote Token: 0.00000000 of 0.00000000\n",
" Referrer Rebate Accrued: 3262\n",
" Orders:\n",
" None\n",
" Client IDs:\n",
" None\n",
" »]\n",
"»\n",
"« MarginAccount: 9mbUg2HueYoEVNppULvxs5Ja86jXZ7ymkBpf1KkJqjf1\n",
" Flags: « MangoAccountFlags: initialized | margin_account »\n",
" Has Borrows: False\n",
" Owner: J8X9pWCtnAS7papPd91JTFUJBiyMrD8XbXWSvL8f7Z45\n",
" Mango Group: 7pVYhpKUHw88neQHxgExSH6cerMZ1Axx1ALQP9sxtvQV\n",
" Deposits: [0.00000000, 0.00000000, 5,130.21809519]\n",
" Borrows: [0.00000000, 0.00000000, 0.00229805]\n",
" Mango Open Orders: [« OpenOrders:\n",
" Flags: « SerumAccountFlags: initialized | open_orders »\n",
" Program ID: 9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin\n",
" Address: 52E9dcvFFPjGoioN5J2BYoHhFu5DbD8ZvdANSRaFAkac\n",
" Market: C1EuT9VokAKLiW7i2ASnZUvxDoKuKkCpDDeNxAptuNe4\n",
" Owner: 6Lce22Pbnb1AkKyGfQArVAsRsHTP31PAhxLv4XaK8i1P\n",
" Base Token: 0.00000000 of 0.00000000\n",
" Quote Token: 0.00000000 of 0.00000000\n",
" Referrer Rebate Accrued: 8226\n",
" Orders:\n",
" None\n",
" Client IDs:\n",
" None\n",
" », None]\n",
"»\n",
"« MarginAccount: AN9hiF4dM6D9Ycunq3QKBkLKAYGEHswcp3wKEtqBTWSc\n",
" Flags: « MangoAccountFlags: initialized | margin_account »\n",
" Has Borrows: False\n",
" Owner: 6cq8oKCyoBbS6RzH61XJeg6sNhAfQSUt5mbWxNsxNpjY\n",
" Mango Group: 7pVYhpKUHw88neQHxgExSH6cerMZ1Axx1ALQP9sxtvQV\n",
" Deposits: [0.00000000, 0.00000001, 0.00000000]\n",
" Borrows: [0.00000000, 0.00000000, 375,571.32071721]\n",
" Mango Open Orders: [None, « OpenOrders:\n",
" Flags: « SerumAccountFlags: initialized | open_orders »\n",
" Program ID: 9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin\n",
" Address: 61oWcW9pXuRCE5zmsw23Tp6aqjzJVEYUwZAWPVcKcoTU\n",
" Market: 7dLVkUfBVfCGkFhSXDCq1ukM9usathSgS716t643iFGF\n",
" Owner: 6Lce22Pbnb1AkKyGfQArVAsRsHTP31PAhxLv4XaK8i1P\n",
" Base Token: 0.00000000 of 0.00000000\n",
" Quote Token: 1.93775700 of 1.93775700\n",
" Referrer Rebate Accrued: 31910\n",
" Orders:\n",
" None\n",
" Client IDs:\n",
" None\n",
" »]\n",
"»\n",
"« MarginAccount: 5kegvebyWNXnFXpvgBEnKg7ooYEgg61PijK9aMWiwPSw\n",
" Flags: « MangoAccountFlags: initialized | margin_account »\n",
" Has Borrows: False\n",
" Owner: F45kvvW5Ro2jZSCkxKtJB63g76mKkraFpmwd1CrkvAiZ\n",
" Mango Group: 7pVYhpKUHw88neQHxgExSH6cerMZ1Axx1ALQP9sxtvQV\n",
" Deposits: [0.00000000, 0.00000000, 8,225.32763516]\n",
" Borrows: [0.00000000, 0.00000000, 0.00605434]\n",
" Mango Open Orders: [« OpenOrders:\n",
" Flags: « SerumAccountFlags: initialized | open_orders »\n",
" Program ID: 9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin\n",
" Address: 6PyQo4ynXcGPJhvhzqCA8R2yRZgq4VPPikTUGYKgoQxu\n",
" Market: C1EuT9VokAKLiW7i2ASnZUvxDoKuKkCpDDeNxAptuNe4\n",
" Owner: 6Lce22Pbnb1AkKyGfQArVAsRsHTP31PAhxLv4XaK8i1P\n",
" Base Token: 0.00000000 of 0.00000000\n",
" Quote Token: 0.00000000 of 0.00000000\n",
" Referrer Rebate Accrued: 8229\n",
" Orders:\n",
" None\n",
" Client IDs:\n",
" None\n",
" », None]\n",
"»\n",
"« MarginAccount: A2uYLzAt5n4Ri865NkAf9kzKpRcLKfaXDvoumk1ZUFbx\n",
" Flags: « MangoAccountFlags: initialized | margin_account »\n",
" Has Borrows: False\n",
" Owner: FxNppaU9pRAUVNiFhT8coobk891dxTeK6PqAUPAB8kAb\n",
" Mango Group: 7pVYhpKUHw88neQHxgExSH6cerMZ1Axx1ALQP9sxtvQV\n",
" Deposits: [0.00000000, 0.00000000, 9,238.42348112]\n",
" Borrows: [0.00000000, 0.00000000, 0.00000000]\n",
" Mango Open Orders: [« OpenOrders:\n",
" Flags: « SerumAccountFlags: initialized | open_orders »\n",
" Program ID: 9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin\n",
" Address: FNuo3nu4P7HYGAbcsCkgctrJ7zdmkVywLhCbHaxoSCHs\n",
" Market: C1EuT9VokAKLiW7i2ASnZUvxDoKuKkCpDDeNxAptuNe4\n",
" Owner: 6Lce22Pbnb1AkKyGfQArVAsRsHTP31PAhxLv4XaK8i1P\n",
" Base Token: 0.00000000 of 0.00000000\n",
" Quote Token: 0.00000000 of 0.00000000\n",
" Referrer Rebate Accrued: 3326\n",
" Orders:\n",
" None\n",
" Client IDs:\n",
" None\n",
" », None]\n",
"»\n",
"« MarginAccount: 4GNqTpPsMBaT3K86tpERuYuFN1MsVehQ7rqRD3XfKkwX\n",
" Flags: « MangoAccountFlags: initialized | margin_account »\n",
" Has Borrows: False\n",
" Owner: 85ybMhH5gUwvvQZHqVdce28hwxPzfSk9e4TCqBmXGQGS\n",
" Mango Group: 7pVYhpKUHw88neQHxgExSH6cerMZ1Axx1ALQP9sxtvQV\n",
" Deposits: [0.00000000, 0.00000000, 1.01402387]\n",
" Borrows: [0.00000000, 0.00000000, 0.00098703]\n",
" Mango Open Orders: [None, « OpenOrders:\n",
" Flags: « SerumAccountFlags: initialized | open_orders »\n",
" Program ID: 9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin\n",
" Address: AuiHiVFGiimyAEz1TuZn3RN8coHtgMReEXBgT6mRQwVg\n",
" Market: 7dLVkUfBVfCGkFhSXDCq1ukM9usathSgS716t643iFGF\n",
" Owner: 6Lce22Pbnb1AkKyGfQArVAsRsHTP31PAhxLv4XaK8i1P\n",
" Base Token: 0.00000000 of 0.00000000\n",
" Quote Token: 0.00000000 of 0.00000000\n",
" Referrer Rebate Accrued: 1296\n",
" Orders:\n",
" None\n",
" Client IDs:\n",
" None\n",
" »]\n",
"»\n",
"« MarginAccount: Pr43Fwjvx3dXSHWAAFEZq27hVzCQGZdCLTS2q8LV3U8\n",
" Flags: « MangoAccountFlags: initialized | margin_account »\n",
" Has Borrows: False\n",
" Owner: 2BHhXvoKw2FyFSk9j61tKy8Y6Srbe93Q58czZvAeWao1\n",
" Mango Group: 7pVYhpKUHw88neQHxgExSH6cerMZ1Axx1ALQP9sxtvQV\n",
" Deposits: [0.00000000, 0.00000001, 0.00000000]\n",
" Borrows: [0.00000000, 0.00000000, 362,908.60580211]\n",
" Mango Open Orders: [None, « OpenOrders:\n",
" Flags: « SerumAccountFlags: initialized | open_orders »\n",
" Program ID: 9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin\n",
" Address: 2vrMTPmKUaCTS6kinVBhFjCKLWJBm5tbDAMq2Kocjhxj\n",
" Market: 7dLVkUfBVfCGkFhSXDCq1ukM9usathSgS716t643iFGF\n",
" Owner: 6Lce22Pbnb1AkKyGfQArVAsRsHTP31PAhxLv4XaK8i1P\n",
" Base Token: 0.00000000 of 0.00000000\n",
" Quote Token: 1.91264700 of 1.91264700\n",
" Referrer Rebate Accrued: 33806\n",
" Orders:\n",
" None\n",
" Client IDs:\n",
" None\n",
" »]\n",
"»\n",
"« MarginAccount: 5WuQMAHYPehRFc6qSSzEsSvf1WrAMN7Lbourj8EcC3T4\n",
" Flags: « MangoAccountFlags: initialized | margin_account »\n",
" Has Borrows: False\n",
" Owner: B9tV4PQj3qRqSYKUtn3VUPGYM42nbAnNtVCTkuGaTBsv\n",
" Mango Group: 7pVYhpKUHw88neQHxgExSH6cerMZ1Axx1ALQP9sxtvQV\n",
" Deposits: [0.00000000, 0.00000000, 1,089.99707703]\n",
" Borrows: [0.00000000, 0.00000000, 0.02649385]\n",
" Mango Open Orders: [« OpenOrders:\n",
" Flags: « SerumAccountFlags: initialized | open_orders »\n",
" Program ID: 9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin\n",
" Address: Fz4wns2Y5CmSzsG6kerni6ZhKcAdHeGXXJDgmZbz7zC2\n",
" Market: C1EuT9VokAKLiW7i2ASnZUvxDoKuKkCpDDeNxAptuNe4\n",
" Owner: 6Lce22Pbnb1AkKyGfQArVAsRsHTP31PAhxLv4XaK8i1P\n",
" Base Token: 0.00000000 of 0.00000000\n",
" Quote Token: 0.00000000 of 0.00000000\n",
" Referrer Rebate Accrued: 41329\n",
" Orders:\n",
" None\n",
" Client IDs:\n",
" None\n",
" », None]\n",
"»\n",
"« MarginAccount: 3iNnG6r2DN3EocUyz9KVsLgHWYjHiczyDgvT9jb2Mpqt\n",
" Flags: « MangoAccountFlags: initialized | margin_account »\n",
" Has Borrows: False\n",
" Owner: 4YBfEiL8VFiMRYpoTT6SD8vb7RtG1vcG94hcK4PBjHdM\n",
" Mango Group: 7pVYhpKUHw88neQHxgExSH6cerMZ1Axx1ALQP9sxtvQV\n",
" Deposits: [0.00000001, 0.00000000, 4,312.05466194]\n",
" Borrows: [0.00000000, 0.00000000, 0.06887880]\n",
" Mango Open Orders: [« OpenOrders:\n",
" Flags: « SerumAccountFlags: initialized | open_orders »\n",
" Program ID: 9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin\n",
" Address: BcbJxup9JmbHGV9CjR6DMz8rR6qXC7RFgcgEVexrytCL\n",
" Market: C1EuT9VokAKLiW7i2ASnZUvxDoKuKkCpDDeNxAptuNe4\n",
" Owner: 6Lce22Pbnb1AkKyGfQArVAsRsHTP31PAhxLv4XaK8i1P\n",
" Base Token: 0.00000000 of 0.00000000\n",
" Quote Token: 0.00000000 of 0.00000000\n",
" Referrer Rebate Accrued: 93896\n",
" Orders:\n",
" None\n",
" Client IDs:\n",
" None\n",
" », None]\n",
"»\n",
"« MarginAccount: 5FSQK7r2hnWaWXWWLJVqdiNrgwHXtBr2gTV6z5M5Chnz\n",
" Flags: « MangoAccountFlags: initialized | margin_account »\n",
" Has Borrows: False\n",
" Owner: 39ZvGdjoEk2WrPsE97bmWgaFnXHxyThTu36VdesvJkpP\n",
" Mango Group: 7pVYhpKUHw88neQHxgExSH6cerMZ1Axx1ALQP9sxtvQV\n",
" Deposits: [0.00000000, 0.00000000, 4,156.29075592]\n",
" Borrows: [0.00000000, 0.00000000, 0.00041541]\n",
" Mango Open Orders: [« OpenOrders:\n",
" Flags: « SerumAccountFlags: initialized | open_orders »\n",
" Program ID: 9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin\n",
" Address: Fy91DoshKAm7SWFxaPZWJ2N69mJB1bv6uDAdn734c4Vs\n",
" Market: C1EuT9VokAKLiW7i2ASnZUvxDoKuKkCpDDeNxAptuNe4\n",
" Owner: 6Lce22Pbnb1AkKyGfQArVAsRsHTP31PAhxLv4XaK8i1P\n",
" Base Token: 0.00000000 of 0.00000000\n",
" Quote Token: 0.00000000 of 0.00000000\n",
" Referrer Rebate Accrued: 13240\n",
" Orders:\n",
" None\n",
" Client IDs:\n",
" None\n",
" », « OpenOrders:\n",
" Flags: « SerumAccountFlags: initialized | open_orders »\n",
" Program ID: 9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin\n",
" Address: EUKqrzrhcD5jRp4LTWsXLk1PN5h1f78bruaQBc55XpoL\n",
" Market: 7dLVkUfBVfCGkFhSXDCq1ukM9usathSgS716t643iFGF\n",
" Owner: 6Lce22Pbnb1AkKyGfQArVAsRsHTP31PAhxLv4XaK8i1P\n",
" Base Token: 0.00000000 of 0.00000000\n",
" Quote Token: 0.00000000 of 0.00000000\n",
" Referrer Rebate Accrued: 0\n",
" Orders:\n",
" None\n",
" Client IDs:\n",
" None\n",
" »]\n",
"»\n",
"« MarginAccount: Hhcz3etP677yKaeV7RvQof1EtZsRsVKsXx8WJ6ZZtt9q\n",
" Flags: « MangoAccountFlags: initialized | margin_account »\n",
" Has Borrows: False\n",
" Owner: AyZ2keZ6MNxgeFi7xxPYoMXfA1o6HMYhqpb94vuj3jds\n",
" Mango Group: 7pVYhpKUHw88neQHxgExSH6cerMZ1Axx1ALQP9sxtvQV\n",
" Deposits: [0.00000000, 0.00000000, 1,557.56552393]\n",
" Borrows: [0.00000000, 0.00000000, 0.00363035]\n",
" Mango Open Orders: [« OpenOrders:\n",
" Flags: « SerumAccountFlags: initialized | open_orders »\n",
" Program ID: 9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin\n",
" Address: 3o93UAdENfUkqWfQTQ6LBRmCBGWk9bX8FMxtjcTnJ2XX\n",
" Market: C1EuT9VokAKLiW7i2ASnZUvxDoKuKkCpDDeNxAptuNe4\n",
" Owner: 6Lce22Pbnb1AkKyGfQArVAsRsHTP31PAhxLv4XaK8i1P\n",
" Base Token: 0.00000000 of 0.00000000\n",
" Quote Token: 0.00000000 of 0.00000000\n",
" Referrer Rebate Accrued: 24213\n",
" Orders:\n",
" None\n",
" Client IDs:\n",
" None\n",
" », None]\n",
"»\n",
"« MarginAccount: 5aTpri122aEBeSTQow3pxuppAPtYkfLTB8V3c2SMGWo6\n",
" Flags: « MangoAccountFlags: initialized | margin_account »\n",
" Has Borrows: False\n",
" Owner: 7ax1RLMtn2k2edET42LdK6bDB2dyX6sfqrsj9uG2JV66\n",
" Mango Group: 7pVYhpKUHw88neQHxgExSH6cerMZ1Axx1ALQP9sxtvQV\n",
" Deposits: [0.00000000, 0.00000000, 7,682.87814079]\n",
" Borrows: [0.00000000, 0.00000000, 0.00000000]\n",
" Mango Open Orders: [« OpenOrders:\n",
" Flags: « SerumAccountFlags: initialized | open_orders »\n",
" Program ID: 9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin\n",
" Address: 3xSHKXHynzNwCY3PkhDCTD1GjhaLS3xGCuAbxjDu11Z6\n",
" Market: C1EuT9VokAKLiW7i2ASnZUvxDoKuKkCpDDeNxAptuNe4\n",
" Owner: 6Lce22Pbnb1AkKyGfQArVAsRsHTP31PAhxLv4XaK8i1P\n",
" Base Token: 0.00000000 of 0.00000000\n",
" Quote Token: 0.00000000 of 0.00000000\n",
" Referrer Rebate Accrued: 3334\n",
" Orders:\n",
" None\n",
" Client IDs:\n",
" None\n",
" », None]\n",
"»\n",
"« MarginAccount: 9t4v8WbR5hqp1VEzzYUxhrHs9ugjFADbyD4QdxZ6z5Q2\n",
" Flags: « MangoAccountFlags: initialized | margin_account »\n",
" Has Borrows: False\n",
" Owner: 14Begm6DJ5cKtV8N3cVYJDXSQyRFtQcLSDkqxrWvi6RC\n",
" Mango Group: 7pVYhpKUHw88neQHxgExSH6cerMZ1Axx1ALQP9sxtvQV\n",
" Deposits: [0.00000000, 0.00000000, 6,705.43678821]\n",
" Borrows: [0.00000000, 0.00000000, 0.00000000]\n",
" Mango Open Orders: [None, « OpenOrders:\n",
" Flags: « SerumAccountFlags: initialized | open_orders »\n",
" Program ID: 9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin\n",
" Address: 9wifq8P29BkuLai4SWZBs7o2QYMbYSwkcaA7XRQFCjwW\n",
" Market: 7dLVkUfBVfCGkFhSXDCq1ukM9usathSgS716t643iFGF\n",
" Owner: 6Lce22Pbnb1AkKyGfQArVAsRsHTP31PAhxLv4XaK8i1P\n",
" Base Token: 0.00000000 of 0.00000000\n",
" Quote Token: 0.00000000 of 0.00000000\n",
" Referrer Rebate Accrued: 3287\n",
" Orders:\n",
" None\n",
" Client IDs:\n",
" None\n",
" »]\n",
"»\n",
"« MarginAccount: 9Kyp2PaqkdstKMPipgGaRoKE5nnZQSVCAxrmEMriGTED\n",
" Flags: « MangoAccountFlags: initialized | margin_account »\n",
" Has Borrows: False\n",
" Owner: Rvx3SqTxi8A4H2z9Gm6LuJb5T4FwC2x1soR8hogS4an\n",
" Mango Group: 7pVYhpKUHw88neQHxgExSH6cerMZ1Axx1ALQP9sxtvQV\n",
" Deposits: [0.00000000, 0.00000000, 64,340.28138827]\n",
" Borrows: [0.00000007, 0.00000000, 0.00000000]\n",
" Mango Open Orders: [« OpenOrders:\n",
" Flags: « SerumAccountFlags: initialized | open_orders »\n",
" Program ID: 9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin\n",
" Address: BWBTauGv2aBCUrsfpfpyurBgMpJAdpaHnUkB8ERhZDty\n",
" Market: C1EuT9VokAKLiW7i2ASnZUvxDoKuKkCpDDeNxAptuNe4\n",
" Owner: 6Lce22Pbnb1AkKyGfQArVAsRsHTP31PAhxLv4XaK8i1P\n",
" Base Token: 0.00000000 of 0.00000000\n",
" Quote Token: 0.00000000 of 0.00000000\n",
" Referrer Rebate Accrued: 3331\n",
" Orders:\n",
" None\n",
" Client IDs:\n",
" None\n",
" », None]\n",
"»\n",
"« MarginAccount: CCWqPMnTRtoXEQzaeymZQmAbRyP2w9pnnF5ukwYdWMzc\n",
" Flags: « MangoAccountFlags: initialized | margin_account »\n",
" Has Borrows: False\n",
" Owner: 47QcvmRbHTvZTrM7Pp9ATnKc9P2PPiDL1fnq68QY1LxU\n",
" Mango Group: 7pVYhpKUHw88neQHxgExSH6cerMZ1Axx1ALQP9sxtvQV\n",
" Deposits: [0.00000000, 0.00000000, 1,765.36371927]\n",
" Borrows: [0.00000000, 0.00000000, 0.00000000]\n",
" Mango Open Orders: [« OpenOrders:\n",
" Flags: « SerumAccountFlags: initialized | open_orders »\n",
" Program ID: 9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin\n",
" Address: 37Z66KL1cvUD3aa45Mdq7bhP3dgay95Liw6Y7URxf1wT\n",
" Market: C1EuT9VokAKLiW7i2ASnZUvxDoKuKkCpDDeNxAptuNe4\n",
" Owner: 6Lce22Pbnb1AkKyGfQArVAsRsHTP31PAhxLv4XaK8i1P\n",
" Base Token: 0.00000000 of 0.00000000\n",
" Quote Token: 0.00000000 of 0.00000000\n",
" Referrer Rebate Accrued: 29158\n",
" Orders:\n",
" None\n",
" Client IDs:\n",
" None\n",
" », None]\n",
"»\n"
]
}
],
"source": [
"def show_all_margin_accounts(context: Context):\n",
"if __name__ == \"__main__\":\n",
" import mango\n",
" import time\n",
" start_time = time.time()\n",
"\n",
" print(\"Loading group...\")\n",
" group = Group.load(context)\n",
" print(f\"Done loading group. Time taken: {time.time() - start_time}\")\n",
" def show_all_margin_accounts(context: mango.Context):\n",
" start_time = time.time()\n",
"\n",
" print(\"Loading margin accounts...\")\n",
"# margin_accounts = MarginAccount.load_all_for_group(default_context, default_context.program_id, group)\n",
" margin_accounts = MarginAccount.load_all_for_group_with_open_orders(context, context.program_id, group)\n",
"# margin_accounts = group.load_ripe_margin_accounts()\n",
" print(f\"Done loading {len(margin_accounts)} account(s). Total time taken: {time.time() - start_time}\")\n",
" print(\"Loading group...\")\n",
" group = mango.Group.load(context)\n",
" print(f\"Done loading group. Time taken: {time.time() - start_time}\")\n",
"\n",
" print(*margin_accounts, sep=\"\\n\")\n",
" print(\"Loading margin accounts...\")\n",
" # margin_accounts = MarginAccount.load_all_for_group(default_context, default_context.program_id, group)\n",
" margin_accounts = mango.MarginAccount.load_all_for_group_with_open_orders(context, context.program_id, group)\n",
" # margin_accounts = group.load_ripe_margin_accounts()\n",
" print(f\"Done loading {len(margin_accounts)} account(s). Total time taken: {time.time() - start_time}\")\n",
"\n",
" print(*margin_accounts, sep=\"\\n\")\n",
"\n",
"\n",
"show_all_margin_accounts(default_context)\n",
"# import cProfile\n",
"# import pstats\n",
"# cProfile.run(\"show_all_accounts()\", sort=pstats.SortKey.TIME)\n"
" show_all_margin_accounts(mango.default_context)\n",
" # import cProfile\n",
" # import pstats\n",
" # cProfile.run(\"show_all_accounts()\", sort=pstats.SortKey.TIME)\n"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
"name": "python39464bit39b046140da4445497cec6b3acc85e88",
"display_name": "Python 3.9.4 64-bit"
},
"language_info": {
"codemirror_mode": {
@ -103,7 +568,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.8.6"
"version": "3.9.4"
},
"toc": {
"base_numbering": 1,
@ -146,8 +611,13 @@
"_Feature"
],
"window_display": false
},
"metadata": {
"interpreter": {
"hash": "ac2eaa0ea0ebeafcc7822e65e46aa9d4f966f30b695406963e145ea4a91cd4fc"
}
}
},
"nbformat": 4,
"nbformat_minor": 5
}
}

View File

@ -2,7 +2,7 @@
"cells": [
{
"cell_type": "markdown",
"id": "intimate-status",
"id": "pointed-polish",
"metadata": {},
"source": [
"# ⚠ Warning\n",
@ -16,7 +16,7 @@
},
{
"cell_type": "markdown",
"id": "suspended-launch",
"id": "authorized-channel",
"metadata": {},
"source": [
"# 🥭 Show Group\n",
@ -28,7 +28,7 @@
},
{
"cell_type": "markdown",
"id": "hourly-dallas",
"id": "quality-magnet",
"metadata": {},
"source": [
"## How To Use This Page\n",
@ -38,45 +38,147 @@
},
{
"cell_type": "code",
"execution_count": null,
"id": "streaming-subsection",
"metadata": {
"jupyter": {
"source_hidden": true
"execution_count": 1,
"id": "young-exclusion",
"metadata": {},
"outputs": [],
"source": [
"GROUP_TO_LOOK_UP = \"BTC_ETH_SOL_SRM_USDC\""
]
},
{
"cell_type": "code",
"execution_count": 2,
"id": "strange-crack",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"\n",
"« Group [Version.V2] 2oogpTYm1sp6LPZAWD3bp2wsFpnV2kXL1s52yyFhW5vp:\n",
" Flags: « MangoAccountFlags: initialized | group »\n",
" Base Tokens:\n",
" « BasketToken:\n",
" « Token 'Wrapped Bitcoin (Sollet)' [9n4nbM75f5Ui33ZbPYXn59EwSgE8CGsHtAeTH5YFeJ9E (6 decimals)] »\n",
" Vault: ET8VKKD1v11oiotUDf8KgcxxbkXiSBRnkDNMotpc8VB1\n",
" Index: « Index [2021-06-07 07:51:47]: Borrow: 0.00000100, Deposit: 0.00000100 »\n",
" »\n",
" « BasketToken:\n",
" « Token 'Wrapped Ethereum (Sollet)' [2FPyTwcZLUg1MDrwsyoP4D6s1tM7hAkHYRjkNb5w6Pxk (6 decimals)] »\n",
" Vault: 4Yivi3q3MMsUHUWi7pM7PTjS5jDRwRCXorQ35UErgYiW\n",
" Index: « Index [2021-06-07 07:51:47]: Borrow: 0.00000100, Deposit: 0.00000100 »\n",
" »\n",
" « BasketToken:\n",
" « Token 'Wrapped SOL' [So11111111111111111111111111111111111111112 (9 decimals)] »\n",
" Vault: HeeaULKeHY4mXbT1NRk8b2XQuBS1A2PT2uRLT6i8u66B\n",
" Index: « Index [2021-06-07 07:51:47]: Borrow: 0.00000000, Deposit: 0.00000000 »\n",
" »\n",
" « BasketToken:\n",
" « Token 'Serum' [SRMuApVNdxXokk5GT7XD5cUUgXMBCoAz2LHeuAoKWRt (6 decimals)] »\n",
" Vault: BQEfNrLLjHG6kzHVDX6pnSkg9BaUAx6yUVZWZ9wTBe2Z\n",
" Index: « Index [2021-06-07 07:51:47]: Borrow: 0.00000100, Deposit: 0.00000100 »\n",
" »\n",
" Quote Token:\n",
" « BasketToken:\n",
" « Token 'USD Coin' [EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v (6 decimals)] »\n",
" Vault: 24PQCo4H9gkiqrN9n5qmLBb3fg5A4G9mistXWMCnVCqW\n",
" Index: « Index [2021-06-07 07:51:47]: Borrow: 0.00000100, Deposit: 0.00000100 »\n",
" »\n",
" Markets:\n",
" « Market 'BTC/USDC' [A8YFbxQYFVqKZaoYJLLUVcQiWP7G2MeEgW5wsAQgMvFw/A8YFbxQYFVqKZaoYJLLUVcQiWP7G2MeEgW5wsAQgMvFw]:\n",
" Base: « BasketToken:\n",
" « Token 'Wrapped Bitcoin (Sollet)' [9n4nbM75f5Ui33ZbPYXn59EwSgE8CGsHtAeTH5YFeJ9E (6 decimals)] »\n",
" Vault: ET8VKKD1v11oiotUDf8KgcxxbkXiSBRnkDNMotpc8VB1\n",
" Index: « Index [2021-06-07 07:51:47]: Borrow: 0.00000100, Deposit: 0.00000100 »\n",
" »\n",
" Quote: « BasketToken:\n",
" « Token 'USD Coin' [EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v (6 decimals)] »\n",
" Vault: 24PQCo4H9gkiqrN9n5qmLBb3fg5A4G9mistXWMCnVCqW\n",
" Index: « Index [2021-06-07 07:51:47]: Borrow: 0.00000100, Deposit: 0.00000100 »\n",
" »\n",
" Oracle: HxrRDnjj2Ltj9LMmtcN6PDuFqnDe3FqXDHPvs2pwmtYF (2 decimals)\n",
" »\n",
" « Market 'ETH/USDC' [4tSvZvnbyzHXLMTiFonMyxZoHmFqau1XArcRCVHLZ5gX/4tSvZvnbyzHXLMTiFonMyxZoHmFqau1XArcRCVHLZ5gX]:\n",
" Base: « BasketToken:\n",
" « Token 'Wrapped Ethereum (Sollet)' [2FPyTwcZLUg1MDrwsyoP4D6s1tM7hAkHYRjkNb5w6Pxk (6 decimals)] »\n",
" Vault: 4Yivi3q3MMsUHUWi7pM7PTjS5jDRwRCXorQ35UErgYiW\n",
" Index: « Index [2021-06-07 07:51:47]: Borrow: 0.00000100, Deposit: 0.00000100 »\n",
" »\n",
" Quote: « BasketToken:\n",
" « Token 'USD Coin' [EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v (6 decimals)] »\n",
" Vault: 24PQCo4H9gkiqrN9n5qmLBb3fg5A4G9mistXWMCnVCqW\n",
" Index: « Index [2021-06-07 07:51:47]: Borrow: 0.00000100, Deposit: 0.00000100 »\n",
" »\n",
" Oracle: 7rW5nJbAYj6m3zkr1CPPSMirTRYBZYWQUv7ZggVVt2wA (2 decimals)\n",
" »\n",
" « Market 'SOL/USDC' [9wFFyRfZBsuAha4YcuxcXLKwMxJR43S7fPfQLusDBzvT/9wFFyRfZBsuAha4YcuxcXLKwMxJR43S7fPfQLusDBzvT]:\n",
" Base: « BasketToken:\n",
" « Token 'Wrapped SOL' [So11111111111111111111111111111111111111112 (9 decimals)] »\n",
" Vault: HeeaULKeHY4mXbT1NRk8b2XQuBS1A2PT2uRLT6i8u66B\n",
" Index: « Index [2021-06-07 07:51:47]: Borrow: 0.00000000, Deposit: 0.00000000 »\n",
" »\n",
" Quote: « BasketToken:\n",
" « Token 'USD Coin' [EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v (6 decimals)] »\n",
" Vault: 24PQCo4H9gkiqrN9n5qmLBb3fg5A4G9mistXWMCnVCqW\n",
" Index: « Index [2021-06-07 07:51:47]: Borrow: 0.00000100, Deposit: 0.00000100 »\n",
" »\n",
" Oracle: A12sJL3w4rgnbeg7pyAQ8xhuNVbQEWdqN1dgwBoEktvm (4 decimals)\n",
" »\n",
" « Market 'SRM/USDC' [ByRys5tuUWDgL73G8JBAEfkdFf8JWBzPBDHsBVQ5vbQA/ByRys5tuUWDgL73G8JBAEfkdFf8JWBzPBDHsBVQ5vbQA]:\n",
" Base: « BasketToken:\n",
" « Token 'Serum' [SRMuApVNdxXokk5GT7XD5cUUgXMBCoAz2LHeuAoKWRt (6 decimals)] »\n",
" Vault: BQEfNrLLjHG6kzHVDX6pnSkg9BaUAx6yUVZWZ9wTBe2Z\n",
" Index: « Index [2021-06-07 07:51:47]: Borrow: 0.00000100, Deposit: 0.00000100 »\n",
" »\n",
" Quote: « BasketToken:\n",
" « Token 'USD Coin' [EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v (6 decimals)] »\n",
" Vault: 24PQCo4H9gkiqrN9n5qmLBb3fg5A4G9mistXWMCnVCqW\n",
" Index: « Index [2021-06-07 07:51:47]: Borrow: 0.00000100, Deposit: 0.00000100 »\n",
" »\n",
" Oracle: CM6RrPrtUo8W5DVnhwKnQVMAXiWam8vKdxVacrC3a9x6 (4 decimals)\n",
" »\n",
" DEX Program ID: « 9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin »\n",
" SRM Vault: « BQEfNrLLjHG6kzHVDX6pnSkg9BaUAx6yUVZWZ9wTBe2Z »\n",
" Admin: « FinVobfi4tbdMdfN9jhzUuDVqGXfcFnRGX57xHcTWLfW »\n",
" Signer Nonce: 0\n",
" Signer Key: « EgRvS8NJGNDYngccUG38sb4zmkjC3dAyLKuNuLn3dX6w »\n",
" Initial Collateral Ratio: 1.20\n",
" Maintenance Collateral Ratio: 1.10\n",
" Total Deposits:\n",
" 90118\n",
" 1001000.00003136111235012192235624418\n",
" 16481998694.1428658641904714699942709\n",
" 1310900000\n",
" 3340523836.34110272669966652911606453\n",
" Total Borrows:\n",
" 0\n",
" 2000\n",
" 100000000\n",
" 0\n",
" 43394317.4707347876226995078523251959\n",
" Borrow Limits:\n",
" 0\n",
" 0\n",
" 0\n",
" 0\n",
" 0\n",
"»\n",
"\n"
]
}
},
"outputs": [],
],
"source": [
"import logging\n",
"logging.getLogger().setLevel(logging.ERROR)\n",
"if __name__ == \"__main__\":\n",
" import mango\n",
"\n",
"from BaseModel import Group\n",
"from Context import default_context\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "sufficient-physiology",
"metadata": {},
"outputs": [],
"source": [
"GROUP_TO_LOOK_UP = \"\""
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "limited-washer",
"metadata": {},
"outputs": [],
"source": [
"if GROUP_TO_LOOK_UP == \"\":\n",
" raise Exception(\"No group to look up - try setting the variable GROUP_TO_LOOK_UP to an group's public key.\")\n",
" if GROUP_TO_LOOK_UP == \"\":\n",
" raise Exception(\"No group to look up - try setting the variable GROUP_TO_LOOK_UP to an group's public key.\")\n",
"\n",
"context = default_context.new_from_group_name(GROUP_TO_LOOK_UP)\n",
"group = Group.load(context)\n",
"print(group)\n"
" context = mango.default_context.new_from_group_name(GROUP_TO_LOOK_UP)\n",
" group = mango.Group.load(context)\n",
" print(group)\n"
]
}
],
@ -98,6 +200,11 @@
"pygments_lexer": "ipython3",
"version": "3.8.6"
},
"metadata": {
"interpreter": {
"hash": "ac2eaa0ea0ebeafcc7822e65e46aa9d4f966f30b695406963e145ea4a91cd4fc"
}
},
"toc": {
"base_numbering": 1,
"nav_menu": {},

View File

@ -2,7 +2,7 @@
"cells": [
{
"cell_type": "markdown",
"id": "fabulous-partnership",
"id": "moderate-pursuit",
"metadata": {},
"source": [
"# ⚠ Warning\n",
@ -16,7 +16,7 @@
},
{
"cell_type": "markdown",
"id": "square-kazakhstan",
"id": "corporate-bikini",
"metadata": {},
"source": [
"# 🥭 Show Margin Account\n",
@ -28,7 +28,7 @@
},
{
"cell_type": "markdown",
"id": "mathematical-velvet",
"id": "chicken-tsunami",
"metadata": {},
"source": [
"## How To Use This Page\n",
@ -38,46 +38,46 @@
},
{
"cell_type": "code",
"execution_count": null,
"id": "finished-pointer",
"metadata": {
"jupyter": {
"source_hidden": true
"execution_count": 1,
"id": "turkish-clearance",
"metadata": {},
"outputs": [],
"source": [
"MARGIN_ACCOUNT_TO_LOOK_UP = \"8T6eQjLG292M7ydG7FSKyzRdJ7tA6RbLGSbgDEm6FCyi\""
]
},
{
"cell_type": "code",
"execution_count": 2,
"id": "champion-fitness",
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"« MarginAccount: 8T6eQjLG292M7ydG7FSKyzRdJ7tA6RbLGSbgDEm6FCyi\n",
" Flags: « MangoAccountFlags: initialized | margin_account »\n",
" Has Borrows: False\n",
" Owner: 4dgCXC4B6dnrcX3igFujgfzE1fqsEwA4JPcAdvVkysCj\n",
" Mango Group: 2oogpTYm1sp6LPZAWD3bp2wsFpnV2kXL1s52yyFhW5vp\n",
" Deposits: [0.00000000, 0.00000000, 999,999,854.65725663, 0.00000000, 0.00000000]\n",
" Borrows: [0.00000000, 0.00000000, 0.00000000, 0.00000000, 0.00000000]\n",
" Mango Open Orders: [None, None, None, None]\n",
"»\n"
]
}
},
"outputs": [],
],
"source": [
"import logging\n",
"logging.getLogger().setLevel(logging.ERROR)\n",
"if __name__ == \"__main__\":\n",
" import mango\n",
" import solana.publickey as publickey\n",
"\n",
"from solana.publickey import PublicKey\n",
" if MARGIN_ACCOUNT_TO_LOOK_UP == \"\":\n",
" raise Exception(\"No account to look up - try setting the variable ACCOUNT_TO_LOOK_UP to an account public key.\")\n",
"\n",
"from BaseModel import MarginAccount\n",
"from Context import default_context\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "passing-professional",
"metadata": {},
"outputs": [],
"source": [
"MARGIN_ACCOUNT_TO_LOOK_UP = \"\""
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "sixth-smoke",
"metadata": {},
"outputs": [],
"source": [
"if MARGIN_ACCOUNT_TO_LOOK_UP == \"\":\n",
" raise Exception(\"No account to look up - try setting the variable ACCOUNT_TO_LOOK_UP to an account public key.\")\n",
"\n",
"margin_account = MarginAccount.load(default_context, PublicKey(MARGIN_ACCOUNT_TO_LOOK_UP))\n",
"print(margin_account)\n"
" margin_account = mango.MarginAccount.load(mango.default_context, publickey.PublicKey(MARGIN_ACCOUNT_TO_LOOK_UP))\n",
" print(margin_account)\n"
]
}
],
@ -99,6 +99,11 @@
"pygments_lexer": "ipython3",
"version": "3.8.6"
},
"metadata": {
"interpreter": {
"hash": "ac2eaa0ea0ebeafcc7822e65e46aa9d4f966f30b695406963e145ea4a91cd4fc"
}
},
"toc": {
"base_numbering": 1,
"nav_menu": {},

View File

@ -43,42 +43,510 @@
},
{
"cell_type": "code",
"execution_count": null,
"id": "assigned-headquarters",
"metadata": {
"jupyter": {
"source_hidden": true
}
},
"outputs": [],
"source": [
"import logging\n",
"logging.getLogger().setLevel(logging.ERROR)\n",
"\n",
"from BaseModel import Group\n",
"from Context import default_context\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"execution_count": 1,
"id": "sticky-button",
"metadata": {},
"outputs": [],
"metadata": {
"tags": [
"outputPrepend"
]
},
"outputs": [
{
"output_type": "stream",
"name": "stderr",
"text": [
"False\n",
" Owner: 9iYfCSsQSPjMGcMjAnnfQNrcU2323kvEEiKPdD5sLwq4\n",
" Mango Group: 7pVYhpKUHw88neQHxgExSH6cerMZ1Axx1ALQP9sxtvQV\n",
" Deposits: [0.70470341, 0.00000000, 0.00036052]\n",
" Borrows: [0.00000000, 0.00000000, 23,874.16273755]\n",
" Mango Open Orders: [« OpenOrders:\n",
" Flags: « SerumAccountFlags: initialized | open_orders »\n",
" Program ID: 9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin\n",
" Address: jpYNqk5Hj7wLX5LZxGUfsKabwJkHrFGQMvo2JZLLSVg\n",
" Market: C1EuT9VokAKLiW7i2ASnZUvxDoKuKkCpDDeNxAptuNe4\n",
" Owner: 6Lce22Pbnb1AkKyGfQArVAsRsHTP31PAhxLv4XaK8i1P\n",
" Base Token: 0.00000000 of 0.00000000\n",
" Quote Token: 0.00000000 of 0.00000000\n",
" Referrer Rebate Accrued: 0\n",
" Orders:\n",
" None\n",
" Client IDs:\n",
" None\n",
" », None]\n",
"»\n",
"« MarginAccount: EKJz2uMqUPySnwE6SRHsiaCLLtGPTYJVrbmTMaRN8iUi\n",
" Flags: « MangoAccountFlags: initialized | margin_account »\n",
" Has Borrows: False\n",
" Owner: CQVKJmWZQeSp2yqH6yo5uueZAathn4QtuWayMyQqUX3X\n",
" Mango Group: 7pVYhpKUHw88neQHxgExSH6cerMZ1Axx1ALQP9sxtvQV\n",
" Deposits: [0.00000000, 0.00000000, 0.00000000]\n",
" Borrows: [0.00000000, 0.00000462, 4,982,961.47955507]\n",
" Mango Open Orders: [« OpenOrders:\n",
" Flags: « SerumAccountFlags: initialized | open_orders »\n",
" Program ID: 9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin\n",
" Address: 6y78w2zYvapATXvFts3ai6WQADqUk1oKoYFuWuUaXbBS\n",
" Market: C1EuT9VokAKLiW7i2ASnZUvxDoKuKkCpDDeNxAptuNe4\n",
" Owner: 6Lce22Pbnb1AkKyGfQArVAsRsHTP31PAhxLv4XaK8i1P\n",
" Base Token: 0.00000000 of 0.00000000\n",
" Quote Token: 0.00000000 of 0.00000000\n",
" Referrer Rebate Accrued: 28912\n",
" Orders:\n",
" None\n",
" Client IDs:\n",
" None\n",
" », « OpenOrders:\n",
" Flags: « SerumAccountFlags: initialized | open_orders »\n",
" Program ID: 9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin\n",
" Address: 9w7mT4xs3PBTJyKzFywtcDxbEDGtePfdic7k7xgL5VBk\n",
" Market: 7dLVkUfBVfCGkFhSXDCq1ukM9usathSgS716t643iFGF\n",
" Owner: 6Lce22Pbnb1AkKyGfQArVAsRsHTP31PAhxLv4XaK8i1P\n",
" Base Token: 0.00000000 of 0.00000000\n",
" Quote Token: 0.00000000 of 6.00000000\n",
" Referrer Rebate Accrued: 0\n",
" Orders:\n",
" 1863121151444664053089\n",
" Client IDs:\n",
" None\n",
" »]\n",
"»\n",
"« MarginAccount: us9rAANVm8GQJJ5t4GUwKGSq13C8GrcQw664Hyw3RnJ\n",
" Flags: « MangoAccountFlags: initialized | margin_account »\n",
" Has Borrows: False\n",
" Owner: DvN6dDRzwynSPu8RbzaW3E78ZBk7asGn34FWQzQRmH8G\n",
" Mango Group: 7pVYhpKUHw88neQHxgExSH6cerMZ1Axx1ALQP9sxtvQV\n",
" Deposits: [2.66266107, 0.00000000, 0.00110952]\n",
" Borrows: [0.00002916, 0.00000000, 83,615.85121770]\n",
" Mango Open Orders: [« OpenOrders:\n",
" Flags: « SerumAccountFlags: initialized | open_orders »\n",
" Program ID: 9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin\n",
" Address: 2h1j1TiXipabQJXFjjU9SXESNn4D5bNMGDV2cJymBnZb\n",
" Market: C1EuT9VokAKLiW7i2ASnZUvxDoKuKkCpDDeNxAptuNe4\n",
" Owner: 6Lce22Pbnb1AkKyGfQArVAsRsHTP31PAhxLv4XaK8i1P\n",
" Base Token: 0.00000000 of 0.00000000\n",
" Quote Token: 0.00000000 of 0.00000000\n",
" Referrer Rebate Accrued: 0\n",
" Orders:\n",
" None\n",
" Client IDs:\n",
" None\n",
" », None]\n",
"»\n",
"« MarginAccount: DgtvAHZyyAdw5VnR97sgyycAQP9R4jruPgZV1hs8iyVH\n",
" Flags: « MangoAccountFlags: initialized | margin_account »\n",
" Has Borrows: False\n",
" Owner: 9HVP4K4K6vddkPbsyKgCR5GUnHtPsrJF8RA83sCU8fUD\n",
" Mango Group: 7pVYhpKUHw88neQHxgExSH6cerMZ1Axx1ALQP9sxtvQV\n",
" Deposits: [799.99446751, 0.00000000, 0.00000000]\n",
" Borrows: [0.00000000, 0.00000000, 24,537,692.46173073]\n",
" Mango Open Orders: [« OpenOrders:\n",
" Flags: « SerumAccountFlags: initialized | open_orders »\n",
" Program ID: 9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin\n",
" Address: GCPtMUtVyjeiEHHzjdi5v2p31Aq1kcdUgfrnj2AVagpF\n",
" Market: C1EuT9VokAKLiW7i2ASnZUvxDoKuKkCpDDeNxAptuNe4\n",
" Owner: 6Lce22Pbnb1AkKyGfQArVAsRsHTP31PAhxLv4XaK8i1P\n",
" Base Token: 0.00000000 of 0.00000000\n",
" Quote Token: 0.00000000 of 0.00000000\n",
" Referrer Rebate Accrued: 9613\n",
" Orders:\n",
" None\n",
" Client IDs:\n",
" None\n",
" », None]\n",
"»\n",
"« MarginAccount: DjbRAoss5BY8yRNFH9Y9UaJ39fQ6w3gXJGgRjbHGZ7GM\n",
" Flags: « MangoAccountFlags: initialized | margin_account »\n",
" Has Borrows: False\n",
" Owner: DWwf5JPr6nLuoRaCd81J6ARRTSDhkNnm5t6W5ZUevS7u\n",
" Mango Group: 7pVYhpKUHw88neQHxgExSH6cerMZ1Axx1ALQP9sxtvQV\n",
" Deposits: [1.24073783, 0.00000000, 0.00023915]\n",
" Borrows: [0.00000000, 0.00000000, 44,105.66489182]\n",
" Mango Open Orders: [« OpenOrders:\n",
" Flags: « SerumAccountFlags: initialized | open_orders »\n",
" Program ID: 9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin\n",
" Address: 5qcvANnhTnzmFvcMzTgZkCNuBFVkcSfaBWdSaN9V7a1m\n",
" Market: C1EuT9VokAKLiW7i2ASnZUvxDoKuKkCpDDeNxAptuNe4\n",
" Owner: 6Lce22Pbnb1AkKyGfQArVAsRsHTP31PAhxLv4XaK8i1P\n",
" Base Token: 0.00000000 of 0.00000000\n",
" Quote Token: 0.00000000 of 0.00000000\n",
" Referrer Rebate Accrued: 0\n",
" Orders:\n",
" None\n",
" Client IDs:\n",
" None\n",
" », None]\n",
"»\n",
"« MarginAccount: 6JH9u1kWkTBr9mLefk7Cr14XhqXpgQtGhbd169JQG2Pv\n",
" Flags: « MangoAccountFlags: initialized | margin_account »\n",
" Has Borrows: False\n",
" Owner: 47jjSC567PY7RvfcAeWbUMw2rk9m9QvdD3xMJa1YrDAr\n",
" Mango Group: 7pVYhpKUHw88neQHxgExSH6cerMZ1Axx1ALQP9sxtvQV\n",
" Deposits: [0.67340643, 0.00000000, 0.00032510]\n",
" Borrows: [0.00000000, 0.00000000, 21,894.92887941]\n",
" Mango Open Orders: [« OpenOrders:\n",
" Flags: « SerumAccountFlags: initialized | open_orders »\n",
" Program ID: 9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin\n",
" Address: APBPjSebK3XKYGUqeFigr5b21wKaVmDA29y3LUpYTwdA\n",
" Market: C1EuT9VokAKLiW7i2ASnZUvxDoKuKkCpDDeNxAptuNe4\n",
" Owner: 6Lce22Pbnb1AkKyGfQArVAsRsHTP31PAhxLv4XaK8i1P\n",
" Base Token: 0.00000000 of 0.00000000\n",
" Quote Token: 0.00000000 of 0.00000000\n",
" Referrer Rebate Accrued: 0\n",
" Orders:\n",
" None\n",
" Client IDs:\n",
" None\n",
" », None]\n",
"»\n",
"« MarginAccount: AqvAMd1dUefpRMFc96iMb6smWRESoFemJ1g7z7Tn1kg6\n",
" Flags: « MangoAccountFlags: initialized | margin_account »\n",
" Has Borrows: False\n",
" Owner: 8woB2woCVzMcTQyUZFtGhgmJHJ52pLCHyHSdDfzzTiZo\n",
" Mango Group: 7pVYhpKUHw88neQHxgExSH6cerMZ1Axx1ALQP9sxtvQV\n",
" Deposits: [2.48309222, 0.00000000, 0.00110952]\n",
" Borrows: [0.00000000, 0.00000000, 77,476.46852203]\n",
" Mango Open Orders: [« OpenOrders:\n",
" Flags: « SerumAccountFlags: initialized | open_orders »\n",
" Program ID: 9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin\n",
" Address: F7xTq9SmpRKtu4XkjTaGotNTTWLfPj5FsEwPbUHP2zdr\n",
" Market: C1EuT9VokAKLiW7i2ASnZUvxDoKuKkCpDDeNxAptuNe4\n",
" Owner: 6Lce22Pbnb1AkKyGfQArVAsRsHTP31PAhxLv4XaK8i1P\n",
" Base Token: 0.00000000 of 0.00000000\n",
" Quote Token: 0.00000000 of 0.00000000\n",
" Referrer Rebate Accrued: 0\n",
" Orders:\n",
" None\n",
" Client IDs:\n",
" None\n",
" », None]\n",
"»\n",
"« MarginAccount: AWgEjF35Zyqj4W8hFkpo23UW1qx24f56rcoyGsvrikMt\n",
" Flags: « MangoAccountFlags: initialized | margin_account »\n",
" Has Borrows: False\n",
" Owner: FGyLaUmn29UELiGahSiYAX4qAKiaLK8TJYT5FBoVDxz4\n",
" Mango Group: 7pVYhpKUHw88neQHxgExSH6cerMZ1Axx1ALQP9sxtvQV\n",
" Deposits: [1.04925771, 0.00000000, 0.00031795]\n",
" Borrows: [0.00000000, 0.00000000, 33,941.38668046]\n",
" Mango Open Orders: [« OpenOrders:\n",
" Flags: « SerumAccountFlags: initialized | open_orders »\n",
" Program ID: 9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin\n",
" Address: Fez6VJhEVm7wG6XUa5DLZnAjp7NTQGyeU7Eh4cRyGtPT\n",
" Market: C1EuT9VokAKLiW7i2ASnZUvxDoKuKkCpDDeNxAptuNe4\n",
" Owner: 6Lce22Pbnb1AkKyGfQArVAsRsHTP31PAhxLv4XaK8i1P\n",
" Base Token: 0.00000000 of 0.00000000\n",
" Quote Token: 0.00000000 of 0.00000000\n",
" Referrer Rebate Accrued: 0\n",
" Orders:\n",
" None\n",
" Client IDs:\n",
" None\n",
" », None]\n",
"»\n",
"« MarginAccount: 9t9tbCA2GGhg3rJiNqymzsKWHA1WDkEGQhHq8u99Jxsa\n",
" Flags: « MangoAccountFlags: initialized | margin_account »\n",
" Has Borrows: False\n",
" Owner: 7Uv5DBvcE6AcYx428bBBn9iKrCLuFnWi2Ls3GEMPCDrt\n",
" Mango Group: 7pVYhpKUHw88neQHxgExSH6cerMZ1Axx1ALQP9sxtvQV\n",
" Deposits: [0.89129553, 0.00000000, 0.00007727]\n",
" Borrows: [0.00000000, 0.00000000, 31,756.92622748]\n",
" Mango Open Orders: [« OpenOrders:\n",
" Flags: « SerumAccountFlags: initialized | open_orders »\n",
" Program ID: 9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin\n",
" Address: 2FkZGBgGMfXJy2WZ35wGZedS9VMRqhzA1FHU1J12NZai\n",
" Market: C1EuT9VokAKLiW7i2ASnZUvxDoKuKkCpDDeNxAptuNe4\n",
" Owner: 6Lce22Pbnb1AkKyGfQArVAsRsHTP31PAhxLv4XaK8i1P\n",
" Base Token: 0.00000000 of 0.00000000\n",
" Quote Token: 0.00000000 of 0.00000000\n",
" Referrer Rebate Accrued: 0\n",
" Orders:\n",
" None\n",
" Client IDs:\n",
" None\n",
" », None]\n",
"»\n",
"« MarginAccount: 6e7t4GDpcT7d3wrUSF5ESWhEdWvT79oRNJoKAWFewmjb\n",
" Flags: « MangoAccountFlags: initialized | margin_account »\n",
" Has Borrows: False\n",
" Owner: GyoNhSpfnpGtygn7fWH2CX62AgskwMTpiJoxovB6pLxt\n",
" Mango Group: 7pVYhpKUHw88neQHxgExSH6cerMZ1Axx1ALQP9sxtvQV\n",
" Deposits: [1.09962207, 0.00000000, 0.99907015]\n",
" Borrows: [0.00000000, 0.00000000, 43,720.82664816]\n",
" Mango Open Orders: [« OpenOrders:\n",
" Flags: « SerumAccountFlags: initialized | open_orders »\n",
" Program ID: 9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin\n",
" Address: 9uQohbS7Hbn744iGTLrsPyK5apCioB2T9oczcahVAVBh\n",
" Market: C1EuT9VokAKLiW7i2ASnZUvxDoKuKkCpDDeNxAptuNe4\n",
" Owner: 6Lce22Pbnb1AkKyGfQArVAsRsHTP31PAhxLv4XaK8i1P\n",
" Base Token: 0.00000000 of 0.00000000\n",
" Quote Token: 0.00000000 of 0.00000000\n",
" Referrer Rebate Accrued: 0\n",
" Orders:\n",
" None\n",
" Client IDs:\n",
" None\n",
" », None]\n",
"»\n",
"« MarginAccount: 5aqPya5Wofd4Ho9pzCnayjspu1Ld8Wd1CdfuQMj2fQkR\n",
" Flags: « MangoAccountFlags: initialized | margin_account »\n",
" Has Borrows: False\n",
" Owner: 2RAgotraQ1amum15jY5b43ZpfgsfyHn5dzBrCUiMGGeW\n",
" Mango Group: 7pVYhpKUHw88neQHxgExSH6cerMZ1Axx1ALQP9sxtvQV\n",
" Deposits: [1.90045423, 0.00000000, 0.00000000]\n",
" Borrows: [0.00000000, 0.00000000, 67,243.36035682]\n",
" Mango Open Orders: [« OpenOrders:\n",
" Flags: « SerumAccountFlags: initialized | open_orders »\n",
" Program ID: 9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin\n",
" Address: 3LndyTEhLFguXzUqCwone2kEun7QETKE8sCExyqiXsSG\n",
" Market: C1EuT9VokAKLiW7i2ASnZUvxDoKuKkCpDDeNxAptuNe4\n",
" Owner: 6Lce22Pbnb1AkKyGfQArVAsRsHTP31PAhxLv4XaK8i1P\n",
" Base Token: 0.00000000 of 0.00000000\n",
" Quote Token: 0.00000000 of 0.00000000\n",
" Referrer Rebate Accrued: 0\n",
" Orders:\n",
" None\n",
" Client IDs:\n",
" None\n",
" », None]\n",
"»\n",
"« MarginAccount: 7Dg6ECPiBJpjQnwxcw5FYm68KaBbMmcr66ZbNdTLiih5\n",
" Flags: « MangoAccountFlags: initialized | margin_account »\n",
" Has Borrows: False\n",
" Owner: 13vnWoTq7zxu96cW2j6cdLKHASNREL7jDw3oWPYx7WWW\n",
" Mango Group: 7pVYhpKUHw88neQHxgExSH6cerMZ1Axx1ALQP9sxtvQV\n",
" Deposits: [1.22144810, 0.00000000, 0.99907175]\n",
" Borrows: [0.00000000, 0.00000000, 48,564.53536526]\n",
" Mango Open Orders: [« OpenOrders:\n",
" Flags: « SerumAccountFlags: initialized | open_orders »\n",
" Program ID: 9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin\n",
" Address: 4uLVwN7syqzS2jx9aVDyMsBpF5aDCPiJ6xkqyBokGP6c\n",
" Market: C1EuT9VokAKLiW7i2ASnZUvxDoKuKkCpDDeNxAptuNe4\n",
" Owner: 6Lce22Pbnb1AkKyGfQArVAsRsHTP31PAhxLv4XaK8i1P\n",
" Base Token: 0.00000000 of 0.00000000\n",
" Quote Token: 0.00000000 of 0.00000000\n",
" Referrer Rebate Accrued: 0\n",
" Orders:\n",
" None\n",
" Client IDs:\n",
" None\n",
" », None]\n",
"»\n",
"« MarginAccount: AZ5UGPrHUe7ouuihZZew6nzEq37PauRxgB5QqwEnVGea\n",
" Flags: « MangoAccountFlags: initialized | margin_account »\n",
" Has Borrows: False\n",
" Owner: JAL4WVF78MCaqSivMSiBXRSTzEtB4Dxu7r9w86xjjFC2\n",
" Mango Group: 7pVYhpKUHw88neQHxgExSH6cerMZ1Axx1ALQP9sxtvQV\n",
" Deposits: [1.11315911, 0.00000000, 0.99907307]\n",
" Borrows: [0.00000000, 0.00000000, 44,259.04893103]\n",
" Mango Open Orders: [« OpenOrders:\n",
" Flags: « SerumAccountFlags: initialized | open_orders »\n",
" Program ID: 9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin\n",
" Address: DndD4EiMNXyLLmzasySvWJEBVknRU1ggsCoSiMSZJaod\n",
" Market: C1EuT9VokAKLiW7i2ASnZUvxDoKuKkCpDDeNxAptuNe4\n",
" Owner: 6Lce22Pbnb1AkKyGfQArVAsRsHTP31PAhxLv4XaK8i1P\n",
" Base Token: 0.00000000 of 0.00000000\n",
" Quote Token: 0.00000000 of 0.00000000\n",
" Referrer Rebate Accrued: 0\n",
" Orders:\n",
" None\n",
" Client IDs:\n",
" None\n",
" », None]\n",
"»\n",
"« MarginAccount: 9GrEZ5CLaSqiuXopoka9UeQvUfoacp19QmvBsTLkPV8m\n",
" Flags: « MangoAccountFlags: initialized | margin_account »\n",
" Has Borrows: False\n",
" Owner: HwS1ekttYmHk42VhcGwVG6RZujCLpePrJHRg3xGrTX3N\n",
" Mango Group: 7pVYhpKUHw88neQHxgExSH6cerMZ1Axx1ALQP9sxtvQV\n",
" Deposits: [1.73020309, 0.00000000, 0.99906733]\n",
" Borrows: [0.00000000, 0.00000000, 68,792.23938298]\n",
" Mango Open Orders: [« OpenOrders:\n",
" Flags: « SerumAccountFlags: initialized | open_orders »\n",
" Program ID: 9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin\n",
" Address: 6PWv85Hv7RZ2WqANEpNBU7GFZtnxdZ5jbaM2DoALs8wC\n",
" Market: C1EuT9VokAKLiW7i2ASnZUvxDoKuKkCpDDeNxAptuNe4\n",
" Owner: 6Lce22Pbnb1AkKyGfQArVAsRsHTP31PAhxLv4XaK8i1P\n",
" Base Token: 0.00000000 of 0.00000000\n",
" Quote Token: 0.00000000 of 0.00000000\n",
" Referrer Rebate Accrued: 0\n",
" Orders:\n",
" None\n",
" Client IDs:\n",
" None\n",
" », None]\n",
"»\n",
"« MarginAccount: 3NaK62twWNHFJDNuX4dG1NUtv9jjgrJYq99XTHgwgKMy\n",
" Flags: « MangoAccountFlags: initialized | margin_account »\n",
" Has Borrows: False\n",
" Owner: 8JcTx3TabZhRqAJGe1aorwSb2HqP2RqW9WQb3dBw9XKM\n",
" Mango Group: 7pVYhpKUHw88neQHxgExSH6cerMZ1Axx1ALQP9sxtvQV\n",
" Deposits: [1.89241634, 0.00000000, 0.99906493]\n",
" Borrows: [0.00000000, 0.00000000, 75,241.71249245]\n",
" Mango Open Orders: [« OpenOrders:\n",
" Flags: « SerumAccountFlags: initialized | open_orders »\n",
" Program ID: 9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin\n",
" Address: RUDMsDS9soB4vwgvsqewX2s3qpocVKGrQ7oyQMZtzN9\n",
" Market: C1EuT9VokAKLiW7i2ASnZUvxDoKuKkCpDDeNxAptuNe4\n",
" Owner: 6Lce22Pbnb1AkKyGfQArVAsRsHTP31PAhxLv4XaK8i1P\n",
" Base Token: 0.00000000 of 0.00000000\n",
" Quote Token: 0.00000000 of 0.00000000\n",
" Referrer Rebate Accrued: 0\n",
" Orders:\n",
" None\n",
" Client IDs:\n",
" None\n",
" », None]\n",
"»\n",
"« MarginAccount: EMsvtLwKcvPW3hhAQ2s2F28tynkb1tHvYr2G9sKXtwmb\n",
" Flags: « MangoAccountFlags: initialized | margin_account »\n",
" Has Borrows: False\n",
" Owner: 4jUzzGdwivTJxdfZQ6DJLF5ibsJvhei1nBuVHsvCRJ6h\n",
" Mango Group: 7pVYhpKUHw88neQHxgExSH6cerMZ1Axx1ALQP9sxtvQV\n",
" Deposits: [1.39371995, 0.00000000, 0.00111208]\n",
" Borrows: [0.00000000, 0.00000000, 42,592.45916303]\n",
" Mango Open Orders: [« OpenOrders:\n",
" Flags: « SerumAccountFlags: initialized | open_orders »\n",
" Program ID: 9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin\n",
" Address: FgNDgWv8g8VtXfbMWKNezyK9i7XHoN5n3XMMW1tXFacr\n",
" Market: C1EuT9VokAKLiW7i2ASnZUvxDoKuKkCpDDeNxAptuNe4\n",
" Owner: 6Lce22Pbnb1AkKyGfQArVAsRsHTP31PAhxLv4XaK8i1P\n",
" Base Token: 0.00000000 of 0.00000000\n",
" Quote Token: 0.00000000 of 0.00000000\n",
" Referrer Rebate Accrued: 0\n",
" Orders:\n",
" None\n",
" Client IDs:\n",
" None\n",
" », None]\n",
"»\n",
"« MarginAccount: EDuNRGY3zH4G5gJyiwkhQ5tAqZGaBfuXwmu4Cg7gCD6m\n",
" Flags: « MangoAccountFlags: initialized | margin_account »\n",
" Has Borrows: False\n",
" Owner: BDuEJGtdbKnx7Qz98j5S4fBaGTJ1rTcXgkLJ3dT5zYpW\n",
" Mango Group: 7pVYhpKUHw88neQHxgExSH6cerMZ1Axx1ALQP9sxtvQV\n",
" Deposits: [0.00000000, 0.00000000, 0.99908357]\n",
" Borrows: [0.00000000, 0.00000000, 179.10039546]\n",
" Mango Open Orders: [« OpenOrders:\n",
" Flags: « SerumAccountFlags: initialized | open_orders »\n",
" Program ID: 9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin\n",
" Address: nkjzZuHefzuxXGhAeNUGZCTToayU4R3GjqxKpznbmkw\n",
" Market: C1EuT9VokAKLiW7i2ASnZUvxDoKuKkCpDDeNxAptuNe4\n",
" Owner: 6Lce22Pbnb1AkKyGfQArVAsRsHTP31PAhxLv4XaK8i1P\n",
" Base Token: 0.00000000 of 0.00000000\n",
" Quote Token: 0.00000000 of 0.00000000\n",
" Referrer Rebate Accrued: 0\n",
" Orders:\n",
" None\n",
" Client IDs:\n",
" None\n",
" », None]\n",
"»\n",
"« MarginAccount: 2Fe2JiFr1EMANU6qE98WfjP49LRfFWYV6EdVxiapiYJb\n",
" Flags: « MangoAccountFlags: initialized | margin_account »\n",
" Has Borrows: False\n",
" Owner: 2Tnk1DfBHh69jaCqibMH1mNDew2Y65dQRC21pJCVPTrK\n",
" Mango Group: 7pVYhpKUHw88neQHxgExSH6cerMZ1Axx1ALQP9sxtvQV\n",
" Deposits: [0.00000000, 0.00000000, 0.99907435]\n",
" Borrows: [0.00000000, 0.00000000, 14,827.79810261]\n",
" Mango Open Orders: [« OpenOrders:\n",
" Flags: « SerumAccountFlags: initialized | open_orders »\n",
" Program ID: 9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin\n",
" Address: 9i9rQ91qHyGkmU5juhQkffYq8xSoHLLVB1xaJbiAMtYz\n",
" Market: C1EuT9VokAKLiW7i2ASnZUvxDoKuKkCpDDeNxAptuNe4\n",
" Owner: 6Lce22Pbnb1AkKyGfQArVAsRsHTP31PAhxLv4XaK8i1P\n",
" Base Token: 0.00000000 of 0.00000000\n",
" Quote Token: 0.00000000 of 0.00000000\n",
" Referrer Rebate Accrued: 0\n",
" Orders:\n",
" None\n",
" Client IDs:\n",
" None\n",
" », None]\n",
"»\n",
"« MarginAccount: B13VKCAr1o3R2Zx5KFRz6PCeoFC56U6L1yWxYXhWd3x9\n",
" Flags: « MangoAccountFlags: initialized | margin_account »\n",
" Has Borrows: False\n",
" Owner: 32jHGa63ZKYr8eoEF3UPpGVNnu1gTgfxAfahuUbG3kZG\n",
" Mango Group: 7pVYhpKUHw88neQHxgExSH6cerMZ1Axx1ALQP9sxtvQV\n",
" Deposits: [0.82560399, 0.00000000, 0.00030083]\n",
" Borrows: [0.00000000, 0.00000000, 27,958.66842698]\n",
" Mango Open Orders: [« OpenOrders:\n",
" Flags: « SerumAccountFlags: initialized | open_orders »\n",
" Program ID: 9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin\n",
" Address: 5ZqLtxxbfEZBE23muSFjRTQcbZnLLiPZNUUtLcFabU2T\n",
" Market: C1EuT9VokAKLiW7i2ASnZUvxDoKuKkCpDDeNxAptuNe4\n",
" Owner: 6Lce22Pbnb1AkKyGfQArVAsRsHTP31PAhxLv4XaK8i1P\n",
" Base Token: 0.00000000 of 0.00000000\n",
" Quote Token: 0.00000000 of 0.00000000\n",
" Referrer Rebate Accrued: 0\n",
" Orders:\n",
" None\n",
" Client IDs:\n",
" None\n",
" », None]\n",
"»\n",
"« MarginAccount: ASU8j6DENCF4JwuerPPByUba9vnFkoRbziR2cT1exgkR\n",
" Flags: « MangoAccountFlags: initialized | margin_account »\n",
" Has Borrows: False\n",
" Owner: 6pHiJC6mmWYnQSBzCtrfjkdSEQi3PJypRMRgFd1rGiJy\n",
" Mango Group: 7pVYhpKUHw88neQHxgExSH6cerMZ1Axx1ALQP9sxtvQV\n",
" Deposits: [1.72791671, 0.00000000, 0.99907307]\n",
" Borrows: [0.00000000, 0.00000000, 68,701.33469441]\n",
" Mango Open Orders: [« OpenOrders:\n",
" Flags: « SerumAccountFlags: initialized | open_orders »\n",
" Program ID: 9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin\n",
" Address: HWaVwDjJk2jHBV6PvwaBBMPXeh5u4hd4sttt1mbiLrXC\n",
" Market: C1EuT9VokAKLiW7i2ASnZUvxDoKuKkCpDDeNxAptuNe4\n",
" Owner: 6Lce22Pbnb1AkKyGfQArVAsRsHTP31PAhxLv4XaK8i1P\n",
" Base Token: 0.00000000 of 0.00000000\n",
" Quote Token: 0.00000000 of 0.00000000\n",
" Referrer Rebate Accrued: 0\n",
" Orders:\n",
" None\n",
" Client IDs:\n",
" None\n",
" », None]\n",
"»\n",
"« MarginAccount: jUhq5qfKTF39xMhqyu7zNtU1yj1ENeadNQJpJt4RHAd\n",
" Flags: « MangoAccountFlags: initialized | margin_account »\n",
" Has Borrows: False\n",
" Owner: 2RAgotraQ1amum15jY5b43ZpfgsfyHn5dzBrCUiMGGeW\n",
" Mango Group: 7pVYhpKUHw88neQHxgExSH6cerMZ1Axx1ALQP9sxtvQV\n",
" Deposits: [1.91161172, 0.00000000, 0.00000000]\n",
" Borrows: [0.00000000, 0.00000000, 74,054.38212341]\n",
" Mango Open Orders: [« OpenOrders:\n",
" Flags: « SerumAccountFlags: initialized | open_orders »\n",
" Program ID: 9xQeWvG816bUx9EPjHmaT23yvVM2ZWbrrpZb9PusVFin\n",
" Address: BNeHXxuvzQwEDZV4SqHNrUCDXzszY1Xsb6xn9idRauj1\n",
" Market: C1EuT9VokAKLiW7i2ASnZUvxDoKuKkCpDDeNxAptuNe4\n",
" Owner: 6Lce22Pbnb1AkKyGfQArVAsRsHTP31PAhxLv4XaK8i1P\n",
" Base Token: 0.00000000 of 0.00000000\n",
" Quote Token: 0.00000000 of 0.00000000\n",
" Referrer Rebate Accrued: 0\n",
" Orders:\n",
" None\n",
" Client IDs:\n",
" None\n",
" », None]\n",
"»\n"
]
}
],
"source": [
"group = Group.load(default_context)\n",
"if __name__ == \"__main__\":\n",
" import mango\n",
"\n",
"ripe_margin_accounts = group.load_ripe_margin_accounts()\n",
"print(f\"Fetched {len(ripe_margin_accounts)} ripe margin account(s).\")\n",
"print(*ripe_margin_accounts, sep=\"\\n\")\n"
" group = mango.Group.load(mango.default_context)\n",
"\n",
" ripe_margin_accounts = mango.MarginAccount.load_ripe(mango.default_context, group)\n",
" print(f\"Fetched {len(ripe_margin_accounts)} ripe margin account(s).\")\n",
" print(*ripe_margin_accounts, sep=\"\\n\")\n"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
"name": "python39464bit39b046140da4445497cec6b3acc85e88",
"display_name": "Python 3.9.4 64-bit"
},
"language_info": {
"codemirror_mode": {
@ -90,7 +558,7 @@
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.8.6"
"version": "3.9.4"
},
"toc": {
"base_numbering": 1,
@ -133,8 +601,13 @@
"_Feature"
],
"window_display": false
},
"metadata": {
"interpreter": {
"hash": "ac2eaa0ea0ebeafcc7822e65e46aa9d4f966f30b695406963e145ea4a91cd4fc"
}
}
},
"nbformat": 4,
"nbformat_minor": 5
}
}

View File

@ -1,450 +0,0 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "progressive-printer",
"metadata": {},
"source": [
"# ⚠ Warning\n",
"\n",
"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.\n",
"\n",
"[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gl/OpinionatedGeek%2Fmango-explorer/HEAD?filepath=TradeExecutor.ipynb) _🏃 To run this notebook press the ⏩ icon in the toolbar above._\n",
"\n",
"[🥭 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)"
]
},
{
"cell_type": "markdown",
"id": "assured-woman",
"metadata": {},
"source": [
"# 🥭 TradeExecutor\n",
"\n",
"This notebook deals with executing trades. We want the interface to be as simple as:\n",
"```\n",
"trade_executor.buy(\"ETH\", 2.5)\n",
"```\n",
"but this (necessarily) masks a great deal of complexity. The aim is to keep the complexity around trades within these `TradeExecutor` classes."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "confused-definition",
"metadata": {
"jupyter": {
"source_hidden": true
}
},
"outputs": [],
"source": [
"import abc\n",
"import logging\n",
"import rx\n",
"import rx.operators as ops\n",
"import typing\n",
"\n",
"from decimal import Decimal\n",
"from pyserum.enums import OrderType, Side\n",
"from pyserum.market import Market\n",
"from solana.account import Account\n",
"from solana.publickey import PublicKey\n",
"\n",
"from BaseModel import OpenOrders, SpotMarket, SpotMarketLookup, Token, TokenAccount\n",
"from Context import Context\n",
"from Retrier import retry_context\n",
"from Wallet import Wallet\n"
]
},
{
"cell_type": "markdown",
"id": "martial-cement",
"metadata": {},
"source": [
"# TradeExecutor class\n",
"\n",
"This abstracts the process of placing trades, based on our typed objects.\n",
"\n",
"It's abstracted because we may want to have different approaches to executing these trades - do we want to run them against the Serum orderbook? Would it be faster if we ran them against Raydium?\n",
"\n",
"Whichever choice is made, the calling code shouldn't have to care. It should be able to use its `TradeExecutor` class as simply as:\n",
"```\n",
"trade_executor.buy(\"ETH\", 2.5)\n",
"```"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "compatible-optimization",
"metadata": {},
"outputs": [],
"source": [
"class TradeExecutor(metaclass=abc.ABCMeta):\n",
" def __init__(self):\n",
" self.logger: logging.Logger = logging.getLogger(self.__class__.__name__)\n",
"\n",
" @abc.abstractmethod\n",
" def buy(self, symbol: str, quantity: Decimal):\n",
" raise NotImplementedError(\"TradeExecutor.buy() is not implemented on the base type.\")\n",
"\n",
" @abc.abstractmethod\n",
" def sell(self, symbol: str, quantity: Decimal):\n",
" raise NotImplementedError(\"TradeExecutor.sell() is not implemented on the base type.\")\n",
"\n",
" @abc.abstractmethod\n",
" def settle(self, spot_market: SpotMarket, market: Market) -> typing.List[str]:\n",
" raise NotImplementedError(\"TradeExecutor.settle() is not implemented on the base type.\")\n",
"\n",
" @abc.abstractmethod\n",
" def wait_for_settlement_completion(self, settlement_transaction_ids: typing.List[str]):\n",
" raise NotImplementedError(\"TradeExecutor.wait_for_settlement_completion() is not implemented on the base type.\")\n"
]
},
{
"cell_type": "markdown",
"id": "proud-scottish",
"metadata": {},
"source": [
"## NullTradeExecutor class\n",
"\n",
"A null, no-op, dry-run trade executor that can be plugged in anywhere a `TradeExecutor` is expected, but which will not actually trade."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "rocky-opening",
"metadata": {},
"outputs": [],
"source": [
"class NullTradeExecutor(TradeExecutor):\n",
" def __init__(self, reporter: typing.Callable[[str], None] = None):\n",
" super().__init__()\n",
" self.reporter = reporter or (lambda _: None)\n",
"\n",
" def buy(self, symbol: str, quantity: Decimal):\n",
" self.logger.info(f\"Skipping BUY trade of {quantity:,.8f} of '{symbol}'.\")\n",
" self.reporter(f\"Skipping BUY trade of {quantity:,.8f} of '{symbol}'.\")\n",
"\n",
" def sell(self, symbol: str, quantity: Decimal):\n",
" self.logger.info(f\"Skipping SELL trade of {quantity:,.8f} of '{symbol}'.\")\n",
" self.reporter(f\"Skipping SELL trade of {quantity:,.8f} of '{symbol}'.\")\n",
"\n",
" def settle(self, spot_market: SpotMarket, market: Market) -> typing.List[str]:\n",
" self.logger.info(f\"Skipping settling of '{spot_market.base.name}' and '{spot_market.quote.name}' in market {spot_market.address}.\")\n",
" self.reporter(f\"Skipping settling of '{spot_market.base.name}' and '{spot_market.quote.name}' in market {spot_market.address}.\")\n",
" return []\n",
"\n",
" def wait_for_settlement_completion(self, settlement_transaction_ids: typing.List[str]):\n",
" self.logger.info(\"Skipping waiting for settlement.\")\n",
" self.reporter(\"Skipping waiting for settlement.\")\n"
]
},
{
"cell_type": "markdown",
"id": "governing-helena",
"metadata": {},
"source": [
"# SerumImmediateTradeExecutor class\n",
"\n",
"This class puts an IOC trade on the Serum orderbook with the expectation it will be filled immediately.\n",
"\n",
"The process the `SerumImmediateTradeExecutor` follows to place a trade is:\n",
"* Call `place_order()` with the order details plus a random `client_id`,\n",
"* Wait for the `client_id` to appear as a 'fill' in the market's 'event queue',\n",
"* Call `settle_funds()` to move the trade result funds back into the wallet,\n",
"* Wait for the `settle_funds()` transaction ID to be confirmed.\n",
"\n",
"The SerumImmediateTradeExecutor constructor takes a `price_adjustment_factor` to allow moving the price it is willing to pay away from the mid-price. Testing shows the price is filled at the orderbook price if the price we specify is worse, so it looks like it's possible to be quite liberal with this adjustment. In a live test:\n",
"* Original wallet USDT value was 342.8606.\n",
"* `price_adjustment_factor` was 0.05.\n",
"* ETH price was 2935.14 USDT (on 2021-05-02).\n",
"* Adjusted price was 3081.897 USDT, adjusted by 1.05 from 2935.14\n",
"* Buying 0.1 ETH specifying 3081.897 as the price resulted in:\n",
" * Buying 0.1 ETH\n",
" * Spending 294.1597 USDT\n",
"* After settling, the wallet should hold 342.8606 USDT - 294.1597 USDT = 48.7009 USDT\n",
"* The wallet did indeed hold 48.7009 USDT\n",
"\n",
"So: the specified BUY price of 3081.897 USDT was taken as a maximum, and orders were taken from the orderbook starting at the current cheapest, until the order was filled or (I'm assuming) the price exceeded the price specified."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "civil-builder",
"metadata": {},
"outputs": [],
"source": [
"class SerumImmediateTradeExecutor(TradeExecutor):\n",
" def __init__(self, context: Context, wallet: Wallet, spot_market_lookup: SpotMarketLookup, price_adjustment_factor: Decimal = Decimal(0), reporter: typing.Callable[[str], None] = None):\n",
" super().__init__()\n",
" self.context: Context = context\n",
" self.wallet: Wallet = wallet\n",
" self.spot_market_lookup: SpotMarketLookup = spot_market_lookup\n",
" self.price_adjustment_factor: Decimal = price_adjustment_factor\n",
"\n",
" def report(text):\n",
" self.logger.info(text)\n",
" reporter(text)\n",
"\n",
" def just_log(text):\n",
" self.logger.info(text)\n",
"\n",
" if reporter is not None:\n",
" self.reporter = report\n",
" else:\n",
" self.reporter = just_log\n",
"\n",
" def buy(self, symbol: str, quantity: Decimal):\n",
" spot_market = self._lookup_spot_market(symbol)\n",
" market = Market.load(self.context.client, spot_market.address)\n",
" self.reporter(f\"BUY order market: {spot_market.address} {market}\")\n",
"\n",
" asks = market.load_asks()\n",
" top_ask = next(asks.orders())\n",
" top_price = Decimal(top_ask.info.price)\n",
" increase_factor = Decimal(1) + self.price_adjustment_factor\n",
" price = top_price * increase_factor\n",
" self.reporter(f\"Price {price} - adjusted by {self.price_adjustment_factor} from {top_price}\")\n",
"\n",
" source_token_account = TokenAccount.fetch_largest_for_owner_and_token(self.context, self.wallet.address, spot_market.quote)\n",
" self.reporter(f\"Source token account: {source_token_account}\")\n",
" if source_token_account is None:\n",
" raise Exception(f\"Could not find source token account for '{spot_market.quote}'\")\n",
"\n",
" self._execute(\n",
" spot_market,\n",
" market,\n",
" Side.BUY,\n",
" source_token_account,\n",
" spot_market.base,\n",
" spot_market.quote,\n",
" price,\n",
" quantity\n",
" )\n",
"\n",
" def sell(self, symbol: str, quantity: Decimal):\n",
" spot_market = self._lookup_spot_market(symbol)\n",
" market = Market.load(self.context.client, spot_market.address)\n",
" self.reporter(f\"SELL order market: {spot_market.address} {market}\")\n",
"\n",
" bids = market.load_bids()\n",
" bid_orders = list(bids.orders())\n",
" top_bid = bid_orders[len(bid_orders) - 1]\n",
" top_price = Decimal(top_bid.info.price)\n",
" decrease_factor = Decimal(1) - self.price_adjustment_factor\n",
" price = top_price * decrease_factor\n",
" self.reporter(f\"Price {price} - adjusted by {self.price_adjustment_factor} from {top_price}\")\n",
"\n",
" source_token_account = TokenAccount.fetch_largest_for_owner_and_token(self.context, self.wallet.address, spot_market.base)\n",
" self.reporter(f\"Source token account: {source_token_account}\")\n",
" if source_token_account is None:\n",
" raise Exception(f\"Could not find source token account for '{spot_market.base}'\")\n",
"\n",
" self._execute(\n",
" spot_market,\n",
" market,\n",
" Side.SELL,\n",
" source_token_account,\n",
" spot_market.base,\n",
" spot_market.quote,\n",
" price,\n",
" quantity\n",
" )\n",
"\n",
" def _execute(self, spot_market: SpotMarket, market: Market, side: Side, source_token_account: TokenAccount, base_token: Token, quote_token: Token, price: Decimal, quantity: Decimal):\n",
" with retry_context(\"Serum Place Order\", self._place_order, self.context.retry_pauses) as retrier:\n",
" client_id, place_order_transaction_id = retrier.run(market, base_token, quote_token, source_token_account.address, self.wallet.account, OrderType.IOC, side, price, quantity)\n",
"\n",
" with retry_context(\"Serum Wait For Order Fill\", self._wait_for_order_fill, self.context.retry_pauses) as retrier:\n",
" retrier.run(market, client_id)\n",
"\n",
" with retry_context(\"Serum Settle\", self.settle, self.context.retry_pauses) as retrier:\n",
" settlement_transaction_ids = retrier.run(spot_market, market)\n",
"\n",
" with retry_context(\"Serum Wait For Settle Completion\", self.wait_for_settlement_completion, self.context.retry_pauses) as retrier:\n",
" retrier.run(settlement_transaction_ids)\n",
"\n",
" self.reporter(\"Order execution complete\")\n",
"\n",
" def _place_order(self, market: Market, base_token: Token, quote_token: Token, paying_token_address: PublicKey, account: Account, order_type: OrderType, side: Side, price: Decimal, quantity: Decimal) -> typing.Tuple[int, str]:\n",
" to_pay = price * quantity\n",
" self.logger.info(f\"{side.name}ing {quantity} of {base_token.name} at {price} for {to_pay} on {base_token.name}/{quote_token.name} from {paying_token_address}.\")\n",
"\n",
" client_id = self.context.random_client_id()\n",
" self.reporter(f\"\"\"Placing order\n",
" paying_token_address: {paying_token_address}\n",
" account: {account.public_key()}\n",
" order_type: {order_type.name}\n",
" side: {side.name}\n",
" price: {float(price)}\n",
" quantity: {float(quantity)}\n",
" client_id: {client_id}\"\"\")\n",
"\n",
" response = market.place_order(paying_token_address, account, order_type, side, float(price), float(quantity), client_id)\n",
" transaction_id = self.context.unwrap_transaction_id_or_raise_exception(response)\n",
" self.reporter(f\"Order transaction ID: {transaction_id}\")\n",
"\n",
" return client_id, transaction_id\n",
"\n",
" def _wait_for_order_fill(self, market: Market, client_id: int, max_wait_in_seconds: int = 60):\n",
" self.logger.info(f\"Waiting up to {max_wait_in_seconds} seconds for {client_id}.\")\n",
" return rx.interval(1.0).pipe(\n",
" ops.flat_map(lambda _: market.load_event_queue()),\n",
" ops.skip_while(lambda item: item.client_order_id != client_id),\n",
" ops.skip_while(lambda item: not item.event_flags.fill),\n",
" ops.first(),\n",
" ops.map(lambda _: True),\n",
" ops.timeout(max_wait_in_seconds, rx.return_value(False))\n",
" ).run()\n",
"\n",
" def settle(self, spot_market: SpotMarket, market: Market) -> typing.List[str]:\n",
" base_token_account = TokenAccount.fetch_or_create_largest_for_owner_and_token(self.context, self.wallet.account, spot_market.base)\n",
" quote_token_account = TokenAccount.fetch_or_create_largest_for_owner_and_token(self.context, self.wallet.account, spot_market.quote)\n",
"\n",
" open_orders = OpenOrders.load_for_market_and_owner(self.context, spot_market.address, self.wallet.account.public_key(), self.context.dex_program_id, spot_market.base.decimals, spot_market.quote.decimals)\n",
"\n",
" transaction_ids = []\n",
" for open_order_account in open_orders:\n",
" if (open_order_account.base_token_free > 0) or (open_order_account.quote_token_free > 0):\n",
" self.reporter(f\"Need to settle open orders: {open_order_account}\\nBase account: {base_token_account.address}\\nQuote account: {quote_token_account.address}\")\n",
" response = market.settle_funds(self.wallet.account, open_order_account.to_pyserum(), base_token_account.address, quote_token_account.address)\n",
" transaction_id = self.context.unwrap_transaction_id_or_raise_exception(response)\n",
" self.reporter(f\"Settlement transaction ID: {transaction_id}\")\n",
" transaction_ids += [transaction_id]\n",
"\n",
" return transaction_ids\n",
"\n",
" def wait_for_settlement_completion(self, settlement_transaction_ids: typing.List[str]):\n",
" if len(settlement_transaction_ids) > 0:\n",
" self.reporter(f\"Waiting on settlement transaction IDs: {settlement_transaction_ids}\")\n",
" for settlement_transaction_id in settlement_transaction_ids:\n",
" self.reporter(f\"Waiting on specific settlement transaction ID: {settlement_transaction_id}\")\n",
" self.context.wait_for_confirmation(settlement_transaction_id)\n",
" self.reporter(\"All settlement transaction IDs confirmed.\")\n",
"\n",
" def _lookup_spot_market(self, symbol: str) -> SpotMarket:\n",
" spot_market = self.spot_market_lookup.find_by_symbol(symbol)\n",
" if spot_market is None:\n",
" raise Exception(f\"Spot market '{symbol}' could not be found.\")\n",
"\n",
" self.logger.info(f\"Base token: {spot_market.base}\")\n",
" self.logger.info(f\"Quote token: {spot_market.quote}\")\n",
"\n",
" return spot_market\n",
"\n",
" def __str__(self) -> str:\n",
" return f\"\"\"« SerumImmediateTradeExecutor [{self.price_adjustment_factor}] »\"\"\"\n",
"\n",
" def __repr__(self) -> str:\n",
" return f\"{self}\"\n",
"\n"
]
},
{
"cell_type": "markdown",
"id": "ideal-thomson",
"metadata": {},
"source": [
"# 🏃 Running\n",
"\n",
"If running interactively, try to buy then sell 0.1 ETH.\n",
"\n",
"**Warning! This code (if uncommented) will actually try to buy and sell on Serum using the default wallet!**"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "adjacent-testing",
"metadata": {},
"outputs": [],
"source": [
"if __name__ == \"__main__\":\n",
" logging.getLogger().setLevel(logging.INFO)\n",
"\n",
" from Context import default_context\n",
" from Wallet import default_wallet\n",
"\n",
" if default_wallet is None:\n",
" print(\"No default wallet file available.\")\n",
" else:\n",
" print(default_context)\n",
"\n",
" symbol = \"ETH\"\n",
" trade_executor = SerumImmediateTradeExecutor(default_context, default_wallet, SpotMarketLookup.default_lookups(), Decimal(0.05))\n",
"\n",
" # WARNING! Uncommenting the following lines will actually try to trade on Serum using the\n",
" # default wallet!\n",
"# trade_executor.sell(\"ETH\", 0.1)\n",
"# trade_executor.buy(\"ETH\", 0.1)\n"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.8.6"
},
"toc": {
"base_numbering": 1,
"nav_menu": {},
"number_sections": true,
"sideBar": true,
"skip_h1_title": false,
"title_cell": "Table of Contents",
"title_sidebar": "Contents",
"toc_cell": false,
"toc_position": {},
"toc_section_display": true,
"toc_window_display": true
},
"varInspector": {
"cols": {
"lenName": 16,
"lenType": 16,
"lenVar": 40
},
"kernels_config": {
"python": {
"delete_cmd_postfix": "",
"delete_cmd_prefix": "del ",
"library": "var_list.py",
"varRefreshCmd": "print(var_dic_list())"
},
"r": {
"delete_cmd_postfix": ") ",
"delete_cmd_prefix": "rm(",
"library": "var_list.r",
"varRefreshCmd": "cat(var_dic_list()) "
}
},
"types_to_exclude": [
"module",
"function",
"builtin_function_or_method",
"instance",
"_Feature"
],
"window_display": false
}
},
"nbformat": 4,
"nbformat_minor": 5
}

View File

@ -1,580 +0,0 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "described-pencil",
"metadata": {},
"source": [
"# ⚠ Warning\n",
"\n",
"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.\n",
"\n",
"[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gl/OpinionatedGeek%2Fmango-explorer/HEAD?filepath=TransactionScout.ipynb) _🏃 To run this notebook press the ⏩ icon in the toolbar above._\n",
"\n",
"[🥭 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)"
]
},
{
"cell_type": "markdown",
"id": "nervous-bikini",
"metadata": {},
"source": [
"# 🥭 TransactionScount\n",
"\n",
"This notebook tries to show details of historical transactions.\n",
"\n",
"It fetches the data from Solana, parses it, and then prints it.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "fitting-andrews",
"metadata": {
"jupyter": {
"source_hidden": true
}
},
"outputs": [],
"source": [
"import base58\n",
"import datetime\n",
"import logging\n",
"import typing\n",
"\n",
"logging.getLogger().setLevel(logging.ERROR)\n",
"\n",
"from decimal import Decimal\n",
"from solana.publickey import PublicKey\n",
"\n",
"from BaseModel import InstructionType, OwnedTokenValue, TokenLookup, TokenValue\n",
"from Context import Context, default_context\n",
"from Layouts import InstructionParsersByVariant, MANGO_INSTRUCTION_VARIANT_FINDER\n"
]
},
{
"cell_type": "markdown",
"id": "weighted-annotation",
"metadata": {},
"source": [
"## Transaction Indices\n",
"\n",
"Transactions come with a large account list.\n",
"\n",
"Instructions, individually, take accounts.\n",
"\n",
"The accounts instructions take are listed in the the transaction's list of accounts.\n",
"\n",
"The instruction data therefore doesn't need to specify account public keys, only the index of those public keys in the main transaction's list.\n",
"\n",
"So, for example, if an instruction uses 3 accounts, the instruction data could say [3, 2, 14], meaning the first account it uses is index 3 in the whole transaction account list, the second is index 2 in the whole transaction account list, the third is index 14 in the whole transaction account list.\n",
"\n",
"This complicates figuring out which account is which for a given instruction, especially since some of the accounts (like the sender/signer account) can appear at different indices depending on which instruction is being used.\n",
"\n",
"We keep a few static dictionaries here to allow us to dereference important accounts per type.\n",
"\n",
"In addition, we dereference the accounts for each instruction when we instantiate each `TransactionInstruction`, so users of `TransactionInstruction` don't need to worry about these details.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "spiritual-buffer",
"metadata": {},
"outputs": [],
"source": [
"# The index of the sender/signer depends on the instruction.\n",
"_instruction_signer_indices: typing.Dict[InstructionType, int] = {\n",
" InstructionType.InitMangoGroup: 3,\n",
" InstructionType.InitMarginAccount: 2,\n",
" InstructionType.Deposit: 2,\n",
" InstructionType.Withdraw: 2,\n",
" InstructionType.Borrow: 2,\n",
" InstructionType.SettleBorrow: 2,\n",
" InstructionType.Liquidate: 1,\n",
" InstructionType.DepositSrm: 2,\n",
" InstructionType.WithdrawSrm: 2,\n",
" InstructionType.PlaceOrder: 1,\n",
" InstructionType.SettleFunds: 1,\n",
" InstructionType.CancelOrder: 1,\n",
" InstructionType.CancelOrderByClientId: 1,\n",
" InstructionType.ChangeBorrowLimit: 1,\n",
" InstructionType.PlaceAndSettle: 1,\n",
" InstructionType.ForceCancelOrders: 1,\n",
" InstructionType.PartialLiquidate: 1\n",
"}\n",
"\n",
"# The index of the token IN account depends on the instruction, and for some instructions doesn't exist.\n",
"_token_in_indices: typing.Dict[InstructionType, int] = {\n",
" InstructionType.InitMangoGroup: -1,\n",
" InstructionType.InitMarginAccount: -1,\n",
" InstructionType.Deposit: 3, # token_account_acc - TokenAccount owned by user which will be sending the funds\n",
" InstructionType.Withdraw: 4, # vault_acc - TokenAccount owned by MangoGroup which will be sending\n",
" InstructionType.Borrow: -1,\n",
" InstructionType.SettleBorrow: -1,\n",
" InstructionType.Liquidate: -1,\n",
" InstructionType.DepositSrm: 3, # srm_account_acc - TokenAccount owned by user which will be sending the funds\n",
" InstructionType.WithdrawSrm: 4, # vault_acc - SRM vault of MangoGroup\n",
" InstructionType.PlaceOrder: -1,\n",
" InstructionType.SettleFunds: -1,\n",
" InstructionType.CancelOrder: -1,\n",
" InstructionType.CancelOrderByClientId: -1,\n",
" InstructionType.ChangeBorrowLimit: -1,\n",
" InstructionType.PlaceAndSettle: -1,\n",
" InstructionType.ForceCancelOrders: -1,\n",
" InstructionType.PartialLiquidate: 2 # liqor_in_token_acc - liquidator's token account to deposit\n",
"}\n",
"\n",
"# The index of the token OUT account depends on the instruction, and for some instructions doesn't exist.\n",
"_token_out_indices: typing.Dict[InstructionType, int] = {\n",
" InstructionType.InitMangoGroup: -1,\n",
" InstructionType.InitMarginAccount: -1,\n",
" InstructionType.Deposit: 4, # vault_acc - TokenAccount owned by MangoGroup\n",
" InstructionType.Withdraw: 3, # token_account_acc - TokenAccount owned by user which will be receiving the funds\n",
" InstructionType.Borrow: -1,\n",
" InstructionType.SettleBorrow: -1,\n",
" InstructionType.Liquidate: -1,\n",
" InstructionType.DepositSrm: 4, # vault_acc - SRM vault of MangoGroup\n",
" InstructionType.WithdrawSrm: 3, # srm_account_acc - TokenAccount owned by user which will be receiving the funds\n",
" InstructionType.PlaceOrder: -1,\n",
" InstructionType.SettleFunds: -1,\n",
" InstructionType.CancelOrder: -1,\n",
" InstructionType.CancelOrderByClientId: -1,\n",
" InstructionType.ChangeBorrowLimit: -1,\n",
" InstructionType.PlaceAndSettle: -1,\n",
" InstructionType.ForceCancelOrders: -1,\n",
" InstructionType.PartialLiquidate: 3 # liqor_out_token_acc - liquidator's token account to withdraw into\n",
"}\n"
]
},
{
"cell_type": "markdown",
"id": "suspected-broadcasting",
"metadata": {},
"source": [
"## TransactionInstruction class\n",
"\n",
"This class packages up instruction data, which can come from disparate parts of the transaction. Keeping it all together here makes many things simpler."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "compact-extreme",
"metadata": {},
"outputs": [],
"source": [
"class TransactionInstruction:\n",
" def __init__(self, instruction_type: InstructionType, instruction_data: typing.Any, accounts: typing.List[PublicKey]):\n",
" self.instruction_type = instruction_type\n",
" self.instruction_data = instruction_data\n",
" self.accounts = accounts\n",
"\n",
" @property\n",
" def group(self) -> PublicKey:\n",
" # Group PublicKey is always the zero index.\n",
" return self.accounts[0]\n",
"\n",
" @property\n",
" def sender(self) -> PublicKey:\n",
" account_index = _instruction_signer_indices[self.instruction_type]\n",
" return self.accounts[account_index]\n",
"\n",
" @property\n",
" def token_in_account(self) -> typing.Optional[PublicKey]:\n",
" account_index = _token_in_indices[self.instruction_type]\n",
" if account_index < 0:\n",
" return None\n",
" return self.accounts[account_index]\n",
"\n",
" @property\n",
" def token_out_account(self) -> typing.Optional[PublicKey]:\n",
" account_index = _token_out_indices[self.instruction_type]\n",
" if account_index < 0:\n",
" return None\n",
" return self.accounts[account_index]\n",
"\n",
" def describe_parameters(self) -> str:\n",
" instruction_type = self.instruction_type\n",
" additional_data = \"\"\n",
" if instruction_type == InstructionType.InitMangoGroup:\n",
" pass\n",
" elif instruction_type == InstructionType.InitMarginAccount:\n",
" pass\n",
" elif instruction_type == InstructionType.Deposit:\n",
" additional_data = f\"quantity: {self.instruction_data.quantity}\"\n",
" elif instruction_type == InstructionType.Withdraw:\n",
" additional_data = f\"quantity: {self.instruction_data.quantity}\"\n",
" elif instruction_type == InstructionType.Borrow:\n",
" additional_data = f\"quantity: {self.instruction_data.quantity}, token index: {self.instruction_data.token_index}\"\n",
" elif instruction_type == InstructionType.SettleBorrow:\n",
" additional_data = f\"quantity: {self.instruction_data.quantity}, token index: {self.instruction_data.token_index}\"\n",
" elif instruction_type == InstructionType.Liquidate:\n",
" additional_data = f\"deposit quantities: {self.instruction_data.deposit_quantities}\"\n",
" elif instruction_type == InstructionType.DepositSrm:\n",
" additional_data = f\"quantity: {self.instruction_data.quantity}\"\n",
" elif instruction_type == InstructionType.WithdrawSrm:\n",
" additional_data = f\"quantity: {self.instruction_data.quantity}\"\n",
" elif instruction_type == InstructionType.PlaceOrder:\n",
" pass\n",
" elif instruction_type == InstructionType.SettleFunds:\n",
" pass\n",
" elif instruction_type == InstructionType.CancelOrder:\n",
" pass\n",
" elif instruction_type == InstructionType.CancelOrderByClientId:\n",
" additional_data = f\"client ID: {self.instruction_data.client_id}\"\n",
" elif instruction_type == InstructionType.ChangeBorrowLimit:\n",
" additional_data = f\"borrow limit: {self.instruction_data.borrow_limit}, token index: {self.instruction_data.token_index}\"\n",
" elif instruction_type == InstructionType.PlaceAndSettle:\n",
" pass\n",
" elif instruction_type == InstructionType.ForceCancelOrders:\n",
" additional_data = f\"limit: {self.instruction_data.limit}\"\n",
" elif instruction_type == InstructionType.PartialLiquidate:\n",
" additional_data = f\"max deposit: {self.instruction_data.max_deposit}\"\n",
"\n",
" return additional_data\n",
"\n",
" @staticmethod\n",
" def from_response(context: Context, all_accounts: typing.List[PublicKey], instruction_data: typing.Dict) -> typing.Optional[\"TransactionInstruction\"]:\n",
" program_account_index = instruction_data[\"programIdIndex\"]\n",
" if all_accounts[program_account_index] != context.program_id:\n",
" # It's an instruction, it's just not a Mango one.\n",
" return None\n",
"\n",
" data = instruction_data[\"data\"]\n",
" instructions_account_indices = instruction_data[\"accounts\"]\n",
"\n",
" decoded = base58.b58decode(data)\n",
" initial = MANGO_INSTRUCTION_VARIANT_FINDER.parse(decoded)\n",
" parser = InstructionParsersByVariant[initial.variant]\n",
"\n",
" # A whole bunch of accounts are listed for a transaction. Some (or all) of them apply\n",
" # to this instruction. The instruction data gives the index of each account it uses,\n",
" # in the order in which it uses them. So, for example, if it uses 3 accounts, the\n",
" # instruction data could say [3, 2, 14], meaning the first account it uses is index 3\n",
" # in the whole transaction account list, the second is index 2 in the whole transaction\n",
" # account list, the third is index 14 in the whole transaction account list.\n",
" accounts: typing.List[PublicKey] = []\n",
" for index in instructions_account_indices:\n",
" accounts += [all_accounts[index]]\n",
"\n",
" parsed = parser.parse(decoded)\n",
" instruction_type = InstructionType(int(parsed.variant))\n",
"\n",
" return TransactionInstruction(instruction_type, parsed, accounts)\n",
"\n",
" def __str__(self) -> str:\n",
" parameters = self.describe_parameters() or \"None\"\n",
" return f\"« {self.instruction_type.name}: {parameters} »\"\n",
"\n",
" def __repr__(self) -> str:\n",
" return f\"{self}\"\n"
]
},
{
"cell_type": "markdown",
"id": "portuguese-implement",
"metadata": {},
"source": [
"# TransactionScout class"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "focused-poultry",
"metadata": {},
"outputs": [],
"source": [
"class TransactionScout:\n",
" def __init__(self, timestamp: datetime.datetime, signatures: typing.List[str],\n",
" succeeded: bool, group_name: str, accounts: typing.List[PublicKey],\n",
" instructions: typing.List[typing.Any], messages: typing.List[str],\n",
" pre_token_balances: typing.List[OwnedTokenValue],\n",
" post_token_balances: typing.List[OwnedTokenValue]):\n",
" self.timestamp: datetime.datetime = timestamp\n",
" self.signatures: typing.List[str] = signatures\n",
" self.succeeded: bool = succeeded\n",
" self.group_name: str = group_name\n",
" self.accounts: typing.List[PublicKey] = accounts\n",
" self.instructions: typing.List[typing.Any] = instructions\n",
" self.messages: typing.List[str] = messages\n",
" self.pre_token_balances: typing.List[OwnedTokenValue] = pre_token_balances\n",
" self.post_token_balances: typing.List[OwnedTokenValue] = post_token_balances\n",
"\n",
" @property\n",
" def summary(self) -> str:\n",
" result = \"[Success]\" if self.succeeded else \"[Failed]\"\n",
" instructions = \", \".join([ins.instruction_type.name for ins in self.instructions])\n",
" changes = OwnedTokenValue.changes(self.pre_token_balances, self.post_token_balances)\n",
"\n",
" in_tokens = []\n",
" for ins in self.instructions:\n",
" if ins.token_in_account is not None:\n",
" in_tokens += [OwnedTokenValue.find_by_owner(changes, ins.token_in_account)]\n",
"\n",
" out_tokens = []\n",
" for ins in self.instructions:\n",
" if ins.token_out_account is not None:\n",
" out_tokens += [OwnedTokenValue.find_by_owner(changes, ins.token_out_account)]\n",
"\n",
" changed_tokens = in_tokens + out_tokens\n",
" changed_tokens_text = \", \".join([f\"{tok.token_value.value:,.8f} {tok.token_value.token.name}\" for tok in changed_tokens]) or \"None\"\n",
"\n",
" return f\"« TransactionScout {result} {self.group_name} [{self.timestamp}] {instructions}: Token Changes: {changed_tokens_text}\\n {self.signatures} »\"\n",
"\n",
" @property\n",
" def sender(self) -> PublicKey:\n",
" return self.instructions[0].sender\n",
"\n",
" @property\n",
" def group(self) -> PublicKey:\n",
" return self.instructions[0].group\n",
"\n",
" def has_any_instruction_of_type(self, instruction_type: InstructionType) -> bool:\n",
" return any(map(lambda ins: ins.instruction_type == instruction_type, self.instructions))\n",
"\n",
" @staticmethod\n",
" def load_if_available(context: Context, signature: str) -> typing.Optional[\"TransactionScout\"]:\n",
" transaction_response = context.client.get_confirmed_transaction(signature)\n",
" transaction_details = context.unwrap_or_raise_exception(transaction_response)\n",
" if transaction_details is None:\n",
" return None\n",
" return TransactionScout.from_transaction_response(context, transaction_details)\n",
"\n",
" @staticmethod\n",
" def load(context: Context, signature: str) -> \"TransactionScout\":\n",
" tx = TransactionScout.load_if_available(context, signature)\n",
" if tx is None:\n",
" raise Exception(f\"Transaction '{signature}' not found.\")\n",
" return tx\n",
"\n",
" @staticmethod\n",
" def from_transaction_response(context: Context, response: typing.Dict) -> \"TransactionScout\":\n",
" def balance_to_token_value(accounts: typing.List[PublicKey], balance: typing.Dict) -> OwnedTokenValue:\n",
" mint = PublicKey(balance[\"mint\"])\n",
" account = accounts[balance[\"accountIndex\"]]\n",
" amount = Decimal(balance[\"uiTokenAmount\"][\"amount\"])\n",
" decimals = Decimal(balance[\"uiTokenAmount\"][\"decimals\"])\n",
" divisor = Decimal(10) ** decimals\n",
" value = amount / divisor\n",
" token = TokenLookup.default_lookups().find_by_mint(mint)\n",
" return OwnedTokenValue(account, TokenValue(token, value))\n",
"\n",
" try:\n",
" succeeded = True if response[\"meta\"][\"err\"] is None else False\n",
" accounts = list(map(PublicKey, response[\"transaction\"][\"message\"][\"accountKeys\"]))\n",
" instructions = []\n",
" for instruction_data in response[\"transaction\"][\"message\"][\"instructions\"]:\n",
" instruction = TransactionInstruction.from_response(context, accounts, instruction_data)\n",
" if instruction is not None:\n",
" instructions += [instruction]\n",
"\n",
" group_name = context.lookup_group_name(instructions[0].group)\n",
" timestamp = datetime.datetime.fromtimestamp(response[\"blockTime\"])\n",
" signatures = response[\"transaction\"][\"signatures\"]\n",
" messages = response[\"meta\"][\"logMessages\"]\n",
" pre_token_balances = list(map(lambda bal: balance_to_token_value(accounts, bal), response[\"meta\"][\"preTokenBalances\"]))\n",
" post_token_balances = list(map(lambda bal: balance_to_token_value(accounts, bal), response[\"meta\"][\"postTokenBalances\"]))\n",
" return TransactionScout(timestamp,\n",
" signatures,\n",
" succeeded,\n",
" group_name,\n",
" accounts,\n",
" instructions,\n",
" messages,\n",
" pre_token_balances,\n",
" post_token_balances)\n",
" except Exception as exception:\n",
" signature = \"Unknown\"\n",
" if response and (\"transaction\" in response) and (\"signatures\" in response[\"transaction\"]) and len(response[\"transaction\"][\"signatures\"]) > 0:\n",
" signature = \", \".join(response[\"transaction\"][\"signatures\"])\n",
" raise Exception(f\"Exception fetching transaction '{signature}'\", exception)\n",
"\n",
" def __str__(self) -> str:\n",
" def format_tokens(account_token_values: typing.List[OwnedTokenValue]) -> str:\n",
" if len(account_token_values) == 0:\n",
" return \"None\"\n",
" return \"\\n \".join([f\"{atv}\" for atv in account_token_values])\n",
"\n",
" instruction_names = \", \".join([ins.instruction_type.name for ins in self.instructions])\n",
" signatures = \"\\n \".join(self.signatures)\n",
" accounts = \"\\n \".join([f\"{acc}\" for acc in self.accounts])\n",
" messages = \"\\n \".join(self.messages)\n",
" instructions = \"\\n \".join([f\"{ins}\" for ins in self.instructions])\n",
" changes = OwnedTokenValue.changes(self.pre_token_balances, self.post_token_balances)\n",
" tokens_in = format_tokens(self.pre_token_balances)\n",
" tokens_out = format_tokens(self.post_token_balances)\n",
" token_changes = format_tokens(changes)\n",
" return f\"\"\"« TransactionScout {self.timestamp}: {instruction_names}\n",
" Sender:\n",
" {self.sender}\n",
" Succeeded:\n",
" {self.succeeded}\n",
" Group:\n",
" {self.group_name} [{self.group}]\n",
" Signatures:\n",
" {signatures}\n",
" Instructions:\n",
" {instructions}\n",
" Accounts:\n",
" {accounts}\n",
" Messages:\n",
" {messages}\n",
" Tokens In:\n",
" {tokens_in}\n",
" Tokens Out:\n",
" {tokens_out}\n",
" Token Changes:\n",
" {token_changes}\n",
"»\"\"\"\n",
"\n",
" def __repr__(self) -> str:\n",
" return f\"{self}\"\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "electronic-cassette",
"metadata": {},
"outputs": [],
"source": [
"def fetch_all_recent_transaction_signatures(in_the_last: datetime.timedelta = datetime.timedelta(days=1)) -> typing.List[str]:\n",
" now = datetime.datetime.now()\n",
" recency_cutoff = now - in_the_last\n",
" recency_cutoff_timestamp = recency_cutoff.timestamp()\n",
"\n",
" all_fetched = False\n",
" before = None\n",
" signature_results = []\n",
" while not all_fetched:\n",
" signature_response = default_context.client.get_confirmed_signature_for_address2(default_context.group_id, before=before)\n",
" signature_result = default_context.unwrap_or_raise_exception(signature_response)\n",
" signature_results += signature_result\n",
" if (len(signature_result) == 0) or (signature_result[-1][\"blockTime\"] < recency_cutoff_timestamp):\n",
" all_fetched = True\n",
" before = signature_results[-1][\"signature\"]\n",
"\n",
" recent = [result[\"signature\"] for result in signature_results if result[\"blockTime\"] > recency_cutoff_timestamp]\n",
" return recent\n"
]
},
{
"cell_type": "markdown",
"id": "false-merchant",
"metadata": {},
"source": [
"# 🏃 Running\n",
"\n",
"You can run the following cells to examine any Mango transaction.\n",
"\n",
"Enter the signature of the transaction you want to examine in the value for `TRANSACTION_TO_VIEW` in the box below, between the double-quote marks. Then run the notebook by choosing 'Run > Run All Cells' from the notebook menu at the top of the page.\n",
"\n",
"Alternatively you can uncomment the `rx` code to run through all recent Mango Solana transactions, printing a summary or (if it's a `PartialLiquidate` transaction) the full details.\n",
"\n",
"**NOTE**: Transactions disappear from most servers within a few hours. It is often not possible to go too far back searching for transactions or retrieving their data."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "recovered-times",
"metadata": {},
"outputs": [],
"source": [
"TRANSACTION_TO_VIEW = \"\""
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "descending-norwegian",
"metadata": {},
"outputs": [],
"source": [
"if __name__ == \"__main__\":\n",
" if TRANSACTION_TO_VIEW != \"\":\n",
" tx = TransactionScout.load(default_context, TRANSACTION_TO_VIEW)\n",
" print(tx.summary)\n",
" print(tx)\n",
"\n",
"# import rx\n",
"# import rx.operators as ops\n",
"# from Observables import debug_print_item, PrintingObserverSubscriber\n",
"\n",
"# signatures = fetch_all_recent_transaction_signatures()\n",
"# rx.from_(signatures).pipe(\n",
"# # ops.map(debug_print_item(\"Signature:\")),\n",
"# ops.map(lambda signature: TransactionScout.load_if_available(default_context, signature)),\n",
"# ops.filter(lambda item: item is not None),\n",
"# ops.filter(lambda item: item.has_any_instruction_of_type(InstructionType.PartialLiquidate)),\n",
"# ops.take(5),\n",
"# ).subscribe(PrintingObserverSubscriber(True))\n"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.8.6"
},
"toc": {
"base_numbering": 1,
"nav_menu": {},
"number_sections": true,
"sideBar": true,
"skip_h1_title": false,
"title_cell": "Table of Contents",
"title_sidebar": "Contents",
"toc_cell": false,
"toc_position": {},
"toc_section_display": true,
"toc_window_display": true
},
"varInspector": {
"cols": {
"lenName": 16,
"lenType": 16,
"lenVar": 40
},
"kernels_config": {
"python": {
"delete_cmd_postfix": "",
"delete_cmd_prefix": "del ",
"library": "var_list.py",
"varRefreshCmd": "print(var_dic_list())"
},
"r": {
"delete_cmd_postfix": ") ",
"delete_cmd_prefix": "rm(",
"library": "var_list.r",
"varRefreshCmd": "cat(var_dic_list()) "
}
},
"types_to_exclude": [
"module",
"function",
"builtin_function_or_method",
"instance",
"_Feature"
],
"window_display": false
}
},
"nbformat": 4,
"nbformat_minor": 5
}

View File

@ -1,231 +0,0 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "tested-retreat",
"metadata": {},
"source": [
"# ⚠ Warning\n",
"\n",
"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.\n",
"\n",
"[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gl/OpinionatedGeek%2Fmango-explorer/HEAD?filepath=Wallet.ipynb) _🏃 To run this notebook press the ⏩ icon in the toolbar above._\n",
"\n",
"[🥭 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)"
]
},
{
"cell_type": "markdown",
"id": "incomplete-fundamentals",
"metadata": {},
"source": [
"# 🥭 Wallet\n",
"\n",
"This notebook holds all the code around handling a wallet of private and public keys.\n",
"\n",
"**Please be careful with your private keys, and don't provide them to any code if you are not 100% confident you know what it will do.**"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "special-membrane",
"metadata": {
"jupyter": {
"source_hidden": true
}
},
"outputs": [],
"source": [
"import json\n",
"import logging\n",
"import os.path\n",
"\n",
"from solana.account import Account\n",
"from solana.publickey import PublicKey\n"
]
},
{
"cell_type": "markdown",
"id": "significant-sound",
"metadata": {},
"source": [
"## Wallet class\n",
"\n",
"The `Wallet` class wraps our understanding of saving and loading keys, and creating the appropriate Solana `Account` object.\n",
"\n",
"To load a private key from a file, the file must be a JSON-formatted text file with a root array of the 64 bytes making up the secret key.\n",
"\n",
"For example:\n",
"```\n",
"[200,48,184,13... for another 60 bytes...]\n",
"```\n",
"**TODO:** It would be good to be able to load a `Wallet` from a mnemonic string. I haven't yet found a Python library that can generate a BIP44 derived seed for Solana that matches the derived seeds created by Sollet and Ledger."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "ranking-special",
"metadata": {},
"outputs": [],
"source": [
"_DEFAULT_WALLET_FILENAME: str = \"id.json\"\n",
"\n",
"\n",
"class Wallet:\n",
" def __init__(self, secret_key):\n",
" self.logger: logging.Logger = logging.getLogger(self.__class__.__name__)\n",
" self.secret_key = secret_key[0:32]\n",
" self.account = Account(self.secret_key)\n",
"\n",
" @property\n",
" def address(self) -> PublicKey:\n",
" return self.account.public_key()\n",
"\n",
" def save(self, filename: str, overwrite: bool = False) -> None:\n",
" if os.path.isfile(filename) and not overwrite:\n",
" raise Exception(f\"Wallet file '{filename}' already exists.\")\n",
"\n",
" with open(filename, \"w\") as json_file:\n",
" json.dump(list(self.secret_key), json_file)\n",
"\n",
" @staticmethod\n",
" def load(filename: str = _DEFAULT_WALLET_FILENAME) -> \"Wallet\":\n",
" if not os.path.isfile(filename):\n",
" logging.error(f\"Wallet file '{filename}' is not present.\")\n",
" raise Exception(f\"Wallet file '{filename}' is not present.\")\n",
" else:\n",
" with open(filename) as json_file:\n",
" data = json.load(json_file)\n",
" return Wallet(data)\n",
"\n",
" @staticmethod\n",
" def create() -> \"Wallet\":\n",
" new_account = Account()\n",
" new_secret_key = new_account.secret_key()\n",
" return Wallet(new_secret_key)\n"
]
},
{
"cell_type": "markdown",
"id": "romance-manchester",
"metadata": {},
"source": [
"# default_wallet object\n",
"\n",
"A default Wallet object that loads the private key from the id.json file, if it exists."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "professional-graduation",
"metadata": {},
"outputs": [],
"source": [
"default_wallet = None\n",
"if os.path.isfile(_DEFAULT_WALLET_FILENAME):\n",
" try:\n",
" default_wallet = Wallet.load(_DEFAULT_WALLET_FILENAME)\n",
" except Exception as exception:\n",
" logging.warning(f\"Failed to load default wallet from file '{_DEFAULT_WALLET_FILENAME}' - exception: {exception}\")\n"
]
},
{
"cell_type": "markdown",
"id": "actual-swift",
"metadata": {},
"source": [
"# 🏃 Running\n",
"\n",
"A simple harness to load a `Wallet` if the `wallet.json` file exists, and print out its `PublicKey`.\n",
"\n",
"**Please be careful with your private keys!**"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "yellow-illness",
"metadata": {},
"outputs": [],
"source": [
"if __name__ == \"__main__\":\n",
" logging.getLogger().setLevel(logging.INFO)\n",
"\n",
" import os.path\n",
"\n",
" filename = _DEFAULT_WALLET_FILENAME\n",
" if not os.path.isfile(filename):\n",
" print(f\"Wallet file '{filename}' is not present.\")\n",
" else:\n",
" wallet = Wallet.load(filename)\n",
" print(\"Wallet address:\", wallet.address)\n"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.8.6"
},
"toc": {
"base_numbering": 1,
"nav_menu": {},
"number_sections": true,
"sideBar": true,
"skip_h1_title": false,
"title_cell": "Table of Contents",
"title_sidebar": "Contents",
"toc_cell": false,
"toc_position": {},
"toc_section_display": true,
"toc_window_display": true
},
"varInspector": {
"cols": {
"lenName": 16,
"lenType": 16,
"lenVar": 40
},
"kernels_config": {
"python": {
"delete_cmd_postfix": "",
"delete_cmd_prefix": "del ",
"library": "var_list.py",
"varRefreshCmd": "print(var_dic_list())"
},
"r": {
"delete_cmd_postfix": ") ",
"delete_cmd_prefix": "rm(",
"library": "var_list.r",
"varRefreshCmd": "cat(var_dic_list()) "
}
},
"types_to_exclude": [
"module",
"function",
"builtin_function_or_method",
"instance",
"_Feature"
],
"window_display": false
}
},
"nbformat": 4,
"nbformat_minor": 5
}

View File

@ -1,677 +0,0 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "urban-density",
"metadata": {},
"source": [
"# ⚠ Warning\n",
"\n",
"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.\n",
"\n",
"[![Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gl/OpinionatedGeek%2Fmango-explorer/HEAD?filepath=Trade.ipynb) _🏃 To run this notebook press the ⏩ icon in the toolbar above._\n",
"\n",
"[🥭 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)"
]
},
{
"cell_type": "markdown",
"id": "enclosed-postage",
"metadata": {},
"source": [
"# 🥭 WalletBalancer\n",
"\n",
"This notebook deals with balancing a wallet after processing liquidations, so that it has appropriate funds for the next liquidation.\n",
"\n",
"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?\n",
"\n",
"We 'balance' our wallet tokens, buying or selling or swapping them as required."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "constitutional-today",
"metadata": {
"jupyter": {
"source_hidden": true
}
},
"outputs": [],
"source": [
"import abc\n",
"import logging\n",
"import typing\n",
"\n",
"from decimal import Decimal\n",
"from solana.publickey import PublicKey\n",
"\n",
"from BaseModel import BasketToken, Group, Token, TokenValue\n",
"from Context import Context\n",
"from TradeExecutor import TradeExecutor\n",
"from Wallet import Wallet\n"
]
},
{
"cell_type": "markdown",
"id": "given-boutique",
"metadata": {},
"source": [
"# Target Balances\n",
"\n",
"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.\n",
"\n",
"There are two possible approaches to specifying the target value:\n",
"* A 'fixed' value, like 10 ETH\n",
"* A 'percentage' value, like 20% ETH\n",
"\n",
"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.\n",
"\n",
"The way this all hangs together is:\n",
"* A parser parses string values (probably from a command-line) into `TargetBalance` objects.\n",
"* There are two types of `TargetBalance` objects - `FixedTargetBalance` and `PercentageTargetBalance`.\n",
"* To get the actual `TokenValue` for balancing, the `TargetBalance` must be 'resolved' by calling `resolve()` with the appropriate token price and wallet value."
]
},
{
"cell_type": "markdown",
"id": "grand-product",
"metadata": {},
"source": [
"## TargetBalance class\n",
"\n",
"This is the abstract base class for our target balances, to allow them to be treated polymorphically."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "typical-international",
"metadata": {},
"outputs": [],
"source": [
"class TargetBalance(metaclass=abc.ABCMeta):\n",
" def __init__(self, token: Token):\n",
" self.token = token\n",
"\n",
" @abc.abstractmethod\n",
" def resolve(self, current_price: Decimal, total_value: Decimal) -> TokenValue:\n",
" raise NotImplementedError(\"TargetBalance.resolve() is not implemented on the base type.\")\n",
"\n",
" def __repr__(self) -> str:\n",
" return f\"{self}\"\n",
"\n"
]
},
{
"cell_type": "markdown",
"id": "monthly-railway",
"metadata": {},
"source": [
"## FixedTargetBalance class\n",
"\n",
"This is the simple case, where the `FixedTargetBalance` object contains enough information on its own to build the resolved `TokenValue` object."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "trained-clear",
"metadata": {},
"outputs": [],
"source": [
"class FixedTargetBalance(TargetBalance):\n",
" def __init__(self, token: Token, value: Decimal):\n",
" super().__init__(token)\n",
" self.value = value\n",
"\n",
" def resolve(self, current_price: Decimal, total_value: Decimal) -> TokenValue:\n",
" return TokenValue(self.token, self.value)\n",
"\n",
" def __str__(self) -> str:\n",
" return f\"\"\"« FixedTargetBalance [{self.value} {self.token.name}] »\"\"\"\n"
]
},
{
"cell_type": "markdown",
"id": "front-gather",
"metadata": {},
"source": [
"## PercentageTargetBalance\n",
"\n",
"This is the more complex case, where the target is a percentage of the total wallet balance.\n",
"\n",
"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:\n",
"> _wallet fraction_ is _percentage_ of _wallet value_\n",
"\n",
"> _target balance_ is _wallet fraction_ divided by _token price_"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "complete-cleaning",
"metadata": {},
"outputs": [],
"source": [
"class PercentageTargetBalance(TargetBalance):\n",
" def __init__(self, token: Token, target_percentage: Decimal):\n",
" super().__init__(token)\n",
" self.target_fraction = target_percentage / 100\n",
"\n",
" def resolve(self, current_price: Decimal, total_value: Decimal) -> TokenValue:\n",
" target_value = total_value * self.target_fraction\n",
" target_size = target_value / current_price\n",
" return TokenValue(self.token, target_size)\n",
"\n",
" def __str__(self) -> str:\n",
" return f\"\"\"« PercentageTargetBalance [{self.target_fraction * 100}% {self.token.name}] »\"\"\"\n"
]
},
{
"cell_type": "markdown",
"id": "formed-guest",
"metadata": {},
"source": [
"## TargetBalanceParser class\n",
"\n",
"The `TargetBalanceParser` takes a string like \"BTC:0.2\" or \"ETH:20%\" and returns the appropriate TargetBalance object.\n",
"\n",
"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."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "closed-verse",
"metadata": {},
"outputs": [],
"source": [
"class TargetBalanceParser:\n",
" def __init__(self, tokens: typing.List[Token]):\n",
" self.tokens = tokens\n",
"\n",
" def parse(self, to_parse: str) -> TargetBalance:\n",
" try:\n",
" token_name, value = to_parse.split(\":\")\n",
" except Exception as exception:\n",
" raise Exception(f\"Could not parse target balance '{to_parse}'\") from exception\n",
"\n",
" token = Token.find_by_symbol(self.tokens, token_name)\n",
"\n",
" # The value we have may be an int (like 27), a fraction (like 0.1) or a percentage\n",
" # (like 25%). In all cases we want the number as a number, but we also want to know if\n",
" # we have a percent or not\n",
" values = value.split(\"%\")\n",
" numeric_value_string = values[0]\n",
" try:\n",
" numeric_value = Decimal(numeric_value_string)\n",
" except Exception as exception:\n",
" 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\n",
"\n",
" if len(values) > 2:\n",
" 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.\")\n",
"\n",
" if len(values) == 1:\n",
" return FixedTargetBalance(token, numeric_value)\n",
" else:\n",
" return PercentageTargetBalance(token, numeric_value)\n",
"\n"
]
},
{
"cell_type": "markdown",
"id": "legislative-evans",
"metadata": {},
"source": [
"# sort_changes_for_trades function\n",
"\n",
"It's important to process SELLs first, so we have enough funds in the quote balance for the BUYs.\n",
"\n",
"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.)"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "fantastic-doctrine",
"metadata": {},
"outputs": [],
"source": [
"def sort_changes_for_trades(changes: typing.List[TokenValue]) -> typing.List[TokenValue]:\n",
" return sorted(changes, key=lambda change: change.value)\n"
]
},
{
"cell_type": "markdown",
"id": "alpha-intake",
"metadata": {},
"source": [
"# calculate_required_balance_changes function\n",
"\n",
"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."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "superior-millennium",
"metadata": {},
"outputs": [],
"source": [
"def calculate_required_balance_changes(current_balances: typing.List[TokenValue], desired_balances: typing.List[TokenValue]) -> typing.List[TokenValue]:\n",
" changes: typing.List[TokenValue] = []\n",
" for desired in desired_balances:\n",
" current = TokenValue.find_by_token(current_balances, desired.token)\n",
" change = TokenValue(desired.token, desired.value - current.value)\n",
" changes += [change]\n",
"\n",
" return changes\n"
]
},
{
"cell_type": "markdown",
"id": "undefined-forth",
"metadata": {},
"source": [
"# FilterSmallChanges class\n",
"\n",
"Allows us to filter out changes that aren't worth the effort.\n",
"\n",
"For instance, if our desired balance requires changing less than 1% of our total balance, it may not be worth bothering with right not.\n",
"\n",
"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."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "proper-nicholas",
"metadata": {},
"outputs": [],
"source": [
"class FilterSmallChanges:\n",
" def __init__(self, action_threshold: Decimal, balances: typing.List[TokenValue], prices: typing.List[TokenValue]):\n",
" self.logger: logging.Logger = logging.getLogger(self.__class__.__name__)\n",
" self.prices: typing.Dict[str, TokenValue] = {}\n",
" total = Decimal(0)\n",
" for balance in balances:\n",
" price = TokenValue.find_by_token(prices, balance.token)\n",
" self.prices[f\"{price.token.mint}\"] = price\n",
" total += price.value * balance.value\n",
" self.total_balance = total\n",
" self.action_threshold_value = total * action_threshold\n",
" self.logger.info(f\"Wallet total balance of {total} gives action threshold value of {self.action_threshold_value}\")\n",
"\n",
" def allow(self, token_value: TokenValue) -> bool:\n",
" price = self.prices[f\"{token_value.token.mint}\"]\n",
" value = price.value * token_value.value\n",
" absolute_value = value.copy_abs()\n",
" result = absolute_value > self.action_threshold_value\n",
"\n",
" self.logger.info(f\"Value of {token_value.token.name} trade is {absolute_value}, threshold value is {self.action_threshold_value}. Is this worth doing? {result}.\")\n",
" return result\n"
]
},
{
"cell_type": "markdown",
"id": "satisfactory-transparency",
"metadata": {},
"source": [
"# WalletBalancers\n",
"\n",
"We want two types of this class:\n",
"* A 'null' implementation that adheres to the interface but doesn't do anything, and\n",
"* A 'live' implementation that actually does the balancing.\n",
"\n",
"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.\n",
"\n",
"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."
]
},
{
"cell_type": "markdown",
"id": "afraid-lightning",
"metadata": {},
"source": [
"## WalletBalancer class\n",
"\n",
"This is the abstract class which defines the interface."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "clear-workplace",
"metadata": {},
"outputs": [],
"source": [
"class WalletBalancer(metaclass=abc.ABCMeta):\n",
" @abc.abstractmethod\n",
" def balance(self, prices: typing.List[TokenValue]):\n",
" raise NotImplementedError(\"WalletBalancer.balance() is not implemented on the base type.\")\n"
]
},
{
"cell_type": "markdown",
"id": "independent-electronics",
"metadata": {},
"source": [
"## NullWalletBalancer class\n",
"\n",
"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."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "under-pacific",
"metadata": {},
"outputs": [],
"source": [
"class NullWalletBalancer(WalletBalancer):\n",
" def balance(self, prices: typing.List[TokenValue]):\n",
" pass\n"
]
},
{
"cell_type": "markdown",
"id": "labeled-things",
"metadata": {},
"source": [
"## LiveWalletBalancer class\n",
"\n",
"This is the high-level class that does much of the work."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "spoken-bowling",
"metadata": {},
"outputs": [],
"source": [
"class LiveWalletBalancer(WalletBalancer):\n",
" def __init__(self, context: Context, wallet: Wallet, trade_executor: TradeExecutor, action_threshold: Decimal, tokens: typing.List[Token], target_balances: typing.List[TargetBalance]):\n",
" self.logger: logging.Logger = logging.getLogger(self.__class__.__name__)\n",
" self.context: Context = context\n",
" self.wallet: Wallet = wallet\n",
" self.trade_executor: TradeExecutor = trade_executor\n",
" self.action_threshold: Decimal = action_threshold\n",
" self.tokens: typing.List[Token] = tokens\n",
" self.target_balances: typing.List[TargetBalance] = target_balances\n",
"\n",
" def balance(self, prices: typing.List[TokenValue]):\n",
" padding = \"\\n \"\n",
"\n",
" def balances_report(balances) -> str:\n",
" return padding.join(list([f\"{bal}\" for bal in balances]))\n",
"\n",
" current_balances = self._fetch_balances()\n",
" total_value = Decimal(0)\n",
" for bal in current_balances:\n",
" price = TokenValue.find_by_token(prices, bal.token)\n",
" value = bal.value * price.value\n",
" total_value += value\n",
" self.logger.info(f\"Starting balances: {padding}{balances_report(current_balances)} - total: {total_value}\")\n",
" resolved_targets: typing.List[TokenValue] = []\n",
" for target in self.target_balances:\n",
" price = TokenValue.find_by_token(prices, target.token)\n",
" resolved_targets += [target.resolve(price.value, total_value)]\n",
"\n",
" balance_changes = calculate_required_balance_changes(current_balances, resolved_targets)\n",
" self.logger.info(f\"Full balance changes: {padding}{balances_report(balance_changes)}\")\n",
"\n",
" dont_bother = FilterSmallChanges(self.action_threshold, current_balances, prices)\n",
" filtered_changes = list(filter(dont_bother.allow, balance_changes))\n",
" self.logger.info(f\"Filtered balance changes: {padding}{balances_report(filtered_changes)}\")\n",
" if len(filtered_changes) == 0:\n",
" self.logger.info(\"No balance changes to make.\")\n",
" return\n",
"\n",
" sorted_changes = sort_changes_for_trades(filtered_changes)\n",
" self._make_changes(sorted_changes)\n",
" updated_balances = self._fetch_balances()\n",
" self.logger.info(f\"Finishing balances: {padding}{balances_report(updated_balances)}\")\n",
"\n",
" def _make_changes(self, balance_changes: typing.List[TokenValue]):\n",
" self.logger.info(f\"Balance changes to make: {balance_changes}\")\n",
" for change in balance_changes:\n",
" if change.value < 0:\n",
" self.trade_executor.sell(change.token.name, change.value.copy_abs())\n",
" else:\n",
" self.trade_executor.buy(change.token.name, change.value.copy_abs())\n",
"\n",
" def _fetch_balances(self) -> typing.List[TokenValue]:\n",
" balances: typing.List[TokenValue] = []\n",
" for token in self.tokens:\n",
" balance = TokenValue.fetch_total_value(self.context, self.wallet.address, token)\n",
" balances += [balance]\n",
"\n",
" return balances\n",
"\n"
]
},
{
"cell_type": "markdown",
"id": "willing-trout",
"metadata": {},
"source": [
"# ✅ Testing"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "regulation-hurricane",
"metadata": {},
"outputs": [],
"source": [
"def _notebook_tests():\n",
" log_level = logging.getLogger().level\n",
" try:\n",
" logging.getLogger().setLevel(logging.CRITICAL)\n",
" eth = Token(\"ETH\", \"Wrapped Ethereum (Sollet)\", PublicKey(\"2FPyTwcZLUg1MDrwsyoP4D6s1tM7hAkHYRjkNb5w6Pxk\"), Decimal(6))\n",
" btc = Token(\"BTC\", \"Wrapped Bitcoin (Sollet)\", PublicKey(\"9n4nbM75f5Ui33ZbPYXn59EwSgE8CGsHtAeTH5YFeJ9E\"), Decimal(6))\n",
" usdt = Token(\"USDT\", \"USDT\", PublicKey(\"Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB\"), Decimal(6))\n",
" current_prices = [\n",
" TokenValue(eth, Decimal(\"4000\")),\n",
" TokenValue(btc, Decimal(\"60000\")),\n",
" TokenValue(usdt, Decimal(\"1\")),\n",
" ]\n",
" current_balances = [\n",
" TokenValue(eth, Decimal(\"0.5\")),\n",
" TokenValue(btc, Decimal(\"0.2\")),\n",
" TokenValue(usdt, Decimal(\"10000\")),\n",
" ]\n",
" desired_balances = [\n",
" TokenValue(eth, Decimal(\"1\")),\n",
" TokenValue(btc, Decimal(\"0.1\"))\n",
" ]\n",
"\n",
" changes = calculate_required_balance_changes(current_balances, desired_balances)\n",
"\n",
" assert(changes[0].token.symbol == \"ETH\")\n",
" assert(changes[0].value == Decimal(\"0.5\"))\n",
" assert(changes[1].token.symbol == \"BTC\")\n",
" assert(changes[1].value == Decimal(\"-0.1\"))\n",
"\n",
" parsed_balance_change = FixedTargetBalance(eth, Decimal(\"0.1\"))\n",
" assert(parsed_balance_change.token == eth)\n",
" assert(parsed_balance_change.value == Decimal(\"0.1\"))\n",
"\n",
" percentage_parsed_balance_change = PercentageTargetBalance(eth, Decimal(33))\n",
" assert(percentage_parsed_balance_change.token == eth)\n",
"\n",
" current_eth_price = Decimal(2000) # It's $2,000 per ETH\n",
" current_account_value = Decimal(10000) # We hold $10,000 in total across all assets in our account.\n",
" resolved_parsed_balance_change = percentage_parsed_balance_change.resolve(current_eth_price, current_account_value)\n",
" assert(resolved_parsed_balance_change.token == eth)\n",
" # 33% of $10,000 is $3,300\n",
" # $3,300 spent on ETH gives us 1.65 ETH\n",
" assert(resolved_parsed_balance_change.value == Decimal(\"1.65\"))\n",
"\n",
" parser = TargetBalanceParser([eth, btc, usdt])\n",
" parsed_percent = parser.parse(\"eth:10%\")\n",
" assert(parsed_percent.token == eth)\n",
" assert(parsed_percent.target_fraction == Decimal(\"0.1\"))\n",
"\n",
" parsed_fixed = parser.parse(\"eth:70\")\n",
" assert(parsed_fixed.token == eth)\n",
" assert(parsed_fixed.value == Decimal(70))\n",
"\n",
" action_threshold = Decimal(\"0.01\") # Don't bother if it's less than 1% of the total value (24,000)\n",
" dont_bother = FilterSmallChanges(action_threshold, current_balances, current_prices)\n",
"\n",
" # 0.05 ETH is worth $200 at our test prices, which is less than our $240 threshold\n",
" assert(not dont_bother.allow(TokenValue(eth, Decimal(\"0.05\"))))\n",
"\n",
" # 0.05 BTC is worth $3,000 at our test prices, which is much more than our $240 threshold\n",
" assert(dont_bother.allow(TokenValue(btc, Decimal(\"0.05\"))))\n",
"\n",
" eth_buy = TokenValue(eth, Decimal(\"5\"))\n",
" btc_sell = TokenValue(btc, Decimal(\"-1\"))\n",
" sorted_changes = sort_changes_for_trades([\n",
" eth_buy,\n",
" btc_sell\n",
" ])\n",
"\n",
" assert(sorted_changes[0] == btc_sell)\n",
" assert(sorted_changes[1] == eth_buy)\n",
" finally:\n",
" logging.getLogger().setLevel(log_level)\n",
"\n",
"\n",
"_notebook_tests()\n",
"del _notebook_tests"
]
},
{
"cell_type": "markdown",
"id": "thrown-italian",
"metadata": {},
"source": [
"# 🏃 Running\n",
"\n",
"If running interactively, try to buy then sell 0.1 ETH.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "given-midwest",
"metadata": {},
"outputs": [],
"source": [
"if __name__ == \"__main__\":\n",
" logging.getLogger().setLevel(logging.INFO)\n",
"\n",
" from Context import default_context\n",
"\n",
" group = Group.load(default_context)\n",
" eth = BasketToken.find_by_symbol(group.basket_tokens, \"eth\").token\n",
" btc = BasketToken.find_by_symbol(group.basket_tokens, \"btc\").token\n",
" usdt = BasketToken.find_by_symbol(group.basket_tokens, \"usdt\").token\n",
"\n",
" parser = TargetBalanceParser([eth, btc])\n",
" eth_target = parser.parse(\"ETH:20%\")\n",
" btc_target = parser.parse(\"btc:0.05\")\n",
" prices = [Decimal(\"60000\"), Decimal(\"4000\"), Decimal(\"1\")] # Ordered as per Group index ordering\n",
" desired_balances = []\n",
" for target in [eth_target, btc_target]:\n",
" token_index = group.price_index_of_token(target.token)\n",
" price = prices[token_index]\n",
" resolved = target.resolve(price, Decimal(10000))\n",
" desired_balances += [resolved]\n",
"\n",
" assert(desired_balances[0].token.symbol == \"ETH\")\n",
" assert(desired_balances[0].value == Decimal(\"0.5\"))\n",
" assert(desired_balances[1].token.symbol == \"BTC\")\n",
" assert(desired_balances[1].value == Decimal(\"0.05\"))\n",
"\n",
" current_balances = [\n",
" TokenValue(eth, Decimal(\"0.6\")), # Worth $2,400 at the test prices\n",
" TokenValue(btc, Decimal(\"0.01\")), # Worth $6,00 at the test prices\n",
" TokenValue(usdt, Decimal(\"7000\")), # Remainder of $10,000 minus above token values\n",
" ]\n",
"\n",
" changes = calculate_required_balance_changes(current_balances, desired_balances)\n",
" for change in changes:\n",
" order_type = \"BUY\" if change.value > 0 else \"SELL\"\n",
" print(f\"{change.token.name} {order_type} {change.value}\")\n",
"\n",
" # To get from our current balances of 0.6 ETH and 0.01 BTC to our desired balances of\n",
" # 0.5 ETH and 0.05 BTC, we need to sell 0.1 ETH and buy 0.04 BTC. But we want to do the sell\n",
" # first, to make sure we have the proper liquidity when it comes to buying.\n",
" sorted_changes = sort_changes_for_trades(changes)\n",
" assert(sorted_changes[0].token.symbol == \"ETH\")\n",
" assert(sorted_changes[0].value == Decimal(\"-0.1\"))\n",
" assert(sorted_changes[1].token.symbol == \"BTC\")\n",
" assert(sorted_changes[1].value == Decimal(\"0.04\"))\n"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.8.6"
},
"toc": {
"base_numbering": 1,
"nav_menu": {},
"number_sections": true,
"sideBar": true,
"skip_h1_title": false,
"title_cell": "Table of Contents",
"title_sidebar": "Contents",
"toc_cell": false,
"toc_position": {},
"toc_section_display": true,
"toc_window_display": true
},
"varInspector": {
"cols": {
"lenName": 16,
"lenType": 16,
"lenVar": 40
},
"kernels_config": {
"python": {
"delete_cmd_postfix": "",
"delete_cmd_prefix": "del ",
"library": "var_list.py",
"varRefreshCmd": "print(var_dic_list())"
},
"r": {
"delete_cmd_postfix": ") ",
"delete_cmd_prefix": "rm(",
"library": "var_list.r",
"varRefreshCmd": "cat(var_dic_list()) "
}
},
"types_to_exclude": [
"module",
"function",
"builtin_function_or_method",
"instance",
"_Feature"
],
"window_display": false
}
},
"nbformat": 4,
"nbformat_minor": 5
}

View File

@ -1,53 +1,33 @@
#!/usr/bin/env pyston3
import os
import sys
from pathlib import Path
# Get the full path to this script.
script_path = Path(os.path.realpath(__file__))
# The parent of the script is the bin directory.
# The parent of the bin directory is the notebook directory.
# It's this notebook directory we want.
notebook_directory = script_path.parent.parent
# Add the notebook directory to our import path.
sys.path.append(str(notebook_directory))
# Add the startup directory to our import path.
startup_directory = notebook_directory / "meta" / "startup"
sys.path.append(str(startup_directory))
import argparse
import logging
import os
import os.path
import projectsetup # noqa: F401
import sys
import traceback
from solana.publickey import PublicKey
from AccountScout import AccountScout
from BaseModel import Group
from Constants import WARNING_DISCLAIMER_TEXT
from Context import Context, default_cluster, default_cluster_url, default_program_id, default_dex_program_id, default_group_name, default_group_id
from Wallet import Wallet
sys.path.insert(0, os.path.abspath(
os.path.join(os.path.dirname(__file__), '..')))
import mango # nopep8
# We explicitly want argument parsing to be outside the main try-except block because some arguments
# (like --help) will cause an exit, which our except: block traps.
parser = argparse.ArgumentParser(description="Run the Account Scout to display problems and information about an account.")
parser.add_argument("--cluster", type=str, default=default_cluster,
parser = argparse.ArgumentParser(
description="Run the Account Scout to display problems and information about an account.")
parser.add_argument("--cluster", type=str, default=mango.default_cluster,
help="Solana RPC cluster name")
parser.add_argument("--cluster-url", type=str, default=default_cluster_url,
parser.add_argument("--cluster-url", type=str, default=mango.default_cluster_url,
help="Solana RPC cluster URL")
parser.add_argument("--program-id", type=str, default=default_program_id,
parser.add_argument("--program-id", type=str, default=mango.default_program_id,
help="Mango program ID/address")
parser.add_argument("--dex-program-id", type=str, default=default_dex_program_id,
parser.add_argument("--dex-program-id", type=str, default=mango.default_dex_program_id,
help="DEX program ID/address")
parser.add_argument("--group-name", type=str, default=default_group_name,
parser.add_argument("--group-name", type=str, default=mango.default_group_name,
help="Mango group name")
parser.add_argument("--group-id", type=str, default=default_group_id,
parser.add_argument("--group-id", type=str, default=mango.default_group_id,
help="Mango group ID/address")
parser.add_argument("--id-file", type=str, default="id.json",
help="file containing the JSON-formatted wallet private key")
@ -58,7 +38,7 @@ parser.add_argument("--log-level", default=logging.WARNING, type=lambda level: g
args = parser.parse_args()
logging.getLogger().setLevel(args.log_level)
logging.warning(WARNING_DISCLAIMER_TEXT)
logging.warning(mango.WARNING_DISCLAIMER_TEXT)
try:
address = args.address
@ -67,17 +47,17 @@ try:
if not os.path.isfile(id_filename):
logging.error(f"Address parameter not specified and wallet file '{id_filename}' is not present.")
sys.exit(1)
wallet = Wallet.load(id_filename)
wallet = mango.Wallet.load(id_filename)
address = wallet.address
context = Context.from_command_line(args.cluster, args.cluster_url, args.program_id,
args.dex_program_id, args.group_name, args.group_id)
context = mango.Context.from_command_line(args.cluster, args.cluster_url, args.program_id,
args.dex_program_id, args.group_name, args.group_id)
logging.info(f"Context: {context}")
logging.info(f"Address: {address}")
group = Group.load(context)
scout = AccountScout()
group = mango.Group.load(context)
scout = mango.AccountScout()
report = scout.verify_account_prepared_for_group(context, group, address)
print(report)
except Exception as exception:

View File

@ -1,28 +1,9 @@
#!/usr/bin/env pyston3
import os
import sys
from pathlib import Path
# Get the full path to this script.
script_path = Path(os.path.realpath(__file__))
# The parent of the script is the bin directory.
# The parent of the bin directory is the notebook directory.
# It's this notebook directory we want.
notebook_directory = script_path.parent.parent
# Add the notebook directory to our import path.
sys.path.append(str(notebook_directory))
# Add the startup directory to our import path.
startup_directory = notebook_directory / "meta" / "startup"
sys.path.append(str(startup_directory))
import argparse
import logging
import projectsetup # noqa: F401
import os
import sys
import typing
from solana.account import Account
@ -31,10 +12,9 @@ from spl.token.constants import TOKEN_PROGRAM_ID
from solana.transaction import Transaction
from spl.token.instructions import CloseAccountParams, close_account
from BaseModel import TokenAccount, TokenLookup
from Constants import WARNING_DISCLAIMER_TEXT
from Context import default_context as context
from Wallet import Wallet
sys.path.insert(0, os.path.abspath(
os.path.join(os.path.dirname(__file__), '..')))
import mango # nopep8
parser = argparse.ArgumentParser(description="Closes a Wrapped SOL account.")
parser.add_argument("--id-file", type=str, default="id.json",
@ -48,18 +28,18 @@ parser.add_argument("--overwrite", action="store_true", default=False,
args = parser.parse_args()
logging.getLogger().setLevel(args.log_level)
logging.warning(WARNING_DISCLAIMER_TEXT)
logging.warning(mango.WARNING_DISCLAIMER_TEXT)
id_filename = args.id_file
if not os.path.isfile(id_filename):
logging.error(f"Wallet file '{id_filename}' is not present.")
sys.exit(1)
wallet = Wallet.load(id_filename)
wallet = mango.Wallet.load(id_filename)
lookups = TokenLookup.default_lookups()
lookups = mango.TokenLookup.default_lookups()
wrapped_sol = lookups.find_by_symbol("SOL")
token_account = TokenAccount.load(context, args.address)
token_account = mango.TokenAccount.load(mango.default_context, args.address)
if (token_account is None) or (token_account.mint != wrapped_sol.mint):
raise Exception(f"Account {args.address} is not a {wrapped_sol.name} account.")
@ -80,9 +60,9 @@ transaction.add(
print(f"Closing account: {args.address} with balance {token_account.amount} lamports.")
response = context.client.send_transaction(transaction, *signers)
transaction_id = context.unwrap_transaction_id_or_raise_exception(response)
response = mango.default_context.client.send_transaction(transaction, *signers)
transaction_id = mango.default_context.unwrap_transaction_id_or_raise_exception(response)
print(f"Waiting on transaction ID: {transaction_id}")
context.wait_for_confirmation(transaction_id)
mango.default_context.wait_for_confirmation(transaction_id)
print("Done.")

View File

@ -1,31 +1,13 @@
#!/usr/bin/env pyston3
import argparse
import os
import logging
import sys
from pathlib import Path
# Get the full path to this script.
script_path = Path(os.path.realpath(__file__))
# The parent of the script is the bin directory.
# The parent of the bin directory is the notebook directory.
# It's this notebook directory we want.
notebook_directory = script_path.parent.parent
# Add the notebook directory to our import path.
sys.path.append(str(notebook_directory))
# Add the startup directory to our import path.
startup_directory = notebook_directory / "meta" / "startup"
sys.path.append(str(startup_directory))
import argparse
import logging
import projectsetup # noqa: F401
from Constants import WARNING_DISCLAIMER_TEXT
from Wallet import Wallet
sys.path.insert(0, os.path.abspath(
os.path.join(os.path.dirname(__file__), '..')))
import mango # nopep8
parser = argparse.ArgumentParser(description="Creates a new wallet and private key, and saves it to a file.")
parser.add_argument("--id-file", type=str, default="id.json",
@ -37,8 +19,8 @@ parser.add_argument("--overwrite", action="store_true", default=False,
args = parser.parse_args()
logging.getLogger().setLevel(args.log_level)
logging.warning(WARNING_DISCLAIMER_TEXT)
logging.warning(mango.WARNING_DISCLAIMER_TEXT)
new_wallet = Wallet.create()
new_wallet = mango.Wallet.create()
new_wallet.save(args.id_file, args.overwrite)
print(f"Wallet for address {new_wallet.address} created in file: '{args.id_file}'.")

View File

@ -1,55 +1,34 @@
#!/usr/bin/env pyston3
import os
import sys
from pathlib import Path
# Get the full path to this script.
script_path = Path(os.path.realpath(__file__))
# The parent of the script is the bin directory.
# The parent of the bin directory is the notebook directory.
# It's this notebook directory we want.
notebook_directory = script_path.parent.parent
# Add the notebook directory to our import path.
sys.path.append(str(notebook_directory))
# Add the startup directory to our import path.
startup_directory = notebook_directory / "meta" / "startup"
sys.path.append(str(startup_directory))
import argparse
import json
import logging
import os
import os.path
import projectsetup # noqa: F401
import sys
import traceback
from decimal import Decimal
from BaseModel import Group, SpotMarketLookup
from Constants import WARNING_DISCLAIMER_TEXT
from Context import Context, default_cluster, default_cluster_url, default_program_id, default_dex_program_id, default_group_name, default_group_id
from TradeExecutor import NullTradeExecutor, SerumImmediateTradeExecutor, TradeExecutor
from Wallet import Wallet
from WalletBalancer import LiveWalletBalancer, TargetBalanceParser
sys.path.insert(0, os.path.abspath(
os.path.join(os.path.dirname(__file__), '..')))
import mango # nopep8
# We explicitly want argument parsing to be outside the main try-except block because some arguments
# (like --help) will cause an exit, which our except: block traps.
parser = argparse.ArgumentParser(description="Balance the value of tokens in a Mango Markets group to specific values or percentages.")
parser.add_argument("--cluster", type=str, default=default_cluster,
parser = argparse.ArgumentParser(
description="Balance the value of tokens in a Mango Markets group to specific values or percentages.")
parser.add_argument("--cluster", type=str, default=mango.default_cluster,
help="Solana RPC cluster name")
parser.add_argument("--cluster-url", type=str, default=default_cluster_url,
parser.add_argument("--cluster-url", type=str, default=mango.default_cluster_url,
help="Solana RPC cluster URL")
parser.add_argument("--program-id", type=str, default=default_program_id,
parser.add_argument("--program-id", type=str, default=mango.default_program_id,
help="Mango program ID/address")
parser.add_argument("--dex-program-id", type=str, default=default_dex_program_id,
parser.add_argument("--dex-program-id", type=str, default=mango.default_dex_program_id,
help="DEX program ID/address")
parser.add_argument("--group-name", type=str, default=default_group_name,
parser.add_argument("--group-name", type=str, default=mango.default_group_name,
help="Mango group name")
parser.add_argument("--group-id", type=str, default=default_group_id,
parser.add_argument("--group-id", type=str, default=mango.default_group_id,
help="Mango group ID/address")
parser.add_argument("--token-data-file", type=str, default="solana.tokenlist.json",
help="data file that contains token symbols, names, mints and decimals (format is same as https://raw.githubusercontent.com/solana-labs/token-list/main/src/tokens/solana.tokenlist.json)")
@ -68,27 +47,27 @@ parser.add_argument("--dry-run", action="store_true", default=False,
args = parser.parse_args()
logging.getLogger().setLevel(args.log_level)
logging.warning(WARNING_DISCLAIMER_TEXT)
logging.warning(mango.WARNING_DISCLAIMER_TEXT)
try:
id_filename = args.id_file
if not os.path.isfile(id_filename):
logging.error(f"Wallet file '{id_filename}' is not present.")
sys.exit(1)
wallet = Wallet.load(id_filename)
wallet = mango.Wallet.load(id_filename)
action_threshold = args.action_threshold
adjustment_factor = args.adjustment_factor
context = Context.from_command_line(args.cluster, args.cluster_url, args.program_id,
args.dex_program_id, args.group_name, args.group_id)
context = mango.Context.from_command_line(args.cluster, args.cluster_url, args.program_id,
args.dex_program_id, args.group_name, args.group_id)
logging.info(f"Context: {context}")
logging.info(f"Wallet address: {wallet.address}")
group = Group.load(context)
group = mango.Group.load(context)
tokens = [basket_token.token for basket_token in group.basket_tokens]
balance_parser = TargetBalanceParser(tokens)
balance_parser = mango.TargetBalanceParser(tokens)
targets = list(map(balance_parser.parse, args.target))
logging.info(f"Targets: {targets}")
@ -96,14 +75,15 @@ try:
logging.info(f"Prices: {prices}")
if args.dry_run:
trade_executor: TradeExecutor = NullTradeExecutor(print)
trade_executor: mango.TradeExecutor = mango.NullTradeExecutor(print)
else:
with open(args.token_data_file) as json_file:
token_data = json.load(json_file)
spot_market_lookup = SpotMarketLookup(token_data)
trade_executor = SerumImmediateTradeExecutor(context, wallet, spot_market_lookup, adjustment_factor, print)
spot_market_lookup = mango.SpotMarketLookup(token_data)
trade_executor = mango.SerumImmediateTradeExecutor(
context, wallet, spot_market_lookup, adjustment_factor, print)
wallet_balancer = LiveWalletBalancer(context, wallet, trade_executor, action_threshold, tokens, targets)
wallet_balancer = mango.LiveWalletBalancer(context, wallet, trade_executor, action_threshold, tokens, targets)
wallet_balancer.balance(prices)
logging.info("Balancing completed.")

View File

@ -1,50 +1,30 @@
#!/usr/bin/env pyston3
import os
import sys
from pathlib import Path
# Get the full path to this script.
script_path = Path(os.path.realpath(__file__))
# The parent of the script is the bin directory.
# The parent of the bin directory is the notebook directory.
# It's this notebook directory we want.
notebook_directory = script_path.parent.parent
# Add the notebook directory to our import path.
sys.path.append(str(notebook_directory))
# Add the startup directory to our import path.
startup_directory = notebook_directory / "meta" / "startup"
sys.path.append(str(startup_directory))
import argparse
import logging
import os
import os.path
import projectsetup # noqa: F401
import sys
import traceback
from BaseModel import Group, TokenValue
from Constants import WARNING_DISCLAIMER_TEXT
from Context import Context, default_cluster, default_cluster_url, default_program_id, default_dex_program_id, default_group_name, default_group_id
from Wallet import Wallet
sys.path.insert(0, os.path.abspath(
os.path.join(os.path.dirname(__file__), '..')))
import mango # nopep8
# We explicitly want argument parsing to be outside the main try-except block because some arguments
# (like --help) will cause an exit, which our except: block traps.
parser = argparse.ArgumentParser(description="Display the balances of all grop tokens in the current wallet.")
parser.add_argument("--cluster", type=str, default=default_cluster,
parser.add_argument("--cluster", type=str, default=mango.default_cluster,
help="Solana RPC cluster name")
parser.add_argument("--cluster-url", type=str, default=default_cluster_url,
parser.add_argument("--cluster-url", type=str, default=mango.default_cluster_url,
help="Solana RPC cluster URL")
parser.add_argument("--program-id", type=str, default=default_program_id,
parser.add_argument("--program-id", type=str, default=mango.default_program_id,
help="Mango program ID/address")
parser.add_argument("--dex-program-id", type=str, default=default_dex_program_id,
parser.add_argument("--dex-program-id", type=str, default=mango.default_dex_program_id,
help="DEX program ID/address")
parser.add_argument("--group-name", type=str, default=default_group_name,
parser.add_argument("--group-name", type=str, default=mango.default_group_name,
help="Mango group name")
parser.add_argument("--group-id", type=str, default=default_group_id,
parser.add_argument("--group-id", type=str, default=mango.default_group_id,
help="Mango group ID/address")
parser.add_argument("--id-file", type=str, default="id.json",
help="file containing the JSON-formatted wallet private key")
@ -53,24 +33,24 @@ parser.add_argument("--log-level", default=logging.WARNING, type=lambda level: g
args = parser.parse_args()
logging.getLogger().setLevel(args.log_level)
logging.warning(WARNING_DISCLAIMER_TEXT)
logging.warning(mango.WARNING_DISCLAIMER_TEXT)
try:
id_filename = args.id_file
if not os.path.isfile(id_filename):
logging.error(f"Wallet file '{id_filename}' is not present.")
sys.exit(1)
wallet = Wallet.load(id_filename)
context = Context.from_command_line(args.cluster, args.cluster_url, args.program_id,
args.dex_program_id, args.group_name, args.group_id)
wallet = mango.Wallet.load(id_filename)
context = mango.Context.from_command_line(args.cluster, args.cluster_url, args.program_id,
args.dex_program_id, args.group_name, args.group_id)
logging.info(f"Context: {context}")
logging.info(f"Wallet address: {wallet.address}")
group = Group.load(context)
group = mango.Group.load(context)
balances = group.fetch_balances(wallet.address)
print("Balances:")
TokenValue.report(print, balances)
mango.TokenValue.report(print, balances)
except Exception as exception:
logging.critical(f"group-balances stopped because of exception: {exception} - {traceback.format_exc()}")
except:

View File

@ -1,54 +1,33 @@
#!/usr/bin/env pyston3
import os
import sys
from pathlib import Path
# Get the full path to this script.
script_path = Path(os.path.realpath(__file__))
# The parent of the script is the bin directory.
# The parent of the bin directory is the notebook directory.
# It's this notebook directory we want.
notebook_directory = script_path.parent.parent
# Add the notebook directory to our import path.
sys.path.append(str(notebook_directory))
# Add the startup directory to our import path.
startup_directory = notebook_directory / "meta" / "startup"
sys.path.append(str(startup_directory))
import argparse
import json
import logging
import os
import os.path
import projectsetup # noqa: F401
import sys
import traceback
from decimal import Decimal
from BaseModel import Group, SpotMarketLookup
from Constants import WARNING_DISCLAIMER_TEXT
from Context import Context, default_cluster, default_cluster_url, default_program_id, default_dex_program_id, default_group_name, default_group_id
from TradeExecutor import NullTradeExecutor, SerumImmediateTradeExecutor, TradeExecutor
from Wallet import Wallet
sys.path.insert(0, os.path.abspath(
os.path.join(os.path.dirname(__file__), '..')))
import mango # nopep8
# We explicitly want argument parsing to be outside the main try-except block because some arguments
# (like --help) will cause an exit, which our except: block traps.
parser = argparse.ArgumentParser(description="Settles all openorders transactions in the Group.")
parser.add_argument("--cluster", type=str, default=default_cluster,
parser.add_argument("--cluster", type=str, default=mango.default_cluster,
help="Solana RPC cluster name")
parser.add_argument("--cluster-url", type=str, default=default_cluster_url,
parser.add_argument("--cluster-url", type=str, default=mango.default_cluster_url,
help="Solana RPC cluster URL")
parser.add_argument("--program-id", type=str, default=default_program_id,
parser.add_argument("--program-id", type=str, default=mango.default_program_id,
help="Mango program ID/address")
parser.add_argument("--dex-program-id", type=str, default=default_dex_program_id,
parser.add_argument("--dex-program-id", type=str, default=mango.default_dex_program_id,
help="DEX program ID/address")
parser.add_argument("--group-name", type=str, default=default_group_name,
parser.add_argument("--group-name", type=str, default=mango.default_group_name,
help="Mango group name")
parser.add_argument("--group-id", type=str, default=default_group_id,
parser.add_argument("--group-id", type=str, default=mango.default_group_id,
help="Mango group ID/address")
parser.add_argument("--id-file", type=str, default="id.json",
help="file containing the JSON-formatted wallet private key")
@ -61,30 +40,30 @@ parser.add_argument("--dry-run", action="store_true", default=False,
args = parser.parse_args()
logging.getLogger().setLevel(args.log_level)
logging.warning(WARNING_DISCLAIMER_TEXT)
logging.warning(mango.WARNING_DISCLAIMER_TEXT)
try:
id_filename = args.id_file
if not os.path.isfile(id_filename):
logging.error(f"Wallet file '{id_filename}' is not present.")
sys.exit(1)
wallet = Wallet.load(id_filename)
wallet = mango.Wallet.load(id_filename)
context = Context.from_command_line(args.cluster, args.cluster_url, args.program_id,
args.dex_program_id, args.group_name, args.group_id)
context = mango.Context.from_command_line(args.cluster, args.cluster_url, args.program_id,
args.dex_program_id, args.group_name, args.group_id)
logging.info(f"Context: {context}")
logging.info(f"Wallet address: {wallet.address}")
group = Group.load(context)
group = mango.Group.load(context)
if args.dry_run:
trade_executor: TradeExecutor = NullTradeExecutor(print)
trade_executor: mango.TradeExecutor = mango.NullTradeExecutor(print)
else:
with open(args.token_data_file) as json_file:
token_data = json.load(json_file)
spot_market_lookup = SpotMarketLookup(token_data)
trade_executor = SerumImmediateTradeExecutor(context, wallet, spot_market_lookup, Decimal(0), print)
spot_market_lookup = mango.SpotMarketLookup(token_data)
trade_executor = mango.SerumImmediateTradeExecutor(context, wallet, spot_market_lookup, Decimal(0), print)
for market_metadata in group.markets:
market = market_metadata.fetch_market(context)

View File

@ -1,57 +1,32 @@
#!/usr/bin/env pyston3
import os
import sys
from pathlib import Path
# Get the full path to this script.
script_path = Path(os.path.realpath(__file__))
# The parent of the script is the bin directory.
# The parent of the bin directory is the notebook directory.
# It's this notebook directory we want.
notebook_directory = script_path.parent.parent
# Add the notebook directory to our import path.
sys.path.append(str(notebook_directory))
# Add the startup directory to our import path.
startup_directory = notebook_directory / "meta" / "startup"
sys.path.append(str(startup_directory))
import argparse
import logging
import os
import os.path
import projectsetup # noqa: F401
import sys
import traceback
from solana.publickey import PublicKey
from AccountScout import AccountScout
from AccountLiquidator import AccountLiquidator, ForceCancelOrdersAccountLiquidator, NullAccountLiquidator, ReportingAccountLiquidator
from BaseModel import Group, LiquidationEvent, MarginAccount
from Constants import WARNING_DISCLAIMER_TEXT
from Context import Context, default_cluster, default_cluster_url, default_program_id, default_dex_program_id, default_group_name, default_group_id
from Observables import EventSource
from Notification import FilteringNotificationTarget, NotificationHandler, parse_subscription_target
from TransactionScout import TransactionScout
from Wallet import Wallet
sys.path.insert(0, os.path.abspath(
os.path.join(os.path.dirname(__file__), '..')))
import mango # nopep8
# We explicitly want argument parsing to be outside the main try-except block because some arguments
# (like --help) will cause an exit, which our except: block traps.
parser = argparse.ArgumentParser(description="Liquidate a single margin account.")
parser.add_argument("--cluster", type=str, default=default_cluster,
parser.add_argument("--cluster", type=str, default=mango.default_cluster,
help="Solana RPC cluster name")
parser.add_argument("--cluster-url", type=str, default=default_cluster_url,
parser.add_argument("--cluster-url", type=str, default=mango.default_cluster_url,
help="Solana RPC cluster URL")
parser.add_argument("--program-id", type=str, default=default_program_id,
parser.add_argument("--program-id", type=str, default=mango.default_program_id,
help="Mango program ID/address")
parser.add_argument("--dex-program-id", type=str, default=default_dex_program_id,
parser.add_argument("--dex-program-id", type=str, default=mango.default_dex_program_id,
help="DEX program ID/address")
parser.add_argument("--group-name", type=str, default=default_group_name,
parser.add_argument("--group-name", type=str, default=mango.default_group_name,
help="Mango group name")
parser.add_argument("--group-id", type=str, default=default_group_id,
parser.add_argument("--group-id", type=str, default=mango.default_group_id,
help="Mango group ID/address")
parser.add_argument("--id-file", type=str, default="id.json",
help="file containing the JSON-formatted wallet private key")
@ -61,13 +36,13 @@ parser.add_argument("--log-level", default=logging.INFO, type=lambda level: geta
help="level of verbosity to log (possible values: DEBUG, INFO, WARNING, ERROR, CRITICAL)")
parser.add_argument("--margin-account-address", type=PublicKey,
help="Solana address of the Mango Markets margin account to be liquidated")
parser.add_argument("--notify-liquidations", type=parse_subscription_target, action="append", default=[],
parser.add_argument("--notify-liquidations", type=mango.parse_subscription_target, action="append", default=[],
help="The notification target for liquidation events")
parser.add_argument("--notify-successful-liquidations", type=parse_subscription_target,
parser.add_argument("--notify-successful-liquidations", type=mango.parse_subscription_target,
action="append", default=[], help="The notification target for successful liquidation events")
parser.add_argument("--notify-failed-liquidations", type=parse_subscription_target,
parser.add_argument("--notify-failed-liquidations", type=mango.parse_subscription_target,
action="append", default=[], help="The notification target for failed liquidation events")
parser.add_argument("--notify-errors", type=parse_subscription_target, action="append", default=[],
parser.add_argument("--notify-errors", type=mango.parse_subscription_target, action="append", default=[],
help="The notification target for error events")
parser.add_argument("--dry-run", action="store_true", default=False,
help="runs as read-only and does not perform any transactions")
@ -75,33 +50,33 @@ args = parser.parse_args()
logging.getLogger().setLevel(args.log_level)
for notify in args.notify_errors:
handler = NotificationHandler(notify)
handler = mango.NotificationHandler(notify)
handler.setLevel(logging.ERROR)
logging.getLogger().addHandler(handler)
logging.warning(WARNING_DISCLAIMER_TEXT)
logging.warning(mango.WARNING_DISCLAIMER_TEXT)
try:
id_filename = args.id_file
if not os.path.isfile(id_filename):
logging.error(f"Wallet file '{id_filename}' is not present.")
sys.exit(1)
wallet = Wallet.load(id_filename)
wallet = mango.Wallet.load(id_filename)
margin_account_address = args.margin_account_address
liquidator_name = args.name
context = Context.from_command_line(args.cluster, args.cluster_url, args.program_id,
args.dex_program_id, args.group_name, args.group_id)
context = mango.Context.from_command_line(args.cluster, args.cluster_url, args.program_id,
args.dex_program_id, args.group_name, args.group_id)
logging.info(f"Context: {context}")
logging.info(f"Wallet address: {wallet.address}")
logging.info(f"Margin account address: {margin_account_address}")
group = Group.load(context)
group = mango.Group.load(context)
logging.info("Checking wallet accounts.")
scout = AccountScout()
scout = mango.AccountScout()
report = scout.verify_account_prepared_for_group(context, group, wallet.address)
logging.info(f"Wallet account report: {report}")
if report.has_errors:
@ -109,29 +84,32 @@ try:
logging.info("Wallet accounts OK.")
liquidations_publisher = EventSource[LiquidationEvent]()
liquidations_publisher.subscribe(on_next=lambda event: logging.info(str(TransactionScout.load(context, event.signature))))
liquidations_publisher = mango.EventSource[mango.LiquidationEvent]()
liquidations_publisher.subscribe(on_next=lambda event: logging.info(
str(mango.TransactionScout.load(context, event.signature))))
for notification_target in args.notify_liquidations:
liquidations_publisher.subscribe(on_next=notification_target.send)
for notification_target in args.notify_successful_liquidations:
filtering = FilteringNotificationTarget(notification_target, lambda item: isinstance(item, LiquidationEvent) and item.succeeded)
filtering = mango.FilteringNotificationTarget(
notification_target, lambda item: isinstance(item, mango.LiquidationEvent) and item.succeeded)
liquidations_publisher.subscribe(on_next=filtering.send)
for notification_target in args.notify_failed_liquidations:
filtering = FilteringNotificationTarget(notification_target, lambda item: isinstance(item, LiquidationEvent) and not item.succeeded)
filtering = mango.FilteringNotificationTarget(notification_target, lambda item: isinstance(
item, mango.LiquidationEvent) and not item.succeeded)
liquidations_publisher.subscribe(on_next=filtering.send)
if args.dry_run:
account_liquidator: AccountLiquidator = NullAccountLiquidator()
account_liquidator: mango.AccountLiquidator = mango.NullAccountLiquidator()
else:
intermediate = ForceCancelOrdersAccountLiquidator(context, wallet)
account_liquidator = ReportingAccountLiquidator(intermediate,
context,
wallet,
liquidations_publisher,
liquidator_name)
intermediate = mango.ForceCancelOrdersAccountLiquidator(context, wallet)
account_liquidator = mango.ReportingAccountLiquidator(intermediate,
context,
wallet,
liquidations_publisher,
liquidator_name)
prices = group.fetch_token_prices()
margin_account = MarginAccount.load(context, margin_account_address, group)
margin_account = mango.MarginAccount.load(context, margin_account_address, group)
transaction_id = account_liquidator.liquidate(group, margin_account, prices)
if transaction_id is None:
@ -142,7 +120,7 @@ try:
context.wait_for_confirmation(transaction_id)
transaction_scout = TransactionScout.load(context, transaction_id)
transaction_scout = mango.TransactionScout.load(context, transaction_id)
print(str(transaction_scout))
except Exception as exception:

View File

@ -1,63 +1,34 @@
#!/usr/bin/env pyston3
import os
import sys
from pathlib import Path
# Get the full path to this script.
script_path = Path(os.path.realpath(__file__))
# The parent of the script is the bin directory.
# The parent of the bin directory is the notebook directory.
# It's this notebook directory we want.
notebook_directory = script_path.parent.parent
# Add the notebook directory to our import path.
sys.path.append(str(notebook_directory))
# Add the startup directory to our import path.
startup_directory = notebook_directory / "meta" / "startup"
sys.path.append(str(startup_directory))
import argparse
import json
import logging
import os
import os.path
import projectsetup # noqa: F401
import sys
import time
import traceback
from decimal import Decimal
from AccountScout import AccountScout
from AccountLiquidator import AccountLiquidator, ForceCancelOrdersAccountLiquidator, NullAccountLiquidator, ReportingAccountLiquidator
from BaseModel import Group, LiquidationEvent, SpotMarketLookup
from Constants import WARNING_DISCLAIMER_TEXT
from Context import Context, default_cluster, default_cluster_url, default_program_id, default_dex_program_id, default_group_name, default_group_id
from LiquidationProcessor import LiquidationProcessor
from Notification import FilteringNotificationTarget, NotificationHandler, parse_subscription_target
from Observables import EventSource
from Retrier import retry_context
from TradeExecutor import SerumImmediateTradeExecutor
from TransactionScout import TransactionScout
from Wallet import Wallet
from WalletBalancer import LiveWalletBalancer, NullWalletBalancer, TargetBalanceParser, WalletBalancer
sys.path.insert(0, os.path.abspath(
os.path.join(os.path.dirname(__file__), '..')))
import mango # nopep8
# We explicitly want argument parsing to be outside the main try-except block because some arguments
# (like --help) will cause an exit, which our except: block traps.
parser = argparse.ArgumentParser(description="Run a liquidator for a Mango Markets group.")
parser.add_argument("--cluster", type=str, default=default_cluster,
parser.add_argument("--cluster", type=str, default=mango.default_cluster,
help="Solana RPC cluster name")
parser.add_argument("--cluster-url", type=str, default=default_cluster_url,
parser.add_argument("--cluster-url", type=str, default=mango.default_cluster_url,
help="Solana RPC cluster URL")
parser.add_argument("--program-id", type=str, default=default_program_id,
parser.add_argument("--program-id", type=str, default=mango.default_program_id,
help="Mango program ID/address")
parser.add_argument("--dex-program-id", type=str, default=default_dex_program_id,
parser.add_argument("--dex-program-id", type=str, default=mango.default_dex_program_id,
help="DEX program ID/address")
parser.add_argument("--group-name", type=str, default=default_group_name,
parser.add_argument("--group-name", type=str, default=mango.default_group_name,
help="Mango group name")
parser.add_argument("--group-id", type=str, default=default_group_id,
parser.add_argument("--group-id", type=str, default=mango.default_group_id,
help="Mango group ID/address")
parser.add_argument("--id-file", type=str, default="id.json",
help="file containing the JSON-formatted wallet private key")
@ -79,13 +50,13 @@ 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("--adjustment-factor", type=Decimal, default=Decimal("0.05"),
help="factor by which to adjust the SELL price (akin to maximum slippage)")
parser.add_argument("--notify-liquidations", type=parse_subscription_target, action="append", default=[],
parser.add_argument("--notify-liquidations", type=mango.parse_subscription_target, action="append", default=[],
help="The notification target for liquidation events")
parser.add_argument("--notify-successful-liquidations", type=parse_subscription_target,
parser.add_argument("--notify-successful-liquidations", type=mango.parse_subscription_target,
action="append", default=[], help="The notification target for successful liquidation events")
parser.add_argument("--notify-failed-liquidations", type=parse_subscription_target,
parser.add_argument("--notify-failed-liquidations", type=mango.parse_subscription_target,
action="append", default=[], help="The notification target for failed liquidation events")
parser.add_argument("--notify-errors", type=parse_subscription_target, action="append", default=[],
parser.add_argument("--notify-errors", type=mango.parse_subscription_target, action="append", default=[],
help="The notification target for error events")
parser.add_argument("--dry-run", action="store_true", default=False,
help="runs as read-only and does not perform any transactions")
@ -93,18 +64,18 @@ args = parser.parse_args()
logging.getLogger().setLevel(args.log_level)
for notify in args.notify_errors:
handler = NotificationHandler(notify)
handler = mango.NotificationHandler(notify)
handler.setLevel(logging.ERROR)
logging.getLogger().addHandler(handler)
logging.warning(WARNING_DISCLAIMER_TEXT)
logging.warning(mango.WARNING_DISCLAIMER_TEXT)
try:
id_filename = args.id_file
if not os.path.isfile(id_filename):
logging.error(f"Wallet file '{id_filename}' is not present.")
sys.exit(1)
wallet = Wallet.load(id_filename)
wallet = mango.Wallet.load(id_filename)
action_threshold = args.action_threshold
adjustment_factor = args.adjustment_factor
@ -113,17 +84,17 @@ try:
ripe_update_iterations = args.ripe_update_iterations
liquidator_name = args.name
context = Context.from_command_line(args.cluster, args.cluster_url, args.program_id,
args.dex_program_id, args.group_name, args.group_id)
context = mango.Context.from_command_line(args.cluster, args.cluster_url, args.program_id,
args.dex_program_id, args.group_name, args.group_id)
logging.info(f"Context: {context}")
logging.info(f"Wallet address: {wallet.address}")
group = Group.load(context)
group = mango.Group.load(context)
tokens = [basket_token.token for basket_token in group.basket_tokens]
logging.info("Checking wallet accounts.")
scout = AccountScout()
scout = mango.AccountScout()
report = scout.verify_account_prepared_for_group(context, group, wallet.address)
logging.info(f"Wallet account report: {report}")
if report.has_errors:
@ -131,47 +102,50 @@ try:
logging.info("Wallet accounts OK.")
liquidations_publisher = EventSource[LiquidationEvent]()
liquidations_publisher.subscribe(on_next=lambda event: logging.info(str(TransactionScout.load(context, event.signature))))
liquidations_publisher = mango.EventSource[mango.LiquidationEvent]()
liquidations_publisher.subscribe(on_next=lambda event: logging.info(
str(mango.TransactionScout.load(context, event.signature))))
for notification_target in args.notify_liquidations:
liquidations_publisher.subscribe(on_next=notification_target.send)
for notification_target in args.notify_successful_liquidations:
filtering = FilteringNotificationTarget(notification_target, lambda item: isinstance(item, LiquidationEvent) and item.succeeded)
filtering = mango.FilteringNotificationTarget(
notification_target, lambda item: isinstance(item, mango.LiquidationEvent) and item.succeeded)
liquidations_publisher.subscribe(on_next=filtering.send)
for notification_target in args.notify_failed_liquidations:
filtering = FilteringNotificationTarget(notification_target, lambda item: isinstance(item, LiquidationEvent) and not item.succeeded)
filtering = mango.FilteringNotificationTarget(notification_target, lambda item: isinstance(
item, mango.LiquidationEvent) and not item.succeeded)
liquidations_publisher.subscribe(on_next=filtering.send)
if args.dry_run:
account_liquidator: AccountLiquidator = NullAccountLiquidator()
account_liquidator: mango.AccountLiquidator = mango.NullAccountLiquidator()
else:
intermediate = ForceCancelOrdersAccountLiquidator(context, wallet)
account_liquidator = ReportingAccountLiquidator(intermediate,
context,
wallet,
liquidations_publisher,
liquidator_name)
intermediate = mango.ForceCancelOrdersAccountLiquidator(context, wallet)
account_liquidator = mango.ReportingAccountLiquidator(intermediate,
context,
wallet,
liquidations_publisher,
liquidator_name)
if args.dry_run or (args.target is None) or (len(args.target) == 0):
wallet_balancer: WalletBalancer = NullWalletBalancer()
wallet_balancer: mango.WalletBalancer = mango.NullWalletBalancer()
else:
balance_parser = TargetBalanceParser(tokens)
balance_parser = mango.TargetBalanceParser(tokens)
targets = list(map(balance_parser.parse, args.target))
with open(args.token_data_file) as json_file:
token_data = json.load(json_file)
spot_market_lookup = SpotMarketLookup(token_data)
trade_executor = SerumImmediateTradeExecutor(context, wallet, spot_market_lookup, adjustment_factor)
wallet_balancer = LiveWalletBalancer(context, wallet, trade_executor, action_threshold, tokens, targets)
spot_market_lookup = mango.SpotMarketLookup(token_data)
trade_executor = mango.SerumImmediateTradeExecutor(context, wallet, spot_market_lookup, adjustment_factor)
wallet_balancer = mango.LiveWalletBalancer(context, wallet, trade_executor, action_threshold, tokens, targets)
stop = False
liquidation_processor = LiquidationProcessor(context, account_liquidator, wallet_balancer)
liquidation_processor = mango.LiquidationProcessor(context, account_liquidator, wallet_balancer)
while not stop:
try:
margin_account_loop_started_at = time.time()
with retry_context("Margin Account Fetch",
group.load_ripe_margin_accounts,
context.retry_pauses) as margin_account_retrier:
with mango.retry_context("Margin Account Fetch",
lambda: mango.MarginAccount.load_ripe(context, group),
context.retry_pauses) as margin_account_retrier:
ripe = margin_account_retrier.run()
liquidation_processor.update_margin_accounts(ripe)
@ -180,9 +154,9 @@ try:
price_loop_started_at = time.time()
logging.info(f"Update {counter} of {ripe_update_iterations} - {len(ripe)} ripe 🥭 accounts.")
with retry_context("Price Fetch",
lambda: Group.load_with_prices(context),
context.retry_pauses) as price_retrier:
with mango.retry_context("Price Fetch",
lambda: mango.Group.load_with_prices(context),
context.retry_pauses) as price_retrier:
group, prices = price_retrier.run()
liquidation_processor.update_prices(group, prices)
@ -190,13 +164,15 @@ try:
price_loop_time_taken = time.time() - price_loop_started_at
price_loop_should_sleep_for = float(throttle_ripe_update_to_seconds) - price_loop_time_taken
price_loop_sleep_for = max(price_loop_should_sleep_for, 0.0)
logging.info(f"Price fetch and check of all ripe 🥭 accounts complete. Time taken: {price_loop_time_taken:.2f} seconds, sleeping for {price_loop_sleep_for} seconds...")
logging.info(
f"Price fetch and check of all ripe 🥭 accounts complete. Time taken: {price_loop_time_taken:.2f} seconds, sleeping for {price_loop_sleep_for} seconds...")
time.sleep(price_loop_sleep_for)
margin_account_loop_time_taken = time.time() - margin_account_loop_started_at
margin_account_should_sleep_for = float(throttle_reload_to_seconds) - int(margin_account_loop_time_taken)
margin_account_sleep_for = max(margin_account_should_sleep_for, 0.0)
logging.info(f"Check of all margin accounts complete. Time taken: {margin_account_loop_time_taken:.2f} seconds, sleeping for {margin_account_sleep_for} seconds...")
logging.info(
f"Check of all margin accounts complete. Time taken: {margin_account_loop_time_taken:.2f} seconds, sleeping for {margin_account_sleep_for} seconds...")
time.sleep(margin_account_sleep_for)
except KeyboardInterrupt:
stop = True

View File

@ -1,70 +1,43 @@
#!/usr/bin/env pyston3
import os
import sys
from pathlib import Path
# Get the full path to this script.
script_path = Path(os.path.realpath(__file__))
# The parent of the script is the bin directory.
# The parent of the bin directory is the notebook directory.
# It's this notebook directory we want.
notebook_directory = script_path.parent.parent
# Add the notebook directory to our import path.
sys.path.append(str(notebook_directory))
# Add the startup directory to our import path.
startup_directory = notebook_directory / "meta" / "startup"
sys.path.append(str(startup_directory))
import argparse
import logging
import os
import os.path
import projectsetup # noqa: F401
import sys
import time
import traceback
from AccountScout import AccountScout
from AccountLiquidator import AccountLiquidator, ForceCancelOrdersAccountLiquidator, NullAccountLiquidator, ReportingAccountLiquidator
from BaseModel import Group, LiquidationEvent
from Constants import WARNING_DISCLAIMER_TEXT
from Context import Context, default_cluster, default_cluster_url, default_program_id, default_dex_program_id, default_group_name, default_group_id
from LiquidationProcessor import LiquidationProcessor
from Notification import FilteringNotificationTarget, NotificationHandler, parse_subscription_target
from Observables import EventSource
from TransactionScout import TransactionScout
from Wallet import Wallet
from WalletBalancer import NullWalletBalancer
sys.path.insert(0, os.path.abspath(
os.path.join(os.path.dirname(__file__), '..')))
import mango # nopep8
# We explicitly want argument parsing to be outside the main try-except block because some arguments
# (like --help) will cause an exit, which our except: block traps.
parser = argparse.ArgumentParser(description="Run a single pass of the liquidator for a Mango Markets group.")
parser.add_argument("--cluster", type=str, default=default_cluster,
parser.add_argument("--cluster", type=str, default=mango.default_cluster,
help="Solana RPC cluster name")
parser.add_argument("--cluster-url", type=str, default=default_cluster_url,
parser.add_argument("--cluster-url", type=str, default=mango.default_cluster_url,
help="Solana RPC cluster URL")
parser.add_argument("--program-id", type=str, default=default_program_id,
parser.add_argument("--program-id", type=str, default=mango.default_program_id,
help="Mango program ID/address")
parser.add_argument("--dex-program-id", type=str, default=default_dex_program_id,
parser.add_argument("--dex-program-id", type=str, default=mango.default_dex_program_id,
help="DEX program ID/address")
parser.add_argument("--group-name", type=str, default=default_group_name,
parser.add_argument("--group-name", type=str, default=mango.default_group_name,
help="Mango group name")
parser.add_argument("--group-id", type=str, default=default_group_id,
parser.add_argument("--group-id", type=str, default=mango.default_group_id,
help="Mango group ID/address")
parser.add_argument("--id-file", type=str, default="id.json",
help="file containing the JSON-formatted wallet private key")
parser.add_argument("--name", type=str, default="Mango Markets Liquidator",
help="Name of the liquidator (used in reports and alerts)")
parser.add_argument("--notify-liquidations", type=parse_subscription_target, action="append", default=[],
parser.add_argument("--notify-liquidations", type=mango.parse_subscription_target, action="append", default=[],
help="The notification target for liquidation events")
parser.add_argument("--notify-successful-liquidations", type=parse_subscription_target,
parser.add_argument("--notify-successful-liquidations", type=mango.parse_subscription_target,
action="append", default=[], help="The notification target for successful liquidation events")
parser.add_argument("--notify-failed-liquidations", type=parse_subscription_target,
parser.add_argument("--notify-failed-liquidations", type=mango.parse_subscription_target,
action="append", default=[], help="The notification target for failed liquidation events")
parser.add_argument("--notify-errors", type=parse_subscription_target, action="append", default=[],
parser.add_argument("--notify-errors", type=mango.parse_subscription_target, action="append", default=[],
help="The notification target for error events")
parser.add_argument("--log-level", default=logging.INFO, type=lambda level: getattr(logging, level),
help="level of verbosity to log (possible values: DEBUG, INFO, WARNING, ERROR, CRITICAL)")
@ -74,31 +47,31 @@ args = parser.parse_args()
logging.getLogger().setLevel(args.log_level)
for notify in args.notify_errors:
handler = NotificationHandler(notify)
handler = mango.NotificationHandler(notify)
handler.setLevel(logging.ERROR)
logging.getLogger().addHandler(handler)
logging.warning(WARNING_DISCLAIMER_TEXT)
logging.warning(mango.WARNING_DISCLAIMER_TEXT)
try:
id_filename = args.id_file
if not os.path.isfile(id_filename):
logging.error(f"Wallet file '{id_filename}' is not present.")
sys.exit(1)
wallet = Wallet.load(id_filename)
wallet = mango.Wallet.load(id_filename)
liquidator_name = args.name
context = Context.from_command_line(args.cluster, args.cluster_url, args.program_id,
args.dex_program_id, args.group_name, args.group_id)
context = mango.Context.from_command_line(args.cluster, args.cluster_url, args.program_id,
args.dex_program_id, args.group_name, args.group_id)
logging.info(f"Context: {context}")
logging.info(f"Wallet address: {wallet.address}")
group = Group.load(context)
group = mango.Group.load(context)
logging.info("Checking wallet accounts.")
scout = AccountScout()
scout = mango.AccountScout()
report = scout.verify_account_prepared_for_group(context, group, wallet.address)
logging.info(f"Wallet account report: {report}")
if report.has_errors:
@ -106,36 +79,39 @@ try:
logging.info("Wallet accounts OK.")
liquidations_publisher = EventSource[LiquidationEvent]()
liquidations_publisher.subscribe(on_next=lambda event: logging.info(str(TransactionScout.load(context, event.signature))))
liquidations_publisher = mango.EventSource[mango.LiquidationEvent]()
liquidations_publisher.subscribe(on_next=lambda event: logging.info(
str(mango.TransactionScout.load(context, event.signature))))
for notification_target in args.notify_liquidations:
liquidations_publisher.subscribe(on_next=notification_target.send)
for notification_target in args.notify_successful_liquidations:
filtering = FilteringNotificationTarget(notification_target, lambda item: isinstance(item, LiquidationEvent) and item.succeeded)
filtering = mango.FilteringNotificationTarget(
notification_target, lambda item: isinstance(item, mango.LiquidationEvent) and item.succeeded)
liquidations_publisher.subscribe(on_next=filtering.send)
for notification_target in args.notify_failed_liquidations:
filtering = FilteringNotificationTarget(notification_target, lambda item: isinstance(item, LiquidationEvent) and not item.succeeded)
filtering = mango.FilteringNotificationTarget(notification_target, lambda item: isinstance(
item, mango.LiquidationEvent) and not item.succeeded)
liquidations_publisher.subscribe(on_next=filtering.send)
if args.dry_run:
account_liquidator: AccountLiquidator = NullAccountLiquidator()
account_liquidator: mango.AccountLiquidator = mango.NullAccountLiquidator()
else:
intermediate = ForceCancelOrdersAccountLiquidator(context, wallet)
account_liquidator = ReportingAccountLiquidator(intermediate,
context,
wallet,
liquidations_publisher,
liquidator_name)
intermediate = mango.ForceCancelOrdersAccountLiquidator(context, wallet)
account_liquidator = mango.ReportingAccountLiquidator(intermediate,
context,
wallet,
liquidations_publisher,
liquidator_name)
wallet_balancer = NullWalletBalancer()
wallet_balancer = mango.NullWalletBalancer()
liquidation_processor = LiquidationProcessor(context, account_liquidator, wallet_balancer)
liquidation_processor = mango.LiquidationProcessor(context, account_liquidator, wallet_balancer)
started_at = time.time()
ripe = group.load_ripe_margin_accounts()
liquidation_processor.update_margin_accounts(ripe)
group = Group.load(context) # Refresh group data
group = mango.Group.load(context) # Refresh group data
prices = group.fetch_token_prices()
liquidation_processor.update_prices(group, prices)

View File

@ -1,52 +1,34 @@
#!/usr/bin/env pyston3
import os
import sys
from pathlib import Path
# Get the full path to this script.
script_path = Path(os.path.realpath(__file__))
# The parent of the script is the bin directory.
# The parent of the bin directory is the notebook directory.
# It's this notebook directory we want.
notebook_directory = script_path.parent.parent
# Add the notebook directory to our import path.
sys.path.append(str(notebook_directory))
# Add the startup directory to our import path.
startup_directory = notebook_directory / "meta" / "startup"
sys.path.append(str(startup_directory))
import argparse
import logging
import os
import os.path
import projectsetup # noqa: F401
import sys
import traceback
from decimal import Decimal
from solana.publickey import PublicKey
from BaseModel import MarginAccount, OpenOrders
from Constants import WARNING_DISCLAIMER_TEXT
from Context import Context, default_cluster, default_cluster_url, default_program_id, default_dex_program_id, default_group_name, default_group_id
sys.path.insert(0, os.path.abspath(
os.path.join(os.path.dirname(__file__), '..')))
import mango # nopep8
# We explicitly want argument parsing to be outside the main try-except block because some arguments
# (like --help) will cause an exit, which our except: block traps.
parser = argparse.ArgumentParser(description="Fetch Mango Markets objects and show their contents.")
parser.add_argument("--cluster", type=str, default=default_cluster,
parser.add_argument("--cluster", type=str, default=mango.default_cluster,
help="Solana RPC cluster name")
parser.add_argument("--cluster-url", type=str, default=default_cluster_url,
parser.add_argument("--cluster-url", type=str, default=mango.default_cluster_url,
help="Solana RPC cluster URL")
parser.add_argument("--program-id", type=str, default=default_program_id,
parser.add_argument("--program-id", type=str, default=mango.default_program_id,
help="Mango program ID/address")
parser.add_argument("--dex-program-id", type=str, default=default_dex_program_id,
parser.add_argument("--dex-program-id", type=str, default=mango.default_dex_program_id,
help="DEX program ID/address")
parser.add_argument("--group-name", type=str, default=default_group_name,
parser.add_argument("--group-name", type=str, default=mango.default_group_name,
help="Mango group name")
parser.add_argument("--group-id", type=str, default=default_group_id,
parser.add_argument("--group-id", type=str, default=mango.default_group_id,
help="Mango group ID/address")
parser.add_argument("--id-file", type=str, default="id.json",
help="file containing the JSON-formatted wallet private key")
@ -59,23 +41,23 @@ parser.add_argument("--address", type=PublicKey,
args = parser.parse_args()
logging.getLogger().setLevel(args.log_level)
logging.warning(WARNING_DISCLAIMER_TEXT)
logging.warning(mango.WARNING_DISCLAIMER_TEXT)
try:
context = Context.from_command_line(args.cluster, args.cluster_url, args.program_id,
args.dex_program_id, args.group_name, args.group_id)
context = mango.Context.from_command_line(args.cluster, args.cluster_url, args.program_id,
args.dex_program_id, args.group_name, args.group_id)
address = args.address
object_type = args.type.upper()
if object_type == "OPEN-ORDERS":
open_orders = OpenOrders.load(context, address, Decimal(6), Decimal(6))
open_orders = mango.OpenOrders.load(context, address, Decimal(6), Decimal(6))
print(open_orders)
elif object_type == "MARGIN-ACCOUNT":
margin_account = MarginAccount.load(context, address)
margin_account = mango.MarginAccount.load(context, address)
print(margin_account)
elif object_type == "ALL-MARGIN-ACCOUNTS":
margin_accounts = MarginAccount.load_all_for_owner(context, address)
margin_accounts = mango.MarginAccount.load_all_for_owner(context, address)
print(margin_accounts)
else:
print(f"Unknown type: {object_type}")

View File

@ -1,75 +1,53 @@
#!/usr/bin/env pyston3
import os
import sys
from pathlib import Path
# Get the full path to this script.
script_path = Path(os.path.realpath(__file__))
# The parent of the script is the bin directory.
# The parent of the bin directory is the notebook directory.
# It's this notebook directory we want.
notebook_directory = script_path.parent.parent
# Add the notebook directory to our import path.
sys.path.append(str(notebook_directory))
# Add the startup directory to our import path.
startup_directory = notebook_directory / "meta" / "startup"
sys.path.append(str(startup_directory))
import argparse
import logging
import os
import os.path
import projectsetup # noqa: F401
import rx.operators as ops
import rx
import sys
import traceback
import typing
from pathlib import Path
from solana.publickey import PublicKey
from BaseModel import InstructionType, OwnedTokenValue
from Constants import WARNING_DISCLAIMER_TEXT
from Context import Context, default_cluster, default_cluster_url, default_program_id, default_dex_program_id, default_group_name, default_group_id
from Notification import FilteringNotificationTarget, NotificationHandler, parse_subscription_target
from Observables import CaptureFirstItem, PrintingObserverSubscriber
from TransactionScout import TransactionScout, fetch_all_recent_transaction_signatures
sys.path.insert(0, os.path.abspath(
os.path.join(os.path.dirname(__file__), '..')))
import mango # nopep8
# We explicitly want argument parsing to be outside the main try-except block because some arguments
# (like --help) will cause an exit, which our except: block traps.
parser = argparse.ArgumentParser(description="Run the Transaction Scout to display information about a specific transaction.")
parser.add_argument("--cluster", type=str, default=default_cluster,
parser = argparse.ArgumentParser(
description="Run the Transaction Scout to display information about a specific transaction.")
parser.add_argument("--cluster", type=str, default=mango.default_cluster,
help="Solana RPC cluster name")
parser.add_argument("--cluster-url", type=str, default=default_cluster_url,
parser.add_argument("--cluster-url", type=str, default=mango.default_cluster_url,
help="Solana RPC cluster URL")
parser.add_argument("--program-id", type=str, default=default_program_id,
parser.add_argument("--program-id", type=str, default=mango.default_program_id,
help="Mango program ID/address")
parser.add_argument("--dex-program-id", type=str, default=default_dex_program_id,
parser.add_argument("--dex-program-id", type=str, default=mango.default_dex_program_id,
help="DEX program ID/address")
parser.add_argument("--group-name", type=str, default=default_group_name,
parser.add_argument("--group-name", type=str, default=mango.default_group_name,
help="Mango group name")
parser.add_argument("--group-id", type=str, default=default_group_id,
parser.add_argument("--group-id", type=str, default=mango.default_group_id,
help="Mango group ID/address")
parser.add_argument("--id-file", type=str, default="id.json",
help="file containing the JSON-formatted wallet private key")
parser.add_argument("--since-signature", type=str,
help="The signature of the transaction to look up")
parser.add_argument("--instruction-type", type=lambda ins: InstructionType[ins],
choices=list(InstructionType),
parser.add_argument("--instruction-type", type=lambda ins: mango.InstructionType[ins],
choices=list(mango.InstructionType),
help="The signature of the transaction to look up")
parser.add_argument("--sender", type=PublicKey,
help="Only transactions sent by this PublicKey will be returned")
parser.add_argument("--notify-transactions", type=parse_subscription_target, action="append", default=[],
parser.add_argument("--notify-transactions", type=mango.parse_subscription_target, action="append", default=[],
help="The notification target for transaction information")
parser.add_argument("--notify-successful-transactions", type=parse_subscription_target,
parser.add_argument("--notify-successful-transactions", type=mango.parse_subscription_target,
action="append", default=[], help="The notification target for successful transactions")
parser.add_argument("--notify-failed-transactions", type=parse_subscription_target,
parser.add_argument("--notify-failed-transactions", type=mango.parse_subscription_target,
action="append", default=[], help="The notification target for failed transactions")
parser.add_argument("--notify-errors", type=parse_subscription_target, action="append", default=[],
parser.add_argument("--notify-errors", type=mango.parse_subscription_target, action="append", default=[],
help="The notification target for errors")
parser.add_argument("--summarise", action="store_true", default=False,
help="create a short summary rather than the full TransactionScout details")
@ -79,15 +57,15 @@ args = parser.parse_args()
logging.getLogger().setLevel(args.log_level)
for notify in args.notify_errors:
handler = NotificationHandler(notify)
handler = mango.NotificationHandler(notify)
handler.setLevel(logging.ERROR)
logging.getLogger().addHandler(handler)
logging.warning(WARNING_DISCLAIMER_TEXT)
logging.warning(mango.WARNING_DISCLAIMER_TEXT)
def summariser(context: Context) -> typing.Callable[[TransactionScout], str]:
def summarise(transaction_scout: TransactionScout) -> str:
def summariser(context: mango.Context) -> typing.Callable[[mango.TransactionScout], str]:
def summarise(transaction_scout: mango.TransactionScout) -> str:
instruction_details: typing.List[str] = []
for ins in transaction_scout.instructions:
params = ins.describe_parameters()
@ -97,20 +75,22 @@ def summariser(context: Context) -> typing.Callable[[TransactionScout], str]:
instruction_details += [f"[{ins.instruction_type.name}: {params}]"]
instructions = ", ".join(instruction_details)
changes = OwnedTokenValue.changes(transaction_scout.pre_token_balances, transaction_scout.post_token_balances)
changes = mango.OwnedTokenValue.changes(
transaction_scout.pre_token_balances, transaction_scout.post_token_balances)
in_tokens = []
for ins in transaction_scout.instructions:
if ins.token_in_account is not None:
in_tokens += [OwnedTokenValue.find_by_owner(changes, ins.token_in_account)]
in_tokens += [mango.OwnedTokenValue.find_by_owner(changes, ins.token_in_account)]
out_tokens = []
for ins in transaction_scout.instructions:
if ins.token_out_account is not None:
out_tokens += [OwnedTokenValue.find_by_owner(changes, ins.token_out_account)]
out_tokens += [mango.OwnedTokenValue.find_by_owner(changes, ins.token_out_account)]
changed_tokens = in_tokens + out_tokens
changed_tokens_text = ", ".join([f"{tok.token_value.value:,.8f} {tok.token_value.token.name}" for tok in changed_tokens]) or "None"
changed_tokens_text = ", ".join(
[f"{tok.token_value.value:,.8f} {tok.token_value.token.name}" for tok in changed_tokens]) or "None"
return f"« 🥭 {transaction_scout.timestamp} {transaction_scout.group_name} {instructions}\n From: {transaction_scout.sender}\n Token Changes: {changed_tokens_text}\n {transaction_scout.signatures} »"
return summarise
@ -121,20 +101,20 @@ try:
instruction_type = args.instruction_type
sender = args.sender
context = Context.from_command_line(args.cluster, args.cluster_url, args.program_id,
args.dex_program_id, args.group_name, args.group_id)
context = mango.Context.from_command_line(args.cluster, args.cluster_url, args.program_id,
args.dex_program_id, args.group_name, args.group_id)
logging.info(f"Context: {context}")
logging.info(f"Since signature: {since_signature}")
logging.info(f"Filter to instruction type: {instruction_type}")
first_item_capturer = CaptureFirstItem()
signatures = fetch_all_recent_transaction_signatures()
first_item_capturer = mango.CaptureFirstItem()
signatures = mango.fetch_all_recent_transaction_signatures(context)
pipeline = rx.from_(signatures).pipe(
ops.map(first_item_capturer.capture_if_first),
# ops.map(debug_print_item("Signature:")),
ops.take_while(lambda sig: sig != since_signature),
ops.map(lambda sig: TransactionScout.load_if_available(context, sig)),
ops.map(lambda sig: mango.TransactionScout.load_if_available(context, sig)),
ops.filter(lambda item: item is not None),
# ops.take(100),
)
@ -156,15 +136,17 @@ try:
)
fan_out = rx.subject.Subject()
fan_out.subscribe(PrintingObserverSubscriber(False))
fan_out.subscribe(mango.PrintingObserverSubscriber(False))
for notify in args.notify_transactions:
fan_out.subscribe(on_next=notify.send)
for notification_target in args.notify_successful_transactions:
filtering = FilteringNotificationTarget(notification_target, lambda item: isinstance(item, TransactionScout) and item.succeeded)
filtering = mango.FilteringNotificationTarget(
notification_target, lambda item: isinstance(item, mango.TransactionScout) and item.succeeded)
fan_out.subscribe(on_next=filtering.send)
for notification_target in args.notify_failed_transactions:
filtering = FilteringNotificationTarget(notification_target, lambda item: isinstance(item, TransactionScout) and not item.succeeded)
filtering = mango.FilteringNotificationTarget(notification_target, lambda item: isinstance(
item, mango.TransactionScout) and not item.succeeded)
fan_out.subscribe(on_next=filtering.send)
pipeline.subscribe(fan_out)

View File

@ -1,38 +1,20 @@
#!/usr/bin/env pyston3
import os
import sys
from pathlib import Path
# Get the full path to this script.
script_path = Path(os.path.realpath(__file__))
# The parent of the script is the bin directory.
# The parent of the bin directory is the notebook directory.
# It's this notebook directory we want.
notebook_directory = script_path.parent.parent
# Add the notebook directory to our import path.
sys.path.append(str(notebook_directory))
# Add the startup directory to our import path.
startup_directory = notebook_directory / "meta" / "startup"
sys.path.append(str(startup_directory))
import argparse
import logging
import os
import os.path
import projectsetup # noqa: F401
import sys
import traceback
from Constants import WARNING_DISCLAIMER_TEXT
from Notification import parse_subscription_target
sys.path.insert(0, os.path.abspath(
os.path.join(os.path.dirname(__file__), '..')))
import mango # nopep8
# We explicitly want argument parsing to be outside the main try-except block because some arguments
# (like --help) will cause an exit, which our except: block traps.
parser = argparse.ArgumentParser(description="Sends SOL to a different address.")
parser.add_argument("--notification-target", type=parse_subscription_target, required=True, action="append",
parser.add_argument("--notification-target", type=mango.parse_subscription_target, required=True, action="append",
help="The notification target - a compound string that varies depending on the target")
parser.add_argument("--message", type=str, help="Message to send")
parser.add_argument("--log-level", default=logging.WARNING, type=lambda level: getattr(logging, level),
@ -40,7 +22,7 @@ parser.add_argument("--log-level", default=logging.WARNING, type=lambda level: g
args = parser.parse_args()
logging.getLogger().setLevel(args.log_level)
logging.warning(WARNING_DISCLAIMER_TEXT)
logging.warning(mango.WARNING_DISCLAIMER_TEXT)
try:
for notify in args.notification_target:

View File

@ -1,29 +1,10 @@
#!/usr/bin/env pyston3
import os
import sys
from pathlib import Path
# Get the full path to this script.
script_path = Path(os.path.realpath(__file__))
# The parent of the script is the bin directory.
# The parent of the bin directory is the notebook directory.
# It's this notebook directory we want.
notebook_directory = script_path.parent.parent
# Add the notebook directory to our import path.
sys.path.append(str(notebook_directory))
# Add the startup directory to our import path.
startup_directory = notebook_directory / "meta" / "startup"
sys.path.append(str(startup_directory))
import argparse
import logging
import os
import os.path
import projectsetup # noqa: F401
import sys
import traceback
from decimal import Decimal
@ -31,24 +12,24 @@ from solana.publickey import PublicKey
from solana.system_program import TransferParams, transfer
from solana.transaction import Transaction
from Constants import SOL_DECIMAL_DIVISOR, WARNING_DISCLAIMER_TEXT
from Context import Context, default_cluster, default_cluster_url, default_program_id, default_dex_program_id, default_group_name, default_group_id
from Wallet import Wallet
sys.path.insert(0, os.path.abspath(
os.path.join(os.path.dirname(__file__), '..')))
import mango # nopep8
# We explicitly want argument parsing to be outside the main try-except block because some arguments
# (like --help) will cause an exit, which our except: block traps.
parser = argparse.ArgumentParser(description="Sends SOL to a different address.")
parser.add_argument("--cluster", type=str, default=default_cluster,
parser.add_argument("--cluster", type=str, default=mango.default_cluster,
help="Solana RPC cluster name")
parser.add_argument("--cluster-url", type=str, default=default_cluster_url,
parser.add_argument("--cluster-url", type=str, default=mango.default_cluster_url,
help="Solana RPC cluster URL")
parser.add_argument("--program-id", type=str, default=default_program_id,
parser.add_argument("--program-id", type=str, default=mango.default_program_id,
help="Mango program ID/address")
parser.add_argument("--dex-program-id", type=str, default=default_dex_program_id,
parser.add_argument("--dex-program-id", type=str, default=mango.default_dex_program_id,
help="DEX program ID/address")
parser.add_argument("--group-name", type=str, default=default_group_name,
parser.add_argument("--group-name", type=str, default=mango.default_group_name,
help="Mango group name")
parser.add_argument("--group-id", type=str, default=default_group_id,
parser.add_argument("--group-id", type=str, default=mango.default_group_id,
help="Mango group ID/address")
parser.add_argument("--id-file", type=str, default="id.json",
help="file containing the JSON-formatted wallet private key")
@ -62,17 +43,17 @@ parser.add_argument("--dry-run", action="store_true", default=False,
args = parser.parse_args()
logging.getLogger().setLevel(args.log_level)
logging.warning(WARNING_DISCLAIMER_TEXT)
logging.warning(mango.WARNING_DISCLAIMER_TEXT)
try:
id_filename = args.id_file
if not os.path.isfile(id_filename):
logging.error(f"Wallet file '{id_filename}' is not present.")
sys.exit(1)
wallet = Wallet.load(id_filename)
wallet = mango.Wallet.load(id_filename)
context = Context.from_command_line(args.cluster, args.cluster_url, args.program_id,
args.dex_program_id, args.group_name, args.group_id)
context = mango.Context.from_command_line(args.cluster, args.cluster_url, args.program_id,
args.dex_program_id, args.group_name, args.group_id)
logging.info(f"Context: {context}")
logging.info(f"Wallet address: {wallet.address}")
@ -81,7 +62,7 @@ try:
print(f"Balance: {sol_balance} SOL")
# "A lamport has a value of 0.000000001 SOL." from https://docs.solana.com/introduction
lamports = int(args.quantity * SOL_DECIMAL_DIVISOR)
lamports = int(args.quantity * mango.SOL_DECIMAL_DIVISOR)
source = wallet.address
destination = args.address

View File

@ -1,29 +1,10 @@
#!/usr/bin/env pyston3
import os
import sys
from pathlib import Path
# Get the full path to this script.
script_path = Path(os.path.realpath(__file__))
# The parent of the script is the bin directory.
# The parent of the bin directory is the notebook directory.
# It's this notebook directory we want.
notebook_directory = script_path.parent.parent
# Add the notebook directory to our import path.
sys.path.append(str(notebook_directory))
# Add the startup directory to our import path.
startup_directory = notebook_directory / "meta" / "startup"
sys.path.append(str(startup_directory))
import argparse
import logging
import os
import os.path
import projectsetup # noqa: F401
import sys
import traceback
from decimal import Decimal
@ -31,25 +12,24 @@ from solana.publickey import PublicKey
from spl.token.client import Token
from spl.token.constants import TOKEN_PROGRAM_ID
from BaseModel import BasketToken, Group, TokenAccount
from Constants import WARNING_DISCLAIMER_TEXT
from Context import Context, default_cluster, default_cluster_url, default_program_id, default_dex_program_id, default_group_name, default_group_id
from Wallet import Wallet
sys.path.insert(0, os.path.abspath(
os.path.join(os.path.dirname(__file__), '..')))
import mango # nopep8
# We explicitly want argument parsing to be outside the main try-except block because some arguments
# (like --help) will cause an exit, which our except: block traps.
parser = argparse.ArgumentParser(description="Sends one of the Group's SPL tokens to a different address.")
parser.add_argument("--cluster", type=str, default=default_cluster,
parser.add_argument("--cluster", type=str, default=mango.default_cluster,
help="Solana RPC cluster name")
parser.add_argument("--cluster-url", type=str, default=default_cluster_url,
parser.add_argument("--cluster-url", type=str, default=mango.default_cluster_url,
help="Solana RPC cluster URL")
parser.add_argument("--program-id", type=str, default=default_program_id,
parser.add_argument("--program-id", type=str, default=mango.default_program_id,
help="Mango program ID/address")
parser.add_argument("--dex-program-id", type=str, default=default_dex_program_id,
parser.add_argument("--dex-program-id", type=str, default=mango.default_dex_program_id,
help="DEX program ID/address")
parser.add_argument("--group-name", type=str, default=default_group_name,
parser.add_argument("--group-name", type=str, default=mango.default_group_name,
help="Mango group name")
parser.add_argument("--group-id", type=str, default=default_group_id,
parser.add_argument("--group-id", type=str, default=mango.default_group_id,
help="Mango group ID/address")
parser.add_argument("--id-file", type=str, default="id.json",
help="file containing the JSON-formatted wallet private key")
@ -64,23 +44,23 @@ parser.add_argument("--dry-run", action="store_true", default=False,
args = parser.parse_args()
logging.getLogger().setLevel(args.log_level)
logging.warning(WARNING_DISCLAIMER_TEXT)
logging.warning(mango.WARNING_DISCLAIMER_TEXT)
try:
id_filename = args.id_file
if not os.path.isfile(id_filename):
logging.error(f"Wallet file '{id_filename}' is not present.")
sys.exit(1)
wallet = Wallet.load(id_filename)
wallet = mango.Wallet.load(id_filename)
context = Context.from_command_line(args.cluster, args.cluster_url, args.program_id,
args.dex_program_id, args.group_name, args.group_id)
context = mango.Context.from_command_line(args.cluster, args.cluster_url, args.program_id,
args.dex_program_id, args.group_name, args.group_id)
logging.info(f"Context: {context}")
logging.info(f"Wallet address: {wallet.address}")
group = Group.load(context)
group_basket_token = BasketToken.find_by_symbol(group.basket_tokens, args.token_symbol)
group = mango.Group.load(context)
group_basket_token = mango.BasketToken.find_by_symbol(group.basket_tokens, args.token_symbol)
group_token = group_basket_token.token
spl_token = Token(context.client, group_token.mint, TOKEN_PROGRAM_ID, wallet.account)
@ -89,21 +69,23 @@ try:
source = PublicKey(source_account["pubkey"])
# Is the address an actual token account? Or is it the SOL address of the owner?
possible_dest = TokenAccount.load(context, args.address)
possible_dest = mango.TokenAccount.load(context, args.address)
if (possible_dest is not None) and (possible_dest.mint == group_token.mint):
# We successfully loaded the token account.
destination: PublicKey = args.address
else:
destination_accounts = spl_token.get_accounts(args.address)
if len(destination_accounts["result"]["value"]) == 0:
raise Exception(f"Could not find destination account using {args.address} as either owner address or token address.")
raise Exception(
f"Could not find destination account using {args.address} as either owner address or token address.")
destination_account = destination_accounts["result"]["value"][0]
destination = PublicKey(destination_account["pubkey"])
owner = wallet.account
amount = int(args.quantity * Decimal(10 ** group_token.decimals))
print("Balance:", source_account["account"]["data"]["parsed"]["info"]["tokenAmount"]["uiAmountString"], group_token.name)
print("Balance:", source_account["account"]["data"]["parsed"]
["info"]["tokenAmount"]["uiAmountString"], group_token.name)
text_amount = f"{amount} {group_token.name} (@ {group_token.decimals} decimal places)"
print(f"Sending {text_amount}")
print(f" From: {source}")

View File

@ -1,54 +1,33 @@
#!/usr/bin/env pyston3
import os
import sys
from pathlib import Path
# Get the full path to this script.
script_path = Path(os.path.realpath(__file__))
# The parent of the script is the bin directory.
# The parent of the bin directory is the notebook directory.
# It's this notebook directory we want.
notebook_directory = script_path.parent.parent
# Add the notebook directory to our import path.
sys.path.append(str(notebook_directory))
# Add the startup directory to our import path.
startup_directory = notebook_directory / "meta" / "startup"
sys.path.append(str(startup_directory))
import argparse
import json
import logging
import os
import os.path
import projectsetup # noqa: F401
import sys
import traceback
from decimal import Decimal
from BaseModel import SpotMarketLookup
from Constants import WARNING_DISCLAIMER_TEXT
from Context import Context, default_cluster, default_cluster_url, default_program_id, default_dex_program_id, default_group_name, default_group_id
from TradeExecutor import NullTradeExecutor, SerumImmediateTradeExecutor, TradeExecutor
from Wallet import Wallet
sys.path.insert(0, os.path.abspath(
os.path.join(os.path.dirname(__file__), '..')))
import mango # nopep8
# We explicitly want argument parsing to be outside the main try-except block because some arguments
# (like --help) will cause an exit, which our except: block traps.
parser = argparse.ArgumentParser(description="Buys one of the tokens in a Mango Markets group.")
parser.add_argument("--cluster", type=str, default=default_cluster,
parser.add_argument("--cluster", type=str, default=mango.default_cluster,
help="Solana RPC cluster name")
parser.add_argument("--cluster-url", type=str, default=default_cluster_url,
parser.add_argument("--cluster-url", type=str, default=mango.default_cluster_url,
help="Solana RPC cluster URL")
parser.add_argument("--program-id", type=str, default=default_program_id,
parser.add_argument("--program-id", type=str, default=mango.default_program_id,
help="Mango program ID/address")
parser.add_argument("--dex-program-id", type=str, default=default_dex_program_id,
parser.add_argument("--dex-program-id", type=str, default=mango.default_dex_program_id,
help="DEX program ID/address")
parser.add_argument("--group-name", type=str, default=default_group_name,
parser.add_argument("--group-name", type=str, default=mango.default_group_name,
help="Mango group name")
parser.add_argument("--group-id", type=str, default=default_group_id,
parser.add_argument("--group-id", type=str, default=mango.default_group_id,
help="Mango group ID/address")
parser.add_argument("--id-file", type=str, default="id.json",
help="file containing the JSON-formatted wallet private key")
@ -65,18 +44,18 @@ parser.add_argument("--dry-run", action="store_true", default=False,
args = parser.parse_args()
logging.getLogger().setLevel(args.log_level)
logging.warning(WARNING_DISCLAIMER_TEXT)
logging.warning(mango.WARNING_DISCLAIMER_TEXT)
try:
id_filename = args.id_file
if not os.path.isfile(id_filename):
logging.error(f"Wallet file '{id_filename}' is not present.")
sys.exit(1)
wallet = Wallet.load(id_filename)
wallet = mango.Wallet.load(id_filename)
adjustment_factor = args.adjustment_factor
context = Context.from_command_line(args.cluster, args.cluster_url, args.program_id,
args.dex_program_id, args.group_name, args.group_id)
context = mango.Context.from_command_line(args.cluster, args.cluster_url, args.program_id,
args.dex_program_id, args.group_name, args.group_id)
logging.info(f"Context: {context}")
logging.info(f"Wallet address: {wallet.address}")
@ -84,12 +63,12 @@ try:
symbol = args.symbol.upper()
if args.dry_run:
trade_executor: TradeExecutor = NullTradeExecutor()
trade_executor: mango.TradeExecutor = mango.NullTradeExecutor()
else:
with open(args.token_data_file) as json_file:
token_data = json.load(json_file)
spot_market_lookup = SpotMarketLookup(token_data)
trade_executor = SerumImmediateTradeExecutor(context, wallet, spot_market_lookup, adjustment_factor)
spot_market_lookup = mango.SpotMarketLookup(token_data)
trade_executor = mango.SerumImmediateTradeExecutor(context, wallet, spot_market_lookup, adjustment_factor)
trade_executor.buy(symbol, args.quantity)

View File

@ -1,54 +1,33 @@
#!/usr/bin/env pyston3
import os
import sys
from pathlib import Path
# Get the full path to this script.
script_path = Path(os.path.realpath(__file__))
# The parent of the script is the bin directory.
# The parent of the bin directory is the notebook directory.
# It's this notebook directory we want.
notebook_directory = script_path.parent.parent
# Add the notebook directory to our import path.
sys.path.append(str(notebook_directory))
# Add the startup directory to our import path.
startup_directory = notebook_directory / "meta" / "startup"
sys.path.append(str(startup_directory))
import argparse
import json
import logging
import os
import os.path
import projectsetup # noqa: F401
import sys
import traceback
from decimal import Decimal
from BaseModel import SpotMarketLookup
from Constants import WARNING_DISCLAIMER_TEXT
from Context import Context, default_cluster, default_cluster_url, default_program_id, default_dex_program_id, default_group_name, default_group_id
from TradeExecutor import NullTradeExecutor, SerumImmediateTradeExecutor, TradeExecutor
from Wallet import Wallet
sys.path.insert(0, os.path.abspath(
os.path.join(os.path.dirname(__file__), '..')))
import mango # nopep8
# We explicitly want argument parsing to be outside the main try-except block because some arguments
# (like --help) will cause an exit, which our except: block traps.
parser = argparse.ArgumentParser(description="Sells one of the tokens in a Mango Markets group.")
parser.add_argument("--cluster", type=str, default=default_cluster,
parser.add_argument("--cluster", type=str, default=mango.default_cluster,
help="Solana RPC cluster name")
parser.add_argument("--cluster-url", type=str, default=default_cluster_url,
parser.add_argument("--cluster-url", type=str, default=mango.default_cluster_url,
help="Solana RPC cluster URL")
parser.add_argument("--program-id", type=str, default=default_program_id,
parser.add_argument("--program-id", type=str, default=mango.default_program_id,
help="Mango program ID/address")
parser.add_argument("--dex-program-id", type=str, default=default_dex_program_id,
parser.add_argument("--dex-program-id", type=str, default=mango.default_dex_program_id,
help="DEX program ID/address")
parser.add_argument("--group-name", type=str, default=default_group_name,
parser.add_argument("--group-name", type=str, default=mango.default_group_name,
help="Mango group name")
parser.add_argument("--group-id", type=str, default=default_group_id,
parser.add_argument("--group-id", type=str, default=mango.default_group_id,
help="Mango group ID/address")
parser.add_argument("--id-file", type=str, default="id.json",
help="file containing the JSON-formatted wallet private key")
@ -65,18 +44,18 @@ parser.add_argument("--dry-run", action="store_true", default=False,
args = parser.parse_args()
logging.getLogger().setLevel(args.log_level)
logging.warning(WARNING_DISCLAIMER_TEXT)
logging.warning(mango.WARNING_DISCLAIMER_TEXT)
try:
id_filename = args.id_file
if not os.path.isfile(id_filename):
logging.error(f"Wallet file '{id_filename}' is not present.")
sys.exit(1)
wallet = Wallet.load(id_filename)
wallet = mango.Wallet.load(id_filename)
adjustment_factor = args.adjustment_factor
context = Context.from_command_line(args.cluster, args.cluster_url, args.program_id,
args.dex_program_id, args.group_name, args.group_id)
context = mango.Context.from_command_line(args.cluster, args.cluster_url, args.program_id,
args.dex_program_id, args.group_name, args.group_id)
logging.info(f"Context: {context}")
logging.info(f"Wallet address: {wallet.address}")
@ -84,12 +63,12 @@ try:
symbol = args.symbol.upper()
if args.dry_run:
trade_executor: TradeExecutor = NullTradeExecutor()
trade_executor: mango.TradeExecutor = mango.NullTradeExecutor()
else:
with open(args.token_data_file) as json_file:
token_data = json.load(json_file)
spot_market_lookup = SpotMarketLookup(token_data)
trade_executor = SerumImmediateTradeExecutor(context, wallet, spot_market_lookup, adjustment_factor)
spot_market_lookup = mango.SpotMarketLookup(token_data)
trade_executor = mango.SerumImmediateTradeExecutor(context, wallet, spot_market_lookup, adjustment_factor)
trade_executor.sell(symbol, args.quantity)

View File

@ -1,33 +1,13 @@
#!/usr/bin/env pyston3
import os
import sys
from pathlib import Path
# Get the full path to this script.
script_path = Path(os.path.realpath(__file__))
# The parent of the script is the bin directory.
# The parent of the bin directory is the notebook directory.
# It's this notebook directory we want.
notebook_directory = script_path.parent.parent
# Add the notebook directory to our import path.
sys.path.append(str(notebook_directory))
# Add the startup directory to our import path.
startup_directory = notebook_directory / "meta" / "startup"
sys.path.append(str(startup_directory))
import argparse
import logging
import projectsetup # noqa: F401
import os
import sys
from BaseModel import TokenAccount, TokenLookup
from Constants import WARNING_DISCLAIMER_TEXT
from Context import default_context as context
from Wallet import Wallet
sys.path.insert(0, os.path.abspath(
os.path.join(os.path.dirname(__file__), '..')))
import mango # nopep8
parser = argparse.ArgumentParser(description="Shows all Wrapped SOL accounts for the wallet.")
parser.add_argument("--id-file", type=str, default="id.json",
@ -39,24 +19,23 @@ parser.add_argument("--overwrite", action="store_true", default=False,
args = parser.parse_args()
logging.getLogger().setLevel(args.log_level)
logging.warning(WARNING_DISCLAIMER_TEXT)
logging.warning(mango.WARNING_DISCLAIMER_TEXT)
id_filename = args.id_file
if not os.path.isfile(id_filename):
logging.error(f"Wallet file '{id_filename}' is not present.")
sys.exit(1)
wallet = Wallet.load(id_filename)
wallet = mango.Wallet.load(id_filename)
lookups = TokenLookup.default_lookups()
lookups = mango.TokenLookup.default_lookups()
wrapped_sol = lookups.find_by_symbol("SOL")
token_accounts = TokenAccount.fetch_all_for_owner_and_token(context, wallet.address, wrapped_sol)
token_accounts = mango.TokenAccount.fetch_all_for_owner_and_token(mango.default_context, wallet.address, wrapped_sol)
if len(token_accounts) == 0:
print("No wrapped SOL accounts")
print("No wrapped SOL accounts.")
else:
print(f"{wrapped_sol.name}:")
for account in token_accounts:
print(f" {account.address}: {account.amount:>18,.8f} {wrapped_sol.symbol}")

View File

@ -1,49 +1,31 @@
#!/usr/bin/env pyston3
import os
import sys
from pathlib import Path
# Get the full path to this script.
script_path = Path(os.path.realpath(__file__))
# The parent of the script is the bin directory.
# The parent of the bin directory is the notebook directory.
# It's this notebook directory we want.
notebook_directory = script_path.parent.parent
# Add the notebook directory to our import path.
sys.path.append(str(notebook_directory))
# Add the startup directory to our import path.
startup_directory = notebook_directory / "meta" / "startup"
sys.path.append(str(startup_directory))
import argparse
import logging
import os
import os.path
import projectsetup # noqa: F401
import sys
import traceback
from Constants import WARNING_DISCLAIMER_TEXT
from Context import Context, default_cluster, default_cluster_url, default_program_id, default_dex_program_id, default_group_name, default_group_id
from TransactionScout import TransactionScout
sys.path.insert(0, os.path.abspath(
os.path.join(os.path.dirname(__file__), '..')))
import mango # nopep8
# We explicitly want argument parsing to be outside the main try-except block because some arguments
# (like --help) will cause an exit, which our except: block traps.
parser = argparse.ArgumentParser(description="Run the Transaction Scout to display information about a specific transaction.")
parser.add_argument("--cluster", type=str, default=default_cluster,
parser = argparse.ArgumentParser(
description="Run the Transaction Scout to display information about a specific transaction.")
parser.add_argument("--cluster", type=str, default=mango.default_cluster,
help="Solana RPC cluster name")
parser.add_argument("--cluster-url", type=str, default=default_cluster_url,
parser.add_argument("--cluster-url", type=str, default=mango.default_cluster_url,
help="Solana RPC cluster URL")
parser.add_argument("--program-id", type=str, default=default_program_id,
parser.add_argument("--program-id", type=str, default=mango.default_program_id,
help="Mango program ID/address")
parser.add_argument("--dex-program-id", type=str, default=default_dex_program_id,
parser.add_argument("--dex-program-id", type=str, default=mango.default_dex_program_id,
help="DEX program ID/address")
parser.add_argument("--group-name", type=str, default=default_group_name,
parser.add_argument("--group-name", type=str, default=mango.default_group_name,
help="Mango group name")
parser.add_argument("--group-id", type=str, default=default_group_id,
parser.add_argument("--group-id", type=str, default=mango.default_group_id,
help="Mango group ID/address")
parser.add_argument("--id-file", type=str, default="id.json",
help="file containing the JSON-formatted wallet private key")
@ -54,18 +36,18 @@ parser.add_argument("--log-level", default=logging.WARNING, type=lambda level: g
args = parser.parse_args()
logging.getLogger().setLevel(args.log_level)
logging.warning(WARNING_DISCLAIMER_TEXT)
logging.warning(mango.WARNING_DISCLAIMER_TEXT)
try:
signature = args.signature
context = Context.from_command_line(args.cluster, args.cluster_url, args.program_id,
args.dex_program_id, args.group_name, args.group_id)
context = mango.Context.from_command_line(args.cluster, args.cluster_url, args.program_id,
args.dex_program_id, args.group_name, args.group_id)
logging.info(f"Context: {context}")
logging.info(f"Signature: {signature}")
report = TransactionScout.load(context, signature)
report = mango.TransactionScout.load(context, signature)
print(report)
except Exception as exception:
logging.critical(f"transaction-scout stopped because of exception: {exception} - {traceback.format_exc()}")

View File

@ -1,28 +1,9 @@
#!/usr/bin/env pyston3
import os
import sys
from pathlib import Path
# Get the full path to this script.
script_path = Path(os.path.realpath(__file__))
# The parent of the script is the bin directory.
# The parent of the bin directory is the notebook directory.
# It's this notebook directory we want.
notebook_directory = script_path.parent.parent
# Add the notebook directory to our import path.
sys.path.append(str(notebook_directory))
# Add the startup directory to our import path.
startup_directory = notebook_directory / "meta" / "startup"
sys.path.append(str(startup_directory))
import argparse
import logging
import projectsetup # noqa: F401
import os
import sys
import typing
from decimal import Decimal
@ -32,10 +13,9 @@ from solana.transaction import Transaction
from spl.token.constants import ACCOUNT_LEN, TOKEN_PROGRAM_ID, WRAPPED_SOL_MINT
from spl.token.instructions import CloseAccountParams, InitializeAccountParams, Transfer2Params, close_account, initialize_account, transfer2
from BaseModel import TokenAccount, TokenLookup
from Constants import SOL_DECIMAL_DIVISOR, SOL_DECIMALS, WARNING_DISCLAIMER_TEXT
from Context import default_context as context
from Wallet import Wallet
sys.path.insert(0, os.path.abspath(
os.path.join(os.path.dirname(__file__), '..')))
import mango # nopep8
parser = argparse.ArgumentParser(description="Unwraps Wrapped SOL to Pure SOL and adds it to the wallet account.")
parser.add_argument("--quantity", type=Decimal, required=True, help="quantity of SOL to unwrap")
@ -48,25 +28,26 @@ parser.add_argument("--overwrite", action="store_true", default=False,
args = parser.parse_args()
logging.getLogger().setLevel(args.log_level)
logging.warning(WARNING_DISCLAIMER_TEXT)
logging.warning(mango.WARNING_DISCLAIMER_TEXT)
id_filename = args.id_file
if not os.path.isfile(id_filename):
logging.error(f"Wallet file '{id_filename}' is not present.")
sys.exit(1)
wallet = Wallet.load(id_filename)
wallet = mango.Wallet.load(id_filename)
lookups = TokenLookup.default_lookups()
lookups = mango.TokenLookup.default_lookups()
wrapped_sol = lookups.find_by_symbol("SOL")
largest_token_account = TokenAccount.fetch_largest_for_owner_and_token(context, wallet.address, wrapped_sol)
largest_token_account = mango.TokenAccount.fetch_largest_for_owner_and_token(
mango.default_context, wallet.address, wrapped_sol)
if largest_token_account is None:
raise Exception(f"No {wrapped_sol.name} accounts found for owner {wallet.address}.")
# Overpay - remainder should be sent back to our wallet.
FEE = Decimal(".005")
lamports_to_transfer = int((args.quantity + FEE) * SOL_DECIMAL_DIVISOR)
lamports_to_transfer = int((args.quantity + FEE) * mango.SOL_DECIMAL_DIVISOR)
transaction = Transaction()
signers: typing.List[Account] = [wallet.account]
@ -79,7 +60,7 @@ transaction.add(
CreateAccountParams(
from_pubkey=wallet.address,
new_account_pubkey=wrapped_sol_account.public_key(),
lamports=int(FEE * SOL_DECIMAL_DIVISOR),
lamports=int(FEE * mango.SOL_DECIMAL_DIVISOR),
space=ACCOUNT_LEN,
program_id=TOKEN_PROGRAM_ID,
)
@ -98,8 +79,8 @@ transaction.add(
transaction.add(
transfer2(
Transfer2Params(
amount=int(args.quantity * SOL_DECIMAL_DIVISOR),
decimals=int(SOL_DECIMALS),
amount=int(args.quantity * mango.SOL_DECIMAL_DIVISOR),
decimals=int(mango.SOL_DECIMALS),
dest=wrapped_sol_account.public_key(),
mint=WRAPPED_SOL_MINT,
owner=wallet.address,
@ -124,9 +105,9 @@ print(f" Temporary account: {wrapped_sol_account.public_key()}")
print(f" Source: {largest_token_account.address}")
print(f" Destination: {wallet.address}")
response = context.client.send_transaction(transaction, *signers)
transaction_id = context.unwrap_transaction_id_or_raise_exception(response)
response = mango.default_context.client.send_transaction(transaction, *signers)
transaction_id = mango.default_context.unwrap_transaction_id_or_raise_exception(response)
print(f"Waiting on transaction ID: {transaction_id}")
context.wait_for_confirmation(transaction_id)
mango.default_context.wait_for_confirmation(transaction_id)
print("Transaction confirmed.")

View File

@ -1,28 +1,9 @@
#!/usr/bin/env pyston3
import os
import sys
from pathlib import Path
# Get the full path to this script.
script_path = Path(os.path.realpath(__file__))
# The parent of the script is the bin directory.
# The parent of the bin directory is the notebook directory.
# It's this notebook directory we want.
notebook_directory = script_path.parent.parent
# Add the notebook directory to our import path.
sys.path.append(str(notebook_directory))
# Add the startup directory to our import path.
startup_directory = notebook_directory / "meta" / "startup"
sys.path.append(str(startup_directory))
import argparse
import logging
import projectsetup # noqa: F401
import os
import sys
import typing
from decimal import Decimal
@ -32,12 +13,12 @@ from solana.transaction import Transaction
from spl.token.constants import ACCOUNT_LEN, TOKEN_PROGRAM_ID, WRAPPED_SOL_MINT
from spl.token.instructions import CloseAccountParams, InitializeAccountParams, Transfer2Params, close_account, initialize_account, transfer2
from BaseModel import TokenAccount, TokenLookup
from Constants import SOL_DECIMAL_DIVISOR, SOL_DECIMALS, WARNING_DISCLAIMER_TEXT
from Context import default_context as context
from Wallet import Wallet
sys.path.insert(0, os.path.abspath(
os.path.join(os.path.dirname(__file__), '..')))
import mango # nopep8
parser = argparse.ArgumentParser(description="Wraps Pure SOL to Wrapped SOL and adds it to the first Wrapped SOL account, creating it if it doesn't exist.")
parser = argparse.ArgumentParser(
description="Wraps Pure SOL to Wrapped SOL and adds it to the first Wrapped SOL account, creating it if it doesn't exist.")
parser.add_argument("--quantity", type=Decimal, required=True, help="quantity of SOL to wrap")
parser.add_argument("--id-file", type=str, default="id.json",
help="file containing the JSON-formatted wallet private key")
@ -48,19 +29,19 @@ parser.add_argument("--overwrite", action="store_true", default=False,
args = parser.parse_args()
logging.getLogger().setLevel(args.log_level)
logging.warning(WARNING_DISCLAIMER_TEXT)
logging.warning(mango.WARNING_DISCLAIMER_TEXT)
id_filename = args.id_file
if not os.path.isfile(id_filename):
logging.error(f"Wallet file '{id_filename}' is not present.")
sys.exit(1)
wallet = Wallet.load(id_filename)
wallet = mango.Wallet.load(id_filename)
lookups = TokenLookup.default_lookups()
lookups = mango.TokenLookup.default_lookups()
wrapped_sol = lookups.find_by_symbol("SOL")
token_accounts = TokenAccount.fetch_all_for_owner_and_token(context, wallet.address, wrapped_sol)
token_accounts = mango.TokenAccount.fetch_all_for_owner_and_token(mango.default_context, wallet.address, wrapped_sol)
if len(token_accounts) == 0:
close_wrapping_account = False
@ -69,7 +50,7 @@ else:
# Overpay - remainder should be sent back to our wallet.
FEE = Decimal(".005")
lamports_to_transfer = int((args.quantity + FEE) * SOL_DECIMAL_DIVISOR)
lamports_to_transfer = int((args.quantity + FEE) * mango.SOL_DECIMAL_DIVISOR)
transaction = Transaction()
signers: typing.List[Account] = [wallet.account]
@ -110,8 +91,8 @@ else:
transaction.add(
transfer2(
Transfer2Params(
amount=int(args.quantity * SOL_DECIMAL_DIVISOR),
decimals=int(SOL_DECIMALS),
amount=int(args.quantity * mango.SOL_DECIMAL_DIVISOR),
decimals=int(mango.SOL_DECIMALS),
dest=token_accounts[0].address,
mint=WRAPPED_SOL_MINT,
owner=wallet.address,
@ -131,9 +112,9 @@ else:
)
)
response = context.client.send_transaction(transaction, *signers)
transaction_id = context.unwrap_transaction_id_or_raise_exception(response)
response = mango.default_context.client.send_transaction(transaction, *signers)
transaction_id = mango.default_context.unwrap_transaction_id_or_raise_exception(response)
print(f"Waiting on transaction ID: {transaction_id}")
context.wait_for_confirmation(transaction_id)
mango.default_context.wait_for_confirmation(transaction_id)
print("Transaction confirmed.")

0
docs/.keep Normal file
View File

373
ids.json
View File

@ -1,6 +1,6 @@
{
"cluster_urls": {
"devnet": "https://devnet.solana.com",
"devnet": "https://api.devnet.solana.com",
"localnet": "http://127.0.0.1:8899",
"mainnet-beta": "https://solana-api.projectserum.com",
"testnet": "https://testnet.solana.com"
@ -12,127 +12,53 @@
"ETH": "346dJro4LN5mBVHXYNAkLnritKkbXwYKvJQep1aSUB8K",
"MSRM": "9ysywkpvyvxaaezq2Dapj1p1gHPP3U3D3ccTTecVfYHe",
"SRM": "9NzrM7CZ1jq46mX2JGcqyUxBupQEn614A5sZrvg3TrCU",
"USDC": "53g5HU8wHmSCmc54VEqFS5heNAW5sNC8TDzZPAFVdCnQ",
"USDC": "Dosx1CMV6i6cP8GvnsjUoJ8fawvz2Z94M3LDVtHa4R22",
"USDT": "AS1EfwXvpejkkLrEPdz9J84By9kPvVBzYaD6Ks8ya1A6",
"WUSDT": "DV8YAUHc4CiadQoFCHriTjNXbtwCw1Rt834EBYeCyvGt"
},
"fee_symbol": "SRM",
"mango_groups": {
"BTC_ETH_SOL_SRM_USDC": {
"mango_group_pk": "F2XViFNBfGfgAfcsHoZcRvR3UuSWVU31pd96oJGuKqvi",
"mango_group_pk": "B9Uddrao7b7sCjNZp1BJSQqFzqhMEmBxD2SvYTs2TSBn",
"mint_pks": [
"bypQzRBaSDWiKhoAw3hNkf35eF3z3AZCU8Sxks6mTPP",
"ErWGBLBQMwdyC4H3MR8ef6pFK6gyHAyBxy4o1mHoqKzm",
"So11111111111111111111111111111111111111112",
"9FbAMDvXqNjPqZSYt4EWTguJuDrGkfvwr3gSFpiSbX9S",
"H6hy7Ykzc43EuGivv7VVuUKNpKgUoFAfUY3wdPr4UyRX"
"EMjjdsqERN4wJUR9jMBax2pzqQPeGLNn5NeucbHpDUZK"
],
"oracle_pks": [
"6Xvk6VC423bbhwnCfMyPfE4C1vytoqsVMUY1Lbqeh6pf",
"4CoKvk3NUXYiHKGbQvihadw6TC8LTN1qjfadPcsaURbW",
"Ej5FrNjhXaePK7cVMZtSooatzXMeunNsjxrnubefEyNC",
"GR9tYpi8CM8u8sdRaJZoP32KoWBphyoWV3xoNt4XwmRV"
"FuEnReoxhqW8Li6EMLoaaUWbWAEjTfSRuBARo5GrGCqN",
"GzfYWGM1oeVrha9zvM1awnTJEUAuinpnVRUyYQYELzqg",
"AshULbjkGvse8YW2ojjeqHdMbFGigLy2xxiGVhsLqX5T",
"B3nWGxqNQzJeRfpYSXU8qJaTQxspZmqAt91FRAhfoFQL"
],
"spot_market_pks": [
"BCqDfFd119UyNEC2HavKdy3F4qhy6EMGirSurNWKgioW",
"AfB75DQs1E2VoUAMRorUxAz68b18kWZ1uqQuRibGk212",
"6vZd6Ghwkuzpbp7qNzBuRkhcfA9H3S7BJ2LCWSYrjfzo",
"6rRnXBLGzcD5v1q4NfWWZQdgBfqzEuD3g4GqDWVU8yhH"
"E1mfsnnCcL24JcDQxr7F2BpWjkyy5x2WHys8EL2pnCj9",
"9gStrbmhqCUmNsmH1Yjec4TiNXq1g6YQVAamWVhLNdaE",
"4Rf4qZYwBVo6RsxisBnm8RJCRMehiZ2TsDwfyoR9X4dF",
"4SZ7MvMfW2fbEu5SgLMfRaeTR2bXhP6GGLMr1L6N9PeW"
],
"spot_market_symbols": {
"BTC/USDC": "BCqDfFd119UyNEC2HavKdy3F4qhy6EMGirSurNWKgioW",
"ETH/USDC": "AfB75DQs1E2VoUAMRorUxAz68b18kWZ1uqQuRibGk212",
"SOL/USDC": "6vZd6Ghwkuzpbp7qNzBuRkhcfA9H3S7BJ2LCWSYrjfzo",
"SRM/USDC": "6rRnXBLGzcD5v1q4NfWWZQdgBfqzEuD3g4GqDWVU8yhH"
"BTC/USDC": "E1mfsnnCcL24JcDQxr7F2BpWjkyy5x2WHys8EL2pnCj9",
"ETH/USDC": "9gStrbmhqCUmNsmH1Yjec4TiNXq1g6YQVAamWVhLNdaE",
"SOL/USDC": "4Rf4qZYwBVo6RsxisBnm8RJCRMehiZ2TsDwfyoR9X4dF",
"SRM/USDC": "4SZ7MvMfW2fbEu5SgLMfRaeTR2bXhP6GGLMr1L6N9PeW"
},
"srm_vault_pk": "8zGj3Zb15YyZ8dfChXDeREaiDcR4fQ67Jwe1fWWwqwxy",
"srm_vault_pk": "6Jj5MEKHrkeorbSayCk9xHWDmBjuyuLDkLsdngefGHCr",
"symbols": {
"BTC": "bypQzRBaSDWiKhoAw3hNkf35eF3z3AZCU8Sxks6mTPP",
"ETH": "ErWGBLBQMwdyC4H3MR8ef6pFK6gyHAyBxy4o1mHoqKzm",
"SOL": "So11111111111111111111111111111111111111112",
"SRM": "9FbAMDvXqNjPqZSYt4EWTguJuDrGkfvwr3gSFpiSbX9S",
"USDC": "H6hy7Ykzc43EuGivv7VVuUKNpKgUoFAfUY3wdPr4UyRX"
"USDC": "EMjjdsqERN4wJUR9jMBax2pzqQPeGLNn5NeucbHpDUZK"
},
"vault_pks": [
"Av3N1e76sca1f11MKL9PkkbTF1hNqqMZD8GaJQroA1rT",
"EZRrbY2ypi4SdxGHfnkvfQupBCEynEQHt7PQ2XQUCeE9",
"82ce9X5EXyb575kVVbqyyG3wBvNvCqX5U6A6Z7LdzfGC",
"8zGj3Zb15YyZ8dfChXDeREaiDcR4fQ67Jwe1fWWwqwxy",
"AZXVYC6QzD5fTH1QbL9n3fxfGsKGx9yA5nTq16h8MFaf"
]
},
"BTC_ETH_SOL_SRM_USDT": {
"mango_group_pk": "3pxcwMxrQ8xmERAFWkMmNmKLquWcdF143ns4XjZ2P9zd",
"mint_pks": [
"C6kYXcaRUMqeBF5fhg165RWU7AnpT9z92fvKNoMqjmz6",
"8p968u9m7jZzKSsqxFDqki69MjqdFkwPM9FN4AN8hvHR",
"So11111111111111111111111111111111111111112",
"9FbAMDvXqNjPqZSYt4EWTguJuDrGkfvwr3gSFpiSbX9S",
"7KBVenLz5WNH4PA5MdGkJNpDDyNKnBQTwnz1UqJv9GUm"
],
"oracle_pks": [
"6Xvk6VC423bbhwnCfMyPfE4C1vytoqsVMUY1Lbqeh6pf",
"4CoKvk3NUXYiHKGbQvihadw6TC8LTN1qjfadPcsaURbW",
"Ej5FrNjhXaePK7cVMZtSooatzXMeunNsjxrnubefEyNC",
"GR9tYpi8CM8u8sdRaJZoP32KoWBphyoWV3xoNt4XwmRV"
],
"spot_market_pks": [
"6Cpt7EYmzUcHLBQzZuYNqnyKQKieofZXw6bpCWtmwZM1",
"4UQq7c8FdwGkb2TghHVgJShHMJwS4YzjvA3yiF6zArJD",
"8RJA4WhY2Ei48c4xANSgPoqw7DU7mRgvg6eqJS3tvLEN",
"CRLpSnSf7JkoJi9tUnz55R2FoTCrDDkWxQMU6uSVBQgc"
],
"spot_market_symbols": {
"BTC/USDT": "6Cpt7EYmzUcHLBQzZuYNqnyKQKieofZXw6bpCWtmwZM1",
"ETH/USDT": "4UQq7c8FdwGkb2TghHVgJShHMJwS4YzjvA3yiF6zArJD",
"SOL/USDT": "8RJA4WhY2Ei48c4xANSgPoqw7DU7mRgvg6eqJS3tvLEN",
"SRM/USDT": "CRLpSnSf7JkoJi9tUnz55R2FoTCrDDkWxQMU6uSVBQgc"
},
"srm_vault_pk": "9rwmye1qcsD2txMxG7ZKdyJwyTyHXPwkB7y4K1vX9LXZ",
"symbols": {
"BTC": "C6kYXcaRUMqeBF5fhg165RWU7AnpT9z92fvKNoMqjmz6",
"ETH": "8p968u9m7jZzKSsqxFDqki69MjqdFkwPM9FN4AN8hvHR",
"SOL": "So11111111111111111111111111111111111111112",
"SRM": "9FbAMDvXqNjPqZSYt4EWTguJuDrGkfvwr3gSFpiSbX9S",
"USDT": "7KBVenLz5WNH4PA5MdGkJNpDDyNKnBQTwnz1UqJv9GUm"
},
"vault_pks": [
"9pTjFBB3xheuqR9iDG63x2TLZjeb6f3yCBZE6EjYtqV3",
"7HA5Ne1g2t8cRvzEYdoMwGJch1AneMQLiJRJccm1tw9y",
"CGj8exjKg88byyjRCEuYGB5CXvAqB1YzHEHrDiUFLwYK",
"ApX38vWvRybQHKoj6AsQHQDa7gQPChYkNHgqAj2kDxDo",
"CbcaxuYfe53NTX5eRUaRzxGyRyMLTt7JT6p2p6VZVnh7"
]
},
"BTC_ETH_USDC": {
"mango_group_pk": "C9ZtsC1wmqMzbyCUTeBppZSKH82FsKrGnaWjv5BtWvvo",
"mint_pks": [
"C6kYXcaRUMqeBF5fhg165RWU7AnpT9z92fvKNoMqjmz6",
"8p968u9m7jZzKSsqxFDqki69MjqdFkwPM9FN4AN8hvHR",
"Fq939Y5hycK62ZGwBjftLY2VyxqAQ8f1MxRqBMdAaBS7"
],
"oracle_pks": [
"3iQqi9nBREjVvKtVWd44Jcbvs39CDAe6zSd613QzxuPE",
"5qxMJFJXB42j3kKo3FbTughREjziottXHcgLnjCNwjEs"
],
"spot_market_pks": [
"FKysSZkCCh41G1SCxpE7Cb7eaLofYBhEneLzHFz6JvjH",
"BYz5dJegg11x94jS2R7ZTCgaJwimvupmkjeYDm9Y3UwP"
],
"spot_market_symbols": {
"BTC/USDC": "FKysSZkCCh41G1SCxpE7Cb7eaLofYBhEneLzHFz6JvjH",
"ETH/USDC": "BYz5dJegg11x94jS2R7ZTCgaJwimvupmkjeYDm9Y3UwP"
},
"srm_vault_pk": "7YBghCMgSnvs3cg9taiwwKySiwDvby4USL3bmg8JQXF2",
"symbols": {
"BTC": "C6kYXcaRUMqeBF5fhg165RWU7AnpT9z92fvKNoMqjmz6",
"ETH": "8p968u9m7jZzKSsqxFDqki69MjqdFkwPM9FN4AN8hvHR",
"USDC": "Fq939Y5hycK62ZGwBjftLY2VyxqAQ8f1MxRqBMdAaBS7"
},
"vault_pks": [
"Avpn6H3Tu2kJqCouYVpJgqU17iKRR9e2eEjVydQpPuyM",
"GRPxtA2TdS8PaGK4E9utnFo7QGf4jLt7RRqbkQXR5fsU",
"6gmXb2hRKeSRD1vvGwVtAVJSH8fBH7SitseyMnC3cat3"
"EeTjEgYGYS6Ki45zBY5jQ3upVN65Eh8v1TU1ape8Pu7u",
"EmSaqX8VvhZKqgFwWDjgzRrGEKnW97svL8EKjVpW8vM9",
"8BU955zFdqCzzcvHf9XBiRBMraYvAYGZWqyMw5Etd9gv",
"6Jj5MEKHrkeorbSayCk9xHWDmBjuyuLDkLsdngefGHCr",
"CRZemtdc8FjHYWqLDVPwYK5PGbVSxPCKoyf6fyEFkfjq"
]
},
"BTC_ETH_USDT": {
@ -165,93 +91,29 @@
"G5cLZVEu2aJrHP3AE4k2pFEhBqTYTGFTLbq6uuKz8pBq",
"9w3SJ1s1xsfbU5u6yGvtfFLVXtr62EC9x2DH2eX7CvMz"
]
},
"BTC_ETH_WUSDT": {
"mango_group_pk": "6WZGjqRi9XKgkqZdsYwit4ASVDQ1iiHqkiDvVgY1n2uW",
"mint_pks": [
"C6kYXcaRUMqeBF5fhg165RWU7AnpT9z92fvKNoMqjmz6",
"8p968u9m7jZzKSsqxFDqki69MjqdFkwPM9FN4AN8hvHR",
"7tSPGVhneTBWZjLGJGZb9V2UntC7T98cwtSLtgcXjeSs"
],
"oracle_pks": [
"EHasrbBk5mFnTYPjmdNzDoa7cEBH3yN8D4DLJn7Q41hY",
"3ynBi9nQyKoEJwC47cmXviLSgHaaXQBKRxukatYYLN1Y"
],
"spot_market_pks": [
"ELXP9wTE4apvK9sxAqtCtMidbAvJJDrNVg4wL6jqQEBA",
"97mbLfi4S56y5Vg2LCF4Z7ru8jD1QjHa5SH3eyFYrMdg"
],
"spot_market_symbols": {
"BTC/WUSDT": "ELXP9wTE4apvK9sxAqtCtMidbAvJJDrNVg4wL6jqQEBA",
"ETH/WUSDT": "97mbLfi4S56y5Vg2LCF4Z7ru8jD1QjHa5SH3eyFYrMdg"
},
"srm_vault_pk": "3TgeEz19ycH7dH6FvR5rCjxD1sJzAq8Esj8BC98CaeEN",
"symbols": {
"BTC": "C6kYXcaRUMqeBF5fhg165RWU7AnpT9z92fvKNoMqjmz6",
"ETH": "8p968u9m7jZzKSsqxFDqki69MjqdFkwPM9FN4AN8hvHR",
"WUSDT": "7tSPGVhneTBWZjLGJGZb9V2UntC7T98cwtSLtgcXjeSs"
},
"vault_pks": [
"Gf4Z19ygbzmBVSXcyvwZQPdrGs8k5TLPPSikU3hZzq8k",
"5AUNwSv8hdwJVhUuaBtsFadtJQfiudSZcY6U9fNoRd2A",
"41r2LvZZDECzm3sdxuP9AcyXZ9FPXqoTjbmnPfBG7ofn"
]
},
"SOL_SRM_USDT": {
"mango_group_pk": "6eJePhu9dCH2zkwrjgPEx6u3whxPCx1mFCFt6MSDXPr3",
"mint_pks": [
"So11111111111111111111111111111111111111112",
"9FbAMDvXqNjPqZSYt4EWTguJuDrGkfvwr3gSFpiSbX9S",
"7KBVenLz5WNH4PA5MdGkJNpDDyNKnBQTwnz1UqJv9GUm"
],
"oracle_pks": [
"Ej5FrNjhXaePK7cVMZtSooatzXMeunNsjxrnubefEyNC",
"GR9tYpi8CM8u8sdRaJZoP32KoWBphyoWV3xoNt4XwmRV"
],
"spot_market_pks": [
"8RJA4WhY2Ei48c4xANSgPoqw7DU7mRgvg6eqJS3tvLEN",
"CRLpSnSf7JkoJi9tUnz55R2FoTCrDDkWxQMU6uSVBQgc"
],
"spot_market_symbols": {
"SOL/USDT": "8RJA4WhY2Ei48c4xANSgPoqw7DU7mRgvg6eqJS3tvLEN",
"SRM/USDT": "CRLpSnSf7JkoJi9tUnz55R2FoTCrDDkWxQMU6uSVBQgc"
},
"srm_vault_pk": "88LNQitNoCzQivFYqBSMgfUgfPakyJpz3sntA914bLjR",
"symbols": {
"SOL": "So11111111111111111111111111111111111111112",
"SRM": "9FbAMDvXqNjPqZSYt4EWTguJuDrGkfvwr3gSFpiSbX9S",
"USDT": "7KBVenLz5WNH4PA5MdGkJNpDDyNKnBQTwnz1UqJv9GUm"
},
"vault_pks": [
"8msmSv9bdUh7k1QsTo6NYKMnWcARPgSUaN5tQxbcxnyh",
"88LNQitNoCzQivFYqBSMgfUgfPakyJpz3sntA914bLjR",
"Fk1mMcVm9g4oQsXhd771adxSk2WQf3Dzsdp53viJmsim"
]
}
},
"mango_program_id": "9XzhtAtDXxW2rjbeVFhTq4fnhD8dqzr154r5b2z6pxEp",
"oracles": {
"BTC/USDC": "6Xvk6VC423bbhwnCfMyPfE4C1vytoqsVMUY1Lbqeh6pf",
"BTC/USDC": "FuEnReoxhqW8Li6EMLoaaUWbWAEjTfSRuBARo5GrGCqN",
"BTC/USDT": "6Xvk6VC423bbhwnCfMyPfE4C1vytoqsVMUY1Lbqeh6pf",
"BTC/WUSDT": "6Xvk6VC423bbhwnCfMyPfE4C1vytoqsVMUY1Lbqeh6pf",
"ETH/USDC": "4CoKvk3NUXYiHKGbQvihadw6TC8LTN1qjfadPcsaURbW",
"ETH/USDC": "GzfYWGM1oeVrha9zvM1awnTJEUAuinpnVRUyYQYELzqg",
"ETH/USDT": "4CoKvk3NUXYiHKGbQvihadw6TC8LTN1qjfadPcsaURbW",
"ETH/WUSDT": "4CoKvk3NUXYiHKGbQvihadw6TC8LTN1qjfadPcsaURbW",
"SOL/USDC": "Ej5FrNjhXaePK7cVMZtSooatzXMeunNsjxrnubefEyNC",
"SOL/USDT": "Ej5FrNjhXaePK7cVMZtSooatzXMeunNsjxrnubefEyNC",
"SRM/USDC": "GR9tYpi8CM8u8sdRaJZoP32KoWBphyoWV3xoNt4XwmRV",
"SRM/USDT": "GR9tYpi8CM8u8sdRaJZoP32KoWBphyoWV3xoNt4XwmRV"
"SOL/USDC": "AshULbjkGvse8YW2ojjeqHdMbFGigLy2xxiGVhsLqX5T",
"SRM/USDC": "B3nWGxqNQzJeRfpYSXU8qJaTQxspZmqAt91FRAhfoFQL"
},
"spot_markets": {
"BTC/USDC": "BCqDfFd119UyNEC2HavKdy3F4qhy6EMGirSurNWKgioW",
"BTC/USDC": "E1mfsnnCcL24JcDQxr7F2BpWjkyy5x2WHys8EL2pnCj9",
"BTC/USDT": "6Cpt7EYmzUcHLBQzZuYNqnyKQKieofZXw6bpCWtmwZM1",
"BTC/WUSDT": "ELXP9wTE4apvK9sxAqtCtMidbAvJJDrNVg4wL6jqQEBA",
"ETH/USDC": "AfB75DQs1E2VoUAMRorUxAz68b18kWZ1uqQuRibGk212",
"ETH/USDC": "9gStrbmhqCUmNsmH1Yjec4TiNXq1g6YQVAamWVhLNdaE",
"ETH/USDT": "4UQq7c8FdwGkb2TghHVgJShHMJwS4YzjvA3yiF6zArJD",
"ETH/WUSDT": "97mbLfi4S56y5Vg2LCF4Z7ru8jD1QjHa5SH3eyFYrMdg",
"SOL/USDC": "6vZd6Ghwkuzpbp7qNzBuRkhcfA9H3S7BJ2LCWSYrjfzo",
"SOL/USDC": "4Rf4qZYwBVo6RsxisBnm8RJCRMehiZ2TsDwfyoR9X4dF",
"SOL/USDT": "8RJA4WhY2Ei48c4xANSgPoqw7DU7mRgvg6eqJS3tvLEN",
"SRM/USDC": "6rRnXBLGzcD5v1q4NfWWZQdgBfqzEuD3g4GqDWVU8yhH",
"SRM/USDC": "4SZ7MvMfW2fbEu5SgLMfRaeTR2bXhP6GGLMr1L6N9PeW",
"SRM/USDT": "CRLpSnSf7JkoJi9tUnz55R2FoTCrDDkWxQMU6uSVBQgc"
},
"symbols": {
@ -260,7 +122,7 @@
"MSRM": "934bNdNw9QfE8dXD4mKQiKajYURfSkPhxfYZzpvmygca",
"SOL": "So11111111111111111111111111111111111111112",
"SRM": "9FbAMDvXqNjPqZSYt4EWTguJuDrGkfvwr3gSFpiSbX9S",
"USDC": "H6hy7Ykzc43EuGivv7VVuUKNpKgUoFAfUY3wdPr4UyRX",
"USDC": "EMjjdsqERN4wJUR9jMBax2pzqQPeGLNn5NeucbHpDUZK",
"USDT": "7KBVenLz5WNH4PA5MdGkJNpDDyNKnBQTwnz1UqJv9GUm",
"WUSDT": "7tSPGVhneTBWZjLGJGZb9V2UntC7T98cwtSLtgcXjeSs"
}
@ -287,89 +149,46 @@
"fee_token": "SRM",
"mango_groups": {
"BTC_ETH_SOL_SRM_USDC": {
"mango_group_pk": "47dHNHAM7NiYGYhCVXKFLumaSnfyRdsrvQZzPsA5TpgB",
"mango_group_pk": "2oogpTYm1sp6LPZAWD3bp2wsFpnV2kXL1s52yyFhW5vp",
"mint_pks": [
"bypQzRBaSDWiKhoAw3hNkf35eF3z3AZCU8Sxks6mTPP",
"ErWGBLBQMwdyC4H3MR8ef6pFK6gyHAyBxy4o1mHoqKzm",
"9n4nbM75f5Ui33ZbPYXn59EwSgE8CGsHtAeTH5YFeJ9E",
"2FPyTwcZLUg1MDrwsyoP4D6s1tM7hAkHYRjkNb5w6Pxk",
"So11111111111111111111111111111111111111112",
"9FbAMDvXqNjPqZSYt4EWTguJuDrGkfvwr3gSFpiSbX9S",
"H6hy7Ykzc43EuGivv7VVuUKNpKgUoFAfUY3wdPr4UyRX"
"SRMuApVNdxXokk5GT7XD5cUUgXMBCoAz2LHeuAoKWRt",
"EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
],
"oracle_pks": [
"6Xvk6VC423bbhwnCfMyPfE4C1vytoqsVMUY1Lbqeh6pf",
"4CoKvk3NUXYiHKGbQvihadw6TC8LTN1qjfadPcsaURbW",
"Ej5FrNjhXaePK7cVMZtSooatzXMeunNsjxrnubefEyNC",
"GR9tYpi8CM8u8sdRaJZoP32KoWBphyoWV3xoNt4XwmRV"
"HxrRDnjj2Ltj9LMmtcN6PDuFqnDe3FqXDHPvs2pwmtYF",
"7rW5nJbAYj6m3zkr1CPPSMirTRYBZYWQUv7ZggVVt2wA",
"A12sJL3w4rgnbeg7pyAQ8xhuNVbQEWdqN1dgwBoEktvm",
"CM6RrPrtUo8W5DVnhwKnQVMAXiWam8vKdxVacrC3a9x6"
],
"spot_market_pks": [
"BCqDfFd119UyNEC2HavKdy3F4qhy6EMGirSurNWKgioW",
"AfB75DQs1E2VoUAMRorUxAz68b18kWZ1uqQuRibGk212",
"6vZd6Ghwkuzpbp7qNzBuRkhcfA9H3S7BJ2LCWSYrjfzo",
"6rRnXBLGzcD5v1q4NfWWZQdgBfqzEuD3g4GqDWVU8yhH"
"A8YFbxQYFVqKZaoYJLLUVcQiWP7G2MeEgW5wsAQgMvFw",
"4tSvZvnbyzHXLMTiFonMyxZoHmFqau1XArcRCVHLZ5gX",
"9wFFyRfZBsuAha4YcuxcXLKwMxJR43S7fPfQLusDBzvT",
"ByRys5tuUWDgL73G8JBAEfkdFf8JWBzPBDHsBVQ5vbQA"
],
"spot_market_symbols": {
"BTC/USDC": "BCqDfFd119UyNEC2HavKdy3F4qhy6EMGirSurNWKgioW",
"ETH/USDC": "AfB75DQs1E2VoUAMRorUxAz68b18kWZ1uqQuRibGk212",
"SOL/USDC": "6vZd6Ghwkuzpbp7qNzBuRkhcfA9H3S7BJ2LCWSYrjfzo",
"SRM/USDC": "6rRnXBLGzcD5v1q4NfWWZQdgBfqzEuD3g4GqDWVU8yhH"
"BTC/USDC": "A8YFbxQYFVqKZaoYJLLUVcQiWP7G2MeEgW5wsAQgMvFw",
"ETH/USDC": "4tSvZvnbyzHXLMTiFonMyxZoHmFqau1XArcRCVHLZ5gX",
"SOL/USDC": "9wFFyRfZBsuAha4YcuxcXLKwMxJR43S7fPfQLusDBzvT",
"SRM/USDC": "ByRys5tuUWDgL73G8JBAEfkdFf8JWBzPBDHsBVQ5vbQA"
},
"srm_vault_pk": "8EJes1XNUL3ZodtjdAQMvo66V7LkijbDS5XmPRViM8sc",
"srm_vault_pk": "BQEfNrLLjHG6kzHVDX6pnSkg9BaUAx6yUVZWZ9wTBe2Z",
"symbols": {
"BTC": "bypQzRBaSDWiKhoAw3hNkf35eF3z3AZCU8Sxks6mTPP",
"ETH": "ErWGBLBQMwdyC4H3MR8ef6pFK6gyHAyBxy4o1mHoqKzm",
"BTC": "9n4nbM75f5Ui33ZbPYXn59EwSgE8CGsHtAeTH5YFeJ9E",
"ETH": "2FPyTwcZLUg1MDrwsyoP4D6s1tM7hAkHYRjkNb5w6Pxk",
"SOL": "So11111111111111111111111111111111111111112",
"SRM": "9FbAMDvXqNjPqZSYt4EWTguJuDrGkfvwr3gSFpiSbX9S",
"USDC": "H6hy7Ykzc43EuGivv7VVuUKNpKgUoFAfUY3wdPr4UyRX"
"SRM": "SRMuApVNdxXokk5GT7XD5cUUgXMBCoAz2LHeuAoKWRt",
"USDC": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
},
"vault_pks": [
"2LNJSCfxktCvFG9ouBKbArMbQsRf8t2vqK8gBaXSKaQU",
"JA2d4bHESoAsXYG7yhQmG5rPU2T4sfAC4Wf54ERLfswi",
"DaNQipAgf1EAVfHFRUQKY1qBqLMzrVtUyWQZKQB9souH",
"4p3k3wDz6hEcCWBiqLj4egt2NvBiHyE1FUkQUuHvsGL9",
"J9fQsNQRSUyhC2kngGgda4yGKBSgcxQX8WrdcFTcnciL"
]
},
"BTC_ETH_SOL_SRM_USDT": {
"mango_group_pk": "3pxcwMxrQ8xmERAFWkMmNmKLquWcdF143ns4XjZ2P9zd",
"mint_pks": [
"C6kYXcaRUMqeBF5fhg165RWU7AnpT9z92fvKNoMqjmz6",
"8p968u9m7jZzKSsqxFDqki69MjqdFkwPM9FN4AN8hvHR",
"So11111111111111111111111111111111111111112",
"9FbAMDvXqNjPqZSYt4EWTguJuDrGkfvwr3gSFpiSbX9S",
"7KBVenLz5WNH4PA5MdGkJNpDDyNKnBQTwnz1UqJv9GUm"
],
"oracle_pks": [
"6Xvk6VC423bbhwnCfMyPfE4C1vytoqsVMUY1Lbqeh6pf",
"4CoKvk3NUXYiHKGbQvihadw6TC8LTN1qjfadPcsaURbW",
"Ej5FrNjhXaePK7cVMZtSooatzXMeunNsjxrnubefEyNC",
"GR9tYpi8CM8u8sdRaJZoP32KoWBphyoWV3xoNt4XwmRV"
],
"spot_market_pks": [
"6Cpt7EYmzUcHLBQzZuYNqnyKQKieofZXw6bpCWtmwZM1",
"4UQq7c8FdwGkb2TghHVgJShHMJwS4YzjvA3yiF6zArJD",
"8RJA4WhY2Ei48c4xANSgPoqw7DU7mRgvg6eqJS3tvLEN",
"CRLpSnSf7JkoJi9tUnz55R2FoTCrDDkWxQMU6uSVBQgc"
],
"spot_market_symbols": {
"BTC/USDT": "6Cpt7EYmzUcHLBQzZuYNqnyKQKieofZXw6bpCWtmwZM1",
"ETH/USDT": "4UQq7c8FdwGkb2TghHVgJShHMJwS4YzjvA3yiF6zArJD",
"SOL/USDT": "8RJA4WhY2Ei48c4xANSgPoqw7DU7mRgvg6eqJS3tvLEN",
"SRM/USDT": "CRLpSnSf7JkoJi9tUnz55R2FoTCrDDkWxQMU6uSVBQgc"
},
"srm_vault_pk": "9rwmye1qcsD2txMxG7ZKdyJwyTyHXPwkB7y4K1vX9LXZ",
"symbols": {
"BTC": "C6kYXcaRUMqeBF5fhg165RWU7AnpT9z92fvKNoMqjmz6",
"ETH": "8p968u9m7jZzKSsqxFDqki69MjqdFkwPM9FN4AN8hvHR",
"SOL": "So11111111111111111111111111111111111111112",
"SRM": "9FbAMDvXqNjPqZSYt4EWTguJuDrGkfvwr3gSFpiSbX9S",
"USDT": "7KBVenLz5WNH4PA5MdGkJNpDDyNKnBQTwnz1UqJv9GUm"
},
"vault_pks": [
"9pTjFBB3xheuqR9iDG63x2TLZjeb6f3yCBZE6EjYtqV3",
"7HA5Ne1g2t8cRvzEYdoMwGJch1AneMQLiJRJccm1tw9y",
"CGj8exjKg88byyjRCEuYGB5CXvAqB1YzHEHrDiUFLwYK",
"ApX38vWvRybQHKoj6AsQHQDa7gQPChYkNHgqAj2kDxDo",
"CbcaxuYfe53NTX5eRUaRzxGyRyMLTt7JT6p2p6VZVnh7"
"ET8VKKD1v11oiotUDf8KgcxxbkXiSBRnkDNMotpc8VB1",
"4Yivi3q3MMsUHUWi7pM7PTjS5jDRwRCXorQ35UErgYiW",
"HeeaULKeHY4mXbT1NRk8b2XQuBS1A2PT2uRLT6i8u66B",
"BQEfNrLLjHG6kzHVDX6pnSkg9BaUAx6yUVZWZ9wTBe2Z",
"24PQCo4H9gkiqrN9n5qmLBb3fg5A4G9mistXWMCnVCqW"
]
},
"BTC_ETH_USDT": {
@ -402,85 +221,31 @@
"3AGLriXSkujXN3TT2HrmfdLhMR9ApoYSMdPUiuXW95Kn",
"9UL2DZCskV2m7zFsA7h7igzEw4HMtTjRdQr2X6sTCn1i"
]
},
"BTC_ETH_WUSDT": {
"mango_group_pk": "6NsLVpG2pdxn2rEFkmHHYjkY5QP5qG4RN1fRcSqDVxPC",
"mint_pks": [
"9n4nbM75f5Ui33ZbPYXn59EwSgE8CGsHtAeTH5YFeJ9E",
"2FPyTwcZLUg1MDrwsyoP4D6s1tM7hAkHYRjkNb5w6Pxk",
"BQcdHdAQW1hczDbBi9hiegXAR7A98Q9jx3X3iBBBDiq4"
],
"oracle_pks": [
"HWh11EWkVHHZoRV6D6WzfRSna4yFv8ZvwcqDk74oDnSs",
"AcYcDG74nxeFHxuqeD5RRWTMWKi77QVx7t9bEy8Y4eyN"
],
"spot_market_pks": [
"5r8FfnbNYcQbS1m4CYmoHYGjBtu6bxfo6UJHNRfzPiYH",
"71CtEComq2XdhGNbXBuYPmosAjMCPSedcgbNi5jDaGbR"
],
"spot_market_symbols": {
"BTC/WUSDT": "5r8FfnbNYcQbS1m4CYmoHYGjBtu6bxfo6UJHNRfzPiYH",
"ETH/WUSDT": "71CtEComq2XdhGNbXBuYPmosAjMCPSedcgbNi5jDaGbR"
},
"srm_vault_pk": "Fn6VHzE2PkJBeJAtCd6T5PP4ni79SqAdnFozFewgh8i6",
"symbols": {
"BTC": "9n4nbM75f5Ui33ZbPYXn59EwSgE8CGsHtAeTH5YFeJ9E",
"ETH": "2FPyTwcZLUg1MDrwsyoP4D6s1tM7hAkHYRjkNb5w6Pxk",
"WUSDT": "BQcdHdAQW1hczDbBi9hiegXAR7A98Q9jx3X3iBBBDiq4"
},
"vault_pks": [
"FF8h6eCSqoGQyuYmh3BzxdaTfomCBPrCctRq9Yo6nCcd",
"GWwECYXmTUumcUsjbwdJDc9ws4KDWYBJ1GGmckZr2hTK",
"BoGTDjtbEtK8HPCu2VPNJfA7bTLuVDPETDoHvztm6Mqe"
]
},
"SOL_SRM_USDT": {
"mango_group_pk": "C7FVGdqn1rAfgAB2wXcfc1REELSEhRNDa2NK52c7jceY",
"mint_pks": [
"So11111111111111111111111111111111111111112",
"SRMuApVNdxXokk5GT7XD5cUUgXMBCoAz2LHeuAoKWRt",
"Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB"
],
"oracle_pks": [
"AyGcAbUyuCSMi3Mt5e3fqauajneDbFXgiA28FSbXpQer",
"76FmEELjApbgiyPjrNGBsUpWPDyNGq21LzmgUoSuVETC"
],
"spot_market_pks": [
"HWHvQhFmJB3NUcu1aihKmrKegfVxBEHzwVX6yZCKEsi1",
"AtNnsY1AyRERWJ8xCskfz38YdvruWVJQUVXgScC1iPb"
],
"spot_market_symbols": {
"SOL/USDT": "HWHvQhFmJB3NUcu1aihKmrKegfVxBEHzwVX6yZCKEsi1",
"SRM/USDT": "AtNnsY1AyRERWJ8xCskfz38YdvruWVJQUVXgScC1iPb"
},
"srm_vault_pk": "HrEF5mL2tAz8zT1i3kUiJ4M8ZbjmGj7A7fBwh8FKAkQn",
"symbols": {
"SOL": "So11111111111111111111111111111111111111112",
"SRM": "SRMuApVNdxXokk5GT7XD5cUUgXMBCoAz2LHeuAoKWRt",
"USDT": "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB"
},
"vault_pks": [
"2BzBqiVup8UWPHYEB1e6BZoTuunkazr9GdpPSQVrKSnA",
"HrEF5mL2tAz8zT1i3kUiJ4M8ZbjmGj7A7fBwh8FKAkQn",
"FioiRBiCRLqXnA5tUs8WVo8heuuTdPnBWi4cjHSCHYmq"
]
}
},
"mango_program_id": "JD3bq9hGdy38PuWQ4h2YJpELmHVGPPfFSuFkpzAd9zfu",
"mango_program_id": "5fNfvyp5czQVX77yoACa3JJVEhdRaWjPuazuWgjhTqEH",
"oracles": {
"BTC/USDC": "HxrRDnjj2Ltj9LMmtcN6PDuFqnDe3FqXDHPvs2pwmtYF",
"BTC/USDT": "HWh11EWkVHHZoRV6D6WzfRSna4yFv8ZvwcqDk74oDnSs",
"BTC/WUSDT": "HWh11EWkVHHZoRV6D6WzfRSna4yFv8ZvwcqDk74oDnSs",
"ETH/USDC": "7rW5nJbAYj6m3zkr1CPPSMirTRYBZYWQUv7ZggVVt2wA",
"ETH/USDT": "AcYcDG74nxeFHxuqeD5RRWTMWKi77QVx7t9bEy8Y4eyN",
"ETH/WUSDT": "AcYcDG74nxeFHxuqeD5RRWTMWKi77QVx7t9bEy8Y4eyN",
"SOL/USDC": "A12sJL3w4rgnbeg7pyAQ8xhuNVbQEWdqN1dgwBoEktvm",
"SOL/USDT": "AyGcAbUyuCSMi3Mt5e3fqauajneDbFXgiA28FSbXpQer",
"SRM/USDC": "CM6RrPrtUo8W5DVnhwKnQVMAXiWam8vKdxVacrC3a9x6",
"SRM/USDT": "76FmEELjApbgiyPjrNGBsUpWPDyNGq21LzmgUoSuVETC"
},
"spot_markets": {
"BTC/USDC": "A8YFbxQYFVqKZaoYJLLUVcQiWP7G2MeEgW5wsAQgMvFw",
"BTC/USDT": "C1EuT9VokAKLiW7i2ASnZUvxDoKuKkCpDDeNxAptuNe4",
"BTC/WUSDT": "5r8FfnbNYcQbS1m4CYmoHYGjBtu6bxfo6UJHNRfzPiYH",
"ETH/USDC": "4tSvZvnbyzHXLMTiFonMyxZoHmFqau1XArcRCVHLZ5gX",
"ETH/USDT": "7dLVkUfBVfCGkFhSXDCq1ukM9usathSgS716t643iFGF",
"ETH/WUSDT": "71CtEComq2XdhGNbXBuYPmosAjMCPSedcgbNi5jDaGbR",
"SOL/USDC": "9wFFyRfZBsuAha4YcuxcXLKwMxJR43S7fPfQLusDBzvT",
"SOL/USDT": "HWHvQhFmJB3NUcu1aihKmrKegfVxBEHzwVX6yZCKEsi1",
"SRM/USDC": "ByRys5tuUWDgL73G8JBAEfkdFf8JWBzPBDHsBVQ5vbQA",
"SRM/USDT": "AtNnsY1AyRERWJ8xCskfz38YdvruWVJQUVXgScC1iPb"
},
"symbols": {
@ -510,4 +275,4 @@
"USDC": "9q4p8UFxphSipGL3TGku8byTijgk4koTMwhBMV4QKvjw"
}
}
}
}

76
mango/__init__.py Normal file
View File

@ -0,0 +1,76 @@
from .accountinfo import AccountInfo
from .accountliquidator import AccountLiquidator, NullAccountLiquidator, ActualAccountLiquidator, ForceCancelOrdersAccountLiquidator, ReportingAccountLiquidator
from .accountscout import ScoutReport, AccountScout
from .addressableaccount import AddressableAccount
from .aggregator import AggregatorConfig, Round, Answer, Aggregator
from .balancesheet import BalanceSheet
from .baskettoken import BasketToken
from .constants import SYSTEM_PROGRAM_ADDRESS, SOL_MINT_ADDRESS, SOL_DECIMALS, SOL_DECIMAL_DIVISOR, WARNING_DISCLAIMER_TEXT, MangoConstants
from .context import Context, default_cluster, default_cluster_url, default_program_id, default_dex_program_id, default_group_name, default_group_id, default_context, solana_context, serum_context, rpcpool_context
from .encoding import decode_binary, encode_binary, encode_key, encode_int
from .group import Group
from .index import Index
from .instructions import InstructionBuilder, ForceCancelOrdersInstructionBuilder, LiquidateInstructionBuilder
from .instructiontype import InstructionType
from .liquidationevent import LiquidationEvent
from .liquidationprocessor import LiquidationProcessor
from .mangoaccountflags import MangoAccountFlags
from .marginaccount import MarginAccount, MarginAccountMetadata
from .marketmetadata import MarketMetadata
from .notification import NotificationTarget, TelegramNotificationTarget, DiscordNotificationTarget, MailjetNotificationTarget, CsvFileNotificationTarget, FilteringNotificationTarget, NotificationHandler, parse_subscription_target
from .observables import PrintingObserverSubscriber, TimestampedPrintingObserverSubscriber, CollectingObserverSubscriber, CaptureFirstItem, FunctionObserver, create_backpressure_skipping_observer, debug_print_item, log_subscription_error, observable_pipeline_error_reporter, EventSource
from .openorders import OpenOrders
from .ownedtokenvalue import OwnedTokenValue
from .retrier import RetryWithPauses, retry_context
from .serumaccountflags import SerumAccountFlags
from .spotmarket import SpotMarket, SpotMarketLookup
from .token import Token, SolToken, TokenLookup
from .tokenaccount import TokenAccount
from .tokenvalue import TokenValue
from .tradeexecutor import TradeExecutor, NullTradeExecutor, SerumImmediateTradeExecutor
from .transactionscout import MangoInstruction, TransactionScout, fetch_all_recent_transaction_signatures
from .version import Version
from .wallet import Wallet, default_wallet
from .walletbalancer import TargetBalance, FixedTargetBalance, PercentageTargetBalance, TargetBalanceParser, sort_changes_for_trades, calculate_required_balance_changes, FilterSmallChanges, WalletBalancer, NullWalletBalancer, LiveWalletBalancer
from .layouts import layouts
import decimal
import logging
import logging.handlers
import pandas as pd
pd.options.display.float_format = '{:,.8f}'.format
# Increased precision from 18 to 36 because for a decimal like:
# val = Decimal("17436036573.2030800")
#
# The following rounding operations would both throw decimal.InvalidOperation:
# val.quantize(Decimal('.000000001'))
# round(val, 9)
decimal.getcontext().prec = 36
_log_levels = {
logging.CRITICAL: "🛑",
logging.ERROR: "🚨",
logging.WARNING: "",
logging.INFO: "",
logging.DEBUG: "🐛"
}
default_log_record_factory = logging.getLogRecordFactory()
def emojified_record_factory(*args, **kwargs):
record = default_log_record_factory(*args, **kwargs)
# Here's where we add our own format keywords.
record.level_emoji = _log_levels[record.levelno]
return record
logging.setLogRecordFactory(emojified_record_factory)
# Make logging a little more verbose than the default.
logging.basicConfig(level=logging.INFO,
datefmt="%Y-%m-%d %H:%M:%S",
format="%(asctime)s %(level_emoji)s %(name)-12.12s %(message)s")

106
mango/accountinfo.py Normal file
View File

@ -0,0 +1,106 @@
# # ⚠ 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 logging
import time
import typing
from decimal import Decimal
from solana.publickey import PublicKey
from solana.rpc.types import RPCMethod, RPCResponse
from .context import Context
from .encoding import decode_binary, encode_binary
# # 🥭 AccountInfo class
#
class AccountInfo:
def __init__(self, address: PublicKey, executable: bool, lamports: Decimal, owner: PublicKey, rent_epoch: Decimal, data: bytes):
self.logger: logging.Logger = logging.getLogger(self.__class__.__name__)
self.address: PublicKey = address
self.executable: bool = executable
self.lamports: Decimal = lamports
self.owner: PublicKey = owner
self.rent_epoch: Decimal = rent_epoch
self.data: bytes = data
def encoded_data(self) -> typing.List:
return encode_binary(self.data)
def __str__(self) -> str:
return f"""« AccountInfo [{self.address}]:
Owner: {self.owner}
Executable: {self.executable}
Lamports: {self.lamports}
Rent Epoch: {self.rent_epoch}
»"""
def __repr__(self) -> str:
return f"{self}"
@staticmethod
def load(context: Context, address: PublicKey) -> typing.Optional["AccountInfo"]:
response: RPCResponse = context.client.get_account_info(address)
result = context.unwrap_or_raise_exception(response)
if result["value"] is None:
return None
return AccountInfo._from_response_values(result["value"], address)
@staticmethod
def load_multiple(context: Context, addresses: typing.List[PublicKey], chunk_size: int = 100, sleep_between_calls: float = 0.0) -> typing.List["AccountInfo"]:
# This is a tricky one to get right.
# Some errors this can generate:
# 413 Client Error: Payload Too Large for url
# Error response from server: 'Too many inputs provided; max 100', code: -32602
address_strings: typing.List[str] = list(map(PublicKey.__str__, addresses))
multiple: typing.List[AccountInfo] = []
chunks = AccountInfo._split_list_into_chunks(address_strings, chunk_size)
for counter, chunk in enumerate(chunks):
response = context.client._provider.make_request(RPCMethod("getMultipleAccounts"), chunk)
result = context.unwrap_or_raise_exception(response)
response_value_list = zip(result["value"], addresses)
multiple += list(map(lambda pair: AccountInfo._from_response_values(pair[0], pair[1]), response_value_list))
if (sleep_between_calls > 0.0) and (counter < (len(chunks) - 1)):
time.sleep(sleep_between_calls)
return multiple
@staticmethod
def _from_response_values(response_values: typing.Dict[str, typing.Any], address: PublicKey) -> "AccountInfo":
executable = bool(response_values["executable"])
lamports = Decimal(response_values["lamports"])
owner = PublicKey(response_values["owner"])
rent_epoch = Decimal(response_values["rentEpoch"])
data = decode_binary(response_values["data"])
return AccountInfo(address, executable, lamports, owner, rent_epoch, data)
@staticmethod
def from_response(response: RPCResponse, address: PublicKey) -> "AccountInfo":
return AccountInfo._from_response_values(response["result"]["value"], address)
@staticmethod
def _split_list_into_chunks(to_chunk: typing.List, chunk_size: int = 100) -> typing.List[typing.List]:
chunks = []
start = 0
while start < len(to_chunk):
chunk = to_chunk[start:start + chunk_size]
chunks += [chunk]
start += chunk_size
return chunks

245
mango/accountliquidator.py Normal file
View File

@ -0,0 +1,245 @@
# # ⚠ 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 datetime
import logging
import typing
from solana.transaction import Transaction
from .context import Context
from .group import Group
from .instructions import ForceCancelOrdersInstructionBuilder, InstructionBuilder, LiquidateInstructionBuilder
from .liquidationevent import LiquidationEvent
from .marginaccount import MarginAccount, MarginAccountMetadata
from .observables import EventSource
from .tokenvalue import TokenValue
from .transactionscout import TransactionScout
from .wallet import Wallet
# # 🥭 AccountLiquidator
#
# An `AccountLiquidator` liquidates a `MarginAccount`, if possible.
#
# The follows the common pattern of having an abstract base class that defines the interface
# external code should use, along with a 'null' implementation and at least one full
# implementation.
#
# The idea is that preparing code can choose whether to use the null implementation (in the
# case of a 'dry run' for instance) or the full implementation, but the code that defines
# the algorithm - which actually calls the `AccountLiquidator` - doesn't have to care about
# this choice.
#
# # 💧 AccountLiquidator class
#
# This abstract base class defines the interface to account liquidators, which in this case
# is just the `liquidate()` method.
#
class AccountLiquidator(metaclass=abc.ABCMeta):
def __init__(self):
self.logger: logging.Logger = logging.getLogger(self.__class__.__name__)
@abc.abstractmethod
def prepare_instructions(self, group: Group, margin_account: MarginAccount, prices: typing.List[TokenValue]) -> typing.List[InstructionBuilder]:
raise NotImplementedError("AccountLiquidator.prepare_instructions() is not implemented on the base type.")
@abc.abstractmethod
def liquidate(self, group: Group, margin_account: MarginAccount, prices: typing.List[TokenValue]) -> typing.Optional[str]:
raise NotImplementedError("AccountLiquidator.liquidate() is not implemented on the base type.")
# # 🌬️ NullAccountLiquidator class
#
# A 'null', 'no-op', 'dry run' implementation of the `AccountLiquidator` class.
#
class NullAccountLiquidator(AccountLiquidator):
def __init__(self):
super().__init__()
def prepare_instructions(self, group: Group, margin_account: MarginAccount, prices: typing.List[TokenValue]) -> typing.List[InstructionBuilder]:
return []
def liquidate(self, group: Group, margin_account: MarginAccount, prices: typing.List[TokenValue]) -> typing.Optional[str]:
self.logger.info(f"Skipping liquidation of margin account [{margin_account.address}]")
return None
# # 💧 ActualAccountLiquidator class
#
# This full implementation takes a `MarginAccount` and liquidates it.
#
# It can also serve as a base class for further derivation. Derived classes may override
# `prepare_instructions()` to extend the liquidation process (for example to cancel
# outstanding orders before liquidating).
#
class ActualAccountLiquidator(AccountLiquidator):
def __init__(self, context: Context, wallet: Wallet):
super().__init__()
self.logger: logging.Logger = logging.getLogger(self.__class__.__name__)
self.context = context
self.wallet = wallet
def prepare_instructions(self, group: Group, margin_account: MarginAccount, prices: typing.List[TokenValue]) -> typing.List[InstructionBuilder]:
liquidate_instructions: typing.List[InstructionBuilder] = []
liquidate_instruction = LiquidateInstructionBuilder.from_margin_account_and_market(
self.context, group, self.wallet, margin_account, prices)
if liquidate_instruction is not None:
liquidate_instructions += [liquidate_instruction]
return liquidate_instructions
def liquidate(self, group: Group, margin_account: MarginAccount, prices: typing.List[TokenValue]) -> typing.Optional[str]:
instruction_builders = self.prepare_instructions(group, margin_account, prices)
if len(instruction_builders) == 0:
return None
transaction = Transaction()
for builder in instruction_builders:
transaction.add(builder.build())
for instruction in transaction.instructions:
self.logger.debug("INSTRUCTION")
self.logger.debug(" Keys:")
for key in instruction.keys:
self.logger.debug(" ", f"{key.pubkey}".ljust(
45), f"{key.is_signer}".ljust(6), f"{key.is_writable}".ljust(6))
self.logger.debug(" Data:", " ".join(f"{x:02x}" for x in instruction.data))
self.logger.debug(" Program ID:", instruction.program_id)
transaction_response = self.context.client.send_transaction(transaction, self.wallet.account)
transaction_id = self.context.unwrap_transaction_id_or_raise_exception(transaction_response)
return transaction_id
# # 🌪️ ForceCancelOrdersAccountLiquidator class
#
# When liquidating an account, it's a good idea to ensure it has no open orders that could
# lock funds. This is why Mango allows a liquidator to force-close orders on a liquidatable
# account.
#
# `ForceCancelOrdersAccountLiquidator` overrides `prepare_instructions()` to inject any
# necessary force-cancel instructions before the `PartialLiquidate` instruction.
#
# This is not always necessary. For example, if the liquidator is partially-liquidating a
# large account, then perhaps only the first partial-liquidate needs to check and force-close
# orders, and subsequent partial liquidations can skip this step as an optimisation.
#
# The separation of the regular `AccountLiquidator` and the
# `ForceCancelOrdersAccountLiquidator` classes allows the caller to determine which process
# is used.
#
class ForceCancelOrdersAccountLiquidator(ActualAccountLiquidator):
def __init__(self, context: Context, wallet: Wallet):
super().__init__(context, wallet)
def prepare_instructions(self, group: Group, margin_account: MarginAccount, prices: typing.List[TokenValue]) -> typing.List[InstructionBuilder]:
force_cancel_orders_instructions: typing.List[InstructionBuilder] = []
for index, market_metadata in enumerate(group.markets):
open_orders = margin_account.open_orders_accounts[index]
if open_orders is not None:
market = market_metadata.fetch_market(self.context)
orders = market.load_orders_for_owner(margin_account.owner)
order_count = len(orders)
if order_count > 0:
force_cancel_orders_instructions += ForceCancelOrdersInstructionBuilder.multiple_instructions_from_margin_account_and_market(
self.context, group, self.wallet, margin_account, market_metadata, order_count)
all_instructions = force_cancel_orders_instructions + super().prepare_instructions(group, margin_account, prices)
return all_instructions
# 📝 ReportingAccountLiquidator class
#
# This class takes a regular `AccountLiquidator` and wraps its `liquidate()` call in some
# useful reporting.
#
class ReportingAccountLiquidator(AccountLiquidator):
def __init__(self, inner: AccountLiquidator, context: Context, wallet: Wallet, liquidations_publisher: EventSource[LiquidationEvent], liquidator_name: str):
super().__init__()
self.logger: logging.Logger = logging.getLogger(self.__class__.__name__)
self.inner: AccountLiquidator = inner
self.context: Context = context
self.wallet: Wallet = wallet
self.liquidations_publisher: EventSource[LiquidationEvent] = liquidations_publisher
self.liquidator_name: str = liquidator_name
def prepare_instructions(self, group: Group, margin_account: MarginAccount, prices: typing.List[TokenValue]) -> typing.List[InstructionBuilder]:
return self.inner.prepare_instructions(group, margin_account, prices)
def liquidate(self, group: Group, margin_account: MarginAccount, prices: typing.List[TokenValue]) -> typing.Optional[str]:
balance_sheet = margin_account.get_balance_sheet_totals(group, prices)
balances = margin_account.get_intrinsic_balances(group)
mam = MarginAccountMetadata(margin_account, balance_sheet, balances)
balances_before = group.fetch_balances(self.wallet.address)
self.logger.info("Wallet balances before:")
TokenValue.report(self.logger.info, balances_before)
self.logger.info(f"Margin account balances before:\n{mam.balances}")
self.logger.info(f"Liquidating margin account: {mam.margin_account}\n{mam.balance_sheet}")
transaction_id = self.inner.liquidate(group, mam.margin_account, prices)
if transaction_id is None:
self.logger.info("No transaction sent.")
else:
self.logger.info(f"Transaction ID: {transaction_id} - waiting for confirmation...")
response = self.context.wait_for_confirmation(transaction_id)
if response is None:
self.logger.warning(
f"Could not process 'after' liquidation stage - no data for transaction {transaction_id}")
return transaction_id
transaction_scout = TransactionScout.from_transaction_response(self.context, response)
group_after = Group.load(self.context)
margin_account_after_liquidation = MarginAccount.load(self.context, mam.margin_account.address, group_after)
intrinsic_balances_after = margin_account_after_liquidation.get_intrinsic_balances(group_after)
self.logger.info(f"Margin account balances after: {intrinsic_balances_after}")
self.logger.info("Wallet Balances After:")
balances_after = group_after.fetch_balances(self.wallet.address)
TokenValue.report(self.logger.info, balances_after)
liquidation_event = LiquidationEvent(datetime.datetime.now(),
self.liquidator_name,
self.context.group_name,
transaction_scout.succeeded,
transaction_id,
self.wallet.address,
margin_account_after_liquidation.address,
balances_before,
balances_after)
self.logger.info("Wallet Balances Changes:")
changes = TokenValue.changes(balances_before, balances_after)
TokenValue.report(self.logger.info, changes)
self.liquidations_publisher.publish(liquidation_event)
return transaction_id

183
mango/accountscout.py Normal file
View File

@ -0,0 +1,183 @@
# # ⚠ 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 typing
from solana.publickey import PublicKey
from .accountinfo import AccountInfo
from .constants import SYSTEM_PROGRAM_ADDRESS
from .context import Context
from .group import Group
from .marginaccount import MarginAccount
from .openorders import OpenOrders
from .tokenaccount import TokenAccount
from .wallet import Wallet
# # 🥭 AccountScout
#
# Required Accounts
#
# Mango Markets code expects some accounts to be present, and if they're not present some
# actions can fail.
#
# From [Daffy on Discord](https://discord.com/channels/791995070613159966/820390560085835786/834024719958147073):
#
# > You need an open orders account for each of the spot markets Mango. And you need a
# token account for each of the tokens.
#
# This notebook (and the `AccountScout` class) can be used to check the required accounts
# are present and maybe in future set up all the required accounts.
#
# (There's no reason not to write the code to fix problems and create any missing accounts.
# It just hasn't been done yet.)
#
# # 🥭 ScoutReport class
#
# The `ScoutReport` class is built up by the `AccountScout` to report errors, warnings and
# details pertaining to a user account.
#
class ScoutReport:
def __init__(self, address: PublicKey):
self.address = address
self.errors: typing.List[str] = []
self.warnings: typing.List[str] = []
self.details: typing.List[str] = []
@property
def has_errors(self) -> bool:
return len(self.errors) > 0
@property
def has_warnings(self) -> bool:
return len(self.warnings) > 0
def add_error(self, error) -> None:
self.errors += [error]
def add_warning(self, warning) -> None:
self.warnings += [warning]
def add_detail(self, detail) -> None:
self.details += [detail]
def __str__(self) -> str:
def _pad(text_list: typing.List[str]) -> str:
if len(text_list) == 0:
return "None"
padding = "\n "
return padding.join(map(lambda text: text.replace("\n", padding), text_list))
error_text = _pad(self.errors)
warning_text = _pad(self.warnings)
detail_text = _pad(self.details)
if len(self.errors) > 0 or len(self.warnings) > 0:
summary = f"Found {len(self.errors)} error(s) and {len(self.warnings)} warning(s)."
else:
summary = "No problems found"
return f"""« ScoutReport [{self.address}]:
Summary:
{summary}
Errors:
{error_text}
Warnings:
{warning_text}
Details:
{detail_text}
»"""
def __repr__(self) -> str:
return f"{self}"
# # 🥭 AccountScout class
#
# The `AccountScout` class aims to run various checks against a user account to make sure
# it is in a position to run the liquidator.
#
# Passing all checks here with no errors will be a precondition on liquidator startup.
#
class AccountScout:
def __init__(self):
pass
def require_account_prepared_for_group(self, context: Context, group: Group, account_address: PublicKey) -> None:
report = self.verify_account_prepared_for_group(context, group, account_address)
if report.has_errors:
raise Exception(f"Account '{account_address}' is not prepared for group '{group.address}':\n\n{report}")
def verify_account_prepared_for_group(self, context: Context, group: Group, account_address: PublicKey) -> ScoutReport:
report = ScoutReport(account_address)
# First of all, the account must actually exist. If it doesn't, just return early.
root_account = AccountInfo.load(context, account_address)
if root_account is None:
report.add_error(f"Root account '{account_address}' does not exist.")
return report
# For this to be a user/wallet account, it must be owned by 11111111111111111111111111111111.
if root_account.owner != SYSTEM_PROGRAM_ADDRESS:
report.add_error(f"Account '{account_address}' is not a root user account.")
return report
# Must have token accounts for each of the tokens in the group's basket.
for basket_token in group.basket_tokens:
token_accounts = TokenAccount.fetch_all_for_owner_and_token(context, account_address, basket_token.token)
if len(token_accounts) == 0:
report.add_error(
f"Account '{account_address}' has no account for token '{basket_token.token.name}', mint '{basket_token.token.mint}'.")
else:
report.add_detail(
f"Account '{account_address}' has {len(token_accounts)} {basket_token.token.name} token accounts with mint '{basket_token.token.mint}'.")
# Should have an open orders account for each market in the group. (Only required for re-balancing via Serum, which isn't implemented here yet.)
for market in group.markets:
open_orders = OpenOrders.load_for_market_and_owner(
context, market.address, account_address, context.dex_program_id, market.base.token.decimals, market.quote.token.decimals)
if len(open_orders) == 0:
report.add_warning(
f"No Serum open orders account for market '{market.base.token.name}/{market.quote.token.name}' [{market.address}]'.")
else:
for open_orders_account in open_orders:
report.add_detail(
f"Serum open orders account for market '{market.base.token.name}/{market.quote.token.name}': {open_orders_account}")
# May have one or more Mango Markets margin account, but it's optional for liquidating
margin_accounts = MarginAccount.load_all_for_owner(context, account_address, group)
if len(margin_accounts) == 0:
report.add_detail(f"Account '{account_address}' has no Mango Markets margin accounts.")
else:
for margin_account in margin_accounts:
report.add_detail(f"Margin account: {margin_account}")
return report
# It would be good to be able to fix an account automatically, which should
# be possible if a Wallet is passed.
def prepare_wallet_for_group(self, wallet: Wallet, group: Group) -> ScoutReport:
report = ScoutReport(wallet.address)
report.add_error("AccountScout can't yet prepare wallets.")
return report

View File

@ -0,0 +1,45 @@
# # ⚠ 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
from solana.publickey import PublicKey
from .accountinfo import AccountInfo
# # 🥭 AddressableAccount class
#
# Some of our most-used objects (like `Group` or `MarginAccount`) are accounts on Solana
# with packed data. When these are loaded, they're typically loaded by loading the
# `AccountInfo` and parsing it in an object-specific way.
#
# It's sometimes useful to be able to treat these in a common fashion so we use
# `AddressableAccount` as a way of sharing common features and providing a common base.
class AddressableAccount(metaclass=abc.ABCMeta):
def __init__(self, account_info: AccountInfo):
self.logger: logging.Logger = logging.getLogger(self.__class__.__name__)
self.account_info = account_info
@property
def address(self) -> PublicKey:
return self.account_info.address
def __repr__(self) -> str:
return f"{self}"

166
mango/aggregator.py Normal file
View File

@ -0,0 +1,166 @@
# # ⚠ 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 datetime
import logging
from decimal import Decimal
from solana.publickey import PublicKey
from .accountinfo import AccountInfo
from .addressableaccount import AddressableAccount
from .context import Context
from .layouts import layouts
from .version import Version
# # 🥭 AggregatorConfig class
#
class AggregatorConfig:
def __init__(self, version: Version, description: str, decimals: Decimal, restart_delay: Decimal,
max_submissions: Decimal, min_submissions: Decimal, reward_amount: Decimal,
reward_token_account: PublicKey):
self.logger: logging.Logger = logging.getLogger(self.__class__.__name__)
self.version: Version = version
self.description: str = description
self.decimals: Decimal = decimals
self.restart_delay: Decimal = restart_delay
self.max_submissions: Decimal = max_submissions
self.min_submissions: Decimal = min_submissions
self.reward_amount: Decimal = reward_amount
self.reward_token_account: PublicKey = reward_token_account
@staticmethod
def from_layout(layout: layouts.AGGREGATOR_CONFIG) -> "AggregatorConfig":
return AggregatorConfig(Version.UNSPECIFIED, layout.description, layout.decimals,
layout.restart_delay, layout.max_submissions, layout.min_submissions,
layout.reward_amount, layout.reward_token_account)
def __str__(self) -> str:
return f"« AggregatorConfig: '{self.description}', Decimals: {self.decimals} [restart delay: {self.restart_delay}], Max: {self.max_submissions}, Min: {self.min_submissions}, Reward: {self.reward_amount}, Reward Account: {self.reward_token_account} »"
def __repr__(self) -> str:
return f"{self}"
# # 🥭 Round class
#
class Round:
def __init__(self, version: Version, id: Decimal, created_at: datetime.datetime, updated_at: datetime.datetime):
self.logger: logging.Logger = logging.getLogger(self.__class__.__name__)
self.version: Version = version
self.id: Decimal = id
self.created_at: datetime.datetime = created_at
self.updated_at: datetime.datetime = updated_at
@staticmethod
def from_layout(layout: layouts.ROUND) -> "Round":
return Round(Version.UNSPECIFIED, layout.id, layout.created_at, layout.updated_at)
def __str__(self) -> str:
return f"« Round[{self.id}], Created: {self.updated_at}, Updated: {self.updated_at} »"
def __repr__(self) -> str:
return f"{self}"
# # 🥭 Answer class
#
class Answer:
def __init__(self, version: Version, round_id: Decimal, median: Decimal, created_at: datetime.datetime, updated_at: datetime.datetime):
self.logger: logging.Logger = logging.getLogger(self.__class__.__name__)
self.version: Version = version
self.round_id: Decimal = round_id
self.median: Decimal = median
self.created_at: datetime.datetime = created_at
self.updated_at: datetime.datetime = updated_at
@staticmethod
def from_layout(layout: layouts.ANSWER) -> "Answer":
return Answer(Version.UNSPECIFIED, layout.round_id, layout.median, layout.created_at, layout.updated_at)
def __str__(self) -> str:
return f"« Answer: Round[{self.round_id}], Median: {self.median:,.8f}, Created: {self.updated_at}, Updated: {self.updated_at} »"
def __repr__(self) -> str:
return f"{self}"
# # 🥭 Aggregator class
#
class Aggregator(AddressableAccount):
def __init__(self, account_info: AccountInfo, version: Version, config: AggregatorConfig,
initialized: bool, name: str, owner: PublicKey, round_: Round,
round_submissions: PublicKey, answer: Answer, answer_submissions: PublicKey):
super().__init__(account_info)
self.version: Version = version
self.config: AggregatorConfig = config
self.initialized: bool = initialized
self.name: str = name
self.owner: PublicKey = owner
self.round: Round = round_
self.round_submissions: PublicKey = round_submissions
self.answer: Answer = answer
self.answer_submissions: PublicKey = answer_submissions
@property
def price(self) -> Decimal:
return self.answer.median / (10 ** self.config.decimals)
@staticmethod
def from_layout(layout: layouts.AGGREGATOR, account_info: AccountInfo, name: str) -> "Aggregator":
config = AggregatorConfig.from_layout(layout.config)
initialized = bool(layout.initialized)
round_ = Round.from_layout(layout.round)
answer = Answer.from_layout(layout.answer)
return Aggregator(account_info, Version.UNSPECIFIED, config, initialized, name, layout.owner,
round_, layout.round_submissions, answer, layout.answer_submissions)
@staticmethod
def parse(context: Context, account_info: AccountInfo) -> "Aggregator":
data = account_info.data
if len(data) != layouts.AGGREGATOR.sizeof():
raise Exception(f"Data length ({len(data)}) does not match expected size ({layouts.AGGREGATOR.sizeof()})")
name = context.lookup_oracle_name(account_info.address)
layout = layouts.AGGREGATOR.parse(data)
return Aggregator.from_layout(layout, account_info, name)
@staticmethod
def load(context: Context, account_address: PublicKey):
account_info = AccountInfo.load(context, account_address)
if account_info is None:
raise Exception(f"Aggregator account not found at address '{account_address}'")
return Aggregator.parse(context, account_info)
def __str__(self) -> str:
return f"""
« Aggregator '{self.name}' [{self.version}]:
Config: {self.config}
Initialized: {self.initialized}
Owner: {self.owner}
Round: {self.round}
Round Submissions: {self.round_submissions}
Answer: {self.answer}
Answer Submissions: {self.answer_submissions}
»
"""

65
mango/balancesheet.py Normal file
View File

@ -0,0 +1,65 @@
# # ⚠ 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 logging
from decimal import Decimal
from .token import Token
# # 🥭 BalanceSheet class
#
class BalanceSheet:
def __init__(self, token: Token, liabilities: Decimal, settled_assets: Decimal, unsettled_assets: Decimal):
self.logger: logging.Logger = logging.getLogger(self.__class__.__name__)
self.token: Token = token
self.liabilities: Decimal = liabilities
self.settled_assets: Decimal = settled_assets
self.unsettled_assets: Decimal = unsettled_assets
@property
def assets(self) -> Decimal:
return self.settled_assets + self.unsettled_assets
@property
def value(self) -> Decimal:
return self.assets - self.liabilities
@property
def collateral_ratio(self) -> Decimal:
if self.liabilities == Decimal(0):
return Decimal(0)
return self.assets / self.liabilities
def __str__(self) -> str:
name = "«Unspecified»"
if self.token is not None:
name = self.token.name
return f"""« BalanceSheet [{name}]:
Assets : {self.assets:>18,.8f}
Settled Assets : {self.settled_assets:>18,.8f}
Unsettled Assets : {self.unsettled_assets:>18,.8f}
Liabilities : {self.liabilities:>18,.8f}
Value : {self.value:>18,.8f}
Collateral Ratio : {self.collateral_ratio:>18,.2%}
»
"""
def __repr__(self) -> str:
return f"{self}"

79
mango/baskettoken.py Normal file
View File

@ -0,0 +1,79 @@
# # ⚠ 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 logging
import typing
from solana.publickey import PublicKey
from .index import Index
from .token import Token
# # 🥭 BasketToken class
#
# `BasketToken` defines aspects of `Token`s that are part of a `Group` basket.
#
class BasketToken:
def __init__(self, token: Token, vault: PublicKey, index: Index):
self.logger: logging.Logger = logging.getLogger(self.__class__.__name__)
self.token: Token = token
self.vault: PublicKey = vault
self.index: Index = index
@staticmethod
def find_by_symbol(values: typing.List["BasketToken"], symbol: str) -> "BasketToken":
found = [value for value in values if value.token.symbol_matches(symbol)]
if len(found) == 0:
raise Exception(f"Token '{symbol}' not found in token values: {values}")
if len(found) > 1:
raise Exception(f"Token '{symbol}' matched multiple tokens in values: {values}")
return found[0]
@staticmethod
def find_by_mint(values: typing.List["BasketToken"], mint: PublicKey) -> "BasketToken":
found = [value for value in values if value.token.mint == mint]
if len(found) == 0:
raise Exception(f"Token '{mint}' not found in token values: {values}")
if len(found) > 1:
raise Exception(f"Token '{mint}' matched multiple tokens in values: {values}")
return found[0]
@staticmethod
def find_by_token(values: typing.List["BasketToken"], token: Token) -> "BasketToken":
return BasketToken.find_by_mint(values, token.mint)
# BasketTokens are equal if they have the same underlying token.
def __eq__(self, other):
if hasattr(other, 'token'):
return self.token == other.token
return False
def __str__(self) -> str:
return f"""« BasketToken:
{self.token}
Vault: {self.vault}
Index: {self.index}
»"""
def __repr__(self) -> str:
return f"{self}"

101
mango/constants.py Normal file
View File

@ -0,0 +1,101 @@
# # ⚠ 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 decimal
import json
from solana.publickey import PublicKey
# # 🥭 Constants
#
# This file contains some hard-coded values, all kept in one place, as well as the mechanism
# for loading the Mango `ids.json` file.
# ## SYSTEM_PROGRAM_ADDRESS
#
# The Solana system program address is always 11111111111111111111111111111111.
SYSTEM_PROGRAM_ADDRESS = PublicKey("11111111111111111111111111111111")
# ## SOL_MINT_ADDRESS
#
# The fake mint address of the SOL token. **Note:** Wrapped SOL has a different mint address - it is So11111111111111111111111111111111111111112.
SOL_MINT_ADDRESS = PublicKey("So11111111111111111111111111111111111111111")
# ## SOL_DECIMALS
#
# The number of decimal places used to convert Lamports into SOLs.
SOL_DECIMALS = decimal.Decimal(9)
# ## SOL_DECIMAL_DIVISOR decimal
#
# The divisor to use to turn an integer value of SOLs from an account's `balance` into a value with the correct number of decimal places.
SOL_DECIMAL_DIVISOR = decimal.Decimal(10 ** SOL_DECIMALS)
# ## NUM_TOKENS
#
# This is currently hard-coded to 3.
NUM_TOKENS = 3
# ## NUM_MARKETS
#
# There is one fewer market than tokens.
NUM_MARKETS = NUM_TOKENS - 1
# # WARNING_DISCLAIMER_TEXT
#
# This is the warning text that is output on each run of a command.
WARNING_DISCLAIMER_TEXT = """
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
📄 Documentation: 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
"""
# ## MangoConstants
#
# Load all Mango Market's constants from its own `ids.json` file (retrieved from [GitHub](https://raw.githubusercontent.com/blockworks-foundation/mango-client-ts/main/src/ids.json).
with open("ids.json") as json_file:
MangoConstants = json.load(json_file)

263
mango/context.py Normal file
View File

@ -0,0 +1,263 @@
# # ⚠ 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 logging
import os
import random
import time
import typing
from decimal import Decimal
from solana.publickey import PublicKey
from solana.rpc.api import Client
from solana.rpc.types import MemcmpOpts, RPCError, RPCResponse
from solana.rpc.commitment import Commitment, Single
from .constants import MangoConstants, SOL_DECIMAL_DIVISOR
# # 🥭 Context
#
# ## Context class
#
# A `Context` object to manage Solana connection and Mango configuration.
#
# ## Environment Variables
#
# It's possible to override the values in the `Context` variables provided. This can be easier than creating
# the `Context` in code or introducing dependencies and configuration.
#
# The following environment variables are read:
# * CLUSTER (defaults to: mainnet-beta)
# * CLUSTER_URL (defaults to URL for RPC server for CLUSTER defined in `ids.json`)
# * GROUP_NAME (defaults to: BTC_ETH_USDT)
#
class Context:
def __init__(self, cluster: str, cluster_url: str, program_id: PublicKey, dex_program_id: PublicKey,
group_name: str, group_id: PublicKey):
self.logger: logging.Logger = logging.getLogger(self.__class__.__name__)
self.cluster: str = cluster
self.cluster_url: str = cluster_url
self.client: Client = Client(cluster_url)
self.program_id: PublicKey = program_id
self.dex_program_id: PublicKey = dex_program_id
self.group_name: str = group_name
self.group_id: PublicKey = group_id
self.commitment: Commitment = Single
self.encoding: str = "base64"
# kangda said in Discord: https://discord.com/channels/791995070613159966/836239696467591186/847816026245693451
# "I think you are better off doing 4,8,16,20,30"
self.retry_pauses: typing.List[Decimal] = [Decimal(4), Decimal(
8), Decimal(16), Decimal(20), Decimal(30)]
def fetch_sol_balance(self, account_public_key: PublicKey) -> Decimal:
result = self.client.get_balance(account_public_key, commitment=self.commitment)
value = Decimal(result["result"]["value"])
return value / SOL_DECIMAL_DIVISOR
def fetch_program_accounts_for_owner(self, program_id: PublicKey, owner: PublicKey):
memcmp_opts = [
MemcmpOpts(offset=40, bytes=str(owner)),
]
return self.client.get_program_accounts(program_id, memcmp_opts=memcmp_opts, commitment=self.commitment, encoding=self.encoding)
def unwrap_or_raise_exception(self, response: RPCResponse) -> typing.Any:
if "error" in response:
if response["error"] is str:
message: str = typing.cast(str, response["error"])
code: int = -1
else:
error: RPCError = typing.cast(RPCError, response["error"])
message = error["message"]
code = error["code"]
raise Exception(
f"Error response from server: '{message}', code: {code}")
return response["result"]
def unwrap_transaction_id_or_raise_exception(self, response: RPCResponse) -> str:
return typing.cast(str, self.unwrap_or_raise_exception(response))
def random_client_id(self) -> int:
# 9223372036854775807 is sys.maxsize for 64-bit systems, with a bit_length of 63.
# We explicitly want to use a max of 64-bits though, so we use the number instead of
# sys.maxsize, which could be lower on 32-bit systems or higher on 128-bit systems.
return random.randrange(9223372036854775807)
@staticmethod
def _lookup_name_by_address(address: PublicKey, collection: typing.Dict[str, str]) -> typing.Optional[str]:
address_string = str(address)
for stored_name, stored_address in collection.items():
if stored_address == address_string:
return stored_name
return None
@staticmethod
def _lookup_address_by_name(name: str, collection: typing.Dict[str, str]) -> typing.Optional[PublicKey]:
for stored_name, stored_address in collection.items():
if stored_name == name:
return PublicKey(stored_address)
return None
def lookup_group_name(self, group_address: PublicKey) -> str:
for name, values in MangoConstants[self.cluster]["mango_groups"].items():
if values["mango_group_pk"] == str(group_address):
return name
return "« Unknown Group »"
def lookup_oracle_name(self, token_address: PublicKey) -> str:
return Context._lookup_name_by_address(token_address, MangoConstants[self.cluster]["oracles"]) or "« Unknown Oracle »"
def wait_for_confirmation(self, transaction_id: str, max_wait_in_seconds: int = 60) -> typing.Optional[typing.Dict]:
self.logger.info(
f"Waiting up to {max_wait_in_seconds} seconds for {transaction_id}.")
for wait in range(0, max_wait_in_seconds):
time.sleep(1)
confirmed = default_context.client.get_confirmed_transaction(transaction_id)
if confirmed["result"] is not None:
self.logger.info(f"Confirmed after {wait} seconds.")
return confirmed["result"]
self.logger.info(f"Timed out after {wait} seconds waiting on transaction {transaction_id}.")
return None
def new_from_cluster(self, cluster: str) -> "Context":
cluster_url = MangoConstants["cluster_urls"][cluster]
program_id = PublicKey(MangoConstants[cluster]["mango_program_id"])
dex_program_id = PublicKey(MangoConstants[cluster]["dex_program_id"])
group_id = PublicKey(MangoConstants[cluster]["mango_groups"][self.group_name]["mango_group_pk"])
return Context(cluster, cluster_url, program_id, dex_program_id, self.group_name, group_id)
def new_from_cluster_url(self, cluster_url: str) -> "Context":
return Context(self.cluster, cluster_url, self.program_id, self.dex_program_id, self.group_name, self.group_id)
def new_from_group_name(self, group_name: str) -> "Context":
group_id = PublicKey(MangoConstants[self.cluster]["mango_groups"][group_name]["mango_group_pk"])
return Context(self.cluster, self.cluster_url, self.program_id, self.dex_program_id, group_name, group_id)
def new_from_group_id(self, group_id: PublicKey) -> "Context":
actual_group_name = "« Unknown Group »"
group_id_str = str(group_id)
for group_name in MangoConstants[self.cluster]["mango_groups"]:
if MangoConstants[self.cluster]["mango_groups"][group_name]["mango_group_pk"] == group_id_str:
actual_group_name = group_name
break
return Context(self.cluster, self.cluster_url, self.program_id, self.dex_program_id, actual_group_name, group_id)
@staticmethod
def from_command_line(cluster: str, cluster_url: str, program_id: PublicKey,
dex_program_id: PublicKey, group_name: str,
group_id: PublicKey) -> "Context":
# Here we should have values for all our parameters (because they'll either be specified
# on the command-line or will be the default_* value) but we may be in the situation where
# a group name is specified but not a group ID, and in that case we want to look up the
# group ID.
#
# In that situation, the group_name will not be default_group_name but the group_id will
# still be default_group_id. In that situation we want to override what we were passed
# as the group_id.
if (group_name != default_group_name) and (group_id == default_group_id):
group_id = PublicKey(MangoConstants[cluster]["mango_groups"][group_name]["mango_group_pk"])
return Context(cluster, cluster_url, program_id, dex_program_id, group_name, group_id)
@staticmethod
def from_cluster_and_group_name(cluster: str, group_name: str) -> "Context":
cluster_url = MangoConstants["cluster_urls"][cluster]
program_id = PublicKey(MangoConstants[cluster]["mango_program_id"])
dex_program_id = PublicKey(MangoConstants[cluster]["dex_program_id"])
group_id = PublicKey(MangoConstants[cluster]["mango_groups"][group_name]["mango_group_pk"])
return Context(cluster, cluster_url, program_id, dex_program_id, group_name, group_id)
def __str__(self) -> str:
return f"""« Context:
Cluster: {self.cluster}
Cluster URL: {self.cluster_url}
Program ID: {self.program_id}
DEX Program ID: {self.dex_program_id}
Group Name: {self.group_name}
Group ID: {self.group_id}
»"""
def __repr__(self) -> str:
return f"{self}"
# ## Provided Configured Objects
#
# This file provides 3 `Context` objects, already configured and ready to use.
# * default_context (uses the environment variables specified above and `ids.json` file for configuration)
# * solana_context (uses the environment variables specified above and `ids.json` file for configuration but
# explicitly sets the RPC server to be [Solana's mainnet RPC server](https://api.mainnet-beta.solana.com))
# * serum_context (uses the environment variables specified above and `ids.json` file for configuration but
# explicitly sets the RPC server to be [Project Serum's mainnet RPC server](https://solana-api.projectserum.com))
# * rpcpool_context (uses the environment variables specified above and `ids.json` file for configuration but
# explicitly sets the RPC server to be [RPCPool's free mainnet RPC server](https://api.rpcpool.com))
#
# Where notebooks depend on `default_context`, you can change this behaviour by adding an import line like:
# ```
# from Context import solana_context as default_context
# ```
# This can be useful if one of the RPC servers starts behaving oddly.
# ### default_context object
#
# A default `Context` object that connects to mainnet, to save having to create one all over the place. This
# `Context` uses the default values in the `ids.json` file, overridden by environment variables if they're set.
default_cluster = os.environ.get("CLUSTER") or "mainnet-beta"
default_cluster_url = os.environ.get("CLUSTER_URL") or MangoConstants["cluster_urls"][default_cluster]
default_program_id = PublicKey(MangoConstants[default_cluster]["mango_program_id"])
default_dex_program_id = PublicKey(MangoConstants[default_cluster]["dex_program_id"])
default_group_name = os.environ.get("GROUP_NAME") or "BTC_ETH_USDT"
default_group_id = PublicKey(MangoConstants[default_cluster]["mango_groups"][default_group_name]["mango_group_pk"])
default_context = Context(default_cluster, default_cluster_url, default_program_id,
default_dex_program_id, default_group_name, default_group_id)
# ### solana_context object
#
# A `Context` object that connects to mainnet using Solana's own https://api.mainnet-beta.solana.com server.
# Apart from the RPC server URL, this `Context` uses the default values in the `ids.json` file, overridden by
# environment variables if they're set.
solana_context = default_context.new_from_cluster_url("https://api.mainnet-beta.solana.com")
# ### serum_context object
#
# A `Context` object that connects to mainnet using Serum's own https://solana-api.projectserum.com server.
# Apart from the RPC server URL, this `Context` uses the default values in the `ids.json` file, overridden by
# environment variables if they're set.
serum_context = default_context.new_from_cluster_url("https://solana-api.projectserum.com")
# ### rpcpool_context object
#
# A `Context` object that connects to mainnet using RPCPool's free https://api.rpcpool.com server.
# Apart from the RPC server URL, this `Context` uses the default values in the `ids.json` file, overridden by
# environment variables if they're set.
rpcpool_context = default_context.new_from_cluster_url("https://api.rpcpool.com")

75
mango/encoding.py Normal file
View File

@ -0,0 +1,75 @@
# # ⚠ 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 base64
import base58
import typing
from solana.publickey import PublicKey
# # 🥭 Decoder
#
# This file contains some useful functions for decoding base64 and base58 data.
# ## decode_binary() function
#
# A Solana binary data structure may come back as an array with the base64 or base58 encoded data, and a text moniker saying which encoding was used.
#
# For example:
# ```
# ['AwAAAAAAAACCaOmpoURMK6XHelGTaFawcuQ/78/15LAemWI8jrt3SRKLy2R9i60eclDjuDS8+p/ZhvTUd9G7uQVOYCsR6+BhmqGCiO6EPYP2PQkf/VRTvw7JjXvIjPFJy06QR1Cq1WfTonHl0OjCkyEf60SD07+MFJu5pVWNFGGEO/8AiAYfduaKdnFTaZEHPcK5Eq72WWHeHg2yIbBF09kyeOhlCJwOoG8O5SgpPV8QOA64ZNV4aKroFfADg6kEy/wWCdp3fv2B8WJgAAAAANVfH3HGtjwAAQAAAAAAAADr8cwFi9UOAAEAAAAAAAAAgfFiYAAAAABo3Dbz0L0oAAEAAAAAAAAAr8K+TvCjCwABAAAAAAAAAIHxYmAAAAAA49t5tVNZhwABAAAAAAAAAAmPtcB1zC8AAQAAAAAAAABIBGiCcyaEZdNhrTyeqUY692vOzzPdHaxAxguht3JQGlkzjtd05dX9LENHkl2z1XvUbTNKZlweypNRetmH0lmQ9VYQAHqylxZVK65gEg85g27YuSyvOBZAjJyRmYU9KdCO1D+4ehdPu9dQB1yI1uh75wShdAaFn2o4qrMYwq3SQQEAAAAAAAAAAiH1PPJKAuh6oGiE35aGhUQhFi/bxgKOudpFv8HEHNCFDy1uAqR6+CTQmradxC1wyyjL+iSft+5XudJWwSdi72NJGmyK96x7Obj/AgAAAAB8RjOEdJow6r9LMhIAAAAAGkNK4CXHh5M2st7PnwAAAE33lx1h8hPFD04AAAAAAAA8YRV3Oa309B2wGwAAAAAAOIlOLmkr6+r605n+AQAAAACgmZmZmZkZAQAAAAAAAAAAMDMzMzMzMwEAAAAAAAAA25D1XcAtRzSuuyx3U+X7aE9vM1EJySU9KprgL0LMJ/vat9+SEEUZuga7O5tTUrcMDYWDg+LYaAWhSQiN2fYk7aCGAQAAAAAAgIQeAAAAAAAA8gUqAQAAAAYGBgICAAAA', 'base64']
# ```
# Alternatively, it may just be a base58-encoded string.
#
# `decode_binary()` decodes the data properly based on which encoding was used.
def decode_binary(encoded: typing.List) -> bytes:
if isinstance(encoded, str):
return base58.b58decode(encoded)
elif encoded[1] == "base64":
return base64.b64decode(encoded[0])
else:
return base58.b58decode(encoded[0])
# ## encode_binary() function
#
# Inverse of `decode_binary()`, this takes a binary list and encodes it (using base 64), then returns the encoded string and the string "base64" in an array.
#
def encode_binary(decoded: bytes) -> typing.List:
return [base64.b64encode(decoded), "base64"]
# ## encode_key() function
#
# Encodes a `PublicKey` in the proper way for RPC calls.
def encode_key(key: PublicKey) -> str:
return str(key)
# ## encode_int() function
#
# Encodes an `int` in the proper way for RPC calls.
def encode_int(value: int) -> str:
return base58.b58encode_int(value).decode('ascii')

298
mango/group.py Normal file
View File

@ -0,0 +1,298 @@
# # ⚠ 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 time
import typing
from decimal import Decimal
from solana.publickey import PublicKey
from .accountinfo import AccountInfo
from .addressableaccount import AddressableAccount
from .aggregator import Aggregator
from .baskettoken import BasketToken
from .context import Context
from .index import Index
from .layouts import layouts
from .mangoaccountflags import MangoAccountFlags
from .marketmetadata import MarketMetadata
from .spotmarket import SpotMarketLookup
from .token import SolToken, Token, TokenLookup
from .tokenvalue import TokenValue
from .version import Version
# from .marginaccount import MarginAccount
# from .openorders import OpenOrders
# # 🥭 Group class
#
# The `Group` class encapsulates the data for the Mango Group - the cross-margined basket
# of tokens with lending.
class Group(AddressableAccount):
def __init__(self, account_info: AccountInfo, version: Version, context: Context,
account_flags: MangoAccountFlags, basket_tokens: typing.List[BasketToken],
markets: typing.List[MarketMetadata],
signer_nonce: Decimal, signer_key: PublicKey, dex_program_id: PublicKey,
total_deposits: typing.List[Decimal], total_borrows: typing.List[Decimal],
maint_coll_ratio: Decimal, init_coll_ratio: Decimal, srm_vault: PublicKey,
admin: PublicKey, borrow_limits: typing.List[Decimal]):
super().__init__(account_info)
self.version: Version = version
self.context: Context = context
self.account_flags: MangoAccountFlags = account_flags
self.basket_tokens: typing.List[BasketToken] = basket_tokens
self.markets: typing.List[MarketMetadata] = markets
self.signer_nonce: Decimal = signer_nonce
self.signer_key: PublicKey = signer_key
self.dex_program_id: PublicKey = dex_program_id
self.total_deposits: typing.List[Decimal] = total_deposits
self.total_borrows: typing.List[Decimal] = total_borrows
self.maint_coll_ratio: Decimal = maint_coll_ratio
self.init_coll_ratio: Decimal = init_coll_ratio
self.srm_vault: PublicKey = srm_vault
self.admin: PublicKey = admin
self.borrow_limits: typing.List[Decimal] = borrow_limits
@property
def shared_quote_token(self) -> BasketToken:
return self.basket_tokens[-1]
@property
def base_tokens(self) -> typing.List[BasketToken]:
return self.basket_tokens[:-1]
@staticmethod
def from_layout(layout: construct.Struct, context: Context, account_info: AccountInfo, version: Version, token_lookup: TokenLookup = TokenLookup.default_lookups(), spot_market_lookup: SpotMarketLookup = SpotMarketLookup.default_lookups()) -> "Group":
account_flags: MangoAccountFlags = MangoAccountFlags.from_layout(layout.account_flags)
indexes = list(map(lambda pair: Index.from_layout(pair[0], pair[1]), zip(layout.indexes, layout.mint_decimals)))
basket_tokens: typing.List[BasketToken] = []
for index, token_address in enumerate(layout.tokens):
static_token_data = token_lookup.find_by_mint(token_address)
if static_token_data is None:
raise Exception(f"Could not find token with mint '{token_address}'.")
# We create a new Token object here specifically to force the use of our own decimals
token = Token(static_token_data.symbol, static_token_data.name, token_address, layout.mint_decimals[index])
basket_token = BasketToken(token, layout.vaults[index], indexes[index])
basket_tokens += [basket_token]
markets: typing.List[MarketMetadata] = []
for index, market_address in enumerate(layout.spot_markets):
spot_market = spot_market_lookup.find_by_address(market_address)
if spot_market is None:
raise Exception(f"Could not find spot market with address '{market_address}'.")
base_token = BasketToken.find_by_mint(basket_tokens, spot_market.base.mint)
quote_token = BasketToken.find_by_mint(basket_tokens, spot_market.quote.mint)
market = MarketMetadata(spot_market.name, market_address, base_token, quote_token,
spot_market, layout.oracles[index], layout.oracle_decimals[index])
markets += [market]
maint_coll_ratio = layout.maint_coll_ratio.quantize(Decimal('.01'))
init_coll_ratio = layout.init_coll_ratio.quantize(Decimal('.01'))
return Group(account_info, version, context, account_flags, basket_tokens, markets,
layout.signer_nonce, layout.signer_key, layout.dex_program_id, layout.total_deposits,
layout.total_borrows, maint_coll_ratio, init_coll_ratio, layout.srm_vault,
layout.admin, layout.borrow_limits)
@staticmethod
def parse(context: Context, account_info: AccountInfo) -> "Group":
data = account_info.data
if len(data) == layouts.GROUP_V1.sizeof():
layout = layouts.GROUP_V1.parse(data)
version: Version = Version.V1
elif len(data) == layouts.GROUP_V2.sizeof():
version = Version.V2
layout = layouts.GROUP_V2.parse(data)
else:
raise Exception(
f"Group data length ({len(data)}) does not match expected size ({layouts.GROUP_V1.sizeof()} or {layouts.GROUP_V2.sizeof()})")
return Group.from_layout(layout, context, account_info, version)
@staticmethod
def load(context: Context):
account_info = AccountInfo.load(context, context.group_id)
if account_info is None:
raise Exception(f"Group account not found at address '{context.group_id}'")
return Group.parse(context, account_info)
def price_index_of_token(self, token: Token) -> int:
for index, existing in enumerate(self.basket_tokens):
if existing.token == token:
return index
return -1
def fetch_token_prices(self) -> typing.List[TokenValue]:
started_at = time.time()
# Note: we can just load the oracle data in a simpler way, with:
# oracles = map(lambda market: Aggregator.load(self.context, market.oracle), self.markets)
# but that makes a network request for every oracle. We can reduce that to just one request
# if we use AccountInfo.load_multiple() and parse the data ourselves.
#
# This seems to halve the time this function takes.
oracle_addresses = list([market.oracle for market in self.markets])
oracle_account_infos = AccountInfo.load_multiple(self.context, oracle_addresses)
oracles = map(lambda oracle_account_info: Aggregator.parse(
self.context, oracle_account_info), oracle_account_infos)
prices = list(map(lambda oracle: oracle.price, oracles)) + [Decimal(1)]
token_prices = []
for index, price in enumerate(prices):
token_prices += [TokenValue(self.basket_tokens[index].token, price)]
time_taken = time.time() - started_at
self.logger.info(f"Fetching prices complete. Time taken: {time_taken:.2f} seconds.")
return token_prices
@staticmethod
def load_with_prices(context: Context) -> typing.Tuple["Group", typing.List[TokenValue]]:
group = Group.load(context)
prices = group.fetch_token_prices()
return group, prices
def fetch_balances(self, root_address: PublicKey) -> typing.List[TokenValue]:
balances: typing.List[TokenValue] = []
sol_balance = self.context.fetch_sol_balance(root_address)
balances += [TokenValue(SolToken, sol_balance)]
for basket_token in self.basket_tokens:
balance = TokenValue.fetch_total_value(self.context, root_address, basket_token.token)
balances += [balance]
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_margin_accounts_v1().
#
# The newer mechanism is to look for the has_borrows flag in the MangoAccount. That should
# mean fewer MarginAccounts need to be fetched.
#
# This newer method is implemented in load_ripe_margin_accounts_v2()
# def load_ripe_margin_accounts(self) -> typing.List["MarginAccount"]:
# if self.version == Version.V1:
# return self.load_ripe_margin_accounts_v1()
# else:
# return self.load_ripe_margin_accounts_v2()
# def load_ripe_margin_accounts_v2(self) -> typing.List["MarginAccount"]:
# started_at = time.time()
# 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(self.address)
# )
# ]
# response = self.context.client.get_program_accounts(self.context.program_id, data_size=layouts.MARGIN_ACCOUNT_V2.sizeof(
# ), memcmp_opts=filters, commitment=Single, encoding="base64")
# result = self.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)
# open_orders_addresses += margin_account.open_orders
# margin_accounts += [margin_account]
# self.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(self.context, self)
# self.logger.info(f"Fetched {len(open_orders)} openorders accounts.")
# for margin_account in margin_accounts:
# margin_account.install_open_orders_accounts(self, open_orders)
# prices = self.fetch_token_prices()
# ripe_accounts = MarginAccount.filter_out_unripe(margin_accounts, self, prices)
# time_taken = time.time() - started_at
# self.logger.info(f"Loading ripe 🥭 accounts complete. Time taken: {time_taken:.2f} seconds.")
# return ripe_accounts
# def load_ripe_margin_accounts_v1(self) -> typing.List["MarginAccount"]:
# started_at = time.time()
# margin_accounts = MarginAccount.load_all_for_group_with_open_orders(self.context, self.context.program_id, self)
# self.logger.info(f"Fetched {len(margin_accounts)} V1 margin accounts to process.")
# prices = self.fetch_token_prices()
# ripe_accounts = MarginAccount.filter_out_unripe(margin_accounts, self, prices)
# time_taken = time.time() - started_at
# self.logger.info(f"Loading ripe 🥭 accounts complete. Time taken: {time_taken:.2f} seconds.")
# return ripe_accounts
def __str__(self) -> str:
total_deposits = "\n ".join(map(str, self.total_deposits))
total_borrows = "\n ".join(map(str, self.total_borrows))
borrow_limits = "\n ".join(map(str, self.borrow_limits))
shared_quote_token = str(self.shared_quote_token).replace("\n", "\n ")
base_tokens = "\n ".join([f"{tok}".replace("\n", "\n ") for tok in self.base_tokens])
markets = "\n ".join([f"{mkt}".replace("\n", "\n ") for mkt in self.markets])
return f"""
« Group [{self.version}] {self.address}:
Flags: {self.account_flags}
Base Tokens:
{base_tokens}
Quote Token:
{shared_quote_token}
Markets:
{markets}
DEX Program ID: « {self.dex_program_id} »
SRM Vault: « {self.srm_vault} »
Admin: « {self.admin} »
Signer Nonce: {self.signer_nonce}
Signer Key: « {self.signer_key} »
Initial Collateral Ratio: {self.init_coll_ratio}
Maintenance Collateral Ratio: {self.maint_coll_ratio}
Total Deposits:
{total_deposits}
Total Borrows:
{total_borrows}
Borrow Limits:
{borrow_limits}
»
"""

48
mango/index.py Normal file
View File

@ -0,0 +1,48 @@
# # ⚠ 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 datetime
import logging
from decimal import Decimal
from .layouts import layouts
from .version import Version
# # 🥭 Index class
#
class Index:
def __init__(self, version: Version, last_update: datetime.datetime, borrow: Decimal, deposit: Decimal):
self.logger: logging.Logger = logging.getLogger(self.__class__.__name__)
self.version: Version = version
self.last_update: datetime.datetime = last_update
self.borrow: Decimal = borrow
self.deposit: Decimal = deposit
@staticmethod
def from_layout(layout: layouts.INDEX, decimals: Decimal) -> "Index":
borrow = layout.borrow / Decimal(10 ** decimals)
deposit = layout.deposit / Decimal(10 ** decimals)
return Index(Version.UNSPECIFIED, layout.last_update, borrow, deposit)
def __str__(self) -> str:
return f"« Index [{self.last_update}]: Borrow: {self.borrow:,.8f}, Deposit: {self.deposit:,.8f} »"
def __repr__(self) -> str:
return f"{self}"

446
mango/instructions.py Normal file
View File

@ -0,0 +1,446 @@
# # ⚠ 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 struct
import typing
from decimal import Decimal
from pyserum.market import Market
from solana.publickey import PublicKey
from solana.transaction import AccountMeta, TransactionInstruction
from solana.sysvar import SYSVAR_CLOCK_PUBKEY
from spl.token.constants import TOKEN_PROGRAM_ID
from .baskettoken import BasketToken
from .context import Context
from .group import Group
from .layouts import layouts
from .marginaccount import MarginAccount
from .marketmetadata import MarketMetadata
from .tokenaccount import TokenAccount
from .tokenvalue import TokenValue
from .wallet import Wallet
# 🥭 Instructions
#
# This notebook contains the low-level `InstructionBuilder`s that build the raw instructions
# to send to Solana.
#
# # 🥭 InstructionBuilder class
#
# An abstract base class for all our `InstructionBuilder`s.
#
class InstructionBuilder(metaclass=abc.ABCMeta):
def __init__(self, context: Context):
self.logger: logging.Logger = logging.getLogger(self.__class__.__name__)
self.context = context
@abc.abstractmethod
def build(self) -> TransactionInstruction:
raise NotImplementedError("InstructionBuilder.build() is not implemented on the base class.")
def __repr__(self) -> str:
return f"{self}"
# # 🥭 ForceCancelOrdersInstructionBuilder class
#
#
# ## Rust Interface
#
# This is what the `force_cancel_orders` instruction looks like in the [Mango Rust](https://github.com/blockworks-foundation/mango/blob/master/program/src/instruction.rs) code:
# ```
# pub fn force_cancel_orders(
# program_id: &Pubkey,
# mango_group_pk: &Pubkey,
# liqor_pk: &Pubkey,
# liqee_margin_account_acc: &Pubkey,
# base_vault_pk: &Pubkey,
# quote_vault_pk: &Pubkey,
# spot_market_pk: &Pubkey,
# bids_pk: &Pubkey,
# asks_pk: &Pubkey,
# signer_pk: &Pubkey,
# dex_event_queue_pk: &Pubkey,
# dex_base_pk: &Pubkey,
# dex_quote_pk: &Pubkey,
# dex_signer_pk: &Pubkey,
# dex_prog_id: &Pubkey,
# open_orders_pks: &[Pubkey],
# oracle_pks: &[Pubkey],
# limit: u8
# ) -> Result<Instruction, ProgramError>
# ```
#
# ## Client API call
#
# This is how it is built using the Mango Markets client API:
# ```
# const keys = [
# { isSigner: false, isWritable: true, pubkey: mangoGroup },
# { isSigner: true, isWritable: false, pubkey: liqor },
# { isSigner: false, isWritable: true, pubkey: liqeeMarginAccount },
# { isSigner: false, isWritable: true, pubkey: baseVault },
# { isSigner: false, isWritable: true, pubkey: quoteVault },
# { isSigner: false, isWritable: true, pubkey: spotMarket },
# { isSigner: false, isWritable: true, pubkey: bids },
# { isSigner: false, isWritable: true, pubkey: asks },
# { isSigner: false, isWritable: false, pubkey: signerKey },
# { isSigner: false, isWritable: true, pubkey: dexEventQueue },
# { isSigner: false, isWritable: true, pubkey: dexBaseVault },
# { isSigner: false, isWritable: true, pubkey: dexQuoteVault },
# { isSigner: false, isWritable: false, pubkey: dexSigner },
# { isSigner: false, isWritable: false, pubkey: TOKEN_PROGRAM_ID },
# { isSigner: false, isWritable: false, pubkey: dexProgramId },
# { isSigner: false, isWritable: false, pubkey: SYSVAR_CLOCK_PUBKEY },
# ...openOrders.map((pubkey) => ({
# isSigner: false,
# isWritable: true,
# pubkey,
# })),
# ...oracles.map((pubkey) => ({
# isSigner: false,
# isWritable: false,
# pubkey,
# })),
# ];
#
# const data = encodeMangoInstruction({ ForceCancelOrders: { limit } });
# return new TransactionInstruction({ keys, data, programId });
# ```
#
class ForceCancelOrdersInstructionBuilder(InstructionBuilder):
# We can create up to a maximum of max_instructions instructions. I'm not sure of the reason
# for this threshold but it's what's in the original liquidator source code and I'm assuming
# it's there for a good reason.
max_instructions: int = 10
# We cancel up to max_cancels_per_instruction orders with each instruction.
max_cancels_per_instruction: int = 5
def __init__(self, context: Context, group: Group, wallet: Wallet, margin_account: MarginAccount, market_metadata: MarketMetadata, market: Market, oracles: typing.List[PublicKey], dex_signer: PublicKey):
super().__init__(context)
self.group = group
self.wallet = wallet
self.margin_account = margin_account
self.market_metadata = market_metadata
self.market = market
self.oracles = oracles
self.dex_signer = dex_signer
def build(self) -> TransactionInstruction:
transaction = TransactionInstruction(
keys=[
AccountMeta(is_signer=False, is_writable=True, pubkey=self.group.address),
AccountMeta(is_signer=True, is_writable=False, pubkey=self.wallet.address),
AccountMeta(is_signer=False, is_writable=True, pubkey=self.margin_account.address),
AccountMeta(is_signer=False, is_writable=True, pubkey=self.market_metadata.base.vault),
AccountMeta(is_signer=False, is_writable=True, pubkey=self.market_metadata.quote.vault),
AccountMeta(is_signer=False, is_writable=True, pubkey=self.market_metadata.spot.address),
AccountMeta(is_signer=False, is_writable=True, pubkey=self.market.state.bids()),
AccountMeta(is_signer=False, is_writable=True, pubkey=self.market.state.asks()),
AccountMeta(is_signer=False, is_writable=False, pubkey=self.group.signer_key),
AccountMeta(is_signer=False, is_writable=True, pubkey=self.market.state.event_queue()),
AccountMeta(is_signer=False, is_writable=True, pubkey=self.market.state.base_vault()),
AccountMeta(is_signer=False, is_writable=True, pubkey=self.market.state.quote_vault()),
AccountMeta(is_signer=False, is_writable=False, pubkey=self.dex_signer),
AccountMeta(is_signer=False, is_writable=False, pubkey=TOKEN_PROGRAM_ID),
AccountMeta(is_signer=False, is_writable=False, pubkey=self.context.dex_program_id),
AccountMeta(is_signer=False, is_writable=False, pubkey=SYSVAR_CLOCK_PUBKEY),
*list([AccountMeta(is_signer=False, is_writable=True, pubkey=oo_address)
for oo_address in self.margin_account.open_orders]),
*list([AccountMeta(is_signer=False, is_writable=False, pubkey=oracle_address) for oracle_address in self.oracles])
],
program_id=self.context.program_id,
data=layouts.FORCE_CANCEL_ORDERS.build(
{"limit": ForceCancelOrdersInstructionBuilder.max_cancels_per_instruction})
)
self.logger.debug(f"Built transaction: {transaction}")
return transaction
@staticmethod
def from_margin_account_and_market(context: Context, group: Group, wallet: Wallet, margin_account: MarginAccount, market_metadata: MarketMetadata) -> "ForceCancelOrdersInstructionBuilder":
market = market_metadata.fetch_market(context)
nonce = struct.pack("<Q", market.state.vault_signer_nonce())
dex_signer = PublicKey.create_program_address(
[bytes(market_metadata.spot.address), nonce], context.dex_program_id)
oracles = list([mkt.oracle for mkt in group.markets])
return ForceCancelOrdersInstructionBuilder(context, group, wallet, margin_account, market_metadata, market, oracles, dex_signer)
@classmethod
def multiple_instructions_from_margin_account_and_market(cls, context: Context, group: Group, wallet: Wallet, margin_account: MarginAccount, market_metadata: MarketMetadata, at_least_this_many_cancellations: int) -> typing.List["ForceCancelOrdersInstructionBuilder"]:
logger: logging.Logger = logging.getLogger(cls.__name__)
# We cancel up to max_cancels_per_instruction orders with each instruction, but if
# we have more than cancel_limit we create more instructions (each handling up to
# 5 orders)
calculated_instruction_count = int(
at_least_this_many_cancellations / ForceCancelOrdersInstructionBuilder.max_cancels_per_instruction) + 1
# We create a maximum of max_instructions instructions.
instruction_count = min(calculated_instruction_count, ForceCancelOrdersInstructionBuilder.max_instructions)
instructions: typing.List[ForceCancelOrdersInstructionBuilder] = []
for counter in range(instruction_count):
instructions += [ForceCancelOrdersInstructionBuilder.from_margin_account_and_market(
context, group, wallet, margin_account, market_metadata)]
logger.debug(f"Built {len(instructions)} transaction(s).")
return instructions
def __str__(self) -> str:
# Print the members out using the Rust parameter order and names.
return f"""« ForceCancelOrdersInstructionBuilder:
program_id: &Pubkey: {self.context.program_id},
mango_group_pk: &Pubkey: {self.group.address},
liqor_pk: &Pubkey: {self.wallet.address},
liqee_margin_account_acc: &Pubkey: {self.margin_account.address},
base_vault_pk: &Pubkey: {self.market_metadata.base.vault},
quote_vault_pk: &Pubkey: {self.market_metadata.quote.vault},
spot_market_pk: &Pubkey: {self.market_metadata.spot.address},
bids_pk: &Pubkey: {self.market.state.bids()},
asks_pk: &Pubkey: {self.market.state.asks()},
signer_pk: &Pubkey: {self.group.signer_key},
dex_event_queue_pk: &Pubkey: {self.market.state.event_queue()},
dex_base_pk: &Pubkey: {self.market.state.base_vault()},
dex_quote_pk: &Pubkey: {self.market.state.quote_vault()},
dex_signer_pk: &Pubkey: {self.dex_signer},
dex_prog_id: &Pubkey: {self.context.dex_program_id},
open_orders_pks: &[Pubkey]: {self.margin_account.open_orders},
oracle_pks: &[Pubkey]: {self.oracles},
limit: u8: {ForceCancelOrdersInstructionBuilder.max_cancels_per_instruction}
»"""
# # 🥭 LiquidateInstructionBuilder class
#
# This is the `Instruction` we send to Solana to perform the (partial) liquidation.
#
# We take care to pass the proper high-level parameters to the `LiquidateInstructionBuilder`
# constructor so that `build_transaction()` is straightforward. That tends to push
# complexities to `from_margin_account_and_market()` though.
#
# ## Rust Interface
#
# This is what the `partial_liquidate` instruction looks like in the [Mango Rust](https://github.com/blockworks-foundation/mango/blob/master/program/src/instruction.rs) code:
# ```
# /// Take over a MarginAccount that is below init_coll_ratio by depositing funds
# ///
# /// Accounts expected by this instruction (10 + 2 * NUM_MARKETS):
# ///
# /// 0. `[writable]` mango_group_acc - MangoGroup that this margin account is for
# /// 1. `[signer]` liqor_acc - liquidator's solana account
# /// 2. `[writable]` liqor_in_token_acc - liquidator's token account to deposit
# /// 3. `[writable]` liqor_out_token_acc - liquidator's token account to withdraw into
# /// 4. `[writable]` liqee_margin_account_acc - MarginAccount of liquidatee
# /// 5. `[writable]` in_vault_acc - Mango vault of in_token
# /// 6. `[writable]` out_vault_acc - Mango vault of out_token
# /// 7. `[]` signer_acc
# /// 8. `[]` token_prog_acc - Token program id
# /// 9. `[]` clock_acc - Clock sysvar account
# /// 10..10+NUM_MARKETS `[]` open_orders_accs - open orders for each of the spot market
# /// 10+NUM_MARKETS..10+2*NUM_MARKETS `[]`
# /// oracle_accs - flux aggregator feed accounts
# ```
#
# ```
# pub fn partial_liquidate(
# program_id: &Pubkey,
# mango_group_pk: &Pubkey,
# liqor_pk: &Pubkey,
# liqor_in_token_pk: &Pubkey,
# liqor_out_token_pk: &Pubkey,
# liqee_margin_account_acc: &Pubkey,
# in_vault_pk: &Pubkey,
# out_vault_pk: &Pubkey,
# signer_pk: &Pubkey,
# open_orders_pks: &[Pubkey],
# oracle_pks: &[Pubkey],
# max_deposit: u64
# ) -> Result<Instruction, ProgramError>
# ```
#
# ## Client API call
#
# This is how it is built using the Mango Markets client API:
# ```
# const keys = [
# { isSigner: false, isWritable: true, pubkey: mangoGroup },
# { isSigner: true, isWritable: false, pubkey: liqor },
# { isSigner: false, isWritable: true, pubkey: liqorInTokenWallet },
# { isSigner: false, isWritable: true, pubkey: liqorOutTokenWallet },
# { isSigner: false, isWritable: true, pubkey: liqeeMarginAccount },
# { isSigner: false, isWritable: true, pubkey: inTokenVault },
# { isSigner: false, isWritable: true, pubkey: outTokenVault },
# { isSigner: false, isWritable: false, pubkey: signerKey },
# { isSigner: false, isWritable: false, pubkey: TOKEN_PROGRAM_ID },
# { isSigner: false, isWritable: false, pubkey: SYSVAR_CLOCK_PUBKEY },
# ...openOrders.map((pubkey) => ({
# isSigner: false,
# isWritable: false,
# pubkey,
# })),
# ...oracles.map((pubkey) => ({
# isSigner: false,
# isWritable: false,
# pubkey,
# })),
# ];
# const data = encodeMangoInstruction({ PartialLiquidate: { maxDeposit } });
#
# return new TransactionInstruction({ keys, data, programId });
# ```
#
# ## from_margin_account_and_market() function
#
# `from_margin_account_and_market()` merits a bit of explaining.
#
# `from_margin_account_and_market()` takes (among other things) a `Wallet` and a
# `MarginAccount`. The idea is that the `MarginAccount` has some assets in one token, and
# some liabilities in some different token.
#
# To liquidate the account, we want to:
# * supply tokens from the `Wallet` in the token currency that has the greatest liability
# value in the `MarginAccount`
# * receive tokens in the `Wallet` in the token currency that has the greatest asset value
# in the `MarginAccount`
#
# So we calculate the token currencies from the largest liabilities and assets in the
# `MarginAccount`, but we use those token types to get the correct `Wallet` accounts.
# * `input_token` is the `BasketToken` of the currency the `Wallet` is _paying_ and the
# `MarginAccount` is _receiving_ to pay off its largest liability.
# * `output_token` is the `BasketToken` of the currency the `Wallet` is _receiving_ and the
# `MarginAccount` is _paying_ from its largest asset.
#
class LiquidateInstructionBuilder(InstructionBuilder):
def __init__(self, context: Context, group: Group, wallet: Wallet, margin_account: MarginAccount, oracles: typing.List[PublicKey], input_token: BasketToken, output_token: BasketToken, wallet_input_token_account: TokenAccount, wallet_output_token_account: TokenAccount, maximum_input_amount: Decimal):
super().__init__(context)
self.group: Group = group
self.wallet: Wallet = wallet
self.margin_account: MarginAccount = margin_account
self.oracles: typing.List[PublicKey] = oracles
self.input_token: BasketToken = input_token
self.output_token: BasketToken = output_token
self.wallet_input_token_account: TokenAccount = wallet_input_token_account
self.wallet_output_token_account: TokenAccount = wallet_output_token_account
self.maximum_input_amount: Decimal = maximum_input_amount
def build(self) -> TransactionInstruction:
transaction = TransactionInstruction(
keys=[
AccountMeta(is_signer=False, is_writable=True, pubkey=self.group.address),
AccountMeta(is_signer=True, is_writable=False, pubkey=self.wallet.address),
AccountMeta(is_signer=False, is_writable=True, pubkey=self.wallet_input_token_account.address),
AccountMeta(is_signer=False, is_writable=True, pubkey=self.wallet_output_token_account.address),
AccountMeta(is_signer=False, is_writable=True, pubkey=self.margin_account.address),
AccountMeta(is_signer=False, is_writable=True, pubkey=self.input_token.vault),
AccountMeta(is_signer=False, is_writable=True, pubkey=self.output_token.vault),
AccountMeta(is_signer=False, is_writable=False, pubkey=self.group.signer_key),
AccountMeta(is_signer=False, is_writable=False, pubkey=TOKEN_PROGRAM_ID),
AccountMeta(is_signer=False, is_writable=False, pubkey=SYSVAR_CLOCK_PUBKEY),
*list([AccountMeta(is_signer=False, is_writable=True, pubkey=oo_address)
for oo_address in self.margin_account.open_orders]),
*list([AccountMeta(is_signer=False, is_writable=False, pubkey=oracle_address) for oracle_address in self.oracles])
],
program_id=self.context.program_id,
data=layouts.PARTIAL_LIQUIDATE.build({"max_deposit": int(self.maximum_input_amount)})
)
self.logger.debug(f"Built transaction: {transaction}")
return transaction
@classmethod
def from_margin_account_and_market(cls, context: Context, group: Group, wallet: Wallet, margin_account: MarginAccount, prices: typing.List[TokenValue]) -> typing.Optional["LiquidateInstructionBuilder"]:
logger: logging.Logger = logging.getLogger(cls.__name__)
oracles = list([mkt.oracle for mkt in group.markets])
balance_sheets = margin_account.get_priced_balance_sheets(group, prices)
sorted_by_assets = sorted(balance_sheets, key=lambda sheet: sheet.assets, reverse=True)
sorted_by_liabilities = sorted(balance_sheets, key=lambda sheet: sheet.liabilities, reverse=True)
most_assets = sorted_by_assets[0]
most_liabilities = sorted_by_liabilities[0]
if most_assets.token == most_liabilities.token:
# If there's a weirdness where the account with the biggest assets is also the one
# with the biggest liabilities, pick the next-best one by assets.
logger.info(
f"Switching asset token from {most_assets.token.name} to {sorted_by_assets[1].token.name} because {most_liabilities.token.name} is the token with most liabilities.")
most_assets = sorted_by_assets[1]
logger.info(f"Most assets: {most_assets}")
logger.info(f"Most liabilities: {most_liabilities}")
most_assets_basket_token = BasketToken.find_by_token(group.basket_tokens, most_assets.token)
most_liabilities_basket_token = BasketToken.find_by_token(group.basket_tokens, most_liabilities.token)
logger.info(f"Most assets basket token: {most_assets_basket_token}")
logger.info(f"Most liabilities basket token: {most_liabilities_basket_token}")
if most_assets.value == Decimal(0):
logger.warning(f"Margin account {margin_account.address} has no assets to take.")
return None
if most_liabilities.value == Decimal(0):
logger.warning(f"Margin account {margin_account.address} has no liabilities to fund.")
return None
wallet_input_token_account = TokenAccount.fetch_largest_for_owner_and_token(
context, wallet.address, most_liabilities.token)
if wallet_input_token_account is None:
raise Exception(f"Could not load wallet input token account for mint '{most_liabilities.token.mint}'")
if wallet_input_token_account.amount == Decimal(0):
logger.warning(
f"Wallet token account {wallet_input_token_account.address} has no tokens to send that could fund a liquidation.")
return None
wallet_output_token_account = TokenAccount.fetch_largest_for_owner_and_token(
context, wallet.address, most_assets.token)
if wallet_output_token_account is None:
raise Exception(f"Could not load wallet output token account for mint '{most_assets.token.mint}'")
return LiquidateInstructionBuilder(context, group, wallet, margin_account, oracles,
most_liabilities_basket_token, most_assets_basket_token,
wallet_input_token_account,
wallet_output_token_account,
wallet_input_token_account.amount)
def __str__(self) -> str:
# Print the members out using the Rust parameter order and names.
return f"""« LiquidateInstructionBuilder:
program_id: &Pubkey: {self.context.program_id},
mango_group_pk: &Pubkey: {self.group.address},
liqor_pk: &Pubkey: {self.wallet.address},
liqor_in_token_pk: &Pubkey: {self.wallet_input_token_account.address},
liqor_out_token_pk: &Pubkey: {self.wallet_output_token_account.address},
liqee_margin_account_acc: &Pubkey: {self.margin_account.address},
in_vault_pk: &Pubkey: {self.input_token.vault},
out_vault_pk: &Pubkey: {self.output_token.vault},
signer_pk: &Pubkey: {self.group.signer_key},
open_orders_pks: &[Pubkey]: {self.margin_account.open_orders},
oracle_pks: &[Pubkey]: {self.oracles},
max_deposit: u64: : {self.maximum_input_amount}
»"""

46
mango/instructiontype.py Normal file
View File

@ -0,0 +1,46 @@
# # ⚠ 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 enum
# # 🥭 InstructionType enum
#
# This `enum` encapsulates all current Mango Market instruction variants.
#
class InstructionType(enum.IntEnum):
InitMangoGroup = 0
InitMarginAccount = 1
Deposit = 2
Withdraw = 3
Borrow = 4
SettleBorrow = 5
Liquidate = 6
DepositSrm = 7
WithdrawSrm = 8
PlaceOrder = 9
SettleFunds = 10
CancelOrder = 11
CancelOrderByClientId = 12
ChangeBorrowLimit = 13
PlaceAndSettle = 14
ForceCancelOrders = 15
PartialLiquidate = 16
def __str__(self):
return self.name

View File

1006
mango/layouts/layouts.py Normal file

File diff suppressed because it is too large Load Diff

54
mango/liquidationevent.py Normal file
View File

@ -0,0 +1,54 @@
# # ⚠ 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 datetime
import typing
from solana.publickey import PublicKey
from .tokenvalue import TokenValue
# # 🥭 LiquidationEvent class
#
class LiquidationEvent:
def __init__(self, timestamp: datetime.datetime, liquidator_name: str, group_name: str, succeeded: bool, signature: str, wallet_address: PublicKey, margin_account_address: PublicKey, balances_before: typing.List[TokenValue], balances_after: typing.List[TokenValue]):
self.timestamp: datetime.datetime = timestamp
self.liquidator_name: str = liquidator_name
self.group_name: str = group_name
self.succeeded: bool = succeeded
self.signature: str = signature
self.wallet_address: PublicKey = wallet_address
self.margin_account_address: PublicKey = margin_account_address
self.balances_before: typing.List[TokenValue] = balances_before
self.balances_after: typing.List[TokenValue] = balances_after
self.changes: typing.List[TokenValue] = TokenValue.changes(balances_before, balances_after)
def __str__(self) -> str:
result = "" if self.succeeded else ""
changes_text = "\n ".join([f"{change.value:>15,.8f} {change.token.symbol}" for change in self.changes])
return f"""« 🥭 Liqudation Event {result} at {self.timestamp}
💧 Liquidator: {self.liquidator_name}
🗃 Group: {self.group_name}
📇 Signature: {self.signature}
👛 Wallet: {self.wallet_address}
💳 Margin Account: {self.margin_account_address}
💸 Changes:
{changes_text}
»"""
def __repr__(self) -> str:
return f"{self}"

View File

@ -0,0 +1,117 @@
# # ⚠ 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 logging
import time
import typing
from decimal import Decimal
from .accountliquidator import AccountLiquidator
from .context import Context
from .group import Group
from .liquidationevent import LiquidationEvent
from .marginaccount import MarginAccount, MarginAccountMetadata
from .observables import EventSource
from .tokenvalue import TokenValue
from .walletbalancer import WalletBalancer
# # 🥭 Liquidation Processor
#
# This file contains a liquidator processor that looks after the mechanics of liquidating an
# account.
#
# # 💧 LiquidationProcessor class
#
# An `AccountLiquidator` liquidates a `MarginAccount`. A `LiquidationProcessor` processes a
# list of `MarginAccount`s, determines if they're liquidatable, and calls an
# `AccountLiquidator` to do the work.
#
class LiquidationProcessor:
def __init__(self, context: Context, account_liquidator: AccountLiquidator, wallet_balancer: WalletBalancer, worthwhile_threshold: Decimal = Decimal("0.01")):
self.logger: logging.Logger = logging.getLogger(self.__class__.__name__)
self.context: Context = context
self.account_liquidator: AccountLiquidator = account_liquidator
self.wallet_balancer: WalletBalancer = wallet_balancer
self.worthwhile_threshold: Decimal = worthwhile_threshold
self.liquidations: EventSource[LiquidationEvent] = EventSource[LiquidationEvent]()
self.ripe_accounts: typing.Optional[typing.List[MarginAccount]] = None
def update_margin_accounts(self, ripe_margin_accounts: typing.List[MarginAccount]):
self.logger.info(f"Received {len(ripe_margin_accounts)} ripe 🥭 margin accounts to process.")
self.ripe_accounts = ripe_margin_accounts
def update_prices(self, group, prices):
started_at = time.time()
if self.ripe_accounts is None:
self.logger.info("Ripe accounts is None - skipping")
return
self.logger.info(f"Running on {len(self.ripe_accounts)} ripe accounts.")
updated: typing.List[MarginAccountMetadata] = []
for margin_account in self.ripe_accounts:
balance_sheet = margin_account.get_balance_sheet_totals(group, prices)
balances = margin_account.get_intrinsic_balances(group)
updated += [MarginAccountMetadata(margin_account, balance_sheet, balances)]
liquidatable = list(filter(lambda mam: mam.balance_sheet.collateral_ratio <= group.maint_coll_ratio, updated))
self.logger.info(f"Of those {len(updated)}, {len(liquidatable)} are liquidatable.")
above_water = list(filter(lambda mam: mam.collateral_ratio > 1, liquidatable))
self.logger.info(
f"Of those {len(liquidatable)} liquidatable margin accounts, {len(above_water)} are 'above water' margin accounts with assets greater than their liabilities.")
worthwhile = list(filter(lambda mam: mam.assets - mam.liabilities > self.worthwhile_threshold, above_water))
self.logger.info(
f"Of those {len(above_water)} above water margin accounts, {len(worthwhile)} are worthwhile margin accounts with more than ${self.worthwhile_threshold} net assets.")
self._liquidate_all(group, prices, worthwhile)
time_taken = time.time() - started_at
self.logger.info(f"Check of all ripe 🥭 accounts complete. Time taken: {time_taken:.2f} seconds.")
def _liquidate_all(self, group: Group, prices: typing.List[TokenValue], to_liquidate: typing.List[MarginAccountMetadata]):
to_process = to_liquidate
while len(to_process) > 0:
highest_first = sorted(to_process, key=lambda mam: mam.assets - mam.liabilities, reverse=True)
highest = highest_first[0]
try:
self.account_liquidator.liquidate(group, highest.margin_account, prices)
self.wallet_balancer.balance(prices)
updated_margin_account = MarginAccount.load(self.context, highest.margin_account.address, group)
balance_sheet = updated_margin_account.get_balance_sheet_totals(group, prices)
balances = updated_margin_account.get_intrinsic_balances(group)
updated_mam = MarginAccountMetadata(updated_margin_account, balance_sheet, balances)
if updated_mam.assets - updated_mam.liabilities > self.worthwhile_threshold:
self.logger.info(
f"Margin account {updated_margin_account.address} has been drained and is no longer worthwhile.")
else:
self.logger.info(
f"Margin account {updated_margin_account.address} is still worthwhile - putting it back on list.")
to_process += [updated_mam]
except Exception as exception:
self.logger.error(f"Failed to liquidate account '{highest.margin_account.address}' - {exception}")
finally:
# highest should always be in to_process, but we're outside the try-except block
# so let's be a little paranoid about it.
if highest in to_process:
to_process.remove(highest)

View File

@ -0,0 +1,53 @@
# # ⚠ 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 logging
import typing
from .version import Version
from .layouts import layouts
# # 🥭 MangoAccountFlags class
#
# The Mango prefix is because there's also `SerumAccountFlags` for the standard Serum flags.
#
class MangoAccountFlags:
def __init__(self, version: Version, initialized: bool, group: bool, margin_account: bool, srm_account: bool):
self.logger: logging.Logger = logging.getLogger(self.__class__.__name__)
self.version: Version = version
self.initialized = initialized
self.group = group
self.margin_account = margin_account
self.srm_account = srm_account
@staticmethod
def from_layout(layout: layouts.MANGO_ACCOUNT_FLAGS) -> "MangoAccountFlags":
return MangoAccountFlags(Version.UNSPECIFIED, layout.initialized, layout.group,
layout.margin_account, layout.srm_account)
def __str__(self) -> str:
flags: typing.List[typing.Optional[str]] = []
flags += ["initialized" if self.initialized else None]
flags += ["group" if self.group else None]
flags += ["margin_account" if self.margin_account else None]
flags += ["srm_account" if self.srm_account else None]
flag_text = " | ".join(flag for flag in flags if flag is not None) or "None"
return f"« MangoAccountFlags: {flag_text} »"
def __repr__(self) -> str:
return f"{self}"

409
mango/marginaccount.py Normal file
View File

@ -0,0 +1,409 @@
# # ⚠ 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[Decimal], borrows: typing.List[Decimal],
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[Decimal] = deposits
self.borrows: typing.List[Decimal] = 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) -> "MarginAccount":
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[Decimal] = []
for index, deposit in enumerate(layout.deposits):
deposits += [deposit]
borrows: typing.List[Decimal] = []
for index, borrow in enumerate(layout.borrows):
borrows += [borrow]
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) -> "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)
@staticmethod
def load(context: Context, margin_account_address: PublicKey, group: typing.Optional[Group] = None) -> "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)
if group is None:
group_context = context.new_from_group_id(margin_account.mango_group)
group = Group.load(group_context)
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)
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)
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 * self.deposits[index]
liabilities[index] = token.index.borrow * self.borrows[index]
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()
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)
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()
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 = ", ".join([f"{item:,.8f}" for item in self.deposits])
borrows = ", ".join([f"{item:,.8f}" 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

59
mango/marketmetadata.py Normal file
View File

@ -0,0 +1,59 @@
# # ⚠ 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 logging
from decimal import Decimal
from pyserum.market import Market
from solana.publickey import PublicKey
from .baskettoken import BasketToken
from .context import Context
from .spotmarket import SpotMarket
# # 🥭 MarketMetadata class
#
class MarketMetadata:
def __init__(self, name: str, address: PublicKey, base: BasketToken, quote: BasketToken,
spot: SpotMarket, oracle: PublicKey, decimals: Decimal):
self.logger: logging.Logger = logging.getLogger(self.__class__.__name__)
self.name: str = name
self.address: PublicKey = address
self.base: BasketToken = base
self.quote: BasketToken = quote
self.spot: SpotMarket = spot
self.oracle: PublicKey = oracle
self.decimals: Decimal = decimals
self._market = None
def fetch_market(self, context: Context) -> Market:
if self._market is None:
self._market = Market.load(context.client, self.spot.address)
return self._market
def __str__(self) -> str:
base = f"{self.base}".replace("\n", "\n ")
quote = f"{self.quote}".replace("\n", "\n ")
return f"""« Market '{self.name}' [{self.address}/{self.spot.address}]:
Base: {base}
Quote: {quote}
Oracle: {self.oracle} ({self.decimals} decimals)
»"""
def __repr__(self) -> str:
return f"{self}"

316
mango/notification.py Normal file
View File

@ -0,0 +1,316 @@
# # ⚠ 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 csv
import logging
import os.path
import requests
import typing
from urllib.parse import unquote
from .liquidationevent import LiquidationEvent
# # 🥭 Notification
#
# This file contains code to send arbitrary notifications.
#
# # 🥭 NotificationTarget class
#
# This base class is the root of the different notification mechanisms.
#
# Derived classes should override `send_notification()` to implement their own sending logic.
#
# Derived classes should not override `send()` since that is the interface outside classes call and it's used to ensure `NotificationTarget`s don't throw an exception when sending.
#
class NotificationTarget(metaclass=abc.ABCMeta):
def __init__(self):
self.logger: logging.Logger = logging.getLogger(self.__class__.__name__)
def send(self, item: typing.Any) -> None:
try:
self.send_notification(item)
except Exception as exception:
self.logger.error(f"Error sending {item} - {self} - {exception}")
@abc.abstractmethod
def send_notification(self, item: typing.Any) -> None:
raise NotImplementedError("NotificationTarget.send() is not implemented on the base type.")
def __repr__(self) -> str:
return f"{self}"
# # 🥭 TelegramNotificationTarget class
#
# The `TelegramNotificationTarget` sends messages to Telegram.
#
# The format for the telegram notification is:
# 1. The word 'telegram'
# 2. A colon ':'
# 3. The chat ID
# 4. An '@' symbol
# 5. The bot token
#
# For example:
# ```
# telegram:<CHAT-ID>@<BOT-TOKEN>
# ```
#
# The [Telegram instructions to create a bot](https://core.telegram.org/bots#creating-a-new-bot)
# show you how to create the bot token.
class TelegramNotificationTarget(NotificationTarget):
def __init__(self, address):
super().__init__()
chat_id, bot_id = address.split("@", 1)
self.chat_id = chat_id
self.bot_id = bot_id
def send_notification(self, item: typing.Any) -> None:
payload = {"disable_notification": True, "chat_id": self.chat_id, "text": str(item)}
url = f"https://api.telegram.org/bot{self.bot_id}/sendMessage"
headers = {"Content-Type": "application/json"}
requests.post(url, json=payload, headers=headers)
def __str__(self) -> str:
return f"Telegram chat ID: {self.chat_id}"
# # 🥭 DiscordNotificationTarget class
#
# The `DiscordNotificationTarget` sends messages to Discord.
#
class DiscordNotificationTarget(NotificationTarget):
def __init__(self, address):
super().__init__()
self.address = address
def send_notification(self, item: typing.Any) -> None:
payload = {
"content": str(item)
}
url = self.address
headers = {"Content-Type": "application/json"}
requests.post(url, json=payload, headers=headers)
def __str__(self) -> str:
return "Discord webhook"
# # 🥭 MailjetNotificationTarget class
#
# The `MailjetNotificationTarget` sends an email through [Mailjet](https://mailjet.com).
#
# In order to pass everything in to the notifier as a single string (needed to stop
# command-line parameters form getting messy), `MailjetNotificationTarget` requires a
# compound string, separated by colons.
# ```
# mailjet:<MAILJET-API-KEY>:<MAILJET-API-SECRET>:FROM-NAME:FROM-ADDRESS:TO-NAME:TO-ADDRESS
#
# ```
# Individual components are URL-encoded (so, for example, spaces are replaces with %20,
# colons are replaced with %3A).
#
# * `<MAILJET-API-KEY>` and `<MAILJET-API-SECRET>` are from your [Mailjet](https://mailjet.com) account.
# * `FROM-NAME` and `TO-NAME` are just text fields that are used as descriptors in the email messages.
# * `FROM-ADDRESS` is the address the email appears to come from. This must be validated with [Mailjet](https://mailjet.com).
# * `TO-ADDRESS` is the destination address - the email account to which the email is being sent.
#
# Mailjet provides a client library, but really we don't need or want more dependencies. This`
# code just replicates the `curl` way of doing things:
# ```
# curl -s \
# -X POST \
# --user "$MJ_APIKEY_PUBLIC:$MJ_APIKEY_PRIVATE" \
# https://api.mailjet.com/v3.1/send \
# -H 'Content-Type: application/json' \
# -d '{
# "SandboxMode":"true",
# "Messages":[
# {
# "From":[
# {
# "Email":"pilot@mailjet.com",
# "Name":"Your Mailjet Pilot"
# }
# ],
# "HTMLPart":"<h3>Dear passenger, welcome to Mailjet!</h3><br />May the delivery force be with you!",
# "Subject":"Your email flight plan!",
# "TextPart":"Dear passenger, welcome to Mailjet! May the delivery force be with you!",
# "To":[
# {
# "Email":"passenger@mailjet.com",
# "Name":"Passenger 1"
# }
# ]
# }
# ]
# }'
# ```
class MailjetNotificationTarget(NotificationTarget):
def __init__(self, encoded_parameters):
super().__init__()
self.address = "https://api.mailjet.com/v3.1/send"
api_key, api_secret, subject, from_name, from_address, to_name, to_address = encoded_parameters.split(":")
self.api_key: str = unquote(api_key)
self.api_secret: str = unquote(api_secret)
self.subject: str = unquote(subject)
self.from_name: str = unquote(from_name)
self.from_address: str = unquote(from_address)
self.to_name: str = unquote(to_name)
self.to_address: str = unquote(to_address)
def send_notification(self, item: typing.Any) -> None:
payload = {
"Messages": [
{
"From": {
"Email": self.from_address,
"Name": self.from_name
},
"Subject": self.subject,
"TextPart": str(item),
"To": [
{
"Email": self.to_address,
"Name": self.to_name
}
]
}
]
}
url = self.address
headers = {"Content-Type": "application/json"}
requests.post(url, json=payload, headers=headers, auth=(self.api_key, self.api_secret))
def __str__(self) -> str:
return f"Mailjet notifications to '{self.to_name}' '{self.to_address}' with subject '{self.subject}'"
# # 🥭 CsvFileNotificationTarget class
#
# Outputs a liquidation event to CSV. Nothing is written if the item is not a
# `LiquidationEvent`.
#
# Headers for the CSV file should be:
# ```
# "Timestamp","Liquidator Name","Group","Succeeded","Signature","Wallet","Margin Account","Token Changes"
# ```
# Token changes are listed as pairs of value plus symbol, so each token change adds two
# columns to the output. Token changes may arrive in different orders, so ordering of token
# changes is not guaranteed to be consistent from transaction to transaction.
#
class CsvFileNotificationTarget(NotificationTarget):
def __init__(self, filename):
super().__init__()
self.filename = filename
def send_notification(self, item: typing.Any) -> None:
if isinstance(item, LiquidationEvent):
event: LiquidationEvent = item
if not os.path.isfile(self.filename) or os.path.getsize(self.filename) == 0:
with open(self.filename, "w") as empty_file:
empty_file.write(
'"Timestamp","Liquidator Name","Group","Succeeded","Signature","Wallet","Margin Account","Token Changes"\n')
with open(self.filename, "a") as csvfile:
result = "Succeeded" if event.succeeded else "Failed"
row_data = [event.timestamp, event.liquidator_name, event.group_name, result,
event.signature, event.wallet_address, event.margin_account_address]
for change in event.changes:
row_data += [f"{change.value:.8f}", change.token.name]
file_writer = csv.writer(csvfile, quoting=csv.QUOTE_MINIMAL)
file_writer.writerow(row_data)
def __str__(self) -> str:
return f"CSV notifications to file {self.filename}"
# # 🥭 FilteringNotificationTarget class
#
# This class takes a `NotificationTarget` and a filter function, and only calls the
# `NotificationTarget` if the filter function returns `True` for the notification item.
#
class FilteringNotificationTarget(NotificationTarget):
def __init__(self, inner_notifier: NotificationTarget, filter_func: typing.Callable[[typing.Any], bool]):
super().__init__()
self.inner_notifier: NotificationTarget = inner_notifier
self.filter_func = filter_func
def send_notification(self, item: typing.Any) -> None:
if self.filter_func(item):
self.inner_notifier.send_notification(item)
def __str__(self) -> str:
return f"Filtering notification target for '{self.inner_notifier}'"
# # 🥭 NotificationHandler class
#
# A bridge between the worlds of notifications and logging. This allows any
# `NotificationTarget` to be plugged in to the `logging` subsystem to receive log messages
# and notify however it chooses.
#
class NotificationHandler(logging.StreamHandler):
def __init__(self, target: NotificationTarget):
logging.StreamHandler.__init__(self)
self.target = target
def emit(self, record):
message = self.format(record)
self.target.send_notification(message)
# # 🥭 parse_subscription_target() function
#
# `parse_subscription_target()` takes a parameter as a string and returns a notification
# target.
#
# This is most likely used when parsing command-line arguments - this function can be used
# in the `type` parameter of an `add_argument()` call.
#
def parse_subscription_target(target):
protocol, destination = target.split(":", 1)
if protocol == "telegram":
return TelegramNotificationTarget(destination)
elif protocol == "discord":
return DiscordNotificationTarget(destination)
elif protocol == "mailjet":
return MailjetNotificationTarget(destination)
elif protocol == "csvfile":
return CsvFileNotificationTarget(destination)
else:
raise Exception(f"Unknown protocol: {protocol}")

257
mango/observables.py Normal file
View File

@ -0,0 +1,257 @@
# # ⚠ 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 datetime
import logging
import rx
import rx.subject
import typing
from rxpy_backpressure import BackPressure
# # 🥭 Observables
#
# This notebook contains some useful shared tools to work with
# [RX Observables](https://rxpy.readthedocs.io/en/latest/reference_observable.html).
#
# # 🥭 PrintingObserverSubscriber class
#
# This class can subscribe to an `Observable` and print out each item.
#
class PrintingObserverSubscriber(rx.core.typing.Observer):
def __init__(self, report_no_output: bool) -> None:
super().__init__()
self.report_no_output = report_no_output
def on_next(self, item: typing.Any) -> None:
self.report_no_output = False
print(item)
def on_error(self, ex: Exception) -> None:
self.report_no_output = False
print(ex)
def on_completed(self) -> None:
if self.report_no_output:
print("No items to show.")
# # 🥭 TimestampedPrintingObserverSubscriber class
#
# Just like `PrintingObserverSubscriber` but it puts a timestamp on each printout.
#
class TimestampedPrintingObserverSubscriber(PrintingObserverSubscriber):
def __init__(self, report_no_output: bool) -> None:
super().__init__(report_no_output)
def on_next(self, item: typing.Any) -> None:
super().on_next(f"{datetime.datetime.now()}: {item}")
# # 🥭 CollectingObserverSubscriber class
#
# This class can subscribe to an `Observable` and collect each item.
#
class CollectingObserverSubscriber(rx.core.typing.Observer):
def __init__(self) -> None:
self.logger: logging.Logger = logging.getLogger(self.__class__.__name__)
self.collected: typing.List[typing.Any] = []
def on_next(self, item: typing.Any) -> None:
self.collected += [item]
def on_error(self, ex: Exception) -> None:
self.logger.error(f"Received error: {ex}")
def on_completed(self) -> None:
pass
# # 🥭 CaptureFirstItem class
#
# This captures the first item to pass through the pipeline, allowing it to be instpected
# later.
#
class CaptureFirstItem:
def __init__(self):
self.captured: typing.Any = None
self.has_captured: bool = False
def capture_if_first(self, item: typing.Any) -> typing.Any:
if not self.has_captured:
self.captured = item
self.has_captured = True
return item
# # 🥭 FunctionObserver
#
# This class takes functions for `on_next()`, `on_error()` and `on_completed()` and returns
# an `Observer` object.
#
# This is mostly for libraries (like `rxpy_backpressure`) that take observers but not their
# component functions.
#
class FunctionObserver(rx.core.typing.Observer):
def __init__(self,
on_next: typing.Callable[[typing.Any], None],
on_error: typing.Callable[[Exception], None] = lambda _: None,
on_completed: typing.Callable[[], None] = lambda: None):
self.logger: logging.Logger = logging.getLogger(self.__class__.__name__)
self._on_next = on_next
self._on_error = on_error
self._on_completed = on_completed
def on_next(self, value: typing.Any) -> None:
try:
self._on_next(value)
except Exception as exception:
self.logger.warning(f"on_next callable raised exception: {exception}")
def on_error(self, error: Exception) -> None:
try:
self._on_error(error)
except Exception as exception:
self.logger.warning(f"on_error callable raised exception: {exception}")
def on_completed(self) -> None:
try:
self._on_completed()
except Exception as exception:
self.logger.warning(f"on_completed callable raised exception: {exception}")
# # 🥭 create_backpressure_skipping_observer function
#
# Creates an `Observer` that skips inputs if they are building up while a subscriber works.
#
# This is useful for situations that, say, poll every second but the operation can sometimes
# take multiple seconds to complete. In that case, the latest item will be immediately
# emitted and the in-between items skipped.
#
def create_backpressure_skipping_observer(on_next: typing.Callable[[typing.Any], None], on_error: typing.Callable[[Exception], None] = lambda _: None, on_completed: typing.Callable[[], None] = lambda: None) -> rx.core.typing.Observer:
observer = FunctionObserver(on_next=on_next, on_error=on_error, on_completed=on_completed)
return BackPressure.LATEST(observer)
# # 🥭 debug_print_item function
#
# This is a handy item that can be added to a pipeline to show what is being passed at that particular stage. For example, this shows how to print the item before and after filtering:
# ```
# fetch().pipe(
# ops.map(debug_print_item("Unfiltered:")),
# ops.filter(lambda item: item.something is not None),
# ops.map(debug_print_item("Filtered:")),
# ops.filter(lambda item: item.something_else()),
# ops.map(act_on_item)
# ).subscribe(some_subscriber)
# ```
#
def debug_print_item(title: str) -> typing.Callable[[typing.Any], typing.Any]:
def _debug_print_item(item: typing.Any) -> typing.Any:
print(title, item)
return item
return _debug_print_item
# # 🥭 log_subscription_error function
#
# Logs subscription exceptions to the root logger.
#
def log_subscription_error(error: Exception) -> None:
logging.error(f"Observable subscription error: {error}")
# # 🥭 observable_pipeline_error_reporter function
#
# This intercepts and re-raises an exception, to help report on errors.
#
# RxPy pipelines are tricky to restart, so it's often easier to use the `ops.retry()`
# function in the pipeline. That just swallows the error though, so there's no way to know
# what was raised to cause the retry.
#
# Enter `observable_pipeline_error_reporter()`! Put it in a `catch` just before the `retry`
# and it should log the error properly.
#
# For example:
# ```
# from rx import of, operators as ops
#
# def raise_on_every_third(item):
# if (item % 3 == 0):
# raise Exception("Divisible by 3")
# else:
# return item
#
# sub1 = of(1, 2, 3, 4, 5, 6).pipe(
# ops.map(lambda e : raise_on_every_third(e)),
# ops.catch(observable_pipeline_error_reporter),
# ops.retry(3)
# )
# sub1.subscribe(lambda item: print(item), on_error = lambda error: print(f"Error : {error}"))
# ```
#
def observable_pipeline_error_reporter(ex, _):
logging.error(f"Intercepted error in observable pipeline: {ex}")
raise ex
# # 🥭 EventSource class
#
# A strongly(ish)-typed event source that can handle many subscribers.
#
TEventDatum = typing.TypeVar('TEventDatum')
class EventSource(rx.subject.Subject, typing.Generic[TEventDatum]):
def __init__(self) -> None:
super().__init__()
self.logger: logging.Logger = logging.getLogger(self.__class__.__name__)
def on_next(self, event: TEventDatum) -> None:
super().on_next(event)
def on_error(self, ex: Exception) -> None:
super().on_error(ex)
def on_completed(self) -> None:
super().on_completed()
def publish(self, event: TEventDatum) -> None:
try:
self.on_next(event)
except Exception as exception:
self.logger.warning(f"Failed to publish event '{event}' - {exception}")
def dispose(self) -> None:
super().dispose()

155
mango/openorders.py Normal file
View File

@ -0,0 +1,155 @@
# # ⚠ 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 typing
from decimal import Decimal
from pyserum.open_orders_account import OpenOrdersAccount
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 .context import Context
from .encoding import encode_key
from .group import Group
from .layouts import layouts
from .serumaccountflags import SerumAccountFlags
from .version import Version
# # 🥭 OpenOrders class
#
class OpenOrders(AddressableAccount):
def __init__(self, account_info: AccountInfo, version: Version, program_id: PublicKey,
account_flags: SerumAccountFlags, market: PublicKey, owner: PublicKey,
base_token_free: Decimal, base_token_total: Decimal, quote_token_free: Decimal,
quote_token_total: Decimal, free_slot_bits: Decimal, is_bid_bits: Decimal,
orders: typing.List[Decimal], client_ids: typing.List[Decimal],
referrer_rebate_accrued: Decimal):
super().__init__(account_info)
self.version: Version = version
self.program_id: PublicKey = program_id
self.account_flags: SerumAccountFlags = account_flags
self.market: PublicKey = market
self.owner: PublicKey = owner
self.base_token_free: Decimal = base_token_free
self.base_token_total: Decimal = base_token_total
self.quote_token_free: Decimal = quote_token_free
self.quote_token_total: Decimal = quote_token_total
self.free_slot_bits: Decimal = free_slot_bits
self.is_bid_bits: Decimal = is_bid_bits
self.orders: typing.List[Decimal] = orders
self.client_ids: typing.List[Decimal] = client_ids
self.referrer_rebate_accrued: Decimal = referrer_rebate_accrued
# Sometimes pyserum wants to take its own OpenOrdersAccount as a parameter (e.g. in settle_funds())
def to_pyserum(self) -> OpenOrdersAccount:
return OpenOrdersAccount.from_bytes(self.address, self.account_info.data)
@staticmethod
def from_layout(layout: layouts.OPEN_ORDERS, account_info: AccountInfo,
base_decimals: Decimal, quote_decimals: Decimal) -> "OpenOrders":
account_flags = SerumAccountFlags.from_layout(layout.account_flags)
program_id = account_info.owner
base_divisor = 10 ** base_decimals
quote_divisor = 10 ** quote_decimals
base_token_free: Decimal = layout.base_token_free / base_divisor
base_token_total: Decimal = layout.base_token_total / base_divisor
quote_token_free: Decimal = layout.quote_token_free / quote_divisor
quote_token_total: Decimal = layout.quote_token_total / quote_divisor
nonzero_orders: typing.List[Decimal] = list([order for order in layout.orders if order != 0])
nonzero_client_ids: typing.List[Decimal] = list(
[client_id for client_id in layout.client_ids if client_id != 0])
return OpenOrders(account_info, Version.UNSPECIFIED, program_id, account_flags, layout.market,
layout.owner, base_token_free, base_token_total, quote_token_free, quote_token_total,
layout.free_slot_bits, layout.is_bid_bits, nonzero_orders, nonzero_client_ids,
layout.referrer_rebate_accrued)
@staticmethod
def parse(account_info: AccountInfo, base_decimals: Decimal, quote_decimals: Decimal) -> "OpenOrders":
data = account_info.data
if len(data) != layouts.OPEN_ORDERS.sizeof():
raise Exception(f"Data length ({len(data)}) does not match expected size ({layouts.OPEN_ORDERS.sizeof()})")
layout = layouts.OPEN_ORDERS.parse(data)
return OpenOrders.from_layout(layout, account_info, base_decimals, quote_decimals)
@staticmethod
def load_raw_open_orders_account_infos(context: Context, group: Group) -> typing.Dict[str, AccountInfo]:
filters = [
MemcmpOpts(
offset=layouts.SERUM_ACCOUNT_FLAGS.sizeof() + 37,
bytes=encode_key(group.signer_key)
)
]
response = context.client.get_program_accounts(
group.dex_program_id, data_size=layouts.OPEN_ORDERS.sizeof(), memcmp_opts=filters, commitment=Single, encoding="base64")
account_infos = list(map(lambda pair: AccountInfo._from_response_values(pair[0], pair[1]), [
(result["account"], PublicKey(result["pubkey"])) for result in response["result"]]))
account_infos_by_address = {key: value for key, value in [
(str(account_info.address), account_info) for account_info in account_infos]}
return account_infos_by_address
@staticmethod
def load(context: Context, address: PublicKey, base_decimals: Decimal, quote_decimals: Decimal) -> "OpenOrders":
open_orders_account = AccountInfo.load(context, address)
if open_orders_account is None:
raise Exception(f"OpenOrders account not found at address '{address}'")
return OpenOrders.parse(open_orders_account, base_decimals, quote_decimals)
@staticmethod
def load_for_market_and_owner(context: Context, market: PublicKey, owner: PublicKey, program_id: PublicKey, base_decimals: Decimal, quote_decimals: Decimal):
filters = [
MemcmpOpts(
offset=layouts.SERUM_ACCOUNT_FLAGS.sizeof() + 5,
bytes=encode_key(market)
),
MemcmpOpts(
offset=layouts.SERUM_ACCOUNT_FLAGS.sizeof() + 37,
bytes=encode_key(owner)
)
]
response = context.client.get_program_accounts(
context.dex_program_id, data_size=layouts.OPEN_ORDERS.sizeof(), memcmp_opts=filters, commitment=Single, encoding="base64")
accounts = list(map(lambda pair: AccountInfo._from_response_values(pair[0], pair[1]), [
(result["account"], PublicKey(result["pubkey"])) for result in response["result"]]))
return list(map(lambda acc: OpenOrders.parse(acc, base_decimals, quote_decimals), accounts))
def __str__(self) -> str:
orders = ", ".join(map(str, self.orders)) or "None"
client_ids = ", ".join(map(str, self.client_ids)) or "None"
return f"""« OpenOrders:
Flags: {self.account_flags}
Program ID: {self.program_id}
Address: {self.address}
Market: {self.market}
Owner: {self.owner}
Base Token: {self.base_token_free:,.8f} of {self.base_token_total:,.8f}
Quote Token: {self.quote_token_free:,.8f} of {self.quote_token_total:,.8f}
Referrer Rebate Accrued: {self.referrer_rebate_accrued}
Orders:
{orders}
Client IDs:
{client_ids}
»"""

64
mango/ownedtokenvalue.py Normal file
View File

@ -0,0 +1,64 @@
# # ⚠ 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 logging
import typing
from solana.publickey import PublicKey
from .tokenvalue import TokenValue
# # 🥭 OwnedTokenValue class
#
# Ties an owner and `TokenValue` together. This is useful in the `TransactionScout`, where
# token mints and values are given separate from the owner `PublicKey` - we can package them
# together in this `OwnedTokenValue` class.
class OwnedTokenValue:
def __init__(self, owner: PublicKey, token_value: TokenValue):
self.logger: logging.Logger = logging.getLogger(self.__class__.__name__)
self.owner = owner
self.token_value = token_value
@staticmethod
def find_by_owner(values: typing.List["OwnedTokenValue"], owner: PublicKey) -> "OwnedTokenValue":
found = [value for value in values if value.owner == owner]
if len(found) == 0:
raise Exception(f"Owner '{owner}' not found in: {values}")
if len(found) > 1:
raise Exception(f"Owner '{owner}' matched multiple tokens in: {values}")
return found[0]
@staticmethod
def changes(before: typing.List["OwnedTokenValue"], after: typing.List["OwnedTokenValue"]) -> typing.List["OwnedTokenValue"]:
changes: typing.List[OwnedTokenValue] = []
for before_value in before:
after_value = OwnedTokenValue.find_by_owner(after, before_value.owner)
token_value = TokenValue(before_value.token_value.token,
after_value.token_value.value - before_value.token_value.value)
result = OwnedTokenValue(before_value.owner, token_value)
changes += [result]
return changes
def __str__(self) -> str:
return f"[{self.owner}]: {self.token_value}"
def __repr__(self) -> str:
return f"{self}"

102
mango/retrier.py Normal file
View File

@ -0,0 +1,102 @@
# # ⚠ 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 logging
import typing
import requests.exceptions
import time
from contextlib import contextmanager
from decimal import Decimal
# # 🥭 RetryWithPauses class
#
# This class takes a function and a list of durations to pause after a failed call.
#
# If the function succeeds, the resultant value is returned.
#
# If the function fails by raising an exception, the call pauses for the duration at the
# head of the list, then the head of the list is moved and the function is retried.
#
# It is retried up to the number of entries in the list of delays. If they all fail, the
# last failing exception is re-raised.
#
# This can be particularly helpful in cases where rate limits prevent further processing.
#
# This class is best used in a `with...` block using the `retry_context()` function below.
#
class RetryWithPauses:
def __init__(self, name: str, func: typing.Callable, pauses: typing.List[Decimal]) -> None:
self.logger: logging.Logger = logging.getLogger(self.__class__.__name__)
self.name: str = name
self.func: typing.Callable = func
self.pauses: typing.List[Decimal] = pauses
def run(self, *args):
captured_exception: Exception = None
for sleep_time_on_error in self.pauses:
try:
return self.func(*args)
except requests.exceptions.HTTPError as exception:
captured_exception = exception
if exception.response is not None:
# "You will see HTTP respose codes 429 for too many requests
# or 413 for too much bandwidth."
if exception.response.status_code == 413:
self.logger.info(
f"Retriable call [{self.name}] rate limited (too much bandwidth) with error '{exception}'.")
elif exception.response.status_code == 429:
self.logger.info(
f"Retriable call [{self.name}] rate limited (too many requests) with error '{exception}'.")
else:
self.logger.info(
f"Retriable call [{self.name}] failed with unexpected HTTP error '{exception}'.")
else:
self.logger.info(f"Retriable call [{self.name}] failed with unknown HTTP error '{exception}'.")
except Exception as exception:
self.logger.info(f"Retriable call failed [{self.name}] with error '{exception}'.")
captured_exception = exception
if sleep_time_on_error < 0:
self.logger.info(f"No more retries for [{self.name}] - propagating exception.")
raise captured_exception
self.logger.info(f"Will retry [{self.name}] call in {sleep_time_on_error} second(s).")
time.sleep(float(sleep_time_on_error))
self.logger.info(f"End of retry loop for [{self.name}] - propagating exception.")
raise captured_exception
# # 🥭 retry_context generator
#
# This is a bit of Python 'magic' to allow using the Retrier in a `with...` block.
#
# For example, this will call function `some_function(param1, param2)` up to `retry_count`
# times (7 in this case). It will only retry if the function throws an exception - the
# result of the first successful call is used to set the `result` variable:
# ```
# pauses = [Decimal(1), Decimal(2), Decimal(4)]
# with retry_context("Account Access", some_function, pauses) as retrier:
# result = retrier.run(param1, param2)
# ```
@contextmanager
def retry_context(name: str, func: typing.Callable, pauses: typing.List[Decimal]) -> typing.Iterator[RetryWithPauses]:
yield RetryWithPauses(name, func, pauses)

View File

@ -0,0 +1,63 @@
# # ⚠ 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 logging
import typing
from .layouts import layouts
from .version import Version
# # 🥭 SerumAccountFlags class
#
# The Serum prefix is because there's also `MangoAccountFlags` for the Mango-specific flags.
#
class SerumAccountFlags:
def __init__(self, version: Version, initialized: bool, market: bool, open_orders: bool,
request_queue: bool, event_queue: bool, bids: bool, asks: bool, disabled: bool):
self.logger: logging.Logger = logging.getLogger(self.__class__.__name__)
self.version: Version = version
self.initialized: bool = initialized
self.market: bool = market
self.open_orders: bool = open_orders
self.request_queue: bool = request_queue
self.event_queue: bool = event_queue
self.bids: bool = bids
self.asks: bool = asks
self.disabled: bool = disabled
@staticmethod
def from_layout(layout: layouts.SERUM_ACCOUNT_FLAGS) -> "SerumAccountFlags":
return SerumAccountFlags(Version.UNSPECIFIED, layout.initialized, layout.market,
layout.open_orders, layout.request_queue, layout.event_queue,
layout.bids, layout.asks, layout.disabled)
def __str__(self) -> str:
flags: typing.List[typing.Optional[str]] = []
flags += ["initialized" if self.initialized else None]
flags += ["market" if self.market else None]
flags += ["open_orders" if self.open_orders else None]
flags += ["request_queue" if self.request_queue else None]
flags += ["event_queue" if self.event_queue else None]
flags += ["bids" if self.bids else None]
flags += ["asks" if self.asks else None]
flags += ["disabled" if self.disabled else None]
flag_text = " | ".join(flag for flag in flags if flag is not None) or "None"
return f"« SerumAccountFlags: {flag_text} »"
def __repr__(self) -> str:
return f"{self}"

163
mango/spotmarket.py Normal file
View File

@ -0,0 +1,163 @@
# # ⚠ 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 json
import logging
import typing
from decimal import Decimal
from solana.publickey import PublicKey
from .token import Token
# # 🥭 SpotMarket class
#
# This class encapsulates our knowledge of a Serum spot market.
#
class SpotMarket:
def __init__(self, address: PublicKey, base: Token, quote: Token):
self.logger: logging.Logger = logging.getLogger(self.__class__.__name__)
self.address: PublicKey = address
self.base: Token = base
self.quote: Token = quote
@property
def name(self) -> str:
return f"{self.base.symbol}/{self.quote.symbol}"
def __str__(self) -> str:
return f"« Market {self.name}: {self.address} »"
def __repr__(self) -> str:
return f"{self}"
# # 🥭 SpotMarketLookup class
#
# This class allows us to look up Serum market addresses and tokens, all from our Solana
# static data.
#
# The static data is the [Solana token list](https://raw.githubusercontent.com/solana-labs/token-list/main/src/tokens/solana.tokenlist.json) provided by Serum.
#
# You can load a `SpotMarketLookup` class by something like:
# ```
# with open("solana.tokenlist.json") as json_file:
# token_data = json.load(json_file)
# spot_market_lookup = SpotMarketLookup(token_data)
# ```
# This uses the same data file as `TokenLookup` but it looks a lot more complicated. The
# main reason for this is that tokens are described in a list, whereas markets are optional
# child attributes of tokens.
#
# To find a token, we can just go through the list.
#
# To find a market, we need to split the market symbol into the two token symbols, go through
# the list, check if the item has the optional `extensions` attribute, and in there see if
# there is a name-value pair for the particular market we're interested in. Also, the
# current file only lists USDC and USDT markets, so that's all we can support this way.
class SpotMarketLookup:
@staticmethod
def _find_data_by_symbol(symbol: str, token_data: typing.Dict) -> typing.Optional[typing.Dict]:
for token in token_data["tokens"]:
if token["symbol"] == symbol:
return token
return None
def __init__(self, token_data: typing.Dict) -> None:
self.logger: logging.Logger = logging.getLogger(self.__class__.__name__)
self.token_data: typing.Dict = token_data
def find_by_symbol(self, symbol: str) -> typing.Optional[SpotMarket]:
base_symbol, quote_symbol = symbol.split("/")
base_data = SpotMarketLookup._find_data_by_symbol(base_symbol, self.token_data)
if base_data is None:
self.logger.warning(f"Could not find data for base token '{base_symbol}'")
return None
base = Token(base_data["symbol"], base_data["name"], PublicKey(
base_data["address"]), Decimal(base_data["decimals"]))
quote_data = SpotMarketLookup._find_data_by_symbol(quote_symbol, self.token_data)
if quote_data is None:
self.logger.warning(f"Could not find data for quote token '{quote_symbol}'")
return None
quote = Token(quote_data["symbol"], quote_data["name"], PublicKey(
quote_data["address"]), Decimal(quote_data["decimals"]))
if "extensions" not in base_data:
self.logger.warning(f"No markets found for base token '{base.symbol}'.")
return None
if quote.symbol == "USDC":
if "serumV3Usdc" not in base_data["extensions"]:
self.logger.warning(f"No USDC market found for base token '{base.symbol}'.")
return None
market_address_string = base_data["extensions"]["serumV3Usdc"]
market_address = PublicKey(market_address_string)
elif quote.symbol == "USDT":
if "serumV3Usdt" not in base_data["extensions"]:
self.logger.warning(f"No USDT market found for base token '{base.symbol}'.")
return None
market_address_string = base_data["extensions"]["serumV3Usdt"]
market_address = PublicKey(market_address_string)
else:
self.logger.warning(
f"Could not find market with quote token '{quote.symbol}'. Only markets based on USDC or USDT are supported.")
return None
return SpotMarket(market_address, base, quote)
def find_by_address(self, address: PublicKey) -> typing.Optional[SpotMarket]:
address_string: str = str(address)
for token_data in self.token_data["tokens"]:
if "extensions" in token_data:
if "serumV3Usdc" in token_data["extensions"]:
if token_data["extensions"]["serumV3Usdc"] == address_string:
market_address_string = token_data["extensions"]["serumV3Usdc"]
market_address = PublicKey(market_address_string)
base = Token(token_data["symbol"], token_data["name"], PublicKey(
token_data["address"]), Decimal(token_data["decimals"]))
quote_data = SpotMarketLookup._find_data_by_symbol("USDC", self.token_data)
if quote_data is None:
raise Exception("Could not load token data for USDC (which should always be present).")
quote = Token(quote_data["symbol"], quote_data["name"], PublicKey(
quote_data["address"]), Decimal(quote_data["decimals"]))
return SpotMarket(market_address, base, quote)
if "serumV3Usdt" in token_data["extensions"]:
if token_data["extensions"]["serumV3Usdt"] == address_string:
market_address_string = token_data["extensions"]["serumV3Usdt"]
market_address = PublicKey(market_address_string)
base = Token(token_data["symbol"], token_data["name"], PublicKey(
token_data["address"]), Decimal(token_data["decimals"]))
quote_data = SpotMarketLookup._find_data_by_symbol("USDT", self.token_data)
if quote_data is None:
raise Exception("Could not load token data for USDT (which should always be present).")
quote = Token(quote_data["symbol"], quote_data["name"], PublicKey(
quote_data["address"]), Decimal(quote_data["decimals"]))
return SpotMarket(market_address, base, quote)
return None
@staticmethod
def default_lookups() -> "SpotMarketLookup":
with open("solana.tokenlist.json") as json_file:
token_data = json.load(json_file)
return SpotMarketLookup(token_data)

137
mango/token.py Normal file
View File

@ -0,0 +1,137 @@
# # ⚠ 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 json
import logging
import typing
from decimal import Decimal
from solana.publickey import PublicKey
from .constants import SOL_DECIMALS, SOL_MINT_ADDRESS
# # 🥭 Token class
#
# `Token` defines aspects common to every token.
#
class Token:
def __init__(self, symbol: str, name: str, mint: PublicKey, decimals: Decimal):
self.logger: logging.Logger = logging.getLogger(self.__class__.__name__)
self.symbol: str = symbol.upper()
self.name: str = name
self.mint: PublicKey = mint
self.decimals: Decimal = decimals
def round(self, value: Decimal) -> Decimal:
return round(value, int(self.decimals))
def symbol_matches(self, symbol: str) -> bool:
return self.symbol.upper() == symbol.upper()
@staticmethod
def find_by_symbol(values: typing.List["Token"], symbol: str) -> "Token":
found = [value for value in values if value.symbol_matches(symbol)]
if len(found) == 0:
raise Exception(f"Token '{symbol}' not found in token values: {values}")
if len(found) > 1:
raise Exception(f"Token '{symbol}' matched multiple tokens in values: {values}")
return found[0]
@staticmethod
def find_by_mint(values: typing.List["Token"], mint: PublicKey) -> "Token":
found = [value for value in values if value.mint == mint]
if len(found) == 0:
raise Exception(f"Token '{mint}' not found in token values: {values}")
if len(found) > 1:
raise Exception(f"Token '{mint}' matched multiple tokens in values: {values}")
return found[0]
# TokenMetadatas are equal if they have the same mint address.
def __eq__(self, other):
if hasattr(other, 'mint'):
return self.mint == other.mint
return False
def __str__(self) -> str:
return f"« Token '{self.name}' [{self.mint} ({self.decimals} decimals)] »"
def __repr__(self) -> str:
return f"{self}"
# # 🥭 SolToken object
#
# It's sometimes handy to have a `Token` for SOL, but SOL isn't actually a token and can't appear in baskets. This object defines a special case for SOL.
#
SolToken = Token("SOL", "Pure SOL", SOL_MINT_ADDRESS, SOL_DECIMALS)
# # 🥭 TokenLookup class
#
# This class allows us to look up token symbols, names, mint addresses and decimals, all from our Solana static data.
#
# The `_find_data_by_symbol()` is used here and later in the `SpotMarketLookup` class.
#
# The static data is the [Solana token list](https://raw.githubusercontent.com/solana-labs/token-list/main/src/tokens/solana.tokenlist.json) provided by Serum.
#
# You can load a `TokenLookup` class by something like:
# ```
# with open("solana.tokenlist.json") as json_file:
# token_data = json.load(json_file)
# token_lookup = TokenLookup(token_data)
# ```
class TokenLookup:
@staticmethod
def _find_data_by_symbol(symbol: str, token_data: typing.Dict) -> typing.Optional[typing.Dict]:
for token in token_data["tokens"]:
if token["symbol"] == symbol:
return token
return None
def __init__(self, token_data: typing.Dict) -> None:
self.logger: logging.Logger = logging.getLogger(self.__class__.__name__)
self.token_data = token_data
def find_by_symbol(self, symbol: str):
found = TokenLookup._find_data_by_symbol(symbol, self.token_data)
if found is not None:
return Token(found["symbol"], found["name"], PublicKey(found["address"]), Decimal(found["decimals"]))
return None
def find_by_mint(self, mint: PublicKey):
mint_string: str = str(mint)
for token in self.token_data["tokens"]:
if token["address"] == mint_string:
return Token(token["symbol"], token["name"], PublicKey(token["address"]), Decimal(token["decimals"]))
return None
@staticmethod
def default_lookups() -> "TokenLookup":
with open("solana.tokenlist.json") as json_file:
token_data = json.load(json_file)
return TokenLookup(token_data)

115
mango/tokenaccount.py Normal file
View File

@ -0,0 +1,115 @@
# # ⚠ 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 typing
from decimal import Decimal
from solana.account import Account
from solana.publickey import PublicKey
from solana.rpc.types import TokenAccountOpts
from spl.token.client import Token as SplToken
from spl.token.constants import TOKEN_PROGRAM_ID
from .accountinfo import AccountInfo
from .addressableaccount import AddressableAccount
from .context import Context
from .layouts import layouts
from .token import Token
from .version import Version
# # 🥭 TokenAccount class
#
class TokenAccount(AddressableAccount):
def __init__(self, account_info: AccountInfo, version: Version, mint: PublicKey, owner: PublicKey, amount: Decimal):
super().__init__(account_info)
self.version: Version = version
self.mint: PublicKey = mint
self.owner: PublicKey = owner
self.amount: Decimal = amount
@staticmethod
def create(context: Context, account: Account, token: Token):
spl_token = SplToken(context.client, token.mint, TOKEN_PROGRAM_ID, account)
owner = account.public_key()
new_account_address = spl_token.create_account(owner)
return TokenAccount.load(context, new_account_address)
@staticmethod
def fetch_all_for_owner_and_token(context: Context, owner_public_key: PublicKey, token: Token) -> typing.List["TokenAccount"]:
opts = TokenAccountOpts(mint=token.mint)
token_accounts_response = context.client.get_token_accounts_by_owner(
owner_public_key, opts, commitment=context.commitment)
all_accounts: typing.List[TokenAccount] = []
for token_account_response in token_accounts_response["result"]["value"]:
account_info = AccountInfo._from_response_values(
token_account_response["account"], PublicKey(token_account_response["pubkey"]))
token_account = TokenAccount.parse(account_info)
all_accounts += [token_account]
return all_accounts
@staticmethod
def fetch_largest_for_owner_and_token(context: Context, owner_public_key: PublicKey, token: Token) -> typing.Optional["TokenAccount"]:
all_accounts = TokenAccount.fetch_all_for_owner_and_token(context, owner_public_key, token)
largest_account: typing.Optional[TokenAccount] = None
for token_account in all_accounts:
if largest_account is None or token_account.amount > largest_account.amount:
largest_account = token_account
return largest_account
@staticmethod
def fetch_or_create_largest_for_owner_and_token(context: Context, account: Account, token: Token) -> "TokenAccount":
all_accounts = TokenAccount.fetch_all_for_owner_and_token(context, account.public_key(), token)
largest_account: typing.Optional[TokenAccount] = None
for token_account in all_accounts:
if largest_account is None or token_account.amount > largest_account.amount:
largest_account = token_account
if largest_account is None:
return TokenAccount.create(context, account, token)
return largest_account
@staticmethod
def from_layout(layout: layouts.TOKEN_ACCOUNT, account_info: AccountInfo) -> "TokenAccount":
return TokenAccount(account_info, Version.UNSPECIFIED, layout.mint, layout.owner, layout.amount)
@staticmethod
def parse(account_info: AccountInfo) -> "TokenAccount":
data = account_info.data
if len(data) != layouts.TOKEN_ACCOUNT.sizeof():
raise Exception(
f"Data length ({len(data)}) does not match expected size ({layouts.TOKEN_ACCOUNT.sizeof()})")
layout = layouts.TOKEN_ACCOUNT.parse(data)
return TokenAccount.from_layout(layout, account_info)
@staticmethod
def load(context: Context, address: PublicKey) -> typing.Optional["TokenAccount"]:
account_info = AccountInfo.load(context, address)
if account_info is None or (len(account_info.data) != layouts.TOKEN_ACCOUNT.sizeof()):
return None
return TokenAccount.parse(account_info)
def __str__(self) -> str:
return f"« Token: Address: {self.address}, Mint: {self.mint}, Owner: {self.owner}, Amount: {self.amount} »"

112
mango/tokenvalue.py Normal file
View File

@ -0,0 +1,112 @@
# # ⚠ 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 logging
import typing
from decimal import Decimal
from solana.publickey import PublicKey
from solana.rpc.types import TokenAccountOpts
from .context import Context
from .token import Token
# # 🥭 TokenValue class
#
# The `TokenValue` class is a simple way of keeping a token and value together, and
# displaying them nicely consistently.
#
class TokenValue:
def __init__(self, token: Token, value: Decimal):
self.logger: logging.Logger = logging.getLogger(self.__class__.__name__)
self.token = token
self.value = value
@staticmethod
def fetch_total_value_or_none(context: Context, account_public_key: PublicKey, token: Token) -> typing.Optional["TokenValue"]:
opts = TokenAccountOpts(mint=token.mint)
token_accounts_response = context.client.get_token_accounts_by_owner(
account_public_key, opts, commitment=context.commitment)
token_accounts = token_accounts_response["result"]["value"]
if len(token_accounts) == 0:
return None
total_value = Decimal(0)
for token_account in token_accounts:
result = context.client.get_token_account_balance(token_account["pubkey"], commitment=context.commitment)
value = Decimal(result["result"]["value"]["amount"])
decimal_places = result["result"]["value"]["decimals"]
divisor = Decimal(10 ** decimal_places)
total_value += value / divisor
return TokenValue(token, total_value)
@staticmethod
def fetch_total_value(context: Context, account_public_key: PublicKey, token: Token) -> "TokenValue":
value = TokenValue.fetch_total_value_or_none(context, account_public_key, token)
if value is None:
return TokenValue(token, Decimal(0))
return value
@staticmethod
def report(reporter: typing.Callable[[str], None], values: typing.List["TokenValue"]) -> None:
for value in values:
reporter(f"{value.value:>18,.8f} {value.token.name}")
@staticmethod
def find_by_symbol(values: typing.List["TokenValue"], symbol: str) -> "TokenValue":
found = [value for value in values if value.token.symbol_matches(symbol)]
if len(found) == 0:
raise Exception(f"Token '{symbol}' not found in token values: {values}")
if len(found) > 1:
raise Exception(f"Token '{symbol}' matched multiple tokens in values: {values}")
return found[0]
@staticmethod
def find_by_mint(values: typing.List["TokenValue"], mint: PublicKey) -> "TokenValue":
found = [value for value in values if value.token.mint == mint]
if len(found) == 0:
raise Exception(f"Token '{mint}' not found in token values: {values}")
if len(found) > 1:
raise Exception(f"Token '{mint}' matched multiple tokens in values: {values}")
return found[0]
@staticmethod
def find_by_token(values: typing.List["TokenValue"], token: Token) -> "TokenValue":
return TokenValue.find_by_mint(values, token.mint)
@staticmethod
def changes(before: typing.List["TokenValue"], after: typing.List["TokenValue"]) -> typing.List["TokenValue"]:
changes: typing.List[TokenValue] = []
for before_balance in before:
after_balance = TokenValue.find_by_token(after, before_balance.token)
result = TokenValue(before_balance.token, after_balance.value - before_balance.value)
changes += [result]
return changes
def __str__(self) -> str:
return f"« TokenValue: {self.value:>18,.8f} {self.token.name} »"
def __repr__(self) -> str:
return f"{self}"

317
mango/tradeexecutor.py Normal file
View File

@ -0,0 +1,317 @@
# # ⚠ 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 rx
import rx.operators as ops
import typing
from decimal import Decimal
from pyserum.enums import OrderType, Side
from pyserum.market import Market
from solana.account import Account
from solana.publickey import PublicKey
from .context import Context
from .openorders import OpenOrders
from .retrier import retry_context
from .spotmarket import SpotMarket, SpotMarketLookup
from .token import Token
from .tokenaccount import TokenAccount
from .wallet import Wallet
# # 🥭 TradeExecutor
#
# This file deals with executing trades. We want the interface to be as simple as:
# ```
# trade_executor.buy("ETH", 2.5)
# ```
# but this (necessarily) masks a great deal of complexity. The aim is to keep the complexity
# around trades within these `TradeExecutor` classes.
#
# # 🥭 TradeExecutor class
#
# This abstracts the process of placing trades, based on our typed objects.
#
# It's abstracted because we may want to have different approaches to executing these
# trades - do we want to run them against the Serum orderbook? Would it be faster if we
# ran them against Raydium?
#
# Whichever choice is made, the calling code shouldn't have to care. It should be able to
# use its `TradeExecutor` class as simply as:
# ```
# trade_executor.buy("ETH", 2.5)
# ```
#
class TradeExecutor(metaclass=abc.ABCMeta):
def __init__(self):
self.logger: logging.Logger = logging.getLogger(self.__class__.__name__)
@abc.abstractmethod
def buy(self, symbol: str, quantity: Decimal):
raise NotImplementedError("TradeExecutor.buy() is not implemented on the base type.")
@abc.abstractmethod
def sell(self, symbol: str, quantity: Decimal):
raise NotImplementedError("TradeExecutor.sell() is not implemented on the base type.")
@abc.abstractmethod
def settle(self, spot_market: SpotMarket, market: Market) -> typing.List[str]:
raise NotImplementedError("TradeExecutor.settle() is not implemented on the base type.")
@abc.abstractmethod
def wait_for_settlement_completion(self, settlement_transaction_ids: typing.List[str]):
raise NotImplementedError("TradeExecutor.wait_for_settlement_completion() is not implemented on the base type.")
# # 🥭 NullTradeExecutor class
#
# A null, no-op, dry-run trade executor that can be plugged in anywhere a `TradeExecutor`
# is expected, but which will not actually trade.
#
class NullTradeExecutor(TradeExecutor):
def __init__(self, reporter: typing.Callable[[str], None] = None):
super().__init__()
self.reporter = reporter or (lambda _: None)
def buy(self, symbol: str, quantity: Decimal):
self.logger.info(f"Skipping BUY trade of {quantity:,.8f} of '{symbol}'.")
self.reporter(f"Skipping BUY trade of {quantity:,.8f} of '{symbol}'.")
def sell(self, symbol: str, quantity: Decimal):
self.logger.info(f"Skipping SELL trade of {quantity:,.8f} of '{symbol}'.")
self.reporter(f"Skipping SELL trade of {quantity:,.8f} of '{symbol}'.")
def settle(self, spot_market: SpotMarket, market: Market) -> typing.List[str]:
self.logger.info(
f"Skipping settling of '{spot_market.base.name}' and '{spot_market.quote.name}' in market {spot_market.address}.")
self.reporter(
f"Skipping settling of '{spot_market.base.name}' and '{spot_market.quote.name}' in market {spot_market.address}.")
return []
def wait_for_settlement_completion(self, settlement_transaction_ids: typing.List[str]):
self.logger.info("Skipping waiting for settlement.")
self.reporter("Skipping waiting for settlement.")
# # 🥭 SerumImmediateTradeExecutor class
#
# This class puts an IOC trade on the Serum orderbook with the expectation it will be filled
# immediately.
#
# The process the `SerumImmediateTradeExecutor` follows to place a trade is:
# * Call `place_order()` with the order details plus a random `client_id`,
# * Wait for the `client_id` to appear as a 'fill' in the market's 'event queue',
# * Call `settle_funds()` to move the trade result funds back into the wallet,
# * Wait for the `settle_funds()` transaction ID to be confirmed.
#
# The SerumImmediateTradeExecutor constructor takes a `price_adjustment_factor` to allow
# moving the price it is willing to pay away from the mid-price. Testing shows the price is
# filled at the orderbook price if the price we specify is worse, so it looks like it's
# possible to be quite liberal with this adjustment. In a live test:
# * Original wallet USDT value was 342.8606.
# * `price_adjustment_factor` was 0.05.
# * ETH price was 2935.14 USDT (on 2021-05-02).
# * Adjusted price was 3081.897 USDT, adjusted by 1.05 from 2935.14
# * Buying 0.1 ETH specifying 3081.897 as the price resulted in:
# * Buying 0.1 ETH
# * Spending 294.1597 USDT
# * After settling, the wallet should hold 342.8606 USDT - 294.1597 USDT = 48.7009 USDT
# * The wallet did indeed hold 48.7009 USDT
#
# So: the specified BUY price of 3081.897 USDT was taken as a maximum, and orders were taken
# from the orderbook starting at the current cheapest, until the order was filled or (I'm
# assuming) the price exceeded the price specified.
#
class SerumImmediateTradeExecutor(TradeExecutor):
def __init__(self, context: Context, wallet: Wallet, spot_market_lookup: SpotMarketLookup, price_adjustment_factor: Decimal = Decimal(0), reporter: typing.Callable[[str], None] = None):
super().__init__()
self.context: Context = context
self.wallet: Wallet = wallet
self.spot_market_lookup: SpotMarketLookup = spot_market_lookup
self.price_adjustment_factor: Decimal = price_adjustment_factor
def report(text):
self.logger.info(text)
reporter(text)
def just_log(text):
self.logger.info(text)
if reporter is not None:
self.reporter = report
else:
self.reporter = just_log
def buy(self, symbol: str, quantity: Decimal):
spot_market = self._lookup_spot_market(symbol)
market = Market.load(self.context.client, spot_market.address)
self.reporter(f"BUY order market: {spot_market.address} {market}")
asks = market.load_asks()
top_ask = next(asks.orders())
top_price = Decimal(top_ask.info.price)
increase_factor = Decimal(1) + self.price_adjustment_factor
price = top_price * increase_factor
self.reporter(f"Price {price} - adjusted by {self.price_adjustment_factor} from {top_price}")
source_token_account = TokenAccount.fetch_largest_for_owner_and_token(
self.context, self.wallet.address, spot_market.quote)
self.reporter(f"Source token account: {source_token_account}")
if source_token_account is None:
raise Exception(f"Could not find source token account for '{spot_market.quote}'")
self._execute(
spot_market,
market,
Side.BUY,
source_token_account,
spot_market.base,
spot_market.quote,
price,
quantity
)
def sell(self, symbol: str, quantity: Decimal):
spot_market = self._lookup_spot_market(symbol)
market = Market.load(self.context.client, spot_market.address)
self.reporter(f"SELL order market: {spot_market.address} {market}")
bids = market.load_bids()
bid_orders = list(bids.orders())
top_bid = bid_orders[len(bid_orders) - 1]
top_price = Decimal(top_bid.info.price)
decrease_factor = Decimal(1) - self.price_adjustment_factor
price = top_price * decrease_factor
self.reporter(f"Price {price} - adjusted by {self.price_adjustment_factor} from {top_price}")
source_token_account = TokenAccount.fetch_largest_for_owner_and_token(
self.context, self.wallet.address, spot_market.base)
self.reporter(f"Source token account: {source_token_account}")
if source_token_account is None:
raise Exception(f"Could not find source token account for '{spot_market.base}'")
self._execute(
spot_market,
market,
Side.SELL,
source_token_account,
spot_market.base,
spot_market.quote,
price,
quantity
)
def _execute(self, spot_market: SpotMarket, market: Market, side: Side, source_token_account: TokenAccount, base_token: Token, quote_token: Token, price: Decimal, quantity: Decimal):
with retry_context("Serum Place Order", self._place_order, self.context.retry_pauses) as retrier:
client_id, place_order_transaction_id = retrier.run(
market, base_token, quote_token, source_token_account.address, self.wallet.account, OrderType.IOC, side, price, quantity)
with retry_context("Serum Wait For Order Fill", self._wait_for_order_fill, self.context.retry_pauses) as retrier:
retrier.run(market, client_id)
with retry_context("Serum Settle", self.settle, self.context.retry_pauses) as retrier:
settlement_transaction_ids = retrier.run(spot_market, market)
with retry_context("Serum Wait For Settle Completion", self.wait_for_settlement_completion, self.context.retry_pauses) as retrier:
retrier.run(settlement_transaction_ids)
self.reporter("Order execution complete")
def _place_order(self, market: Market, base_token: Token, quote_token: Token, paying_token_address: PublicKey, account: Account, order_type: OrderType, side: Side, price: Decimal, quantity: Decimal) -> typing.Tuple[int, str]:
to_pay = price * quantity
self.logger.info(
f"{side.name}ing {quantity} of {base_token.name} at {price} for {to_pay} on {base_token.name}/{quote_token.name} from {paying_token_address}.")
client_id = self.context.random_client_id()
self.reporter(f"""Placing order
paying_token_address: {paying_token_address}
account: {account.public_key()}
order_type: {order_type.name}
side: {side.name}
price: {float(price)}
quantity: {float(quantity)}
client_id: {client_id}""")
response = market.place_order(paying_token_address, account, order_type,
side, float(price), float(quantity), client_id)
transaction_id = self.context.unwrap_transaction_id_or_raise_exception(response)
self.reporter(f"Order transaction ID: {transaction_id}")
return client_id, transaction_id
def _wait_for_order_fill(self, market: Market, client_id: int, max_wait_in_seconds: int = 60):
self.logger.info(f"Waiting up to {max_wait_in_seconds} seconds for {client_id}.")
return rx.interval(1.0).pipe(
ops.flat_map(lambda _: market.load_event_queue()),
ops.skip_while(lambda item: item.client_order_id != client_id),
ops.skip_while(lambda item: not item.event_flags.fill),
ops.first(),
ops.map(lambda _: True),
ops.timeout(max_wait_in_seconds, rx.return_value(False))
).run()
def settle(self, spot_market: SpotMarket, market: Market) -> typing.List[str]:
base_token_account = TokenAccount.fetch_or_create_largest_for_owner_and_token(
self.context, self.wallet.account, spot_market.base)
quote_token_account = TokenAccount.fetch_or_create_largest_for_owner_and_token(
self.context, self.wallet.account, spot_market.quote)
open_orders = OpenOrders.load_for_market_and_owner(self.context, spot_market.address, self.wallet.account.public_key(
), self.context.dex_program_id, spot_market.base.decimals, spot_market.quote.decimals)
transaction_ids = []
for open_order_account in open_orders:
if (open_order_account.base_token_free > 0) or (open_order_account.quote_token_free > 0):
self.reporter(
f"Need to settle open orders: {open_order_account}\nBase account: {base_token_account.address}\nQuote account: {quote_token_account.address}")
response = market.settle_funds(self.wallet.account, open_order_account.to_pyserum(
), base_token_account.address, quote_token_account.address)
transaction_id = self.context.unwrap_transaction_id_or_raise_exception(response)
self.reporter(f"Settlement transaction ID: {transaction_id}")
transaction_ids += [transaction_id]
return transaction_ids
def wait_for_settlement_completion(self, settlement_transaction_ids: typing.List[str]):
if len(settlement_transaction_ids) > 0:
self.reporter(f"Waiting on settlement transaction IDs: {settlement_transaction_ids}")
for settlement_transaction_id in settlement_transaction_ids:
self.reporter(f"Waiting on specific settlement transaction ID: {settlement_transaction_id}")
self.context.wait_for_confirmation(settlement_transaction_id)
self.reporter("All settlement transaction IDs confirmed.")
def _lookup_spot_market(self, symbol: str) -> SpotMarket:
spot_market = self.spot_market_lookup.find_by_symbol(symbol)
if spot_market is None:
raise Exception(f"Spot market '{symbol}' could not be found.")
self.logger.info(f"Base token: {spot_market.base}")
self.logger.info(f"Quote token: {spot_market.quote}")
return spot_market
def __str__(self) -> str:
return f"""« SerumImmediateTradeExecutor [{self.price_adjustment_factor}] »"""
def __repr__(self) -> str:
return f"{self}"

417
mango/transactionscout.py Normal file
View File

@ -0,0 +1,417 @@
# # ⚠ 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 base58
import datetime
import typing
from decimal import Decimal
from solana.publickey import PublicKey
from .context import Context
from .instructiontype import InstructionType
from .layouts import layouts
from .ownedtokenvalue import OwnedTokenValue
from .token import TokenLookup
from .tokenvalue import TokenValue
# # 🥭 TransactionScout
#
# ## Transaction Indices
#
# Transactions come with a large account list.
#
# Instructions, individually, take accounts.
#
# The accounts instructions take are listed in the the transaction's list of accounts.
#
# The instruction data therefore doesn't need to specify account public keys, only the
# index of those public keys in the main transaction's list.
#
# So, for example, if an instruction uses 3 accounts, the instruction data could say
# [3, 2, 14], meaning the first account it uses is index 3 in the whole transaction account
# list, the second is index 2 in the whole transaction account list, the third is index 14
# in the whole transaction account list.
#
# This complicates figuring out which account is which for a given instruction, especially
# since some of the accounts (like the sender/signer account) can appear at different
# indices depending on which instruction is being used.
#
# We keep a few static dictionaries here to allow us to dereference important accounts per
# type.
#
# In addition, we dereference the accounts for each instruction when we instantiate each
# `MangoInstruction`, so users of `MangoInstruction` don't need to worry about
# these details.
#
# The index of the sender/signer depends on the instruction.
_instruction_signer_indices: typing.Dict[InstructionType, int] = {
InstructionType.InitMangoGroup: 3,
InstructionType.InitMarginAccount: 2,
InstructionType.Deposit: 2,
InstructionType.Withdraw: 2,
InstructionType.Borrow: 2,
InstructionType.SettleBorrow: 2,
InstructionType.Liquidate: 1,
InstructionType.DepositSrm: 2,
InstructionType.WithdrawSrm: 2,
InstructionType.PlaceOrder: 1,
InstructionType.SettleFunds: 1,
InstructionType.CancelOrder: 1,
InstructionType.CancelOrderByClientId: 1,
InstructionType.ChangeBorrowLimit: 1,
InstructionType.PlaceAndSettle: 1,
InstructionType.ForceCancelOrders: 1,
InstructionType.PartialLiquidate: 1
}
# The index of the token IN account depends on the instruction, and for some instructions
# doesn't exist.
_token_in_indices: typing.Dict[InstructionType, int] = {
InstructionType.InitMangoGroup: -1,
InstructionType.InitMarginAccount: -1,
InstructionType.Deposit: 3, # token_account_acc - TokenAccount owned by user which will be sending the funds
InstructionType.Withdraw: 4, # vault_acc - TokenAccount owned by MangoGroup which will be sending
InstructionType.Borrow: -1,
InstructionType.SettleBorrow: -1,
InstructionType.Liquidate: -1,
InstructionType.DepositSrm: 3, # srm_account_acc - TokenAccount owned by user which will be sending the funds
InstructionType.WithdrawSrm: 4, # vault_acc - SRM vault of MangoGroup
InstructionType.PlaceOrder: -1,
InstructionType.SettleFunds: -1,
InstructionType.CancelOrder: -1,
InstructionType.CancelOrderByClientId: -1,
InstructionType.ChangeBorrowLimit: -1,
InstructionType.PlaceAndSettle: -1,
InstructionType.ForceCancelOrders: -1,
InstructionType.PartialLiquidate: 2 # liqor_in_token_acc - liquidator's token account to deposit
}
# The index of the token OUT account depends on the instruction, and for some instructions
# doesn't exist.
_token_out_indices: typing.Dict[InstructionType, int] = {
InstructionType.InitMangoGroup: -1,
InstructionType.InitMarginAccount: -1,
InstructionType.Deposit: 4, # vault_acc - TokenAccount owned by MangoGroup
InstructionType.Withdraw: 3, # token_account_acc - TokenAccount owned by user which will be receiving the funds
InstructionType.Borrow: -1,
InstructionType.SettleBorrow: -1,
InstructionType.Liquidate: -1,
InstructionType.DepositSrm: 4, # vault_acc - SRM vault of MangoGroup
InstructionType.WithdrawSrm: 3, # srm_account_acc - TokenAccount owned by user which will be receiving the funds
InstructionType.PlaceOrder: -1,
InstructionType.SettleFunds: -1,
InstructionType.CancelOrder: -1,
InstructionType.CancelOrderByClientId: -1,
InstructionType.ChangeBorrowLimit: -1,
InstructionType.PlaceAndSettle: -1,
InstructionType.ForceCancelOrders: -1,
InstructionType.PartialLiquidate: 3 # liqor_out_token_acc - liquidator's token account to withdraw into
}
# # 🥭 MangoInstruction class
#
# This class packages up Mango instruction data, which can come from disparate parts of the
# transaction. Keeping it all together here makes many things simpler.
#
class MangoInstruction:
def __init__(self, instruction_type: InstructionType, instruction_data: typing.Any, accounts: typing.List[PublicKey]):
self.instruction_type = instruction_type
self.instruction_data = instruction_data
self.accounts = accounts
@property
def group(self) -> PublicKey:
# Group PublicKey is always the zero index.
return self.accounts[0]
@property
def sender(self) -> PublicKey:
account_index = _instruction_signer_indices[self.instruction_type]
return self.accounts[account_index]
@property
def token_in_account(self) -> typing.Optional[PublicKey]:
account_index = _token_in_indices[self.instruction_type]
if account_index < 0:
return None
return self.accounts[account_index]
@property
def token_out_account(self) -> typing.Optional[PublicKey]:
account_index = _token_out_indices[self.instruction_type]
if account_index < 0:
return None
return self.accounts[account_index]
def describe_parameters(self) -> str:
instruction_type = self.instruction_type
additional_data = ""
if instruction_type == InstructionType.InitMangoGroup:
pass
elif instruction_type == InstructionType.InitMarginAccount:
pass
elif instruction_type == InstructionType.Deposit:
additional_data = f"quantity: {self.instruction_data.quantity}"
elif instruction_type == InstructionType.Withdraw:
additional_data = f"quantity: {self.instruction_data.quantity}"
elif instruction_type == InstructionType.Borrow:
additional_data = f"quantity: {self.instruction_data.quantity}, token index: {self.instruction_data.token_index}"
elif instruction_type == InstructionType.SettleBorrow:
additional_data = f"quantity: {self.instruction_data.quantity}, token index: {self.instruction_data.token_index}"
elif instruction_type == InstructionType.Liquidate:
additional_data = f"deposit quantities: {self.instruction_data.deposit_quantities}"
elif instruction_type == InstructionType.DepositSrm:
additional_data = f"quantity: {self.instruction_data.quantity}"
elif instruction_type == InstructionType.WithdrawSrm:
additional_data = f"quantity: {self.instruction_data.quantity}"
elif instruction_type == InstructionType.PlaceOrder:
pass
elif instruction_type == InstructionType.SettleFunds:
pass
elif instruction_type == InstructionType.CancelOrder:
pass
elif instruction_type == InstructionType.CancelOrderByClientId:
additional_data = f"client ID: {self.instruction_data.client_id}"
elif instruction_type == InstructionType.ChangeBorrowLimit:
additional_data = f"borrow limit: {self.instruction_data.borrow_limit}, token index: {self.instruction_data.token_index}"
elif instruction_type == InstructionType.PlaceAndSettle:
pass
elif instruction_type == InstructionType.ForceCancelOrders:
additional_data = f"limit: {self.instruction_data.limit}"
elif instruction_type == InstructionType.PartialLiquidate:
additional_data = f"max deposit: {self.instruction_data.max_deposit}"
return additional_data
@staticmethod
def from_response(context: Context, all_accounts: typing.List[PublicKey], instruction_data: typing.Dict) -> typing.Optional["MangoInstruction"]:
program_account_index = instruction_data["programIdIndex"]
if all_accounts[program_account_index] != context.program_id:
# It's an instruction, it's just not a Mango one.
return None
data = instruction_data["data"]
instructions_account_indices = instruction_data["accounts"]
decoded = base58.b58decode(data)
initial = layouts.MANGO_INSTRUCTION_VARIANT_FINDER.parse(decoded)
parser = layouts.InstructionParsersByVariant[initial.variant]
# A whole bunch of accounts are listed for a transaction. Some (or all) of them apply
# to this instruction. The instruction data gives the index of each account it uses,
# in the order in which it uses them. So, for example, if it uses 3 accounts, the
# instruction data could say [3, 2, 14], meaning the first account it uses is index 3
# in the whole transaction account list, the second is index 2 in the whole transaction
# account list, the third is index 14 in the whole transaction account list.
accounts: typing.List[PublicKey] = []
for index in instructions_account_indices:
accounts += [all_accounts[index]]
parsed = parser.parse(decoded)
instruction_type = InstructionType(int(parsed.variant))
return MangoInstruction(instruction_type, parsed, accounts)
def __str__(self) -> str:
parameters = self.describe_parameters() or "None"
return f"« {self.instruction_type.name}: {parameters} »"
def __repr__(self) -> str:
return f"{self}"
# # 🥭 TransactionScout class
#
class TransactionScout:
def __init__(self, timestamp: datetime.datetime, signatures: typing.List[str],
succeeded: bool, group_name: str, accounts: typing.List[PublicKey],
instructions: typing.List[typing.Any], messages: typing.List[str],
pre_token_balances: typing.List[OwnedTokenValue],
post_token_balances: typing.List[OwnedTokenValue]):
self.timestamp: datetime.datetime = timestamp
self.signatures: typing.List[str] = signatures
self.succeeded: bool = succeeded
self.group_name: str = group_name
self.accounts: typing.List[PublicKey] = accounts
self.instructions: typing.List[typing.Any] = instructions
self.messages: typing.List[str] = messages
self.pre_token_balances: typing.List[OwnedTokenValue] = pre_token_balances
self.post_token_balances: typing.List[OwnedTokenValue] = post_token_balances
@property
def summary(self) -> str:
result = "[Success]" if self.succeeded else "[Failed]"
instructions = ", ".join([ins.instruction_type.name for ins in self.instructions])
changes = OwnedTokenValue.changes(self.pre_token_balances, self.post_token_balances)
in_tokens = []
for ins in self.instructions:
if ins.token_in_account is not None:
in_tokens += [OwnedTokenValue.find_by_owner(changes, ins.token_in_account)]
out_tokens = []
for ins in self.instructions:
if ins.token_out_account is not None:
out_tokens += [OwnedTokenValue.find_by_owner(changes, ins.token_out_account)]
changed_tokens = in_tokens + out_tokens
changed_tokens_text = ", ".join(
[f"{tok.token_value.value:,.8f} {tok.token_value.token.name}" for tok in changed_tokens]) or "None"
return f"« TransactionScout {result} {self.group_name} [{self.timestamp}] {instructions}: Token Changes: {changed_tokens_text}\n {self.signatures} »"
@property
def sender(self) -> PublicKey:
return self.instructions[0].sender
@property
def group(self) -> PublicKey:
return self.instructions[0].group
def has_any_instruction_of_type(self, instruction_type: InstructionType) -> bool:
return any(map(lambda ins: ins.instruction_type == instruction_type, self.instructions))
@staticmethod
def load_if_available(context: Context, signature: str) -> typing.Optional["TransactionScout"]:
transaction_response = context.client.get_confirmed_transaction(signature)
transaction_details = context.unwrap_or_raise_exception(transaction_response)
if transaction_details is None:
return None
return TransactionScout.from_transaction_response(context, transaction_details)
@staticmethod
def load(context: Context, signature: str) -> "TransactionScout":
tx = TransactionScout.load_if_available(context, signature)
if tx is None:
raise Exception(f"Transaction '{signature}' not found.")
return tx
@staticmethod
def from_transaction_response(context: Context, response: typing.Dict) -> "TransactionScout":
def balance_to_token_value(accounts: typing.List[PublicKey], balance: typing.Dict) -> OwnedTokenValue:
mint = PublicKey(balance["mint"])
account = accounts[balance["accountIndex"]]
amount = Decimal(balance["uiTokenAmount"]["amount"])
decimals = Decimal(balance["uiTokenAmount"]["decimals"])
divisor = Decimal(10) ** decimals
value = amount / divisor
token = TokenLookup.default_lookups().find_by_mint(mint)
return OwnedTokenValue(account, TokenValue(token, value))
try:
succeeded = True if response["meta"]["err"] is None else False
accounts = list(map(PublicKey, response["transaction"]["message"]["accountKeys"]))
instructions = []
for instruction_data in response["transaction"]["message"]["instructions"]:
instruction = MangoInstruction.from_response(context, accounts, instruction_data)
if instruction is not None:
instructions += [instruction]
group_name = context.lookup_group_name(instructions[0].group)
timestamp = datetime.datetime.fromtimestamp(response["blockTime"])
signatures = response["transaction"]["signatures"]
messages = response["meta"]["logMessages"]
pre_token_balances = list(map(lambda bal: balance_to_token_value(
accounts, bal), response["meta"]["preTokenBalances"]))
post_token_balances = list(map(lambda bal: balance_to_token_value(
accounts, bal), response["meta"]["postTokenBalances"]))
return TransactionScout(timestamp,
signatures,
succeeded,
group_name,
accounts,
instructions,
messages,
pre_token_balances,
post_token_balances)
except Exception as exception:
signature = "Unknown"
if response and ("transaction" in response) and ("signatures" in response["transaction"]) and len(response["transaction"]["signatures"]) > 0:
signature = ", ".join(response["transaction"]["signatures"])
raise Exception(f"Exception fetching transaction '{signature}'", exception)
def __str__(self) -> str:
def format_tokens(account_token_values: typing.List[OwnedTokenValue]) -> str:
if len(account_token_values) == 0:
return "None"
return "\n ".join([f"{atv}" for atv in account_token_values])
instruction_names = ", ".join([ins.instruction_type.name for ins in self.instructions])
signatures = "\n ".join(self.signatures)
accounts = "\n ".join([f"{acc}" for acc in self.accounts])
messages = "\n ".join(self.messages)
instructions = "\n ".join([f"{ins}" for ins in self.instructions])
changes = OwnedTokenValue.changes(self.pre_token_balances, self.post_token_balances)
tokens_in = format_tokens(self.pre_token_balances)
tokens_out = format_tokens(self.post_token_balances)
token_changes = format_tokens(changes)
return f"""« TransactionScout {self.timestamp}: {instruction_names}
Sender:
{self.sender}
Succeeded:
{self.succeeded}
Group:
{self.group_name} [{self.group}]
Signatures:
{signatures}
Instructions:
{instructions}
Accounts:
{accounts}
Messages:
{messages}
Tokens In:
{tokens_in}
Tokens Out:
{tokens_out}
Token Changes:
{token_changes}
»"""
def __repr__(self) -> str:
return f"{self}"
# # 🥭 fetch_all_recent_transaction_signatures function
#
def fetch_all_recent_transaction_signatures(context: Context, in_the_last: datetime.timedelta = datetime.timedelta(days=1)) -> typing.List[str]:
now = datetime.datetime.now()
recency_cutoff = now - in_the_last
recency_cutoff_timestamp = recency_cutoff.timestamp()
all_fetched = False
before = None
signature_results = []
while not all_fetched:
signature_response = context.client.get_confirmed_signature_for_address2(context.group_id, before=before)
signature_result = context.unwrap_or_raise_exception(signature_response)
signature_results += signature_result
if (len(signature_result) == 0) or (signature_result[-1]["blockTime"] < recency_cutoff_timestamp):
all_fetched = True
before = signature_results[-1]["signature"]
recent = [result["signature"] for result in signature_results if result["blockTime"] > recency_cutoff_timestamp]
return recent

33
mango/version.py Normal file
View File

@ -0,0 +1,33 @@
# # ⚠ 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 enum
# # 🥭 Version enum
#
# This is used to keep track of which version of the layout struct was used to load the
# data.
#
class Version(enum.Enum):
UNSPECIFIED = 0
V1 = 1
V2 = 2
V3 = 3
V4 = 4
V5 = 5

90
mango/wallet.py Normal file
View File

@ -0,0 +1,90 @@
# # ⚠ 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 json
import logging
import os.path
import typing
from solana.account import Account
from solana.publickey import PublicKey
# # 🥭 Wallet class
#
# The `Wallet` class wraps our understanding of saving and loading keys, and creating the
# appropriate Solana `Account` object.
#
# To load a private key from a file, the file must be a JSON-formatted text file with a root
# array of the 64 bytes making up the secret key.
#
# For example:
# ```
# [200,48,184,13... for another 60 bytes...]
# ```
# **TODO:** It would be good to be able to load a `Wallet` from a mnemonic string. I haven't yet found a Python library that can generate a BIP44 derived seed for Solana that matches the derived seeds created by Sollet and Ledger.
#
_DEFAULT_WALLET_FILENAME: str = "id.json"
class Wallet:
def __init__(self, secret_key):
self.logger: logging.Logger = logging.getLogger(self.__class__.__name__)
self.secret_key = secret_key[0:32]
self.account = Account(self.secret_key)
@property
def address(self) -> PublicKey:
return self.account.public_key()
def save(self, filename: str, overwrite: bool = False) -> None:
if os.path.isfile(filename) and not overwrite:
raise Exception(f"Wallet file '{filename}' already exists.")
with open(filename, "w") as json_file:
json.dump(list(self.secret_key), json_file)
@staticmethod
def load(filename: str = _DEFAULT_WALLET_FILENAME) -> "Wallet":
if not os.path.isfile(filename):
logging.error(f"Wallet file '{filename}' is not present.")
raise Exception(f"Wallet file '{filename}' is not present.")
else:
with open(filename) as json_file:
data = json.load(json_file)
return Wallet(data)
@staticmethod
def create() -> "Wallet":
new_account = Account()
new_secret_key = new_account.secret_key()
return Wallet(new_secret_key)
# default_wallet object
#
# A default Wallet object that loads the private key from the id.json file, if it exists.
#
default_wallet: typing.Optional[Wallet] = None
if os.path.isfile(_DEFAULT_WALLET_FILENAME):
try:
default_wallet = Wallet.load(_DEFAULT_WALLET_FILENAME)
except Exception as exception:
logging.warning(
f"Failed to load default wallet from file '{_DEFAULT_WALLET_FILENAME}' - exception: {exception}")

338
mango/walletbalancer.py Normal file
View File

@ -0,0 +1,338 @@
# # ⚠ 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 .context import Context
from .token import Token
from .tokenvalue import TokenValue
from .tradeexecutor import TradeExecutor
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 `TokenValue` 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, token: Token):
self.token = token
@abc.abstractmethod
def resolve(self, current_price: Decimal, total_value: Decimal) -> TokenValue:
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 `TokenValue` object.
#
class FixedTargetBalance(TargetBalance):
def __init__(self, token: Token, value: Decimal):
super().__init__(token)
self.value = value
def resolve(self, current_price: Decimal, total_value: Decimal) -> TokenValue:
return TokenValue(self.token, self.value)
def __str__(self) -> str:
return f"""« FixedTargetBalance [{self.value} {self.token.name}] »"""
# # 🥭 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, token: Token, target_percentage: Decimal):
super().__init__(token)
self.target_fraction = target_percentage / 100
def resolve(self, 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)
def __str__(self) -> str:
return f"""« PercentageTargetBalance [{self.target_fraction * 100}% {self.token.name}] »"""
# # 🥭 TargetBalanceParser class
#
# 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.
#
class TargetBalanceParser:
def __init__(self, tokens: typing.List[Token]):
self.tokens = tokens
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
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) > 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(token, numeric_value)
else:
return PercentageTargetBalance(token, 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.List[TokenValue]) -> typing.List[TokenValue]:
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.List[TokenValue], desired_balances: typing.List[TokenValue]) -> typing.List[TokenValue]:
changes: typing.List[TokenValue] = []
for desired in desired_balances:
current = TokenValue.find_by_token(current_balances, desired.token)
change = TokenValue(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.List[TokenValue], prices: typing.List[TokenValue]):
self.logger: logging.Logger = logging.getLogger(self.__class__.__name__)
self.prices: typing.Dict[str, TokenValue] = {}
total = Decimal(0)
for balance in balances:
price = TokenValue.find_by_token(prices, balance.token)
self.prices[f"{price.token.mint}"] = 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} gives action threshold value of {self.action_threshold_value}")
def allow(self, token_value: TokenValue) -> bool:
price = self.prices[f"{token_value.token.mint}"]
value = price.value * token_value.value
absolute_value = value.copy_abs()
result = absolute_value > self.action_threshold_value
self.logger.info(
f"Value of {token_value.token.name} trade is {absolute_value}, threshold value is {self.action_threshold_value}. Is this worth doing? {result}.")
return result
# # 🥭 WalletBalancers
#
# We want two types of this class:
# * A 'null' implementation that adheres to the interface but doesn't do anything, and
# * A 'live' implementation that actually does 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):
@abc.abstractmethod
def balance(self, prices: typing.List[TokenValue]):
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 balance(self, prices: typing.List[TokenValue]):
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, trade_executor: TradeExecutor, action_threshold: Decimal, tokens: typing.List[Token], target_balances: typing.List[TargetBalance]):
self.logger: logging.Logger = logging.getLogger(self.__class__.__name__)
self.context: Context = context
self.wallet: Wallet = wallet
self.trade_executor: TradeExecutor = trade_executor
self.action_threshold: Decimal = action_threshold
self.tokens: typing.List[Token] = tokens
self.target_balances: typing.List[TargetBalance] = target_balances
def balance(self, prices: typing.List[TokenValue]):
padding = "\n "
def balances_report(balances) -> str:
return padding.join(list([f"{bal}" for bal in balances]))
current_balances = self._fetch_balances()
total_value = Decimal(0)
for bal in current_balances:
price = TokenValue.find_by_token(prices, bal.token)
value = bal.value * price.value
total_value += value
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)]
balance_changes = calculate_required_balance_changes(current_balances, resolved_targets)
self.logger.info(f"Full balance changes: {padding}{balances_report(balance_changes)}")
dont_bother = FilterSmallChanges(self.action_threshold, current_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()
self.logger.info(f"Finishing balances: {padding}{balances_report(updated_balances)}")
def _make_changes(self, balance_changes: typing.List[TokenValue]):
self.logger.info(f"Balance changes to make: {balance_changes}")
for change in balance_changes:
if change.value < 0:
self.trade_executor.sell(change.token.name, change.value.copy_abs())
else:
self.trade_executor.buy(change.token.name, change.value.copy_abs())
def _fetch_balances(self) -> typing.List[TokenValue]:
balances: typing.List[TokenValue] = []
for token in self.tokens:
balance = TokenValue.fetch_total_value(self.context, self.wallet.address, token)
balances += [balance]
return balances

View File

@ -4,6 +4,7 @@ mypy
nblint
pandas
pyserum
pytest
rx
rxpy_backpressure
solana

View File

@ -1,10 +0,0 @@
#!/usr/bin/env bash
CURRENT_DIRECTORY="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
cd "${CURRENT_DIRECTORY}/.."
TAG=${1:-latest}
echo Building opinionatedgeek/mango-explorer:${TAG}
docker build . -t opinionatedgeek/mango-explorer:${TAG}

View File

@ -1,73 +0,0 @@
#!/usr/bin/env python3
import argparse
import nbformat
import os
import re
import shutil
import sys
from mypy import api
from nbconvert.exporters import PythonExporter
from pathlib import Path
from os.path import isfile, join
# Get the full path to this script.
script_path = Path(os.path.realpath(__file__))
# The parent of the script is the bin directory, the parent of that is the
# notebook directory. It's this notebook directory we want.
notebook_directory = script_path.parent.parent
parser = argparse.ArgumentParser(description="Run a liquidator for a Mango Markets group.")
parser.add_argument("--skip-cleanup", action="store_true", default=False,
help="skip removal of the temp directory when finished (will leave processed files for inspection)")
args = parser.parse_args()
working_directory_name = ".tmplintdir"
try:
if os.path.exists(notebook_directory / working_directory_name):
shutil.rmtree(notebook_directory / working_directory_name)
os.mkdir(str(notebook_directory / working_directory_name))
all_notebook_files = [f for f in os.listdir(notebook_directory) if isfile(
notebook_directory / f) and f.endswith(".ipynb")]
for notebook_name in all_notebook_files:
with open(notebook_name, 'r') as notebook_file:
notebook_body = notebook_file.read()
notebook = nbformat.reads(notebook_body, as_version=4)
python_exporter = PythonExporter()
body, _ = python_exporter.from_notebook_node(notebook)
notebook_base_name, _ = os.path.splitext(notebook_name)
notebook_python_file = notebook_base_name + ".py"
with open(str(notebook_directory / working_directory_name / notebook_python_file), 'w') as pyfile:
pyfile.write(body)
all_command_files = [f for f in os.listdir(notebook_directory / "bin") if isfile(
notebook_directory / "bin" / f)]
for command_name in all_command_files:
source = notebook_directory / "bin" / command_name
command_python_file = command_name + ".py"
destination = notebook_directory / working_directory_name / command_python_file
shutil.copy(source, destination)
all_startup_files = [f for f in os.listdir(notebook_directory / "meta" / "startup") if isfile(
notebook_directory / "meta" / "startup" / f)]
for startup_file in all_startup_files:
source = notebook_directory / "meta" / "startup" / startup_file
destination = notebook_directory / working_directory_name / startup_file
shutil.copy(source, destination)
command = f'flake8 --extend-ignore E402,E501,E722,W291,W391 "{str(notebook_directory / working_directory_name)}"'
os.system(command)
command = f'mypy "{str(notebook_directory / working_directory_name)}"'
os.system(command)
except Exception as ex:
print(f"Caught exception: {ex}")
if not args.skip_cleanup:
if os.path.exists(notebook_directory / working_directory_name):
shutil.rmtree(notebook_directory / working_directory_name)

View File

@ -3,4 +3,4 @@
CURRENT_DIRECTORY="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
PROJECT_DIRECTORY="${CURRENT_DIRECTORY}/.."
grep -i "${1}" ${PROJECT_DIRECTORY}/*.ipynb ${PROJECT_DIRECTORY}/bin/*
grep -i "${1}" ${PROJECT_DIRECTORY}/mango/* ${PROJECT_DIRECTORY}/bin/* ${PROJECT_DIRECTORY}/*.ipynb

12
setup.py Normal file
View File

@ -0,0 +1,12 @@
#!/usr/bin/env python
from distutils.core import setup
setup(name="mango-explorer",
version="0.1",
description="Mango Explorer",
author="Geoff Taylor",
author_email="geoff@mango.markets",
url="https://gitlab.com/OpinionatedGeek/mango-explorer",
packages=["mango"],
)

0
tests/__init__.py Normal file
View File

6
tests/context.py Normal file
View File

@ -0,0 +1,6 @@
import os
import sys
sys.path.insert(0, os.path.abspath(
os.path.join(os.path.dirname(__file__), '..')))
import mango

29
tests/fakes.py Normal file
View File

@ -0,0 +1,29 @@
from decimal import Decimal
from solana.publickey import PublicKey
import datetime
import mango
def fake_public_key() -> PublicKey:
return PublicKey("11111111111111111111111111111112")
def fake_seeded_public_key(seed: str) -> PublicKey:
return PublicKey.create_with_seed(PublicKey("11111111111111111111111111111112"), seed, PublicKey("11111111111111111111111111111111"))
def fake_account_info(address: PublicKey = fake_public_key(), executable: bool = False, lamports: Decimal = Decimal(0), owner: PublicKey = fake_public_key(), rent_epoch: Decimal = Decimal(0), data: bytes = bytes([0])):
return mango.AccountInfo(address, executable, lamports, owner, rent_epoch, data)
def fake_token() -> mango.Token:
return mango.Token("FAKE", "Fake Token", fake_seeded_public_key("fake token"), Decimal(6))
def fake_context() -> mango.Context:
return mango.Context("fake-cluster", "https://fake-cluster-host", fake_seeded_public_key("program ID"), fake_seeded_public_key("DEX program ID"), "FAKE_GROUP", fake_seeded_public_key("group ID"))
def fake_index() -> mango.Index:
return mango.Index(mango.Version.V1, datetime.datetime.now(), Decimal(0), Decimal(0))

View File

View File

@ -0,0 +1,58 @@
from decimal import Decimal
from solana.publickey import PublicKey
import base64
import mango.layouts as layouts
def test_3_group_layout():
encoded_3_token_group = "AwAAAAAAAACCaOmpoURMK6XHelGTaFawcuQ/78/15LAemWI8jrt3SRKLy2R9i60eclDjuDS8+p/ZhvTUd9G7uQVOYCsR6+BhmqGCiO6EPYP2PQkf/VRTvw7JjXvIjPFJy06QR1Cq1WfTonHl0OjCkyEf60SD07+MFJu5pVWNFGGEO/8AiAYfduaKdnFTaZEHPcK5Eq72WWHeHg2yIbBF09kyeOhlCJwOoG8O5SgpPV8QOA64ZNV4aKroFfADg6kEy/wWCdp3fv0O4GJgAAAAAPH6Ud6jtjwAAQAAAAAAAADiDkkCi9UOAAEAAAAAAAAADuBiYAAAAACNS5bSy7soAAEAAAAAAAAACMvgO+2jCwABAAAAAAAAAA7gYmAAAAAAZFeDUBNVhwABAAAAAAAAABtRNytozC8AAQAAAAAAAABIBGiCcyaEZdNhrTyeqUY692vOzzPdHaxAxguht3JQGlkzjtd05dX9LENHkl2z1XvUbTNKZlweypNRetmH0lmQ9VYQAHqylxZVK65gEg85g27YuSyvOBZAjJyRmYU9KdCO1D+4ehdPu9dQB1yI1uh75wShdAaFn2o4qrMYwq3SQQEAAAAAAAAAAiH1PPJKAuh6oGiE35aGhUQhFi/bxgKOudpFv8HEHNCFDy1uAqR6+CTQmradxC1wyyjL+iSft+5XudJWwSdi7wvphsxb96x7Obj/AgAAAAAKlV4LL5ow6r9LMhIAAAAADvsOtqcVFmChDPzPnwAAAE33lx1h8hPFD04AAAAAAAA8YRV3Oa309B2wGwAAAAAA+yPBZRlZz7b605n+AQAAAACgmZmZmZkZAQAAAAAAAAAAMDMzMzMzMwEAAAAAAAAA25D1XcAtRzSuuyx3U+X7aE9vM1EJySU9KprgL0LMJ/vat9+SEEUZuga7O5tTUrcMDYWDg+LYaAWhSQiN2fYk7aCGAQAAAAAAgIQeAAAAAAAA8gUqAQAAAAYGBgICAAAA"
decoded_3_token_group = base64.b64decode(encoded_3_token_group)
group = layouts.GROUP_V1.parse(decoded_3_token_group)
# Not an exhaustive check, just a few key areas
assert len(group.tokens) == 3
assert group.tokens[0] == PublicKey("9n4nbM75f5Ui33ZbPYXn59EwSgE8CGsHtAeTH5YFeJ9E")
assert group.tokens[1] == PublicKey("2FPyTwcZLUg1MDrwsyoP4D6s1tM7hAkHYRjkNb5w6Pxk")
assert group.tokens[2] == PublicKey("BQcdHdAQW1hczDbBi9hiegXAR7A98Q9jx3X3iBBBDiq4")
assert len(group.vaults) == 3
assert group.vaults[0] == PublicKey("FF8h6eCSqoGQyuYmh3BzxdaTfomCBPrCctRq9Yo6nCcd")
assert group.vaults[1] == PublicKey("GWwECYXmTUumcUsjbwdJDc9ws4KDWYBJ1GGmckZr2hTK")
assert group.vaults[2] == PublicKey("BoGTDjtbEtK8HPCu2VPNJfA7bTLuVDPETDoHvztm6Mqe")
assert len(group.total_deposits) == 3
assert group.total_deposits[0] == Decimal("50313273.4831080054396143109020182738")
assert group.total_deposits[1] == Decimal("305286079.914804111943676127025916467")
assert group.total_deposits[2] == Decimal("686389202081.375336984105214031197210")
def test_5_group_layout():
encoded_5_token_group = "AwAAAAAAAACk6bHzfLvX/YZNskK+2brLGvXTPR3P4qF2Hkc2HZANL3QVQJS5HzYh3sTbcf99JgISg7g07yK6MxP5nzzTyEy8BpuIV/6rgYT7aH9jRhjANdrEOdwa6ztVmKDwAAAAAAF6mkC+0kyNlxFPeKUyBpYL11A4a/pUcOYunlw92EFaYV3OcRQkdPidUvY2YtaR52uqW752f+Ufcci7ei8SWZYWgwYD6XWYDToBxFf2L2JoXCKqjFDfQ8+dfYUGnLBupwJdSa0UH20tWtHJh6VlhzFcMCQSU8LYiS6z6cR/GWXcWKd36DfFLhkULOxma3DDEBmYIqDsEZC02N3Vm5lJSc+okeVxcJzdQ48XVJuyTEli9TP2HAbrNqFJSyFbQo0f+dKsTopavA5ndrNOk9So4ANgHdwBGUVGY0SLS7+TVXkcCgwgqGAAAAAALYxEAuIBAAABAAAAAAAAAErLeL8QAAAAAQAAAAAAAAAMIKhgAAAAACwC4BEHAAAAAQAAAAAAAACByD0AAAAAAAEAAAAAAAAADCCoYAAAAABJpmxt87MCAAEAAAAAAAAArwaaHqGSAgABAAAAAAAAAAwgqGAAAAAANHqKx8PiAwABAAAAAAAAACyFpt5myQMAAQAAAAAAAAAMIKhgAAAAAIrCIhLj2gUAAQAAAAAAAACd0UEiFPsAAAEAAAAAAAAATVH2gvK/JiB7fCRmoBvLuTcba2/qHCRnzOjDFFYNStgzmK/Q8Wk0MLLXAIvgBrVJW1RiSvwiqrTezEkb3zUF4m47JYDk9DZOvmCGR63JwGv8BXI/DLCdMIagQ6MUC4krqaznZiQhDOVuXXpiS+7Q4PgE8V9wkGVQHFpDgVQVx6VSNqBWdfB5O0wXcsXSAxviIZnpzZb8yVPAJhqxFNViEC+Y1L1+ULx6KHTSRIwn5wXTktPuZEsRtSpUJpqZD9V5y+8i6KVSbQNBUDzf0xeAFH85lOlowgaoBW/MjycjsrXlD0s7jbPTK2SvlK0fgerysq1O8y7bk5yp6001Zr/sLAEAAAAAAAAAkE0bF+zrix2vIYYIDbjjQiF/B5h1fwDNpfltVuG2QD61vZ7LZ502Gt8wb/WL0dyrOS4eySbPnOiFlVUBjsVtHweAuy088r4ZtPPGxCoAAABG2qkYb53x/zKdhfmqjgMA+NOnThFN5Rte4iNxcwIAACjnpx/Yisq5DiDQQgAAAAAGSKNb2bYXz1S4odlZDwAAxfiZZdXRLGMAAAAAAAAAALNmZBB7GyHIDgAAAAAAAABPGsO1PmFjqp3jcQAAAAAAil3Lhe3fP1HPAgAAAAAAANnFw5TROkYQ1B82OwkAAAAAoJmZmZmZGQEAAAAAAAAAADAzMzMzMzMBAAAAAAAAAIOo484GpuWM2iV176h0nKGu2lHIio/GeSYKkkptjxvYBpU3RtcibIXhdSaNSvboTLAKz91Es3OeaoUjHgSxfAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACQkJBgYCAgICAAAAAAAAAA=="
decoded_5_token_group = base64.b64decode(encoded_5_token_group)
group = layouts.GROUP_V2.parse(decoded_5_token_group)
# Not an exhaustive check, just a few key areas
assert len(group.tokens) == 5
assert group.tokens[0] == PublicKey("C6kYXcaRUMqeBF5fhg165RWU7AnpT9z92fvKNoMqjmz6")
assert group.tokens[1] == PublicKey("8p968u9m7jZzKSsqxFDqki69MjqdFkwPM9FN4AN8hvHR")
assert group.tokens[2] == PublicKey("So11111111111111111111111111111111111111112")
assert group.tokens[3] == PublicKey("9FbAMDvXqNjPqZSYt4EWTguJuDrGkfvwr3gSFpiSbX9S")
assert group.tokens[4] == PublicKey("7KBVenLz5WNH4PA5MdGkJNpDDyNKnBQTwnz1UqJv9GUm")
assert len(group.vaults) == 5
assert group.vaults[0] == PublicKey("9pTjFBB3xheuqR9iDG63x2TLZjeb6f3yCBZE6EjYtqV3")
assert group.vaults[1] == PublicKey("7HA5Ne1g2t8cRvzEYdoMwGJch1AneMQLiJRJccm1tw9y")
assert group.vaults[2] == PublicKey("CGj8exjKg88byyjRCEuYGB5CXvAqB1YzHEHrDiUFLwYK")
assert group.vaults[3] == PublicKey("ApX38vWvRybQHKoj6AsQHQDa7gQPChYkNHgqAj2kDxDo")
assert group.vaults[4] == PublicKey("CbcaxuYfe53NTX5eRUaRzxGyRyMLTt7JT6p2p6VZVnh7")
assert len(group.total_deposits) == 5
assert group.total_deposits[0] == Decimal("183689999284.100569858257342659537455")
assert group.total_deposits[1] == Decimal("1001289911999794.99978050195992499251")
assert group.total_deposits[2] == Decimal("2694842671710.10896760628261797877389")
assert group.total_deposits[3] == Decimal("1120935950.72574680115181388001879825")
assert group.total_deposits[4] == Decimal("16878577760340.8089556008013804037004")

42
tests/test_accountinfo.py Normal file
View File

@ -0,0 +1,42 @@
from .context import mango
from decimal import Decimal
from solana.publickey import PublicKey
def test_constructor():
address: PublicKey = PublicKey("11111111111111111111111111111118")
executable: bool = False
lamports: Decimal = Decimal(12345)
owner: PublicKey = PublicKey("11111111111111111111111111111119")
rent_epoch: Decimal = Decimal(250)
data: bytes = bytes([1, 2, 3])
actual = mango.AccountInfo(address, executable, lamports, owner, rent_epoch, data)
assert actual is not None
assert actual.logger is not None
assert actual.address == address
assert actual.executable == executable
assert actual.lamports == lamports
assert actual.owner == owner
assert actual.rent_epoch == rent_epoch
assert actual.data == data
def test_split_list_into_chunks():
list_to_split = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"]
split_3 = mango.AccountInfo._split_list_into_chunks(list_to_split, 3)
assert len(split_3) == 4
assert split_3[0] == ["a", "b", "c"]
assert split_3[1] == ["d", "e", "f"]
assert split_3[2] == ["g", "h", "i"]
assert split_3[3] == ["j"]
split_2 = mango.AccountInfo._split_list_into_chunks(list_to_split, 2)
assert len(split_2) == 5
assert split_2[0] == ["a", "b"]
assert split_2[1] == ["c", "d"]
assert split_2[2] == ["e", "f"]
assert split_2[3] == ["g", "h"]
assert split_2[4] == ["i", "j"]
split_20 = mango.AccountInfo._split_list_into_chunks(list_to_split, 20)
assert len(split_20) == 1
assert split_20[0] == ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"]

View File

@ -0,0 +1,54 @@
from .context import mango
from .fakes import fake_context
def test_account_liquidator_constructor():
succeeded = False
try:
mango.AccountLiquidator()
except TypeError:
# Can't instantiate the abstract base class.
succeeded = True
assert succeeded
def test_null_account_liquidator_constructor():
actual = mango.NullAccountLiquidator()
assert actual is not None
assert actual.logger is not None
def test_actual_account_liquidator_constructor():
context: mango.Context = fake_context()
wallet: mango.Wallet = {"fake": "Walle"}
actual = mango.ActualAccountLiquidator(context, wallet)
assert actual is not None
assert actual.logger is not None
assert actual.context == context
assert actual.wallet == wallet
def test_force_cancel_orders_account_liquidator_constructor():
context: mango.Context = fake_context()
wallet: mango.Wallet = {"fake": "Walle"}
actual = mango.ForceCancelOrdersAccountLiquidator(context, wallet)
assert actual is not None
assert actual.logger is not None
assert actual.context == context
assert actual.wallet == wallet
def test_reporting_account_liquidator_constructor():
inner = mango.NullAccountLiquidator()
context: mango.Context = fake_context()
wallet: mango.Wallet = {"fake": "Walle"}
liquidations_publisher: mango.EventSource[mango.LiquidationEvent] = mango.EventSource()
liquidator_name = "Test"
actual = mango.ReportingAccountLiquidator(inner, context, wallet, liquidations_publisher, liquidator_name)
assert actual is not None
assert actual.logger is not None
assert actual.inner == inner
assert actual.context == context
assert actual.wallet == wallet
assert actual.liquidations_publisher == liquidations_publisher
assert actual.liquidator_name == liquidator_name

View File

@ -0,0 +1,18 @@
from .context import mango
from solana.publickey import PublicKey
def test_scout_report_constructor():
address: PublicKey = PublicKey("11111111111111111111111111111112")
actual = mango.ScoutReport(address)
assert actual is not None
assert actual.address == address
assert actual.errors == []
assert actual.warnings == []
assert actual.details == []
def test_account_scout_constructor():
actual = mango.AccountScout()
assert actual is not None

View File

@ -0,0 +1,10 @@
from .context import mango
from .fakes import fake_account_info
def test_constructor():
account_info = fake_account_info()
actual = mango.AddressableAccount(account_info)
assert actual is not None
assert actual.logger is not None
assert actual.address == account_info.address

102
tests/test_aggregator.py Normal file
View File

@ -0,0 +1,102 @@
from .context import mango
from .fakes import fake_account_info, fake_seeded_public_key
from datetime import datetime, timedelta
from decimal import Decimal
from solana.publickey import PublicKey
def test_aggregator_config_constructor():
description: str = "Test Aggregator Config"
decimals: Decimal = Decimal(5)
restart_delay: Decimal = Decimal(30)
max_submissions: Decimal = Decimal(10)
min_submissions: Decimal = Decimal(2)
reward_amount: Decimal = Decimal(30)
reward_token_account: PublicKey = fake_seeded_public_key("reward token account")
actual = mango.AggregatorConfig(mango.Version.V1, description, decimals, restart_delay,
max_submissions, min_submissions, reward_amount, reward_token_account)
assert actual is not None
assert actual.logger is not None
assert actual.version == mango.Version.V1
assert actual.description == description
assert actual.decimals == decimals
assert actual.restart_delay == restart_delay
assert actual.max_submissions == max_submissions
assert actual.min_submissions == min_submissions
assert actual.reward_amount == reward_amount
assert actual.reward_token_account == reward_token_account
def test_round_constructor():
id: Decimal = Decimal(85)
updated_at: datetime = datetime.now()
created_at: datetime = updated_at - timedelta(minutes=5)
actual = mango.Round(mango.Version.V1, id, created_at, updated_at)
assert actual is not None
assert actual.logger is not None
assert actual.version == mango.Version.V1
assert actual.id == id
assert actual.created_at == created_at
assert actual.updated_at == updated_at
# def __init__(self, version: Version, round_id: Decimal, median: Decimal, created_at: datetime.datetime, updated_at: datetime.datetime):
def test_answer_constructor():
round_id: Decimal = Decimal(85)
median: Decimal = Decimal(25)
updated_at: datetime = datetime.now()
created_at: datetime = updated_at - timedelta(minutes=5)
actual = mango.Answer(mango.Version.V1, round_id, median, created_at, updated_at)
assert actual is not None
assert actual.logger is not None
assert actual.version == mango.Version.V1
assert actual.round_id == round_id
assert actual.median == median
assert actual.created_at == created_at
assert actual.updated_at == updated_at
def test_aggregator_constructor():
account_info = fake_account_info()
description: str = "Test Aggregator Config"
decimals: Decimal = Decimal(5)
restart_delay: Decimal = Decimal(30)
max_submissions: Decimal = Decimal(10)
min_submissions: Decimal = Decimal(2)
reward_amount: Decimal = Decimal(30)
reward_token_account: PublicKey = fake_seeded_public_key("reward token account")
config = mango.AggregatorConfig(mango.Version.V1, description, decimals, restart_delay,
max_submissions, min_submissions, reward_amount, reward_token_account)
initialized = True
name = "Test Aggregator"
owner = fake_seeded_public_key("owner")
id: Decimal = Decimal(85)
updated_at: datetime = datetime.now()
created_at: datetime = updated_at - timedelta(minutes=5)
round = mango.Round(mango.Version.V1, id, created_at, updated_at)
round_submissions = fake_seeded_public_key("round submissions")
round_id: Decimal = Decimal(85)
median: Decimal = Decimal(25)
answer = mango.Answer(mango.Version.V1, round_id, median, created_at, updated_at)
answer_submissions = fake_seeded_public_key("answer submissions")
actual = mango.Aggregator(account_info, mango.Version.V1, config, initialized,
name, owner, round, round_submissions, answer, answer_submissions)
assert actual is not None
assert actual.logger is not None
assert actual.account_info == account_info
assert actual.version == mango.Version.V1
assert actual.config == config
assert actual.initialized == initialized
assert actual.name == name
assert actual.owner == owner
assert actual.round == round
assert actual.round_submissions == round_submissions
assert actual.answer == answer
assert actual.answer_submissions == answer_submissions

Some files were not shown because too many files have changed in this diff Show More