Library/Market Microstructure/Market-Depth Analytics

D11-F05-A02 / Complete engineering topic

Top-N Depth Imbalance

A production-minded guide to Top-N Depth Imbalance.

D11 · MARKET MICROSTRUCTURE
D11-F05-A02Canonical / Tested / Open
D11 / D11-F05

Convert a symmetric top-N book window into a bounded, signed depth imbalance with an explicit neutral band.

The decision this tutorial makes visible

Comparing equal level counts makes visible supply and demand pressure easier to audit than comparing one best quote, but the result remains a static snapshot feature.

The precise question is: Within the first N populated levels on each side, what fraction of total visible depth favors bids over asks?

This matters to two readers at once. A practitioner needs to know what the diagnostic does and does not justify. A builder needs a contract that can be reproduced from the same ordered inputs in Python, TypeScript, a visual, and a browser lab. This tutorial supplies both views and keeps the selected single-venue convention visible throughout.

Intuition before notation

The numerator measures the signed depth difference while the denominator scales it into [-1, 1].

The diagnostic is not a free-floating signal. Its meaning depends on the declared book or lifecycle scope, the event or snapshot clock, equality handling, the included price window, and the treatment of unavailable state. If any of those change, the result is a different estimand or engine rule even when the final field name looks similar.

Scope and nearby methods

The canonical statistic uses exactly N populated levels per side and returns a positive value for bid-heavy depth. It is not order-flow imbalance.

VariantDefinitionBest useMain limitation
Static top-N depth imbalanceCompare depth stocks in N levelsSnapshot diagnosticsCancellations can reverse it
Top-of-book imbalanceUse N=1Low-latency compact featureIgnores deeper depth
Multi-level order-flow imbalanceMeasure event-driven changes by levelFlow and price-formation researchDifferent mathematical object

What is sourced, selected, synthetic, and derived

RoleMaterial claimEvidenceBoundary
Sourced factAuthoritative sources establish only the feed, message, formula-history, or empirical context recorded for this topic.S1, S2, S3, S4They do not validate the synthetic fixture, future availability, prediction, or profitability.
Implementation choiceThe canonical statistic uses exactly N populated levels per side and returns a positive value for bid-heavy depth. It is not order-flow imbalance.Frozen package definitionNearby venue, provider, consolidated, hidden, or event-flow variants are not silently substituted.
Synthetic teaching inputThe canonical and playground inputs are repository-authored teaching states.datasets/canonical-input.json and datasets/scenario-results.jsonThey are not observed exchange snapshots and carry no redistribution claim.
Author-derived calculationThe first three bid levels contain 2,500 shares and asks contain 1,900. The imbalance is 600/4,400 = 0.13636, above the 0.10 bid-heavy boundary.Formula, canonical fixture, Python/TypeScript parity, and independent arithmeticDefinition fidelity does not establish causal, predictive, or trading value.

The authoritative sources define feed capabilities, protocol vocabulary, venue-specific rules, or published empirical context. They do not certify the synthetic numbers in this tutorial. The repository fixture is deliberately invented for auditability, and the displayed output is author-derived from that fixture under the selected implementation choice.

Formula, symbols, and numerical policy

Plain text
I_N = (Σ_{i<N} q_bid,i - Σ_{i<N} q_ask,i) / (Σ_{i<N} q_bid,i + Σ_{i<N} q_ask,i)
SymbolMeaningUnitPolicy
B_Nsum of the best N bid quantitiessharesrequires N populated bid levels
A_Nsum of the best N ask quantitiessharesrequires N populated ask levels
I_Nnormalized top-N depth imbalanceratio(B_N-A_N)/(B_N+A_N)
Nsymmetric populated-level windowlevels per sidepositive integer
θclassification thresholdratiobid-heavy at I_N ≥ θ; ask-heavy at I_N ≤ -θ
  • Use exactly N populated levels on each side; reject an incomplete side rather than comparing asymmetric windows.
  • The denominator must be strictly positive; threshold equality belongs to the directional state.
  • Retain the full-precision ratio for classification and round only for display.

