Technical walkthrough ·
Depth: what happens when liquidity disappears
Remove the nearest bids and asks in the example below. The spread widens from 2 to 6, even though both sides still hold the same total volume. A balanced book can have very little liquidity near the current price.
The two charts use @rekurt/depth 0.1.0. Prices and volumes are made up; the left chart stays fixed while you change the right one.
Try the depth chart

Follow the orders
Start with Balanced book. There are four levels on each side: 18 units of bids and 18 of asks. The best bid is 99, the best ask is 101.
Select Large ask appears. The sell order at 102 grows from 6 to 30 units. This leaves the spread at 2: the best ask is still 101. The extra volume appears farther along the ask curve.
Now select Near-touch liquidity disappears. The orders at 99, 98, 101 and 102 are gone. The best remaining prices are 97 and 103. Each side has 8 units, but the gap between them is three times wider.
| State | Best bid | Best ask | Spread | Bid volume | Ask volume |
|---|---|---|---|---|---|
| Balanced | 99 | 101 | 2 | 18 | 18 |
| Large ask | 99 | 101 | 2 | 18 | 42 |
| Nearest levels removed | 97 | 103 | 6 | 8 | 8 |
The curves show cumulative volume. Bids accumulate from the highest price down; asks accumulate from the lowest price up. The table under the demo exposes those running totals, which is useful when checking a data adapter: compare the numbers at each price with the shape being drawn.
The displayed imbalance is (bid volume − ask volume) / total volume. In the second state it is (18 − 42) / 60 = −0.4. That single number loses the location of the orders. Keep the price levels visible if the question is where the liquidity sits.
Feeding the chart
Create a DepthChart with a container, then pass complete books to setData. Read the spread, midpoint and totals through getMetrics. Call destroy when removing the chart.
The code at the end of this page is the code used by the demo. The sample data lives alongside the entry point, so you can change one level and rerun the example. For an onboarding screen, keep the fixed chart as a reference. For a feed-debugging screen, replace that reference with the previous snapshot to make a jump easier to inspect.
Switch to BTC/USDT
Connect live BTC/USDT opens Binance’s btcusdt@depth20 stream. Each message replaces the visible book with the top 20 levels per side. This is a partial-book snapshot stream; an incremental diff feed needs different handling.
The live chart shows the source and local receipt time. It ignores older update IDs, marks disconnected data stale and attempts to reconnect. Synthetic mode closes the stream and restores the sample book. The fixed chart remains synthetic, and its price scale is unrelated to BTC/USDT.
Visible orders can be cancelled. Their disappearance does not tell you why they vanished, and displayed volume does not guarantee an execution price. This example only displays the supplied book.
Stream format · Public market-data endpoints
Run it locally
Use Node 24.20 or newer within the 24.x series:
git clone https://github.com/rekurt/rekurt.github.io.git
cd rekurt.github.io/site
npm ci
npm run build
npm run dev
Open /articles/depth-liquidity-replay/. The example uses the published depth package pinned in the lockfile. Tests cover the fixed totals, state changes, snapshot replacement and malformed input; live connectivity was checked separately in a browser.
Version 0.1.0 was released on June 12, 2026.
Written with AI assistance.
Example code
Depth example · depth.ts
View depth.ts in the repository
import "./embed";
import { DepthChart, type DepthSnapshot } from "@rekurt/depth";
import { books, cumulative, parseBook } from "./model";
import { LiveFeed } from "./live";
import "./style.css";
const app = document.querySelector<HTMLElement>("#app")!;
app.innerHTML = `<h1>Depth · liquidity replay</h1><p>Compare a fixed baseline with changing visible liquidity. Synthetic units, not an execution forecast.</p><div class="controls"><button id="next">Next step</button><button id="reset">Reset</button>${books.map((b, i) => `<button data-scene="${i}">${b.name}</button>`).join("")}<button id="live">Connect live BTC/USDT</button><button id="synthetic">Synthetic mode</button></div><p id="status" role="status">SYNTHETIC · deterministic snapshots</p><div class="comparison"><section class="panel"><h2>Baseline · balanced</h2><div id="baseline" class="chart"></div><p id="baseline-metrics" class="metrics"></p></section><section class="panel"><h2 id="scene">Balanced book</h2><div id="current" class="chart"></div><p id="metrics" class="metrics"></p></section></div><p><small>Live: Binance BTC/USDT, top 20 levels per side, partial snapshots. Local receipt time is not exchange latency. This is not a full order book.</small></p><div class="table-wrap"><table><caption>Current cumulative levels (base-asset volume)</caption><thead><tr><th>Side</th><th>Price</th><th>Level volume</th><th>Cumulative</th></tr></thead><tbody id="levels"></tbody></table></div>`;
const baseline = new DepthChart({
container: document.querySelector("#baseline")!,
theme: "dark",
data: books[0]!.book,
shape: "step",
});
const current = new DepthChart({
container: document.querySelector("#current")!,
theme: "dark",
data: books[0]!.book,
shape: "step",
});
const base = baseline.getMetrics();
const metrics = (m: ReturnType<DepthChart["getMetrics"]>) =>
`Spread: ${m.spread?.toFixed(2)} · Midpoint: ${m.midPrice?.toFixed(2)}\nBids: ${m.bidTotal.toFixed(2)} · Asks: ${m.askTotal.toFixed(2)} · Imbalance: ${m.imbalance.toFixed(3)}`;
document.querySelector("#baseline-metrics")!.textContent = metrics(base);
let index = 0;
let feed: LiveFeed | undefined;
let sequence = -1;
let live = false;
const status = (text: string) => {
document.querySelector("#status")!.textContent = text;
};
function show(book: DepthSnapshot, name: string) {
current.setData(book);
const m = current.getMetrics();
document.querySelector("#scene")!.textContent = name;
document.querySelector("#metrics")!.textContent =
metrics(m) +
`\nΔ imbalance vs synthetic baseline: ${(m.imbalance - base.imbalance).toFixed(3)}`;
const rows = cumulative(book);
document.querySelector("#levels")!.innerHTML = Object.entries(rows)
.flatMap(([side, levels]) =>
levels.map(
(l) =>
`<tr><td>${side}</td><td>${l.price.toFixed(2)}</td><td>${l.volume.toFixed(3)}</td><td>${l.cumulative.toFixed(3)}</td></tr>`,
),
)
.join("");
}
function synthetic(i: number) {
feed?.stop();
feed = undefined;
live = false;
index = i;
sequence = -1;
status("SYNTHETIC · deterministic snapshots");
show(books[index]!.book, books[index]!.name);
}
app
.querySelectorAll<HTMLButtonElement>("[data-scene]")
.forEach((b) => (b.onclick = () => synthetic(Number(b.dataset.scene))));
app.querySelector<HTMLButtonElement>("#next")!.onclick = () =>
synthetic((index + 1) % books.length);
app.querySelector<HTMLButtonElement>("#reset")!.onclick = () => synthetic(0);
app.querySelector<HTMLButtonElement>("#synthetic")!.onclick = () =>
synthetic(0);
app.querySelector<HTMLButtonElement>("#live")!.onclick = () => {
feed?.stop();
live = true;
sequence = -1;
show({ bids: [], asks: [] }, "Waiting for live BTC/USDT");
document.querySelector("#metrics")!.textContent =
"No live snapshot received yet";
feed = new LiveFeed({
stream: "btcusdt@depth20",
status,
receive: (raw) => {
const book = parseBook(raw);
const next = Number(book.sequence);
if (next <= sequence) return;
sequence = next;
show(book, "LIVE · BTC/USDT top 20");
document.querySelector("#metrics")!.textContent =
metrics(current.getMetrics()) +
"\nLive market · not comparable to the synthetic price scale";
},
});
feed.start();
};
function cleanup() {
feed?.stop();
baseline.destroy();
current.destroy();
}
window.addEventListener("pagehide", cleanup, { once: true });
window.addEventListener("pageshow", (e) => {
if (e.persisted) location.reload();
});
synthetic(0);
Sample data and parsing · model.ts
View model.ts in the repository
import type { Candle } from "@rekurt/openkline-core";
import type { DepthSnapshot } from "@rekurt/depth";
export const books: { name: string; book: DepthSnapshot }[] = [
{
name: "Balanced book",
book: {
bids: [
{ price: 99, volume: 4 },
{ price: 98, volume: 6 },
{ price: 97, volume: 5 },
{ price: 96, volume: 3 },
],
asks: [
{ price: 101, volume: 4 },
{ price: 102, volume: 6 },
{ price: 103, volume: 5 },
{ price: 104, volume: 3 },
],
},
},
{
name: "Large ask appears",
book: {
bids: [
{ price: 99, volume: 4 },
{ price: 98, volume: 6 },
{ price: 97, volume: 5 },
{ price: 96, volume: 3 },
],
asks: [
{ price: 101, volume: 4 },
{ price: 102, volume: 30 },
{ price: 103, volume: 5 },
{ price: 104, volume: 3 },
],
},
},
{
name: "Near-touch liquidity disappears",
book: {
bids: [
{ price: 97, volume: 5 },
{ price: 96, volume: 3 },
],
asks: [
{ price: 103, volume: 5 },
{ price: 104, volume: 3 },
],
},
},
];
export function cumulative(book: DepthSnapshot) {
const side = (levels: DepthSnapshot["bids"], sign: number) => {
let sum = 0;
return [...levels]
.sort((a, b) => sign * (a.price - b.price))
.map((l) => ({
price: l.price,
volume: l.volume,
cumulative: (sum += l.volume),
}));
};
return { bids: side(book.bids, -1), asks: side(book.asks, 1) };
}
export const candles: Candle[] = Array.from({ length: 120 }, (_, i) => {
const close = (j: number) => 42000 + Math.sin(j / 7) * 180 + j * 3;
const o = close(i - 1),
c = close(i);
return {
t: 1700000000 + i * 60,
o,
h: Math.max(o, c) + 30,
l: Math.min(o, c) - 25,
c,
v: 100 + (i % 13) * 17,
};
});
export const maxStep = (candles.length - 40) * 2;
export function replayAt(step: number): Candle[] {
step = Math.max(0, Math.min(maxStep, step));
const complete = 40 + Math.floor(step / 2);
const visible = candles.slice(0, complete).map((c) => ({ ...c }));
if (step % 2) {
const final = candles[complete]!;
const c = (final.o + final.c) / 2;
visible.push({
...final,
c,
h: Math.max(final.o, c) + 10,
l: Math.min(final.o, c) - 10,
v: final.v / 2,
});
}
return visible;
}
export function parseCandle(row: unknown): Candle {
if (!Array.isArray(row) || row.length < 6) throw Error("Invalid candle");
const [t, o, h, l, c, v] = row.map(Number);
return validateCandle({ t: t! / 1000, o: o!, h: h!, l: l!, c: c!, v: v! });
}
export function validateCandle(c: Candle): Candle {
if (
!Object.values(c).every(Number.isFinite) ||
c.t <= 0 ||
c.v < 0 ||
c.l <= 0 ||
c.l > Math.min(c.o, c.c) ||
c.h < Math.max(c.o, c.c)
)
throw Error("Invalid OHLCV values");
return c;
}
export function parseKline(raw: unknown): Candle {
const d = raw as {
k?: { t: number; o: string; h: string; l: string; c: string; v: string };
};
if (!d?.k) throw Error("Invalid kline");
const k = d.k;
return parseCandle([k.t, k.o, k.h, k.l, k.c, k.v]);
}
export function mergeCandle(data: Candle[], c: Candle): Candle[] {
const last = data.at(-1);
if (!last || c.t > last.t) return [...data, c].slice(-300);
if (c.t === last.t) return [...data.slice(0, -1), c];
return data;
}
export function parseBook(raw: unknown): DepthSnapshot {
const d = raw as { bids?: unknown; asks?: unknown; lastUpdateId?: unknown };
const side = (x: unknown) => {
if (!Array.isArray(x) || !x.length || x.length > 20)
throw Error("Invalid depth");
return x.map((row) => {
if (!Array.isArray(row) || row.length < 2) throw Error("Invalid level");
const [price, volume] = row.map(Number);
if (
!Number.isFinite(price) ||
!Number.isFinite(volume) ||
price! <= 0 ||
volume! < 0
)
throw Error("Invalid level values");
return { price: price!, volume: volume! };
});
};
if (!Number.isSafeInteger(d?.lastUpdateId)) throw Error("Invalid update ID");
return {
bids: side(d.bids),
asks: side(d.asks),
sequence: d.lastUpdateId as number,
};
}