example-python-marketmaker/4.CodeWalkthrough.md

13 KiB

🥭 Mango Explorer

🥭 Code Walkthrough

🥭 The Goal

Let's start with the most important bit of the code:

  while True:
      context.client.require_data_from_fresh_slot()
      model_state = model_state_builder.build(context)
      market_maker.pulse(context, model_state)

      time.sleep(PULSE_INTERVAL)

This is the core marketmaking loop - the code runs this to fetch data, place orders, and then sleep for a bit before repeating.

The rest of the code exists to set things up so the above few lines can run.

🥭 Preliminaries

There are a bunch of imports - all needed but not very interesting.

Then there are some constants to define what market the marketmaker runs against, where it fetches prices, and how frequently it places orders:

MARKET = "BTC-PERP"
ORACLE = "ftx"
PULSE_INTERVAL = 10

Then there's a basic logging setup. This will output a lot of information, so later on you may choose to reduce the log volume by changing, for example, logging.DEBUG to logging.INFO):

mango.setup_logging(logging.DEBUG, False)

🥭 The Context, Wallet and Account

This section performs some one-off data loading from different sources.

To connect to Solana with mango-explorer, you need to set up a Context object. There are many possible parameters to mango.ContextBuilder.build(), but here we just specify devnet and some retry pauses.

context = mango.ContextBuilder.build(
    cluster_name="devnet", stale_data_pauses_before_retry=[0.1, 0.2, 0.3, 0.5, 1]
)

The retry pauses are worth explaining a bit. Fetching from load-balanced Solana RPC nodes sometimes gets 'stale' data from an earlier block or slot. This usually isn't a big deal - if your wallet balance is out of date by a second or two it usually doesn't matter. It can matter a lot with a marketmaker, so this setting configures the Context to check to see if the data it has just received comes from a slot earlier than the latest slot it has received data from. If this happens, the Context automatically retries your request after a pause. How many times it retries, and how long the pauses are, is governed by the stale_data_pauses_before_retry parameter - each value is the duration of a pause after being sent stale data. If the problem persists for all of the retries, a StaleSlotException will be raised.

Next, we load the Wallet. Here we do it from an id.json file but you can hard-code the key if like, or load it from an environment variable if that's your thing.

wallet = mango.Wallet.load("id.json")

The Mango Group and Account objects are important data structures. The Group holds the shared market listings and data for all users. The Account holds the per-user data.

group = mango.Group.load(context, context.group_address)
accounts = mango.Account.load_all_for_owner(context, wallet.address, group)
if len(accounts) == 0:
    raise Exception(f"No Mango Accounts for wallet {wallet.address}")
account = accounts[0]

OK, pedants, a user wallet can have multiple Accounts which is why we need to load all of them and choose the first one. So the Account data is really per-wallet-account rather than strictly per-user, but the principle is: data in Group applies to everyone, data in Account is specific to that 'sub-account' for that wallet.

🥭 The Market

Next up is loading the Market data and building the MarketInstructionBuilder for that wallet and market.

market_stub = context.market_lookup.find_by_symbol(MARKET)
if market_stub is None:
    raise Exception(f"Could not find market {MARKET}")
market = mango.ensure_market_loaded(context, market_stub)

instruction_builder = mango.PerpMarketInstructionBuilder(
    context, wallet, market, group, account
)

The Market object holds per-market data (obviously), but the MarketInstructionBuilder is a bit more interesting.

A MarketInstructionBuilder allows you to build Solana Instructions that will form part of a Solana Transaction to be executed. Instructions are how you perform operations in Solana, and the MarketInstructionBuilder is a simplified way of creating Instructions specific to that particular market.

Some examples of instruction types a MarketInstructionBuilder allows you to build are:

  • place order
  • cancel order
  • settle
  • crank

The marketmaker will use these as its way of performing those actions.

It's worth noting however that the marketmaker uses a MarketInstructionBuilder but we're building a more specific PerpMarketInstructionBuilder. There are other MarketInstructionBuilders too - a SerumMarketInstructionBuilder and a SpotMarketInstructionBuilder. They have the same interface so allow the same operations to be performed, just on a different market type.

The code here works against a perp market, but it could easily be adapted to work against a spot market instead.

🥭 The Oracle

To run a marketmaker we need a way of deciding what price to use. For our purposes we're going to fetch prices from FTX - the ORACLE constant was defined up in the 'Preliminaries' section.

oracle_provider: mango.OracleProvider = mango.create_oracle_provider(
    context, ORACLE
)
oracle = oracle_provider.oracle_for_market(context, market)

The oracle is an object with (among others) a fetch_price() method to fetch the latest price from the oracle source (FTX in this case).

🥭 The Marketmaker Chain

This is where things start getting interesting!

A marketmaker wants to have BUY and SELL orders on the market orderbook, at specific prices and quantities.

One way to do that is to repeatedly cancel and replace orders every 'pulse' of the marketmaker.

Another way to do it is to:

  • calculate a set of 'desired' orders, and
  • check to see if the marketmaker already has those orders on the orderbook, and
  • place any orders that don't exist, and cancel any that shouldn't exist

The mango-explorer marketmaker takes this second 'desired orders' approach. Desired orders are built by an 'order chain'.

An order chain is a sequence of Element objects, each of which, in turn, gets a chance to see and modify the desired order list before the marketmaker processes them.

The first Element is usually the one responsible for creating the orders for subsequent Elements to modify, but that's just a convention. Any Element can add desired orders.

