Introduction

Every time you buy a stock, someone must sell it to you — and that someone is often a market maker. Market makers post two prices simultaneously: a bid (the price they will buy at) and an ask (the price they will sell at). The tiny gap between them is the spread, and it is their reward for providing liquidity.

The job sounds simple — always sit in the middle and pocket the spread. But reality bites: every trade tilts the market maker's inventory. If many buyers arrive in a row, the maker sells more than it buys and accumulates a short position. If the price then rises, that position loses money, potentially wiping out many spreads earned earlier.

The challenge is therefore not just how wide to set the spread, but where to center it. A rational market maker who holds too much of an asset should quote a lower ask to unload it — and vice versa. This inventory skew is the key insight that Marco Avellaneda and Sasha Stoikov formalized in their landmark 2008 paper, turning an intuitive trading heuristic into a precise mathematical solution derived from stochastic optimal control.

Related ideas appear across quantitative finance: see also optimal stopping for problems where timing — not quoting — is the core decision.

Try It: Watch the Quotes Lean

The simulation below runs the Avellaneda-Stoikov model in real time. A stream of random buy and sell orders arrives and the market maker fills them, building up inventory. Watch the bid and ask lines shift as the inventory grows.

<!-- Avellaneda-Stoikov market-making demo: live bid/ask with inventory skew -->
<div class="controls">
  <label>Risk aversion γ: <strong id="gamma-val">0.1</strong>
    <input type="range" id="gamma" min="0.01" max="0.5" step="0.01" value="0.1">
  </label>
  <label>Spread: <strong id="spread-val">0.10</strong>
    <input type="range" id="spread" min="0.02" max="0.40" step="0.02" value="0.10">
  </label>
  <button id="reset-btn" type="button">Reset</button>
</div>
<canvas id="chart" width="560" height="230"></canvas>
<div class="stats">
  <span>Inventory: <b id="inv-val">0</b></span>
  <span>Bid: <b id="bid-val">-</b></span>
  <span>Ask: <b id="ask-val">-</b></span>
  <span>P&L: <b id="pnl-val">0.00</b></span>
