3

Market Making: Quote, Get Filled, Manage Inventory

drag X → inventory aversion γ · drag Y → spread δ · tap to reset

A toy Avellaneda–Stoikov market maker. The mid price does a Brownian walk; you (the MM) post a bid and an ask around a *reservation price* that pushes quotes down when you're long and up when you're short, so the market naturally trades your inventory back to flat. Order arrivals are Poisson with intensity — tighter quotes get hit more, but you earn less per fill. Your PnL is mark-to-market: . The price chart shows the mid (white) inside the bid/ask ribbon (red/green); triangles flag fills. Below: inventory over time (cyan) and PnL (yellow). Scrub rightward to make the strategy lean more aggressively against inventory, and upward to widen the baseline spread (fewer fills, more per fill).

idle
232 lines · vanilla
view source
// Market making with inventory skew (Avellaneda–Stoikov-flavored).
//
// Mid price S follows a random walk with vol σ. The MM posts a bid and ask
// symmetric around a *reservation price* r = S - q·γ·σ²·(T−t), where q is
// current inventory and γ is risk aversion: longer inventory => lower r =>
// quotes pushed down, making the ask more aggressive than the bid so the
// position decays.
//
// Order arrivals are Poisson: ask-fill intensity λa = A·exp(-κ·δa), bid-fill
// λb = A·exp(-κ·δb) where δ = quote distance from mid. Tighter quotes get
// hit more.
//
// PnL = cash + q·S. The chart shows mid (white), bid (red), ask (green),
// fills (triangles), inventory (cyan), and PnL (yellow). MouseX scrubs γ,
// mouseY scrubs spread δ, click to reset.

const HIST = 480;
let priceHist, bidHist, askHist, midHist, pnlHist, qHist; // typed arrays
let head = 0, count = 0;
let fills = [];                  // {age, side, price, t} small ring
const FILLS_MAX = 80;

// Market state
let S = 100;                    // mid price
const SIGMA = 1.6;              // vol per "second" of sim time
const A_INTENSITY = 1.6;        // arrival base rate
const KAPPA = 0.9;              // spread-sensitivity
const T_END = 60;               // horizon used in r formula (sec, just for scaling)

// MM controls
let gamma_rA = 0.18;            // inventory aversion (mouseX scrubs)
let baseDelta = 0.35;           // baseline half-spread (mouseY scrubs)
let q = 0;                       // inventory
let cash = 0;
let elapsed = 0;
let lastFills = 0;
let totalFills = 0;
let resetTo;                     // function

let W, H;

// RNG (deterministic per reset for reproducible scrub demos)
let seed = 0;
function rand01() {
  seed = (seed * 1664525 + 1013904223) | 0;
  return ((seed >>> 0) % 1000003) / 1000003;
}
function randn() {
  let u1 = Math.max(1e-9, rand01());
  let u2 = rand01();
  return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
}

function init({ width, height }) {
  W = width; H = height;
  priceHist = new Float32Array(HIST);
  bidHist = new Float32Array(HIST);
  askHist = new Float32Array(HIST);
  midHist = new Float32Array(HIST);
  pnlHist = new Float32Array(HIST);
  qHist = new Float32Array(HIST);
  resetTo = () => {
    seed = (Math.random() * 1e9) | 0;
    head = 0; count = 0; q = 0; cash = 0; elapsed = 0;
    S = 100; lastFills = 0; totalFills = 0;
    fills.length = 0;
  };
  resetTo();
}

function push(p, bid, ask, pnl, qVal) {
  priceHist[head] = p;
  bidHist[head] = bid;
  askHist[head] = ask;
  midHist[head] = p;
  pnlHist[head] = pnl;
  qHist[head] = qVal;
  head = (head + 1) % HIST;
  if (count < HIST) count++;
}

