website/content/blog/market-making-with-ccxt.mdx
Every trade needs a counterparty, and most of the time the counterparty is a market maker: someone continuously quoting both a buy price and a sell price, earning the gap between them. Exchanges want that liquidity so badly they charge makers lower fees than takers — often zero, sometimes negative (a rebate). This post explains the mechanics and builds a minimal quoting loop with CCXT — a teaching skeleton, not a production system, and market making with real size is a genuinely hard, competitive business.
A market maker places a limit buy below the current price and a limit sell above it. When both fill — someone sells into your bid, someone else buys from your ask — you've bought low and sold high, capturing the spread, and you're back to flat. Repeat thousands of times.
Three numbers govern the P&L:
exchange.markets[symbol]['maker'].If price trends down, your bids keep filling and your asks don't — you accumulate the asset all the way down. This is adverse selection: the people trading with you know something (or are simply the front edge of a move), and you're systematically on the wrong side of it.
Every serious market-making model is at heart an inventory-control policy. The classic reference is Avellaneda–Stoikov (2008): quote around a reservation price that leans away from your inventory — long inventory pushes both quotes down (eager to sell, reluctant to buy), short inventory pushes both up. Our skeleton below uses the simplest version of that idea: a linear skew.
import ccxt from 'ccxt';
const SYMBOL = 'BTC/USDT';
const SPREAD = 0.002; // 20 bps quoted spread, fees-inclusive
const ORDER_SIZE = 0.001;
const MAX_INVENTORY = 0.005; // hard position bound, in base currency
const REFRESH = 5000; // ms between re-quotes
const exchange = new ccxt.pro.binance({ apiKey: '...', secret: '...' });
exchange.setSandboxMode(true); // learn on the testnet
await exchange.loadMarkets();
let inventory = 0; // track via watchMyTrades in real code
try {
while (true) {
const book = await exchange.watchOrderBook(SYMBOL);
const mid = (book.bids[0][0] + book.asks[0][0]) / 2;
// lean quotes against inventory (poor man's Avellaneda-Stoikov)
const skew = -(inventory / MAX_INVENTORY) * (SPREAD / 2);
const bid = exchange.priceToPrecision(SYMBOL, mid * (1 - SPREAD / 2 + skew));
const ask = exchange.priceToPrecision(SYMBOL, mid * (1 + SPREAD / 2 + skew));
await exchange.cancelAllOrders(SYMBOL);
if (inventory < MAX_INVENTORY) {
await exchange.createOrder(SYMBOL, 'limit', 'buy', ORDER_SIZE, bid, { postOnly: true });
}
if (inventory > -MAX_INVENTORY) {
await exchange.createOrder(SYMBOL, 'limit', 'sell', ORDER_SIZE, ask, { postOnly: true });
}
await exchange.sleep(REFRESH);
}
} finally {
await exchange.cancelAllOrders(SYMBOL); // never exit with quotes resting
await exchange.close();
}
import asyncio
import ccxt.pro as ccxtpro
SYMBOL = 'BTC/USDT'
SPREAD = 0.002 # 20 bps quoted spread, fees-inclusive
ORDER_SIZE = 0.001
MAX_INVENTORY = 0.005 # hard position bound, in base currency
REFRESH = 5 # seconds between re-quotes
async def main():
exchange = ccxtpro.binance({'apiKey': '...', 'secret': '...'})
exchange.set_sandbox_mode(True) # learn on the testnet
await exchange.load_markets()
inventory = 0.0 # track via watch_my_trades in real code
try:
while True:
book = await exchange.watch_order_book(SYMBOL)
mid = (book['bids'][0][0] + book['asks'][0][0]) / 2
# lean quotes against inventory (poor man's Avellaneda-Stoikov)
skew = -(inventory / MAX_INVENTORY) * (SPREAD / 2)
bid = exchange.price_to_precision(SYMBOL, mid * (1 - SPREAD / 2 + skew))
ask = exchange.price_to_precision(SYMBOL, mid * (1 + SPREAD / 2 + skew))
await exchange.cancel_all_orders(SYMBOL)
if inventory < MAX_INVENTORY:
await exchange.create_order(SYMBOL, 'limit', 'buy', ORDER_SIZE, bid,
{'postOnly': True})
if inventory > -MAX_INVENTORY:
await exchange.create_order(SYMBOL, 'limit', 'sell', ORDER_SIZE, ask,
{'postOnly': True})
await asyncio.sleep(REFRESH)
finally:
await exchange.cancel_all_orders(SYMBOL) # never exit with quotes resting
await exchange.close()
asyncio.run(main())
<?php
use function React\Async\await;
const SYMBOL = 'BTC/USDT';
const SPREAD = 0.002; // 20 bps quoted spread, fees-inclusive
const ORDER_SIZE = 0.001;
const MAX_INVENTORY = 0.005; // hard position bound, in base currency
const REFRESH = 5; // seconds between re-quotes
$exchange = new \ccxt\pro\binance(['apiKey' => '...', 'secret' => '...']);
$exchange->set_sandbox_mode(true); // learn on the testnet
await($exchange->load_markets());
$inventory = 0.0; // track via watch_my_trades in real code
$lastQuote = 0;
try {
while (true) {
$book = await($exchange->watch_order_book(SYMBOL));
if (time() - $lastQuote < REFRESH) {
continue; // re-quote at most every REFRESH seconds
}
$lastQuote = time();
$mid = ($book['bids'][0][0] + $book['asks'][0][0]) / 2;
// lean quotes against inventory (poor man's Avellaneda-Stoikov)
$skew = -($inventory / MAX_INVENTORY) * (SPREAD / 2);
$bid = $exchange->price_to_precision(SYMBOL, $mid * (1 - SPREAD / 2 + $skew));
$ask = $exchange->price_to_precision(SYMBOL, $mid * (1 + SPREAD / 2 + $skew));
await($exchange->cancel_all_orders(SYMBOL));
if ($inventory < MAX_INVENTORY) {
await($exchange->create_order(SYMBOL, 'limit', 'buy', ORDER_SIZE, $bid,
['postOnly' => true]));
}
if ($inventory > -MAX_INVENTORY) {
await($exchange->create_order(SYMBOL, 'limit', 'sell', ORDER_SIZE, $ask,
['postOnly' => true]));
}
}
} finally {
await($exchange->cancel_all_orders(SYMBOL)); // never exit with quotes resting
await($exchange->close());
}
using ccxt;
const string SYMBOL = "BTC/USDT";
const double SPREAD = 0.002; // 20 bps quoted spread, fees-inclusive
const double ORDER_SIZE = 0.001;
const double MAX_INVENTORY = 0.005; // hard position bound, in base currency
const int REFRESH = 5000; // ms between re-quotes
var exchange = new ccxt.pro.binance(new Dictionary<string, object>() {
{ "apiKey", "..." }, { "secret", "..." },
});
exchange.setSandboxMode(true); // learn on the testnet
await exchange.LoadMarkets();
var inventory = 0.0; // track via WatchMyTrades in real code
try
{
while (true)
{
var book = await exchange.WatchOrderBook(SYMBOL);
var mid = (book.bids[0][0] + book.asks[0][0]) / 2;
// lean quotes against inventory (poor man's Avellaneda-Stoikov)
var skew = -(inventory / MAX_INVENTORY) * (SPREAD / 2);
var bid = Convert.ToDouble(exchange.priceToPrecision(SYMBOL, mid * (1 - SPREAD / 2 + skew)));
var ask = Convert.ToDouble(exchange.priceToPrecision(SYMBOL, mid * (1 + SPREAD / 2 + skew)));
await exchange.CancelAllOrders(SYMBOL);
var parameters = new Dictionary<string, object>() { { "postOnly", true } };
if (inventory < MAX_INVENTORY)
{
await exchange.CreateOrder(SYMBOL, "limit", "buy", ORDER_SIZE, bid, parameters);
}
if (inventory > -MAX_INVENTORY)
{
await exchange.CreateOrder(SYMBOL, "limit", "sell", ORDER_SIZE, ask, parameters);
}
await Task.Delay(REFRESH);
}
}
finally
{
await exchange.CancelAllOrders(SYMBOL); // never exit with quotes resting
await exchange.Close();
}
package main
import (
"strconv"
"time"
ccxt "github.com/ccxt/ccxt/go/v4"
ccxtpro "github.com/ccxt/ccxt/go/v4/pro"
)
const (
symbol = "BTC/USDT"
spread = 0.002 // 20 bps quoted spread, fees-inclusive
orderSize = 0.001
maxInventory = 0.005 // hard position bound, in base currency
refresh = 5 * time.Second
)
func toFloat(v any) float64 {
f, _ := strconv.ParseFloat(v.(string), 64)
return f
}
func main() {
exchange := ccxtpro.NewBinance(nil)
exchange.ApiKey = "..."
exchange.Secret = "..."
exchange.SetSandboxMode(true) // learn on the testnet
exchange.LoadMarkets()
inventory := 0.0 // track via WatchMyTrades in real code
defer func() {
exchange.CancelAllOrders(ccxt.WithCancelAllOrdersSymbol(symbol)) // never exit with quotes resting
exchange.Close()
}()
for {
book, err := exchange.WatchOrderBook(symbol)
if err != nil {
panic(err)
}
mid := (book.Bids[0][0] + book.Asks[0][0]) / 2
// lean quotes against inventory (poor man's Avellaneda-Stoikov)
skew := -(inventory / maxInventory) * (spread / 2)
bid := toFloat(exchange.PriceToPrecision(symbol, mid*(1-spread/2+skew)))
ask := toFloat(exchange.PriceToPrecision(symbol, mid*(1+spread/2+skew)))
exchange.CancelAllOrders(ccxt.WithCancelAllOrdersSymbol(symbol))
params := map[string]interface{}{"postOnly": true}
if inventory < maxInventory {
exchange.CreateOrder(symbol, "limit", "buy", orderSize,
ccxt.WithCreateOrderPrice(bid), ccxt.WithCreateOrderParams(params))
}
if inventory > -maxInventory {
exchange.CreateOrder(symbol, "limit", "sell", orderSize,
ccxt.WithCreateOrderPrice(ask), ccxt.WithCreateOrderParams(params))
}
time.Sleep(refresh)
}
}
import io.github.ccxt.exchanges.pro.Binance;
import java.util.Map;
public class MarketMaker {
static final String SYMBOL = "BTC/USDT";
static final double SPREAD = 0.002; // 20 bps quoted spread, fees-inclusive
static final double ORDER_SIZE = 0.001;
static final double MAX_INVENTORY = 0.005; // hard position bound, in base currency
static final long REFRESH = 5000; // ms between re-quotes
public static void main(String[] args) throws InterruptedException {
var exchange = new Binance();
exchange.apiKey = "...";
exchange.secret = "...";
exchange.setSandboxMode(true); // learn on the testnet
exchange.loadMarkets(false);
var inventory = 0.0; // track via watchMyTrades in real code
try {
while (true) {
var book = exchange.watchOrderBook(SYMBOL);
var mid = (book.bids.get(0).get(0) + book.asks.get(0).get(0)) / 2;
// lean quotes against inventory (poor man's Avellaneda-Stoikov)
var skew = -(inventory / MAX_INVENTORY) * (SPREAD / 2);
var bid = Double.parseDouble((String) exchange.priceToPrecision(SYMBOL,
mid * (1 - SPREAD / 2 + skew)));
var ask = Double.parseDouble((String) exchange.priceToPrecision(SYMBOL,
mid * (1 + SPREAD / 2 + skew)));
exchange.cancelAllOrders(SYMBOL, null);
var params = Map.<String, Object>of("postOnly", true);
if (inventory < MAX_INVENTORY) {
exchange.createOrder(SYMBOL, "limit", "buy", ORDER_SIZE, bid, params);
}
if (inventory > -MAX_INVENTORY) {
exchange.createOrder(SYMBOL, "limit", "sell", ORDER_SIZE, ask, params);
}
Thread.sleep(REFRESH);
}
} finally {
exchange.cancelAllOrders(SYMBOL, null); // never exit with quotes resting
exchange.close().join();
}
}
}
What the CCXT pieces are doing:
watchOrderBook streams the book over WebSockets (see
the WebSocket post) — quoting off polled REST
data means quoting off stale prices.postOnly: true guarantees your order rests as a maker. If it would cross the spread
and execute as a taker (paying taker fees and taking the wrong side), the exchange rejects
it instead. Non-negotiable for a maker strategy.cancelAllOrders + re-place is the simple re-quote. Where the exchange supports it
(check exchange.has['editOrder']), editOrder amends price in place — fewer round
trips, and you often keep queue position on exchanges that preserve it for size-down
amendments.priceToPrecision because quotes at invalid ticks are rejected — see
mistake #1.What's deliberately missing: real inventory tracking (subscribe to watchMyTrades and
update inventory on every fill), volatility-aware spread sizing, order-book-imbalance
signals, and multi-level quoting. Those are your roadmap, in that order.
cancelAllOrders exists on the unified API for exactly this. Some exchanges also support
dead-man's-switch semantics (auto-cancel on disconnect) via exchange-specific parameters —
worth wiring up where available.Run the skeleton on a testnet against a liquid pair and watch what happens to inventory
during a trend — that lesson is worth more than any parameter tuning. Then read
Avellaneda–Stoikov and the queue-position literature, and look at how the open-source
market-making bots structure the same loop. The plumbing — books, quotes, cancels, fills —
is the same six-language unified API throughout, which means your first market maker can be
built in the language your team already speaks.