How We Built an Inventory-Aware Market Making Strategy for Crypto Perpetuals
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:
-
Use
orderIntervalas the order frequency—for example, check, calculate, and place orders every second. -
Cancel existing orders before placing new ones.
-
Calculate the current market mid-price from the best bid and ask.
-
Retrieve the current long and short positions.
-
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.
-
Submit buy and sell orders.
-
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.