</div>
<div class="status" id="status"></div>
/* Base styles for the market-making demo */
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; color: #222; }
.controls {
  display: flex; gap: .6rem; align-items: center; flex-wrap: wrap;
  margin-bottom: .5rem;
}
label {
  display: flex; align-items: center; gap: .35rem;
  font-size: .82rem; color: #444;
}
input[type=range] { width: 80px; cursor: pointer; }
button {
  font: 600 13px system-ui; padding: .35rem .75rem;
  border: 1px solid #1d3557; background: #fff; color: #1d3557;
  border-radius: 7px; cursor: pointer;
}
button:hover { background: #f0f4f8; }
canvas { display: block; border-radius: 8px; background: #f7f9fc;
         border: 1px solid #dde3ea; max-width: 100%; }
.stats {
  display: flex; gap: 1rem; flex-wrap: wrap;
  margin-top: .45rem; font-size: .84rem;
}
.stats b { color: #1d3557; }
/* Status message colors: green for buy fill, red for sell fill */
.status { font-size: .88rem; font-weight: 600; min-height: 1.3em; margin-top: .3rem; }
.status.buy  { color: #0a7d33; }
.status.sell { color: #c92f3c; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));

// Avellaneda-Stoikov (2008): set bid/ask so that the reservation price
// r = S - q * gamma * sigma^2 * (T - t) leans against inventory q.
// Half-spread is fixed by the slider; the model centers it on r, not S.

const canvas = document.getElementById('chart');
const ctx    = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;

// Simulation parameters
const SIGMA  = 1.0;   // mid-price volatility (annualised units, scaled for demo)
const T      = 1.0;   // total horizon (normalised)
const HISTORY = 80;   // number of ticks shown on the chart

let gamma, halfSpread, inventory, pnl, mid, timeLeft;
let midHistory, bidHistory, askHistory;
let tickTimer;

function getParams() {
  gamma      = parseFloat(document.getElementById('gamma').value);
  halfSpread = parseFloat(document.getElementById('spread').value) / 2;
}

// Reservation price: skewed mid that leans against inventory
function reservation(q, t) {
  return mid - q * gamma * SIGMA * SIGMA * t;
}

// Optimal bid and ask centered on the reservation price
function quotes(q, t) {
  const r = reservation(q, t);
  return { bid: r - halfSpread, ask: r + halfSpread };
}

function reset() {
  clearInterval(tickTimer);
  getParams();
  inventory   = 0;
  pnl         = 0;
  mid         = 100;
  timeLeft    = T;
  midHistory  = [mid];
  bidHistory  = [];
  askHistory  = [];
  document.getElementById('status').textContent = '';
  document.getElementById('status').className   = 'status';
  update();
  tickTimer = setInterval(tick, 400);
}

// Advance the simulation by one time step
function tick() {
  if (timeLeft <= 0) { clearInterval(tickTimer); return; }

  // random walk for the mid price
  mid += (Math.random() - 0.5) * SIGMA * 0.4;

  const dt = 0.02;
  timeLeft  = Math.max(0, timeLeft - dt);
  getParams();

  const { bid, ask } = quotes(inventory, timeLeft);

  // Poisson-style order arrivals (simplified to uniform random)
  const r = Math.random();
  let side = '';
  if (r < 0.28) {
    // a buyer hits the ask: maker sells at ask, inventory decreases
    pnl      += ask - mid;
    inventory -= 1;
    side = 'sell';
  } else if (r < 0.56) {
    // a seller hits the bid: maker buys at bid, inventory increases
    pnl      += mid - bid;
    inventory += 1;
    side = 'buy';
  }

  midHistory.push(mid);
  bidHistory.push(bid);
  askHistory.push(ask);
  if (midHistory.length > HISTORY) { midHistory.shift(); bidHistory.shift(); askHistory.shift(); }

  update();

  const statusEl = document.getElementById('status');
  if (side === 'sell') {
    statusEl.textContent = i18nFmt('Sold at %price% (ask hit)', { price: ask.toFixed(2) });
    statusEl.className   = 'status sell';
  } else if (side === 'buy') {
    statusEl.textContent = i18nFmt('Bought at %price% (bid hit)', { price: bid.toFixed(2) });
    statusEl.className   = 'status buy';
  }
}

function update() {
  getParams();
  const { bid, ask } = quotes(inventory, timeLeft);
  document.getElementById('inv-val').textContent  = inventory;
  document.getElementById('bid-val').textContent  = bid.toFixed(2);
  document.getElementById('ask-val').textContent  = ask.toFixed(2);
  document.getElementById('pnl-val').textContent  = pnl.toFixed(2);
  document.getElementById('gamma-val').textContent  = gamma.toFixed(2);
  document.getElementById('spread-val').textContent = (halfSpread * 2).toFixed(2);
  draw();
}

// Render the price-history chart on the canvas
function draw() {
  ctx.clearRect(0, 0, W, H);
  if (midHistory.length < 2) return;

  const PAD = { t: 16, b: 24, l: 44, r: 16 };
  const all = [...midHistory, ...bidHistory, ...askHistory];
  const lo  = Math.min(...all) - 0.5;
  const hi  = Math.max(...all) + 0.5;
  const scX = x => PAD.l + (x / (HISTORY - 1)) * (W - PAD.l - PAD.r);
  const scY = y => PAD.t + (1 - (y - lo) / (hi - lo)) * (H - PAD.t - PAD.b);

  // horizontal grid lines and price labels
  ctx.strokeStyle = '#e0e6ed'; ctx.lineWidth = 0.7;
  for (let i = 0; i <= 4; i++) {
    const y = PAD.t + i * (H - PAD.t - PAD.b) / 4;
    ctx.beginPath(); ctx.moveTo(PAD.l, y); ctx.lineTo(W - PAD.r, y); ctx.stroke();
    const val = hi - i * (hi - lo) / 4;
    ctx.fillStyle = '#888'; ctx.font = '10px system-ui';
    ctx.textAlign = 'right';
    ctx.fillText(val.toFixed(1), PAD.l - 3, y + 3);
  }

  // draw mid, bid and ask time series
  function line(data, color, dash) {
    if (data.length < 2) return;
    ctx.beginPath(); ctx.strokeStyle = color; ctx.lineWidth = 1.8;
    ctx.setLineDash(dash || []);
    const offset = HISTORY - data.length;
    data.forEach((v, i) => {
      const x = scX(offset + i), y = scY(v);
      i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
    });
    ctx.stroke(); ctx.setLineDash([]);
  }

  line(midHistory, '#5a7088');
  line(bidHistory, '#0a7d33', [4, 3]);
  line(askHistory, '#c92f3c', [4, 3]);

  // legend box (top-right)
  const lx = W - PAD.r - 5, ly = PAD.t + 4;
  const legends = [
    { label: 'Mid',  color: '#5a7088', dash: false },
    { label: 'Bid',  color: '#0a7d33', dash: true  },
    { label: 'Ask',  color: '#c92f3c', dash: true  },
  ];
  legends.forEach((lg, i) => {
    const y = ly + i * 16;
    ctx.strokeStyle = lg.color; ctx.lineWidth = 1.8;
    ctx.setLineDash(lg.dash ? [4, 3] : []);
    ctx.beginPath(); ctx.moveTo(lx - 24, y); ctx.lineTo(lx - 10, y); ctx.stroke();
    ctx.setLineDash([]);
    ctx.fillStyle = '#444'; ctx.font = '10px system-ui'; ctx.textAlign = 'left';
    ctx.fillText(lg.label, lx - 8, y + 3);
  });
}

document.getElementById('gamma').addEventListener('input', update);
document.getElementById('spread').addEventListener('input', update);
document.getElementById('reset-btn').addEventListener('click', reset);

reset();
Result
Console

When inventory is positive (the maker holds too many shares), the ask falls to encourage sales — the quotes lean downward. When inventory is negative, the bid rises to encourage purchases. The spread itself stays roughly constant; only the center moves. Increasing risk aversion (the γ\gamma parameter) makes the lean more aggressive; increasing spread widens the gap without changing the skew direction.

The Mathematics

The Avellaneda-Stoikov model is a solved stochastic control problem — a rare treasure in quantitative finance. Here is the skeleton:

Setup. The mid-price follows a Brownian motion dSt=σ dWtdS_t = \sigma \, dW_t. The market maker holds inventory qq (positive = long, negative = short) and sets bid/ask offsets δb\delta^b and δa\delta^a from the mid. Orders arrive as independent Poisson processes whose intensity decays with distance from the mid: λ(δ)=Ae−kδ\lambda(\delta) = A e^{-k\delta}.

Objective. Maximize expected terminal wealth minus a penalty for residual inventory:

max⁡δb, δa  E ⁣[WT−12γqT2σ2]\max_{\delta^b,\,\delta^a} \;\mathbb{E}\!\left[W_T - \tfrac{1}{2}\gamma q_T^2 \sigma^2\right]

where γ>0\gamma > 0 is the risk-aversion parameter.

Reservation price. Avellaneda and Stoikov show (via a Hamilton-Jacobi-Bellman PDE) that the optimal center of the quotes — called the reservation price — is:

r=S−qγσ2(T−t)r = S - q \gamma \sigma^2 (T - t)

When inventory q>0q > 0, the reservation price lies below the mid — the maker prices conservatively to shed inventory. When q<0q < 0, it lies above.

Optimal spread. Around this reservation price, the symmetric half-spread is:

δ∗=γσ2(T−t)2+1kln⁡ ⁣(1+γk)\delta^* = \frac{\gamma \sigma^2 (T-t)}{2} + \frac{1}{k}\ln\!\left(1 + \frac{\gamma}{k}\right)

So the bid is r−δ∗r - \delta^* and the ask is r+δ∗r + \delta^*. Both formulas are closed form — no numerical optimization needed at runtime.

The model was published by Marco Avellaneda and Sasha Stoikov (NYU/Cornell) in Quantitative Finance in 2008. It is a solved problem: the optimal strategy is known analytically under the model's assumptions (geometric Brownian motion mid-price, Poisson order flow, exponential intensity decay).

Where It Matters

The inventory-skew insight is now standard practice across electronic markets:

  • Equity and futures exchanges: high-frequency market makers run variants of Avellaneda-Stoikov continuously, adjusting quotes tick-by-tick to keep inventory near zero.
  • Cryptocurrency venues: crypto market makers face the same inventory risk but with higher volatility and 24/7 operation — the model's σ\sigma term dominates, leading to wider spreads and more aggressive skew.
  • ETF arbitrage: ETF market makers hold baskets of stocks and quote the ETF; the reservation-price logic applies to the net delta of the entire basket.
  • FX spot: currency dealers face correlated inventory across currency pairs; multi-asset extensions of the model handle this.
  • Academic extensions: since 2008 the model has been extended to incorporate market impact, stochastic volatility, adverse selection, and multiple assets — but the closed-form intuition from the original paper guides all of them.

The core idea — "lean your quotes away from unwanted inventory, and do so in proportion to risk aversion, volatility, and time remaining" — is a genuinely general principle for any two-sided market. It connects to portfolio theory and the broader world of dynamic programming used throughout computational finance.

Conclusion

Market making is a balancing act: earn the spread on every trade while ensuring that accumulated inventory does not become a liability. The Avellaneda-Stoikov model solves this balancing act exactly, yielding a clean pair of formulas — a reservation price that leans with inventory, and a spread that widens with volatility and risk aversion.

The beauty of the result is its closed form. A market maker running the model does not solve an optimization problem on every tick; it evaluates two arithmetic expressions. The hard work — deriving the optimal strategy from a Hamilton-Jacobi-Bellman PDE — was done once, in 2008, and the answer has been guiding algorithmic quotes on exchanges ever since.

If you found the inventory-management angle interesting, the related problem of when to act (rather than how to price) is the subject of optimal stopping — another case where stochastic control yields a surprisingly clean answer.

Share this article

Pick a channel — or use your device's native share sheet.

Comments

Loading comments...

https://www.kipuhub.com/en/article/market-making-avellaneda/Content licensed under CC BY-NC 4.0.