3
Market Making: Quote, Get Filled, Manage Inventory
drag X → inventory aversion γ · drag Y → spread δ · tap to reset
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.