Read the formula in the same order as the algorithm. Validate identity, ordering, units, and supported state first. Apply the selected equality and window rules second. Calculate with unrounded numeric values. Round only at the declared presentation boundary, and preserve null as a diagnostic rather than coercing it to zero.

Build the algorithm

  1. Validate at least N levels on both sides
  2. Sum the first N bid quantities
  3. Sum the first N ask quantities
  4. Normalize the signed difference by total depth
  5. Classify against the symmetric threshold

Production-minded operational checklist

  1. Confirm the book is complete through the same N on both sides.
  2. Compute B_N and A_N before the normalized ratio.
  3. Check the zero-denominator and equality branches explicitly.
  4. Label the output depth stock, not order-flow imbalance.
  5. Compare sensitivity across N before interpreting a single value.

The checklist is intentionally stricter than a charting demo. A plausible number from stale, incomplete, off-grid, misordered, or unsupported state is more dangerous than an explicit rejection.

Worked synthetic example

The canonical fixture is synthetic teaching data, not an observed venue snapshot or private order record. Its primary author-derived output, imbalance, is 0.136363636364. The complete input and output are in datasets/canonical-input.json and datasets/expected-output.json.

The first three bid levels contain 2,500 shares and asks contain 1,900. The imbalance is 600/4,400 = 0.13636, above the 0.10 bid-heavy boundary.

The structured result retains state and diagnostics in addition to the primary number. That makes the calculation independently reviewable and prevents a partial, null, rejected, or venue-bounded outcome from being mistaken for an unqualified value.

Boundary and counterexample workbook

The playground computes every scenario at 61 deterministic parameter states. The table shows the midpoint used for prose review; the full state ledger is in datasets/scenario-results.json.

ScenarioPurposeStatePrimary outputDiagnostic
Canonical sweepMove one declared driver around the canonical visible book.bid-heavy0.2500N=1 · threshold 0.10
Balanced comparisonCompare a deliberately symmetric or neutral state.balanced-band0.0000N=2 · threshold 0.10
Bid-heavy boundaryIncrease visible bid-side pressure within the declared window.bid-heavy0.3944N=3 · threshold 0.10
Ask-heavy or partial caseStress the opposite side or available-depth boundary.ask-heavy-0.2099N=4 · threshold 0.10
Thin or concentrated caseReduce breadth or concentrate depth to expose failure semantics.bid-heavy0.2046N=5 · threshold 0.10
Equality and null boundaryLand exactly on a classification, price, coverage, or null-producing boundary.bid-heavy0.0435N=5 · threshold 0.00
Adversarial interpretation checkCreate a valid but misleading-looking state that must retain its misuse guardrail.ask-heavy-0.3500N=2 · threshold 0.10

These rows are not backtest observations. They are controlled counterexamples that expose how one driver changes the state, output, or reason code while the rest of the contract stays fixed.

Visualize the boundary

Top-N Depth Imbalance annotated teaching map

Open this SVG at full size, or use the guided playground to compare canonical, equality, null, residual, adversarial, and cross-method scenarios.

The Mermaid flow answers where the selected calculation sits in the processing sequence. The SVG keeps the formula, output, decision boundary, and invariant visible together. The lab lets the reader step through the same structured states without changing the underlying definition.

Implementation walkthrough

The Python and TypeScript references begin with the same validation contract. They reject malformed and unsupported state before calculation, preserve best-to-worse or event-sequence ordering, apply the equality policy in the symbol table, and return a structured diagnostic object rather than one context-free number.

The main implementation branches are:

  • Both sides have at least N levels — Compute the strict window, because Avoid asymmetric truncation.
  • |imbalance| ≤ threshold — Return balanced-band, because The neutral region is inclusive.
  • A side is incomplete — Reject, because A precise value would hide missing coverage.

Neither reference silently fetches data, mutates caller-owned inputs outside the declared engine behavior, guesses hidden state, or substitutes a provider default. Shared JSON fixtures make value, null, state, and reason-code drift visible across languages.

Testing and validation

Definition tests compare every canonical field, reject malformed state, and exercise the material boundary. Family validation recomputes every playground state from the reference function. Independent arithmetic is recorded beside the fixture rather than inferred only from implementation output.