Our marketmaker is quite simple - it uses a RatiosElement to create orders, followed by a RoundToLotSizeElement to properly round prices and quantities.

ratios_element = RatiosElement(
    mango.OrderType.POST_ONLY_SLIDE, [Decimal("0.001")], [Decimal("0.05")], False
)
round_to_lot_size_element = RoundToLotSizeElement()
desired_orders_chain = Chain([ratios_element, round_to_lot_size_element])

The RatiosElement uses POST_ONLY_SLIDE as the order type. This will ensure a marketmaker order never 'crosses the book' and becomes filled, but will instead be placed one tick inside the current best price. (You can instead choose POST_ONLY to have the order cancelled if it would cross the spread, or LIMIT if you want it to be filled in that situation.)

The next two parameters are arrays of ratio values for:

  • spread
  • position size

This configuration of RatiosElement will create a desired BUY and SELL order with a spread of 0.001, or 0.1%. The quantity will be 0.05, or 5% of your current account value.

Since both the spread and position size parameters are arrays, you can specify multiple values here. For example:

ratios_element = RatiosElement(
    mango.OrderType.POST_ONLY_SLIDE, [Decimal("0.001"), Decimal("0.003")], [Decimal("0.05"), Decimal("0.1")], False
)

would create two sets of desired BUY and SELL orders - one set with a spread of 0.1% and a position size of 5%, and a second set with a spread of 0.3% and a position size of 10%. What's important is that the length of both arrays match - it's an error to specify, say, 3 spread ratios but only 2 position size ratios.

The RoundToLotSizeElement is comparatively boring. It just rounds the prices and quantities to align with the market's lot sizes.

This only scratches the surface of what Elements can do. There are a lot more possible Elements that can be added to your 'order chain', and of course you can create your own too.

🥭 Order Reconciliation

At the end of the 'order chain', what comes out is a set of 'desired orders'. The next step is checking the orderbook to see if these orders already exist, or if any need to be added or cancelled. This process is 'order reconciliation'.

Our marketmaker uses a ToleranceOrderReconciler to allow a bit of leeway in deciding if a desired order matchs an existing order:

order_reconciler = mango.marketmaking.ToleranceOrderReconciler(
    Decimal("0.001"), Decimal("0.001")
)

The two parameters - both 0.1% - will match an existing order with a desired order if the desired order's price and quantity are within 0.1% of the existing order's price and quantity. If orders match, they are kept. If orders do not match, existing orders are cancelled and new orders are placed.

This happens at a granular level. It's possible for 1 order to match and 3 orders not to match, in which case 1 existing order is kept, 3 existing orders are cancelled and 3 desired orders are placed.

You can adjust the tolerance by changing the parameters to your satisfaction, or you can turn this off entirely instead by using an AlwaysReplaceOrderReconciler which will always place every desired order and will never keep any existing orders.

🥭 Create the ModelStateBuilder

Every 'pulse' of the marketmaker gathers a fresh set of data to work with. This data includes account balances and prices as well as the orderbook. This data 'model state' is then passed to each Element in turn so it can perform whatever calculations it needs to derive the right desired orders.

The model state includes properties such as:

  • group
  • account
  • price
  • inventory
  • orderbook
  • bids
  • asks
  • top_bid
  • top_ask
  • spread

Since the model state may be built for every pulse, what we create is a 'factory' for building it - an object with enough data to be able to build the model state on demand.

model_state_builder = mango.marketmaking.PerpPollingModelStateBuilder(
    account.address, market, oracle, group.address, group.cache
)

🥭 Create the Marketmaker

With all that done, creating the marketmaker instance is easy:

market_maker = mango.marketmaking.MarketMaker(
    wallet,
    market,
    instruction_builder,
    desired_orders_chain,
    order_reconciler,
    None,
)

🥭 Pulse the Marketmaker

Finally! Finally we get to the code mentioned at the very beginning:

while True:
    context.client.require_data_from_fresh_slot()
    model_state = model_state_builder.build(context)
    market_maker.pulse(context, model_state)

    time.sleep(PULSE_INTERVAL)

A simple loop that runs forever, or until there's an error. (The code here will exit when there's an error, and that's maybe what you want during development. In production you will probably prefer different error logging and handling.)

The loop does 4 things:

  1. Tells our 'stale data' monitor that we want data fresher than any it has seen before. (This is after a pause so the latest slot we know of is already stale.)
  2. Builds a fresh model state from the ModelStateBuilder created earlier.
  3. 'Pulses' the marketmaker with this fresh model state. This, in turn, calls each Element in the order chain, performs the order reconciliation, and sends the order place and cancel instructions.
  4. Pauses for PULSE_INTERVAL seconds.

🥭 Cleaning Up

If there's an error or the user presses Control-C, the code stops. KeyboardInterrupt is ignored but any error is printed out.

Then it's a matter of cancelling any existing orders before exiting.

The payer is a special 'signing' datastructure that is used to sign transactions before sending them to Solana.

payer = mango.CombinableInstructions.from_wallet(wallet)

The PerpMarketInstructionBuilder has a special instruction for cancelling all orders - this isn't available in Spot and Serum markets, so to work there you'd need to fetch the account's current orders and cancel them individually.

cancel_all = instruction_builder.build_cancel_all_orders_instructions()

Both payer and cancel_all are CombinableInstructions, so to execute them you can add them together and then call execute() on the result:

cancel_all_signatures = (payer + cancel_all).execute(context)

🦮 Support

🥭 Mango Markets support is available at: Docs | Discord | Twitter | Github | Email