mango-explorer/TradeExecutor.ipynb

451 lines
20 KiB
Plaintext
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{
"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
}