The audit must preserve these invariants:

  • Imbalance is in [-1,1].
  • Swapping bid and ask quantities negates imbalance.
  • A strict N-window never silently uses asymmetric counts.
  • The structured output retains imbalance, state, and the diagnostics needed to reproduce its branch.

Passing definition and parity checks proves that the implementation matches the selected contract. It does not prove that displayed state remains available, that another venue uses the same rule, or that the diagnostic predicts a later price or fill.

Failure modes and misuse

  • Displayed quantity can be cancelled or executed before a real order arrives.
  • Hidden, reserve, midpoint, routed, and other-venue liquidity is outside the visible snapshot.
  • A deeper book does not by itself establish future price direction or executable capacity.
  • Static depth imbalance must not be cited as the Cont et al. order-flow imbalance variable.

Debugging order

When a result looks surprising, inspect the state in this order:

  1. Confirm instrument, venue, session, order type, and side.
  2. Confirm price, quantity, tick, clock, and sequence units.
  3. Confirm the included depth window or lifecycle-event boundary.
  4. Confirm equality, null, residual, and reset policies.
  5. Recalculate the invariant and midpoint counterexample before changing code.

Evidence and historical boundary

Historical decision: deferred. A named market-depth case would require instrument and venue identity, a sequence-complete licensed feed, clock and correction policy, visible/hidden scope, and redistribution permission. The synthetic case provides stronger public auditability for this build.

The primary sources are NYSE Integrated Feed, Nasdaq TotalView-ITCH 5.0, Cont, Kukanov, and Stoikov, Xu, Gould, and Howison. They support the source roles listed in the research ledger, not a redistributable historical observation, participant-intent claim, venue-conformance certification, profitability claim, or prediction claim.

Summary and next topic

You can now calculate a bounded symmetric depth imbalance. The learning flow is: Cumulative Bid/Ask Depth → Top-N Depth Imbalance → Depth-at-Distance Profile. Carry the result forward only with its scope, clock, state, and evidence label.

Top-N Depth Imbalance calculation flow

This flow identifies the selected calculation stages and the structured output.

Rendering system map…

Takeaway: The sign describes visible depth stock inside one declared window, not order arrival flow or a forecast.

ReferencesPrimary sources and evidence notes

Expand the source trail, evidence role, and limitations behind the engineering choices.

S1 — NYSE Integrated Feed

  • Organization or authors: New York Stock Exchange
  • Source type: Official exchange data-product description
  • Publication or effective date: Current page accessed 2026-07-30
  • Version: Current web version
  • URL or DOI: https://www.nyse.com/data-products/catalog/integrated-feed
  • Accessed: 2026-07-30
  • Jurisdiction: United States equities; NYSE Group venues
  • Supports: Order-by-order events, depth, trades, imbalances, security status, and sequence integration are available in an official depth feed.
  • Limitations: Does not define the package formulas, provide public redistributable observations, or reveal hidden liquidity.

S2 — Nasdaq TotalView-ITCH 5.0 Specification

  • Organization or authors: Nasdaq
  • Source type: Official exchange technical specification
  • Publication or effective date: Specification current at access
  • Version: 5.0
  • URL or DOI: https://nasdaqtrader.com/content/technicalsupport/specifications/dataproducts/NQTVITCHSpecification.pdf
  • Accessed: 2026-07-30
  • Jurisdiction: United States equities; Nasdaq
  • Supports: Add, execute, cancel, delete, and replace messages needed to reconstruct displayed order state.
  • Limitations: Feed messages are venue-specific; the specification does not make displayed depth firm through future latency.

S3 — The Price Impact of Order Book Events

  • Organization or authors: Rama Cont, Arseniy Kukanov, and Sasha Stoikov
  • Source type: Original research paper
  • Publication or effective date: 2014 journal article; 2010 working paper
  • Version: Published method
  • URL or DOI: https://arxiv.org/abs/1011.6402
  • Accessed: 2026-07-30
  • Jurisdiction: Empirical U.S. equities study
  • Supports: Short-interval price changes, order-flow imbalance, and market depth are empirically related in the paper's sample and design.
  • Limitations: Does not validate the package's static top-N imbalance or imply predictive or profitable use.

