Submit limit orders on both sides and watch the book fill up. When bids and asks overlap, the matching engine fires and fills the trade at the resting order's price. A market order sweeps the entire opposite side until it is filled or the book runs dry.
<!-- Limit order book demo — submit limit and market orders, watch price-time matching. -->
<div class="controls">
<div class="row">
<label>Side</label>
<div class="toggle">
<button id="btnBuy" class="active" type="button">Buy</button>
<button id="btnSell" type="button">Sell</button>
</div>
</div>
<div class="row">
<label for="priceIn">Price</label>
<input id="priceIn" type="number" min="1" max="200" step="1" value="100">
</div>
<div class="row">
<label for="qtyIn">Qty</label>
<input id="qtyIn" type="number" min="1" max="100" step="1" value="10">
</div>
<div class="btns">
<button id="btnLimit" type="button">Submit limit order</button>
<button id="btnMarket" type="button" class="ghost">Submit market order</button>
<button id="btnReset" type="button" class="ghost">Reset</button>
</div>
</div>
<div class="book-wrap">
<div class="book-col" id="asks">
<div class="book-hdr">Asks (Sell)</div>
<div id="askRows"></div>
</div>
<div class="spread-box" id="spreadBox">Spread: —</div>
<div class="book-col" id="bids">
<div class="book-hdr">Bids (Buy)</div>
<div id="bidRows"></div>
</div>
</div>
<div class="status" id="status">Submit orders to see the matching engine in action.</div>
/* Styling for the order book columns and controls. */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; font-size: .9rem; }
.controls { display: flex; flex-direction: column; gap: .45rem; margin-bottom: .7rem; }
.row { display: flex; align-items: center; gap: .5rem; }
label { width: 60px; font-weight: 600; color: #444; font-size: .82rem; }
input[type=number] { width: 90px; padding: .3rem .5rem; border: 1px solid #cdd9e3; border-radius: 6px; font-size: .9rem; }
.toggle { display: flex; gap: 0; border-radius: 6px; overflow: hidden; border: 1px solid #1d3557; }
.toggle button { border: none; border-radius: 0; padding: .3rem .8rem; background: #fff; color: #1d3557; cursor: pointer; font: 600 .83rem system-ui, sans-serif; }
.toggle button.active { background: #1d3557; color: #fff; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 .83rem system-ui, sans-serif; padding: .38rem .8rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 6px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
/* Book columns: asks (red) on left, bids (green) on right, spread label in the middle. */
.book-wrap { display: flex; align-items: flex-start; gap: .4rem; margin-top: .4rem; }
.book-col { flex: 1; }
.book-hdr { font-weight: 700; font-size: .78rem; text-align: center; padding: .2rem 0;
background: #e8eef3; border-radius: 5px 5px 0 0; }
#asks .book-hdr { color: #c92f3c; }
#bids .book-hdr { color: #0a7d33; }
.book-row { display: flex; justify-content: space-between; padding: .18rem .5rem;
font-size: .82rem; border-bottom: 1px solid #eee; font-variant-numeric: tabular-nums; }
.ask-row { background: #fff5f5; color: #c92f3c; }
.bid-row { background: #f0fff4; color: #0a7d33; }
.spread-box { font-size: .8rem; font-weight: 600; color: #555; writing-mode: horizontal-tb;
align-self: center; text-align: center; min-width: 54px; padding: .2rem .3rem;
background: #f5f5f5; border-radius: 5px; border: 1px solid #ddd; white-space: nowrap; }
.status { margin-top: .5rem; font-size: .9rem; font-weight: 600; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
.status.info { color: #1d3557; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Matching engine: price-time priority. Incoming limit order crosses the opposite side
// if its price overlaps; remainder rests in the book. Market orders sweep until filled.
// Track which side (buy/sell) the user has selected.
let currentSide = 'buy';
document.getElementById('btnBuy').addEventListener('click', () => setSide('buy'));
document.getElementById('btnSell').addEventListener('click', () => setSide('sell'));
function setSide(s) {
currentSide = s;
document.getElementById('btnBuy').classList.toggle('active', s === 'buy');
document.getElementById('btnSell').classList.toggle('active', s === 'sell');
}
// Two Maps keyed by integer price, each value is an array of resting orders (FIFO queue).
// bids: Map<price, { price, qty, ts }[]> sorted descending
// asks: Map<price, { price, qty, ts }[]> sorted ascending
let bids = new Map();
let asks = new Map();
let orderSeq = 0;
function sortedPrices(map, desc) {
const arr = [...map.keys()].map(Number).sort((a, b) => desc ? b - a : a - b);
return arr;
}
// matchLimit: attempt to fill qty at price on the given side.
// Returns a status string describing what happened.
function matchLimit(side, price, qty) {
let remaining = qty;
const fills = [];
if (side === 'buy') {
// Buy limit: sweep asks from lowest price up to our limit price.
const askPrices = sortedPrices(asks, false);
for (const ap of askPrices) {
if (ap > price || remaining <= 0) break;
const queue = asks.get(ap);
while (queue.length && remaining > 0) {
const top = queue[0];
const filled = Math.min(top.qty, remaining);
fills.push({ price: ap, qty: filled });
top.qty -= filled;
remaining -= filled;
if (top.qty === 0) queue.shift();
}
if (queue.length === 0) asks.delete(ap);
}
} else {
// Sell limit: sweep bids from highest price down to our limit price.
const bidPrices = sortedPrices(bids, true);
for (const bp of bidPrices) {
if (bp < price || remaining <= 0) break;
const queue = bids.get(bp);
while (queue.length && remaining > 0) {
const top = queue[0];
const filled = Math.min(top.qty, remaining);
fills.push({ price: bp, qty: filled });
top.qty -= filled;
remaining -= filled;
if (top.qty === 0) queue.shift();
}
if (queue.length === 0) bids.delete(bp);
}
}
// Whatever quantity did not fill rests as a new order at our limit price.
if (remaining > 0) {
const book = side === 'buy' ? bids : asks;
if (!book.has(price)) book.set(price, []);
book.get(price).push({ price, qty: remaining, ts: orderSeq++ });
}
return fills;
}
function matchMarket(side, qty) {
let remaining = qty;
const fills = [];
if (side === 'buy') {
const askPrices = sortedPrices(asks, false);
for (const ap of askPrices) {
if (remaining <= 0) break;
const queue = asks.get(ap);
while (queue.length && remaining > 0) {
const top = queue[0];
const filled = Math.min(top.qty, remaining);
fills.push({ price: ap, qty: filled });
top.qty -= filled; remaining -= filled;
if (top.qty === 0) queue.shift();
}
if (queue.length === 0) asks.delete(ap);
}
} else {
const bidPrices = sortedPrices(bids, true);
for (const bp of bidPrices) {
if (remaining <= 0) break;
const queue = bids.get(bp);
while (queue.length && remaining > 0) {
const top = queue[0];
const filled = Math.min(top.qty, remaining);
fills.push({ price: bp, qty: filled });
top.qty -= filled; remaining -= filled;
if (top.qty === 0) queue.shift();
}
if (queue.length === 0) bids.delete(bp);
}
}
return { fills, unfilled: remaining };
}
function getSpread() {
const bestBid = sortedPrices(bids, true)[0];
const bestAsk = sortedPrices(asks, false)[0];
if (bestBid == null || bestAsk == null) return null;
return bestAsk - bestBid;
}
function renderBook() {
// Re-render the book UI: asks ascending, bids descending.
const askRows = document.getElementById('askRows');
const bidRows = document.getElementById('bidRows');
askRows.innerHTML = '';
bidRows.innerHTML = '';
// Render ask levels (lowest price at top = best ask).
const askPrices = sortedPrices(asks, false);
for (const ap of askPrices) {
const totalQty = asks.get(ap).reduce((s, o) => s + o.qty, 0);
const row = document.createElement('div');
row.className = 'book-row ask-row';
row.innerHTML = `<span>${ap}</span><span>${totalQty}</span>`;
askRows.appendChild(row);
}
// Render bid levels (highest price at top = best bid).
const bidPrices = sortedPrices(bids, true);
for (const bp of bidPrices) {
const totalQty = bids.get(bp).reduce((s, o) => s + o.qty, 0);
const row = document.createElement('div');
row.className = 'book-row bid-row';
row.innerHTML = `<span>${bp}</span><span>${totalQty}</span>`;
bidRows.appendChild(row);
}
const sp = getSpread();
document.getElementById('spreadBox').textContent =
'Spread: ' + (sp == null ? '—' : sp.toFixed(0));
}
function setStatus(t, cls) {
const el = document.getElementById('status');
el.textContent = t;
el.className = 'status ' + (cls || '');
}
document.getElementById('btnLimit').addEventListener('click', () => {
const price = parseInt(document.getElementById('priceIn').value, 10);
const qty = parseInt(document.getElementById('qtyIn').value, 10);
if (!price || !qty) { setStatus('Please enter a valid price and quantity.', 'bad'); return; }
const fills = matchLimit(currentSide, price, qty);
renderBook();
if (fills.length === 0) {
setStatus(i18nFmt('Order resting: %side% %qty% @ %price%', { price, qty, side: currentSide === 'buy' ? 'buy' : 'sell' }), 'info');
} else {
const totalFilled = fills.reduce((s, f) => s + f.qty, 0);
const avgPrice = fills.reduce((s, f) => s + f.price * f.qty, 0) / totalFilled;
setStatus(i18nFmt('Filled %qty% @ avg %avg%', { qty: totalFilled, avg: avgPrice.toFixed(1) }), 'ok');
}
});
document.getElementById('btnMarket').addEventListener('click', () => {
const qty = parseInt(document.getElementById('qtyIn').value, 10);
if (!qty) { setStatus('Please enter a valid price and quantity.', 'bad'); return; }
const { fills, unfilled } = matchMarket(currentSide, qty);
renderBook();
if (fills.length === 0) {
setStatus('No liquidity on the opposite side to fill a market order.', 'bad');
} else {
const totalFilled = fills.reduce((s, f) => s + f.qty, 0);
const avgPrice = fills.reduce((s, f) => s + f.price * f.qty, 0) / totalFilled;
if (unfilled > 0) {
setStatus(i18nFmt('Partially filled %filled% @ avg %avg% — %unfilled% qty unfilled.', { filled: totalFilled, avg: avgPrice.toFixed(1), unfilled }), 'info');
} else {
setStatus(i18nFmt('Market order filled: %qty% @ avg %avg%', { qty: totalFilled, avg: avgPrice.toFixed(1) }), 'ok');
}
}
});
document.getElementById('btnReset').addEventListener('click', () => {
bids = new Map(); asks = new Map(); orderSeq = 0;
renderBook();
setStatus('Submit orders to see the matching engine in action.', '');
});
// Pre-populate the book with a few resting orders so the demo starts with a visible spread.
function seed() {
[[102,15],[103,10],[105,25]].forEach(([p,q]) => matchLimit('sell',p,q));
[[99,20],[98,10],[96,30]].forEach(([p,q]) => matchLimit('buy',p,q));
}
seed();
renderBook();
setStatus('Submit orders to see the matching engine in action.', '');
<!-- Limit order book demo — submit limit and market orders, watch price-time matching. -->
<div class="controls">
<div class="row">
<label>Side</label>
<div class="toggle">
<button id="btnBuy" class="active" type="button">Buy</button>
<button id="btnSell" type="button">Sell</button>
</div>
</div>
<div class="row">
<label for="priceIn">Price</label>
<input id="priceIn" type="number" min="1" max="200" step="1" value="100">
</div>
<div class="row">
<label for="qtyIn">Qty</label>
<input id="qtyIn" type="number" min="1" max="100" step="1" value="10">
</div>
<div class="btns">
<button id="btnLimit" type="button">Submit limit order</button>
<button id="btnMarket" type="button" class="ghost">Submit market order</button>
<button id="btnReset" type="button" class="ghost">Reset</button>
</div>
</div>
<div class="book-wrap">
<div class="book-col" id="asks">
<div class="book-hdr">Asks (Sell)</div>
<div id="askRows"></div>
</div>
<div class="spread-box" id="spreadBox">Spread: —</div>
<div class="book-col" id="bids">
<div class="book-hdr">Bids (Buy)</div>
<div id="bidRows"></div>
</div>
</div>
<div class="status" id="status">Submit orders to see the matching engine in action.</div>
/* Styling for the order book columns and controls. */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; font-size: .9rem; }
.controls { display: flex; flex-direction: column; gap: .45rem; margin-bottom: .7rem; }
.row { display: flex; align-items: center; gap: .5rem; }
label { width: 60px; font-weight: 600; color: #444; font-size: .82rem; }
input[type=number] { width: 90px; padding: .3rem .5rem; border: 1px solid #cdd9e3; border-radius: 6px; font-size: .9rem; }
.toggle { display: flex; gap: 0; border-radius: 6px; overflow: hidden; border: 1px solid #1d3557; }
.toggle button { border: none; border-radius: 0; padding: .3rem .8rem; background: #fff; color: #1d3557; cursor: pointer; font: 600 .83rem system-ui, sans-serif; }
.toggle button.active { background: #1d3557; color: #fff; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 .83rem system-ui, sans-serif; padding: .38rem .8rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 6px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
/* Book columns: asks (red) on left, bids (green) on right, spread label in the middle. */
.book-wrap { display: flex; align-items: flex-start; gap: .4rem; margin-top: .4rem; }
.book-col { flex: 1; }
.book-hdr { font-weight: 700; font-size: .78rem; text-align: center; padding: .2rem 0;
background: #e8eef3; border-radius: 5px 5px 0 0; }
#asks .book-hdr { color: #c92f3c; }
#bids .book-hdr { color: #0a7d33; }
.book-row { display: flex; justify-content: space-between; padding: .18rem .5rem;
font-size: .82rem; border-bottom: 1px solid #eee; font-variant-numeric: tabular-nums; }
.ask-row { background: #fff5f5; color: #c92f3c; }
.bid-row { background: #f0fff4; color: #0a7d33; }
.spread-box { font-size: .8rem; font-weight: 600; color: #555; writing-mode: horizontal-tb;
align-self: center; text-align: center; min-width: 54px; padding: .2rem .3rem;
background: #f5f5f5; border-radius: 5px; border: 1px solid #ddd; white-space: nowrap; }
.status { margin-top: .5rem; font-size: .9rem; font-weight: 600; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
.status.info { color: #1d3557; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Matching engine: price-time priority. Incoming limit order crosses the opposite side
// if its price overlaps; remainder rests in the book. Market orders sweep until filled.
// Track which side (buy/sell) the user has selected.
let currentSide = 'buy';
document.getElementById('btnBuy').addEventListener('click', () => setSide('buy'));
document.getElementById('btnSell').addEventListener('click', () => setSide('sell'));
function setSide(s) {
currentSide = s;
document.getElementById('btnBuy').classList.toggle('active', s === 'buy');
document.getElementById('btnSell').classList.toggle('active', s === 'sell');
}
// Two Maps keyed by integer price, each value is an array of resting orders (FIFO queue).
// bids: Map<price, { price, qty, ts }[]> sorted descending
// asks: Map<price, { price, qty, ts }[]> sorted ascending
let bids = new Map();
let asks = new Map();
let orderSeq = 0;
function sortedPrices(map, desc) {
const arr = [...map.keys()].map(Number).sort((a, b) => desc ? b - a : a - b);
return arr;
}
// matchLimit: attempt to fill qty at price on the given side.
// Returns a status string describing what happened.
function matchLimit(side, price, qty) {
let remaining = qty;
const fills = [];
if (side === 'buy') {
// Buy limit: sweep asks from lowest price up to our limit price.
const askPrices = sortedPrices(asks, false);
for (const ap of askPrices) {
if (ap > price || remaining <= 0) break;
const queue = asks.get(ap);
while (queue.length && remaining > 0) {
const top = queue[0];
const filled = Math.min(top.qty, remaining);
fills.push({ price: ap, qty: filled });
top.qty -= filled;
remaining -= filled;
if (top.qty === 0) queue.shift();
}
if (queue.length === 0) asks.delete(ap);
}
} else {
// Sell limit: sweep bids from highest price down to our limit price.
const bidPrices = sortedPrices(bids, true);
for (const bp of bidPrices) {
if (bp < price || remaining <= 0) break;
const queue = bids.get(bp);
while (queue.length && remaining > 0) {
const top = queue[0];
const filled = Math.min(top.qty, remaining);
fills.push({ price: bp, qty: filled });
top.qty -= filled;
remaining -= filled;
if (top.qty === 0) queue.shift();
}
if (queue.length === 0) bids.delete(bp);
}
}
// Whatever quantity did not fill rests as a new order at our limit price.
if (remaining > 0) {
const book = side === 'buy' ? bids : asks;
if (!book.has(price)) book.set(price, []);
book.get(price).push({ price, qty: remaining, ts: orderSeq++ });
}
return fills;
}
function matchMarket(side, qty) {
let remaining = qty;
const fills = [];
if (side === 'buy') {
const askPrices = sortedPrices(asks, false);
for (const ap of askPrices) {
if (remaining <= 0) break;
const queue = asks.get(ap);
while (queue.length && remaining > 0) {
const top = queue[0];
const filled = Math.min(top.qty, remaining);
fills.push({ price: ap, qty: filled });
top.qty -= filled; remaining -= filled;
if (top.qty === 0) queue.shift();
}
if (queue.length === 0) asks.delete(ap);
}
} else {
const bidPrices = sortedPrices(bids, true);
for (const bp of bidPrices) {
if (remaining <= 0) break;
const queue = bids.get(bp);
while (queue.length && remaining > 0) {
const top = queue[0];
const filled = Math.min(top.qty, remaining);
fills.push({ price: bp, qty: filled });
top.qty -= filled; remaining -= filled;
if (top.qty === 0) queue.shift();
}
if (queue.length === 0) bids.delete(bp);
}
}
return { fills, unfilled: remaining };
}
function getSpread() {
const bestBid = sortedPrices(bids, true)[0];
const bestAsk = sortedPrices(asks, false)[0];
if (bestBid == null || bestAsk == null) return null;
return bestAsk - bestBid;
}
function renderBook() {
// Re-render the book UI: asks ascending, bids descending.
const askRows = document.getElementById('askRows');
const bidRows = document.getElementById('bidRows');
askRows.innerHTML = '';
bidRows.innerHTML = '';
// Render ask levels (lowest price at top = best ask).
const askPrices = sortedPrices(asks, false);
for (const ap of askPrices) {
const totalQty = asks.get(ap).reduce((s, o) => s + o.qty, 0);
const row = document.createElement('div');
row.className = 'book-row ask-row';
row.innerHTML = `<span>${ap}</span><span>${totalQty}</span>`;
askRows.appendChild(row);
}
// Render bid levels (highest price at top = best bid).
const bidPrices = sortedPrices(bids, true);
for (const bp of bidPrices) {
const totalQty = bids.get(bp).reduce((s, o) => s + o.qty, 0);
const row = document.createElement('div');
row.className = 'book-row bid-row';
row.innerHTML = `<span>${bp}</span><span>${totalQty}</span>`;
bidRows.appendChild(row);
}
const sp = getSpread();
document.getElementById('spreadBox').textContent =
'Spread: ' + (sp == null ? '—' : sp.toFixed(0));
}
function setStatus(t, cls) {
const el = document.getElementById('status');
el.textContent = t;
el.className = 'status ' + (cls || '');
}
document.getElementById('btnLimit').addEventListener('click', () => {
const price = parseInt(document.getElementById('priceIn').value, 10);
const qty = parseInt(document.getElementById('qtyIn').value, 10);
if (!price || !qty) { setStatus('Please enter a valid price and quantity.', 'bad'); return; }
const fills = matchLimit(currentSide, price, qty);
renderBook();
if (fills.length === 0) {
setStatus(i18nFmt('Order resting: %side% %qty% @ %price%', { price, qty, side: currentSide === 'buy' ? 'buy' : 'sell' }), 'info');
} else {
const totalFilled = fills.reduce((s, f) => s + f.qty, 0);
const avgPrice = fills.reduce((s, f) => s + f.price * f.qty, 0) / totalFilled;
setStatus(i18nFmt('Filled %qty% @ avg %avg%', { qty: totalFilled, avg: avgPrice.toFixed(1) }), 'ok');
}
});
document.getElementById('btnMarket').addEventListener('click', () => {
const qty = parseInt(document.getElementById('qtyIn').value, 10);
if (!qty) { setStatus('Please enter a valid price and quantity.', 'bad'); return; }
const { fills, unfilled } = matchMarket(currentSide, qty);
renderBook();
if (fills.length === 0) {
setStatus('No liquidity on the opposite side to fill a market order.', 'bad');
} else {
const totalFilled = fills.reduce((s, f) => s + f.qty, 0);
const avgPrice = fills.reduce((s, f) => s + f.price * f.qty, 0) / totalFilled;
if (unfilled > 0) {
setStatus(i18nFmt('Partially filled %filled% @ avg %avg% — %unfilled% qty unfilled.', { filled: totalFilled, avg: avgPrice.toFixed(1), unfilled }), 'info');
} else {
setStatus(i18nFmt('Market order filled: %qty% @ avg %avg%', { qty: totalFilled, avg: avgPrice.toFixed(1) }), 'ok');
}
}
});
document.getElementById('btnReset').addEventListener('click', () => {
bids = new Map(); asks = new Map(); orderSeq = 0;
renderBook();
setStatus('Submit orders to see the matching engine in action.', '');
});
// Pre-populate the book with a few resting orders so the demo starts with a visible spread.
function seed() {
[[102,15],[103,10],[105,25]].forEach(([p,q]) => matchLimit('sell',p,q));
[[99,20],[98,10],[96,30]].forEach(([p,q]) => matchLimit('buy',p,q));
}
seed();
renderBook();
setStatus('Submit orders to see the matching engine in action.', '');
Comments
Loading comments...