function tick({ ctx, dt, width, height, input }) {
  if (width !== W || height !== H) { W = width; H = height; }

  const clicks = input.consumeClicks();
  if (clicks.length > 0) resetTo();

  // Map cursor: X = γ (inventory aversion), Y = baseDelta (spread).
  // Default to mid values if cursor never moved.
  const cx = input.mouseX, cy = input.mouseY;
  if (cx > 0 && cx < W) gamma_rA = 0.02 + (cx / W) * 0.8;
  if (cy > 0 && cy < H) baseDelta = 0.05 + (1 - cy / H) * 1.2;

  // Sim time advances at 8x wall-clock so the chart fills quickly.
  const SIM_SPEED = 8;
  const stepDt = Math.min(dt * SIM_SPEED, 0.5);
  const STEPS = 4;
  const h = stepDt / STEPS;
  for (let s = 0; s < STEPS; s++) {
    // Mid price walk
    S += SIGMA * Math.sqrt(h) * randn();

    // Reservation price tilt by inventory
    const tau = Math.max(0.1, T_END - elapsed % T_END);
    const r = S - q * gamma_rA * SIGMA * SIGMA * tau;
    const half = Math.max(0.04, baseDelta);
    const bid = r - half;
    const ask = r + half;

    // Spread distances from MID (clamped) drive intensities. We use ASS-style
    // distance from mid, not from reservation, since the public market cares
    // about distance to the actual touch.
    const dB = Math.max(0.02, S - bid);
    const dA = Math.max(0.02, ask - S);
    const lamB = A_INTENSITY * Math.exp(-KAPPA * dB) * h;
    const lamA = A_INTENSITY * Math.exp(-KAPPA * dA) * h;

    // Per-step Bernoulli fill (small h → reasonable approximation of Poisson)
    if (rand01() < lamB) {
      q += 1; cash -= bid;
      fills.push({ age: 0, side: 'B', price: bid });
      if (fills.length > FILLS_MAX) fills.shift();
      totalFills++;
    }
    if (rand01() < lamA) {
      q -= 1; cash += ask;
      fills.push({ age: 0, side: 'A', price: ask });
      if (fills.length > FILLS_MAX) fills.shift();
      totalFills++;
    }
    elapsed += h;

    const pnl = cash + q * S;
    push(S, bid, ask, pnl, q);
  }
  for (const f of fills) f.age += dt;
  fills = fills.filter((f) => f.age < 3);

  // ---------------- render ----------------
  ctx.fillStyle = '#0a0c14';
  ctx.fillRect(0, 0, W, H);

  // Layout: top 55% price, middle 20% inventory, bottom 25% pnl
  const padL = 8, padR = 8, padT = 38, padB = 14;
  const priceH = (H - padT - padB) * 0.55;
  const invH   = (H - padT - padB) * 0.18;
  const pnlH   = (H - padT - padB) * 0.27;
  const priceY = padT;
  const invY = priceY + priceH + 4;
  const pnlY = invY + invH + 4;
  const plotW = W - padL - padR;

  // Compute mid range across recent history
  let minS = Infinity, maxS = -Infinity;
  for (let i = 0; i < count; i++) {
    const idx = (head - count + i + HIST) % HIST;
    const p = priceHist[idx], b = bidHist[idx], a = askHist[idx];
    if (p < minS) minS = p; if (p > maxS) maxS = p;
    if (b < minS) minS = b; if (a > maxS) maxS = a;
  }
  if (minS === Infinity) { minS = S - 2; maxS = S + 2; }
  if (maxS - minS < 1) { const c = (maxS+minS)/2; minS = c-0.5; maxS = c+0.5; }
  const sRange = maxS - minS;
  const sToY = (s) => priceY + priceH - ((s - minS) / sRange) * priceH;

  // Price plot bg
  ctx.fillStyle = '#0d121e';
  ctx.fillRect(padL, priceY, plotW, priceH);

  // Bid/Ask ribbons
  ctx.beginPath();
  let first = true;
  for (let i = 0; i < count; i++) {
    const idx = (head - count + i + HIST) % HIST;
    const x = padL + (i / Math.max(1, HIST - 1)) * plotW;
    const y = sToY(askHist[idx]);
    if (first) { ctx.moveTo(x, y); first = false; } else ctx.lineTo(x, y);
  }
  for (let i = count - 1; i >= 0; i--) {
    const idx = (head - count + i + HIST) % HIST;
    const x = padL + (i / Math.max(1, HIST - 1)) * plotW;
    const y = sToY(bidHist[idx]);
    ctx.lineTo(x, y);
  }
  ctx.closePath();
  ctx.fillStyle = 'rgba(120,180,255,0.10)';
  ctx.fill();

  // Bid line (red), Ask line (green)
  function strokeSeries(arr, color, width_ = 1.0, alpha = 0.85) {
    ctx.strokeStyle = color;
    ctx.lineWidth = width_;
    ctx.globalAlpha = alpha;
    ctx.beginPath();
    let first = true;
    for (let i = 0; i < count; i++) {
      const idx = (head - count + i + HIST) % HIST;
      const x = padL + (i / Math.max(1, HIST - 1)) * plotW;
      const y = sToY(arr[idx]);
      if (first) { ctx.moveTo(x, y); first = false; } else ctx.lineTo(x, y);
    }
    ctx.stroke();
    ctx.globalAlpha = 1;
  }
  strokeSeries(bidHist, '#f06464', 1, 0.7);
  strokeSeries(askHist, '#6eda8a', 1, 0.7);
  strokeSeries(priceHist, '#ddd', 1.4, 1);

  // Recent fill markers in price plot
  for (const f of fills) {
    const age = f.age;
    const alpha = Math.max(0, 1 - age / 3);
    const x = padL + plotW - 2;  // current edge
    const y = sToY(f.price);
    ctx.fillStyle = f.side === 'A' ? `rgba(120,255,160,${alpha})` : `rgba(255,120,120,${alpha})`;
    ctx.beginPath();
    if (f.side === 'A') { // sold at ask — triangle up
      ctx.moveTo(x, y - 6); ctx.lineTo(x - 4, y); ctx.lineTo(x + 4, y);
    } else {              // bought at bid — triangle down
      ctx.moveTo(x, y + 6); ctx.lineTo(x - 4, y); ctx.lineTo(x + 4, y);
    }
    ctx.closePath();
    ctx.fill();
  }

  // Inventory plot
  ctx.fillStyle = '#0d121e';
  ctx.fillRect(padL, invY, plotW, invH);
  let minQ = 0, maxQ = 0;
  for (let i = 0; i < count; i++) {
    const v = qHist[(head - count + i + HIST) % HIST];
    if (v < minQ) minQ = v; if (v > maxQ) maxQ = v;
  }
  const qRange = Math.max(1, maxQ - minQ);
  const qToY = (v) => invY + invH - ((v - minQ) / qRange) * invH;
  // zero baseline
  ctx.strokeStyle = 'rgba(255,255,255,0.20)';
  ctx.lineWidth = 1;
  ctx.beginPath();
  const yZero = qToY(0);
  ctx.moveTo(padL, yZero); ctx.lineTo(padL + plotW, yZero); ctx.stroke();
  strokeSeries(qHist, '#6cd6ff', 1.4, 0.9);
  ctx.fillStyle = 'rgba(200,220,255,0.75)';
  ctx.font = '10px ui-monospace, monospace';
  ctx.fillText(`inventory q  ${q.toFixed(0)}`, padL + 4, invY + 12);

  // PnL plot
  ctx.fillStyle = '#0d121e';
  ctx.fillRect(padL, pnlY, plotW, pnlH);
  let minP = Infinity, maxP = -Infinity;
  for (let i = 0; i < count; i++) {
    const v = pnlHist[(head - count + i + HIST) % HIST];
    if (v < minP) minP = v; if (v > maxP) maxP = v;
  }
  if (minP === Infinity) { minP = -1; maxP = 1; }
  const pRange = Math.max(1, maxP - minP);
  const pToY = (v) => pnlY + pnlH - ((v - minP) / pRange) * pnlH;
  // zero line
  ctx.strokeStyle = 'rgba(255,255,255,0.20)';
  ctx.beginPath();
  const pZ = pToY(0);
  ctx.moveTo(padL, pZ); ctx.lineTo(padL + plotW, pZ); ctx.stroke();
  ctx.strokeStyle = '#ffd964';
  ctx.lineWidth = 1.4;
  ctx.beginPath();
  let f2 = true;
  for (let i = 0; i < count; i++) {
    const v = pnlHist[(head - count + i + HIST) % HIST];
    const x = padL + (i / Math.max(1, HIST - 1)) * plotW;
    const y = pToY(v);
    if (f2) { ctx.moveTo(x, y); f2 = false; } else ctx.lineTo(x, y);
  }
  ctx.stroke();
  const pnl = cash + q * S;
  ctx.fillStyle = 'rgba(255,235,180,0.85)';
  ctx.fillText(`pnl  ${pnl.toFixed(2)}`, padL + 4, pnlY + 12);

  // Top HUD: labels + current values
  ctx.fillStyle = 'rgba(220,230,255,0.95)';
  ctx.font = 'bold 12px ui-monospace, monospace';
  ctx.fillText('MARKET MAKER', padL, 18);
  ctx.font = '11px ui-monospace, monospace';
  ctx.fillStyle = 'rgba(220,230,255,0.85)';
  const lastBid = count > 0 ? bidHist[(head - 1 + HIST) % HIST] : 0;
  const lastAsk = count > 0 ? askHist[(head - 1 + HIST) % HIST] : 0;
  ctx.fillText(`mid ${S.toFixed(2)}  bid ${lastBid.toFixed(2)}  ask ${lastAsk.toFixed(2)}`, padL, 32);
  if (W > 380) {
    ctx.fillText(
      `δ=${baseDelta.toFixed(2)}  γ=${gamma_rA.toFixed(2)}  fills=${totalFills}`,
      padL + 240, 32);
  }

  // Bottom hint
  ctx.fillStyle = 'rgba(200,210,230,0.55)';
  ctx.font = '10px ui-monospace, monospace';
  ctx.fillText('drag X → γ (inventory aversion)   drag Y → δ (spread)   tap = reset',
    padL, H - 4);
}

Comments (0)

Log in to comment.