S4 — Multi-Level Order-Flow Imbalance in a Limit Order Book

  • Organization or authors: Ke Xu, Martin D. Gould, and Sam D. Howison
  • Source type: Original research paper
  • Publication or effective date: 2019 working paper
  • Version: arXiv 1907.06230
  • URL or DOI: https://arxiv.org/abs/1907.06230
  • Accessed: 2026-07-30
  • Jurisdiction: Empirical Nasdaq equities study
  • Supports: Order-flow information at multiple book levels can matter under the paper's fitted contemporaneous design.
  • Limitations: A flow vector is not the same object as static top-N depth imbalance, and empirical results do not transfer automatically.

Evidence boundary

The sources establish feed fields, venue rules, protocol states, or published microstructure definitions. They do not verify the repository-authored synthetic fixture, thresholds, empirical usefulness, execution probability, or profitability. The package-selected choices are labeled as implementation choices wherever they are used.

market-depth-analytics.ts
export type JsonObject = Record<string, any>;

function numberValue(name: string, value: unknown, options: {positive?: boolean; nonnegative?: boolean} = {}): number {
  if (typeof value !== "number" || !Number.isFinite(value)) throw new TypeError(`${name} must be a finite number`);
  if (options.positive && value <= 0) throw new RangeError(`${name} must be positive`);
  if (options.nonnegative && value < 0) throw new RangeError(`${name} must be nonnegative`);
  return value;
}

function integerValue(name: string, value: unknown, positive = false): number {
  const result = numberValue(name, value);
  if (!Number.isInteger(result)) throw new RangeError(`${name} must be an integer`);
  if (positive && result <= 0) throw new RangeError(`${name} must be positive`);
  return result;
}

type Level = {price: number; quantity: number};

function levels(raw: unknown, side: "bid" | "ask"): Level[] {
  if (!Array.isArray(raw) || raw.length === 0) throw new RangeError(`${side}s must be a non-empty list`);
  const seen = new Set<number>();
  const result = raw.map((item, index) => {
    if (!item || typeof item !== "object") throw new TypeError(`${side}s[${index}] must be an object`);
    const row = item as JsonObject;
    const price = numberValue(`${side}s[${index}].price`, row.price, {positive: true});
    const quantity = numberValue(`${side}s[${index}].quantity`, row.quantity, {positive: true});
    if (seen.has(price)) throw new RangeError(`${side} prices must be unique aggregated levels`);
    seen.add(price);
    return {price, quantity};
  });
  for (let index = 1; index < result.length; index += 1) {
    if (side === "bid" && result[index - 1].price <= result[index].price) throw new RangeError("bids must be strictly descending from best price");
    if (side === "ask" && result[index - 1].price >= result[index].price) throw new RangeError("asks must be strictly ascending from best price");
  }
  return result;
}

function book(bidsRaw: unknown, asksRaw: unknown): [Level[], Level[]] {
  const bids = levels(bidsRaw, "bid"), asks = levels(asksRaw, "ask");
  if (bids[0].price >= asks[0].price) throw new RangeError("visible book must be uncrossed");
  return [bids, asks];
}

function tickDistance(price: number, best: number, tick: number, side: "bid" | "ask"): number {
  const raw = side === "bid" ? (best - price) / tick : (price - best) / tick;
  const rounded = Math.round(raw);
  if (Math.abs(raw - rounded) > 1e-7) throw new RangeError("price is not aligned to the declared tick grid");
  return rounded;
}

