How We Built an Inventory-Aware Market Making Strategy for Crypto Perpetuals

DolphinDB
2026-05-28

Introduction

Market making is one of the most demanding strategies in quantitative trading. A market maker simultaneously quotes both sides of the order book — posting a bid and an ask at all times — and profits from the spread between them. Do it well, and the spread income compounds steadily. Do it poorly, and adverse price moves leave you holding an inventory position you never wanted, at a loss you didn't price in.

The core challenge is inventory risk. Every filled order skews your position. If you're not actively adjusting your quotes in response, you accumulate directional exposure that can quickly erode spread profits.

The Avellaneda-Stoikov (AS) model , introduced in the 2008 paper High-Frequency Trading in a Limit Order Book by Marco Avellaneda and Sasha Stoikov, is one of the most influential frameworks for addressing exactly this problem. Rather than quoting symmetrically around the mid-price, the model continuously adjusts quotes based on current inventory, market volatility, and the market maker's risk tolerance — dynamically balancing spread income against inventory risk.

This post walks through a full implementation of the AS market-making strategy on the BTC/USDT perpetual contract, backtested at snapshot frequency using DolphinDB's cryptocurrency backtesting engine.

The Avellaneda-Stoikov Model

The AS model computes optimal quotes in two steps.

Step 1: Reservation Price

The market maker first calculates a reservation price — the price at which they are indifferent between holding and trading, given their current inventory:

  • s : market mid-price

  • q : market maker’s current inventory

  • γ : market maker’s risk aversion coefficient

  • σ : market price volatility

  • T : normalized end time

  • t : current time

The intuition is straightforward: the larger the inventory or the higher the volatility, the more the reservation price shifts away from mid, reducing the incentive to accumulate further exposure.

Step 2: Optimal Spread

The model then computes the optimal bid-ask spread around the reservation price:

  • δ_a , δ_b : symmetric bid-ask spread

  • γ , σ , T , t : same as the previous formula

  • k : market liquidity

This formula shows that a higher risk aversion, higher market volatility, or better market liquidity leads to a larger optimal spread for the market maker, compensating for risk.

Final Quotes

Strategy Implementation

Initialization

The initialize callback runs once when the engine starts. It sets the AS model parameters and initializes state variables for tracking prices and generating daily trade summaries:

