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 Account
s 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 Instruction
s that will form part of a Solana Transaction
to be executed. Instruction
s are how you perform operations in Solana, and the MarketInstructionBuilder
is a simplified way of creating Instruction
s 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 MarketInstructionBuilder
s 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 Element
s can do. There are a lot more possible Element
s 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:
- 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.)
- Builds a fresh model state from the
ModelStateBuilder
created earlier. - '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. - 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