export function cumulativeDepth(bidsRaw: unknown, asksRaw: unknown, tickRaw: unknown): JsonObject {
  const [bids, asks] = book(bidsRaw, asksRaw);
  const tick = numberValue("tick_size", tickRaw, {positive: true});
  const rows = (sideLevels: Level[], side: "bid" | "ask") => {
    let cumulative = 0;
    const best = sideLevels[0].price;
    return sideLevels.map(level => {
      cumulative += level.quantity;
      return {price: level.price, quantity: level.quantity, distance_ticks: tickDistance(level.price, best, tick, side), cumulative_quantity: cumulative};
    });
  };
  const bidRows = rows(bids, "bid"), askRows = rows(asks, "ask");
  const spreadRaw = (asks[0].price - bids[0].price) / tick, spreadTicks = Math.round(spreadRaw);
  if (Math.abs(spreadRaw - spreadTicks) > 1e-7) throw new RangeError("spread is not aligned to the declared tick grid");
  const bidTotal = bidRows[bidRows.length - 1].cumulative_quantity, askTotal = askRows[askRows.length - 1].cumulative_quantity;
  return {model: "visible-cumulative-depth", best_bid: bids[0].price, best_ask: asks[0].price, tick_size: tick, spread_ticks: spreadTicks, bid_levels: bidRows, ask_levels: askRows, total_bid_depth: bidTotal, total_ask_depth: askTotal, state: bidTotal > askTotal ? "bid-deeper" : bidTotal < askTotal ? "ask-deeper" : "balanced"};
}

export function topNDepthImbalance(bidsRaw: unknown, asksRaw: unknown, nRaw: unknown, thresholdRaw: unknown = 0.1): JsonObject {
  const [bids, asks] = book(bidsRaw, asksRaw);
  const n = integerValue("n", nRaw, true), threshold = numberValue("threshold", thresholdRaw, {nonnegative: true});
  if (threshold > 1) throw new RangeError("threshold must not exceed one");
  if (bids.length < n || asks.length < n) throw new RangeError("both sides must contain at least n visible levels");
  const bidDepth = bids.slice(0, n).reduce((sum, row) => sum + row.quantity, 0);
  const askDepth = asks.slice(0, n).reduce((sum, row) => sum + row.quantity, 0);
  const total = bidDepth + askDepth, imbalance = (bidDepth - askDepth) / total;
  return {model: "top-n-visible-depth-imbalance", n, bid_depth: bidDepth, ask_depth: askDepth, total_depth: total, imbalance, threshold, state: imbalance > threshold ? "bid-heavy" : imbalance < -threshold ? "ask-heavy" : "balanced-band", complete_window: true};
}

export function depthAtDistanceProfile(bidsRaw: unknown, asksRaw: unknown, tickRaw: unknown, maxRaw: unknown): JsonObject {
  const [bids, asks] = book(bidsRaw, asksRaw);
  const tick = numberValue("tick_size", tickRaw, {positive: true}), maximum = integerValue("max_distance_ticks", maxRaw);
  if (maximum < 0) throw new RangeError("max_distance_ticks must be nonnegative");
  const profile = (sideLevels: Level[], side: "bid" | "ask") => {
    const best = sideLevels[0].price, byDistance = new Map<number, number>();
    sideLevels.forEach(level => byDistance.set(tickDistance(level.price, best, tick, side), level.quantity));
    let included = 0;
    for (let d = 0; d <= maximum; d += 1) included += byDistance.get(d) ?? 0;
    let cumulative = 0;
    return Array.from({length: maximum + 1}, (_, distance) => {
      const quantity = byDistance.get(distance) ?? 0; cumulative += quantity;
      return {distance_ticks: distance, quantity, cumulative_quantity: cumulative, depth_share: included ? quantity / included : 0};
    });
  };
  const bidProfile = profile(bids, "bid"), askProfile = profile(asks, "ask");
  const bidTotal = bidProfile[bidProfile.length - 1].cumulative_quantity, askTotal = askProfile[askProfile.length - 1].cumulative_quantity;
  return {model: "same-side-best-tick-distance-profile", tick_size: tick, max_distance_ticks: maximum, bid_profile: bidProfile, ask_profile: askProfile, included_bid_depth: bidTotal, included_ask_depth: askTotal, state: bidTotal > askTotal ? "bid-profile-heavier" : askTotal > bidTotal ? "ask-profile-heavier" : "profile-balanced"};
}