def initialize(mutable context){
    print("initialize")
    Backtest::setUniverse(context["engine"], context.Universe)
    // Onsnapshot Parameters
    context["sigma"] = 0.025                                    // market volatility
    context["gamma"] = 0.1                                      // inventory risk aversion parameter
    context["k"] = 1.5                                          // order book liquidity parameter
    context["amount"] = 0.001                                    // order amount
    //...
    context["lastprice"] = NULL
    // Daily Trade Summary
    context['dailyReport'] = table(1000:0,
        [`SecurityID,`tradeDate,`BuyVolume,`BuyAmount,`SellVolume,`SellAmount,`transactionCost,`closePrice,`rev],
        [SYMBOL,DATE,DOUBLE,DOUBLE,DOUBLE,DOUBLE,DOUBLE,DOUBLE,DOUBLE])
}

Snapshot Callback

The core strategy logic runs in onSnapshot, which fires on every incoming order book snapshot. The logic follows seven steps:

  1. Use orderInterval as the order frequency—for example, check, calculate, and place orders every second.

  2. Cancel existing orders before placing new ones.

  3. Calculate the current market mid-price from the best bid and ask.

  4. Retrieve the current long and short positions.

  5. Compute quotes based on the AS model. Note the decimal precision of the order price—for BTC/USDT, the minimum price increment is 0.1.

  6. Submit buy and sell orders.

  7. Manage inventory risk and close positions in a timely manner.

def onSnapshot(mutable context, msg, indicator){
    istock = context["istock"][0]
    if(context["lastprice"]<0){
        context["lastprice"] = msg[istock].lastPrice
    }
    // 1. Set frequency of each order
    t = msg[istock]["timestamp"]
    if(t < context["orderTime"]){return}
    context["orderTime"] = context["orderTime"] + context["orderInterval"]
    // 2. Cancel previous orders before submitting new one
    openOrders = Backtest::getOpenOrders(context["engine"],istock, , , "futures")
    if(count(openOrders)>0){
    Backtest::cancelOrder(context["engine"],istock)
    }
    // 3. Calculate Mid Price
    askPrice0 = msg[istock]["offerPrice"][0]
    bidPrice0 = msg[istock]["bidPrice"][0]
    midPrice = (askPrice0+bidPrice0)/2
    // 4. Get Position
    pos = Backtest::getPosition(context["engine"],istock,"futures")
    longPos = pos['longPosition']
    shortPos = pos['shortPosition']
    netPos = nullFill(pos['longPosition']-pos['longPosition'],0)
    if(count(netPos) == 0){
        netPos = 0
    }
    // 5. Calculate order price
    gamma = context["gamma"]
    sigma = context["sigma"]
    k = context["k"]
    amount = context["amount"]
    endTime = timestamp(context["tradeDate"])
    timeToEnd = (endTime-t)\86400000
    reservePrice = midPrice - netPos * gamma * square(sigma) * timeToEnd
    spread = gamma * square(sigma) * timeToEnd + (2 / gamma) * log(1 + (gamma / k))
    buyPrice = reservePrice-0.5*spread
    sellPrice = reservePrice+0.5*spread
    buyPrice = round(buyPrice, 1)
    sellPrice = round(sellPrice, 1)
    // 6. Set order direction
    sellDirection = 3
    buyDirection = 4
    if(count(longPos) == 0 || longPos == 0){
    buyDirection = 1
    }
    if(count(shortPos) == 0 || shortPos == 0){
    sellDirection = 2
    }
    // 7. Submit buy orders
    Backtest::submitOrder(
    context["engine"],
    (istock, 'Binance', context["tradeTime"], 5, buyPrice,0, 1000000, amount, buyDirection, 0, 0, endTime),
    "buy", 0, "futures")
    // 8. Submit sell orders
    Backtest::submitOrder(
    context["engine"],
     (istock, 'Binance', context["tradeTime"], 5, sellPrice, 0, 1000000, amount, sellDirection, 0, 0, endTime),
    "sell", 0, "futures")
}

Finalization

The finalize callback runs at the end of each trading session. It cancels any remaining open orders and appends a post-trading log entry:

def finalize(mutable context){
    tradeDate = context["tradeDate"]
    print("afterTrading: "+tradeDate)
    // Cancel all open orders
    Backtest::cancelOrder(context["engine"],context["istock"][0])
    // AfterTrading Log
    tb = context["log"]
    context["log"] = tb.append!(table(context["tradeDate"] as tradeDate,now() as time,"afterTrading" as info))
}

Running the Backtest

strategyName = "cryptocurrencyStrategy"  //When saving the code, the name must match the module name
eventCallbacks = {
    "initialize":initialize,
    "beforeTrading": beforeTrading,
    "onSnapshot":onSnapshot,
    "finalize":finalize
}
strategyType = 0  // Backtest — default is 10 days, but you can customize start and end time via userConfig
engine,straname_= CryptocurrencySolution::manageScripts::runCryptoAndUploadToGit(strategyName, eventCallbacks,strategyType)

The full strategy script is provided in the active_market-making.dos.

Conclusion

The Avellaneda-Stoikov model remains one of the most elegant and practical frameworks in algorithmic market making. By grounding quote placement in a principled treatment of inventory risk, it transforms what could be a reactive, ad hoc process into a mathematically disciplined one — dynamically shifting quotes in response to position, volatility, and time horizon.

Backtesting this strategy at snapshot frequency with DolphinDB provides a realistic and high-fidelity environment for evaluating its behavior. The engine's support for order book snapshots, per-tick callbacks, and detailed position tracking makes it well-suited for strategies that depend on microstructure data rather than just OHLC bars. And as with other strategies in this framework, the same callback structure used in backtesting carries directly into live trading — so validating the model in simulation is a meaningful step toward real deployment.

For those looking to extend the strategy, natural next steps include estimating σ and k dynamically from live order book data, adding a hard inventory limit to cap maximum exposure, or adapting the model to a multi-asset setting where correlations between positions can be exploited.