Back to Ccxt

Market making: the strategy exchanges literally pay you to run

website/content/blog/market-making-with-ccxt.mdx

4.5.6514.8 KB
Original Source

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.

The business model

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:

  • Spread captured per round trip (your quoted spread, minus adverse price movement).
  • Maker fees — negotiate the tier; makers live and die on fee schedules. Check exchange.markets[symbol]['maker'].
  • Inventory losses — the reason this isn't free money, and the subject of most of the literature.

Inventory: the real enemy

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.

A minimal quoting loop

javascript
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();
}
python
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
<?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());
}
csharp
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();
}
go
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)
    }
}
java
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.

Risk controls that are not optional

  • Hard inventory bounds — the skeleton stops quoting one side at the bound. Real systems also actively unwind back toward flat.
  • A kill switch you have tested — one command that cancels everything and flattens. 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.
  • Spread floors during volatility. When the book thins out or trades start printing through levels, widen or pull quotes. The makers who survive news events are the ones who weren't quoting through them.
  • Fee-aware spread. Your quoted spread must exceed round-trip fees plus expected adverse selection. If your maker fee is 0.02% per side, a 0.1% spread nets 0.06% before adverse selection — which will claim most of it.

Where this goes next

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.