function sweep(bidsRaw: unknown, asksRaw: unknown, sideRaw: unknown, quantityRaw: unknown, limitRaw: unknown): JsonObject {
  const [bids, asks] = book(bidsRaw, asksRaw);
  if (sideRaw !== "buy" && sideRaw !== "sell") throw new RangeError("side must be buy or sell");
  const side = sideRaw, requested = numberValue("quantity", quantityRaw, {positive: true});
  const limit = limitRaw == null ? null : numberValue("limit_price", limitRaw, {positive: true});
  const sideLevels = side === "buy" ? asks : bids;
  let remaining = requested, notional = 0;
  const fills: JsonObject[] = [];
  for (let index = 0; index < sideLevels.length && remaining > 1e-12; index += 1) {
    const level = sideLevels[index];
    if (limit != null && (side === "buy" ? level.price > limit : level.price < limit)) break;
    const fillQuantity = Math.min(remaining, level.quantity);
    fills.push({level_index: index, price: level.price, quantity: fillQuantity, notional: fillQuantity * level.price});
    notional += fillQuantity * level.price; remaining -= fillQuantity;
  }
  if (remaining <= 1e-12) remaining = 0;
  const filled = requested - remaining, partialVwap = filled ? notional / filled : null;
  return {side, requested_quantity: requested, filled_quantity: filled, unfilled_quantity: remaining, full_fill: remaining === 0, fills, fill_notional: notional, partial_vwap: partialVwap, worst_fill_price: fills.length ? fills[fills.length - 1].price : null, best_bid: bids[0].price, best_ask: asks[0].price, midpoint: (bids[0].price + asks[0].price) / 2};
}

export function expectedFillPrice(bids: unknown, asks: unknown, side: unknown, quantity: unknown, limitPrice: unknown = null): JsonObject {
  const result = sweep(bids, asks, side, quantity, limitPrice);
  return {model: "deterministic-visible-book-fill-estimate", ...result, expected_fill_price: result.full_fill ? result.partial_vwap : null, state: result.full_fill ? "full-fill" : result.filled_quantity ? "partial-fill" : "unfilled"};
}

export function sweepCostAndSlippage(bids: unknown, asks: unknown, side: unknown, quantity: unknown, benchmarkRaw: unknown = "midpoint", benchmarkPriceRaw: unknown = null, limitPrice: unknown = null): JsonObject {
  const result = sweep(bids, asks, side, quantity, limitPrice);
  if (!["midpoint", "best-quote", "explicit"].includes(String(benchmarkRaw))) throw new RangeError("benchmark must be midpoint, best-quote, or explicit");
  const benchmark = String(benchmarkRaw);
  const reference = benchmark === "midpoint" ? result.midpoint : benchmark === "best-quote" ? (side === "buy" ? result.best_ask : result.best_bid) : numberValue("benchmark_price", benchmarkPriceRaw, {positive: true});
  const vwap = result.partial_vwap, direction = side === "buy" ? 1 : -1;
  const slippagePrice = vwap == null ? null : direction * (vwap - reference);
  const slippageBps = slippagePrice == null ? null : slippagePrice / reference * 10_000;
  return {model: "visible-book-sweep-cost", ...result, benchmark, benchmark_price: reference, sweep_vwap: vwap, signed_slippage_price: slippagePrice, signed_slippage_bps: slippageBps, total_slippage_cost: slippagePrice == null ? null : slippagePrice * result.filled_quantity, state: result.full_fill ? "full-sweep" : result.filled_quantity ? "partial-sweep" : "unfilled"};
}

function median(values: number[]): number {
  const sorted = [...values].sort((a, b) => a - b), middle = Math.floor(sorted.length / 2);
  return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
}

export function liquidityWallConcentration(levelsRaw: unknown, multipleRaw: unknown = 2, minShareRaw: unknown = 0.25, thresholdRaw: unknown = 0.30): JsonObject {
  const parsed = levels(levelsRaw, "ask");
  const multiple = numberValue("wall_multiple", multipleRaw, {positive: true});
  const minShare = numberValue("minimum_share", minShareRaw, {nonnegative: true});
  const threshold = numberValue("concentration_threshold", thresholdRaw, {nonnegative: true});
  if (minShare > 1 || threshold > 1) throw new RangeError("share thresholds must not exceed one");
  const quantities = parsed.map(row => row.quantity), total = quantities.reduce((a, b) => a + b, 0), med = median(quantities);
  const shares = quantities.map(value => value / total), hhi = shares.reduce((sum, value) => sum + value * value, 0);
  const walls = parsed.map((level, index) => ({level_index: index, price: level.price, quantity: level.quantity, depth_share: shares[index], median_multiple: level.quantity / med})).filter(row => row.quantity >= multiple * med && row.depth_share >= minShare);
  let largestIndex = 0; quantities.forEach((value, index) => { if (value > quantities[largestIndex]) largestIndex = index; });
  return {model: "level-share-concentration-and-wall-screen", level_count: parsed.length, total_depth: total, median_level_quantity: med, hhi_fraction: hhi, effective_level_count: 1 / hhi, largest_level_index: largestIndex, largest_level_share: shares[largestIndex], walls, wall_multiple: multiple, minimum_share: minShare, concentration_threshold: threshold, state: walls.length ? "wall-detected" : hhi >= threshold ? "concentrated" : "distributed"};
}

export function depthDepletionReplenishment(seriesRaw: unknown): JsonObject {
  if (!Array.isArray(seriesRaw) || seriesRaw.length < 2) throw new RangeError("series must contain at least two snapshots");
  const parsed = seriesRaw.map((item, index) => {
    if (!item || typeof item !== "object") throw new TypeError(`series[${index}] must be an object`);
    const row = item as JsonObject;
    return {timestamp_seconds: numberValue(`series[${index}].timestamp_seconds`, row.timestamp_seconds, {nonnegative: true}), quantity: numberValue(`series[${index}].quantity`, row.quantity, {nonnegative: true})};
  });
  for (let i = 1; i < parsed.length; i += 1) if (parsed[i - 1].timestamp_seconds >= parsed[i].timestamp_seconds) throw new RangeError("timestamps must be strictly increasing");
  let depletion = 0, replenishment = 0;
  const changes = parsed.slice(1).map((current, index) => {
    const previous = parsed[index], delta = current.quantity - previous.quantity;
    replenishment += Math.max(delta, 0); depletion += Math.max(-delta, 0);
    return {timestamp_seconds: current.timestamp_seconds, previous_quantity: previous.quantity, quantity: current.quantity, delta_quantity: delta, classification: delta > 0 ? "replenishment" : delta < 0 ? "depletion" : "unchanged"};
  });
  const net = replenishment - depletion;
  return {model: "snapshot-visible-depth-change-decomposition", snapshot_count: parsed.length, start_quantity: parsed[0].quantity, end_quantity: parsed[parsed.length - 1].quantity, gross_depletion: depletion, gross_replenishment: replenishment, net_depth_change: net, replenishment_to_depletion: depletion ? replenishment / depletion : null, changes, state: net > 0 ? "net-replenishing" : net < 0 ? "net-depleting" : "net-flat"};
}

export function marketDepthHeatmap(snapshotsRaw: unknown, tickRaw: unknown, binRaw: unknown, maxRaw: unknown): JsonObject {
  if (!Array.isArray(snapshotsRaw) || snapshotsRaw.length < 2) throw new RangeError("snapshots must contain at least two regular observations");
  const snapshots = snapshotsRaw as JsonObject[], tick = numberValue("tick_size", tickRaw, {positive: true}), binSize = numberValue("time_bin_seconds", binRaw, {positive: true}), maximum = integerValue("max_distance_ticks", maxRaw);
  if (maximum < 0) throw new RangeError("max_distance_ticks must be nonnegative");
  const times = snapshots.map((item, index) => {
    if (!item || typeof item !== "object") throw new TypeError("each snapshot must be an object");
    return numberValue(`snapshots[${index}].timestamp_seconds`, item.timestamp_seconds, {nonnegative: true});
  });
  for (let i = 1; i < times.length; i += 1) if (times[i - 1] >= times[i]) throw new RangeError("snapshot timestamps must be strictly increasing");
  const intervals = times.slice(1).map((time, index) => time - times[index]);
  if (intervals.slice(1).some(interval => Math.abs(interval - intervals[0]) > 1e-9)) throw new RangeError("canonical heatmap requires regularly sampled snapshots");
  const origin = times[0], buckets = new Map<string, number[]>(), binSet = new Set<number>();
  snapshots.forEach((snapshot, snapshotIndex) => {
    const [bids, asks] = book(snapshot.bids, snapshot.asks), binIndex = Math.floor((times[snapshotIndex] - origin) / binSize);
    binSet.add(binIndex);
    const observed = new Map<number, number>();
    bids.forEach(level => { const d = tickDistance(level.price, bids[0].price, tick, "bid"); if (d <= maximum) observed.set(-(d + 1), level.quantity); });
    asks.forEach(level => { const d = tickDistance(level.price, asks[0].price, tick, "ask"); if (d <= maximum) observed.set(d + 1, level.quantity); });
    const coordinates = [...Array.from({length: maximum + 1}, (_, i) => -(maximum + 1) + i), ...Array.from({length: maximum + 1}, (_, i) => i + 1)];
    coordinates.forEach(coordinate => { const key = `${binIndex}|${coordinate}`, values = buckets.get(key) ?? []; values.push(observed.get(coordinate) ?? 0); buckets.set(key, values); });
  });
  const cells = [...buckets.entries()].map(([key, values]) => {
    const [binIndex, coordinate] = key.split("|").map(Number);
    return {time_bin_index: binIndex, time_bin_start_seconds: origin + binIndex * binSize, book_coordinate: coordinate, mean_quantity: values.reduce((a, b) => a + b, 0) / values.length, max_quantity: Math.max(...values), observation_count: values.length};
  }).sort((a, b) => a.time_bin_index - b.time_bin_index || a.book_coordinate - b.book_coordinate);
  const peak = cells.reduce((best, cell) => cell.mean_quantity > best.mean_quantity ? cell : best);
  return {model: "regular-snapshot-inside-relative-depth-heatmap", snapshot_interval_seconds: intervals[0], time_bin_seconds: binSize, max_distance_ticks: maximum, time_bin_count: binSet.size, book_coordinates: [...Array.from({length: maximum + 1}, (_, i) => -(maximum + 1) + i), ...Array.from({length: maximum + 1}, (_, i) => i + 1)], cells, peak_cell: peak, state: peak.book_coordinate < 0 ? "bid-peak" : "ask-peak"};
}

export function calculate(topicId: string, inputs: JsonObject): JsonObject {
  if (!inputs || typeof inputs !== "object" || Array.isArray(inputs)) throw new TypeError("inputs must be an object");
  if (topicId === "D11-F05-A01") return cumulativeDepth(inputs.bids, inputs.asks, inputs.tick_size);
  if (topicId === "D11-F05-A02") return topNDepthImbalance(inputs.bids, inputs.asks, inputs.n, inputs.threshold ?? 0.1);
  if (topicId === "D11-F05-A03") return depthAtDistanceProfile(inputs.bids, inputs.asks, inputs.tick_size, inputs.max_distance_ticks);
  if (topicId === "D11-F05-A04") return expectedFillPrice(inputs.bids, inputs.asks, inputs.side, inputs.quantity, inputs.limit_price);
  if (topicId === "D11-F05-A05") return sweepCostAndSlippage(inputs.bids, inputs.asks, inputs.side, inputs.quantity, inputs.benchmark ?? "midpoint", inputs.benchmark_price, inputs.limit_price);
  if (topicId === "D11-F05-A06") return liquidityWallConcentration(inputs.levels, inputs.wall_multiple ?? 2, inputs.minimum_share ?? 0.25, inputs.concentration_threshold ?? 0.30);
  if (topicId === "D11-F05-A07") return depthDepletionReplenishment(inputs.series);
  if (topicId === "D11-F05-A08") return marketDepthHeatmap(inputs.snapshots, inputs.tick_size, inputs.time_bin_seconds, inputs.max_distance_ticks);
  throw new RangeError(`unsupported topic_id: ${topicId}`);
}
Full-height labplaygroundOpen full screen