Library/Market Microstructure/Order-Flow and Impact

D11-F03-A03 / Complete engineering topic

Kyle Lambda

A production-minded guide to Kyle Lambda.

D11 · MARKET MICROSTRUCTURE
D11-F03-A03Canonical / Tested / Open
D11 / D11-F03

How many midpoint basis points are associated with one declared unit of signed order flow? This tutorial builds the answer from causal records, not from a persuasive chart. You will estimate and interpret a unit-explicit empirical Kyle-style price-impact slope, inspect the exact boundary, and learn what the output cannot support.

Evidence boundary: all numeric records and results below are synthetic and author-derived. Sources establish definitions and feed semantics only.

Start with the estimand

The canonical estimator is OLS of interval midpoint change in basis points on signed volume in thousands of shares, with an intercept. Nearby measures may sound similar while using different state, clocks, or units. The calculation is:

Δmₜ(bps) = α + λ qₜ(thousand shares) + εₜ.

The data contract freezes equality, missing-state, reset, window, transformation, fitting, and rounding choices before we look at results.

The causal evidence and calculation map for Kyle Lambda

The first diagram separates feed reconstruction from measurement and interpretation. A correct formula cannot repair incomplete point-in-time evidence.

Work the canonical synthetic case

The canonical scenario linear-impact contains a substantive sequence rather than a three-row toy. Under the first profile, the primary value is 0.4210091524.

The exact decision boundary for Kyle Lambda

The decision graphic makes the formula readable without hiding its profile-specific assumptions.

The first canonical trace states for Kyle Lambda

The trace is the real teaching object: it exposes the current input, state, reason code, and accumulated output. A headline statistic without this lineage is difficult to debug and easy to misuse.

Compare profiles before interpreting

ProfileConventionConfiguration
linearLinear signed volume with intercept{"volume_transform": "linear", "intercept": true}
sqrtSigned square-root volume with intercept{"volume_transform": "signed-sqrt", "intercept": true}
originLinear volume through the origin{"volume_transform": "linear", "intercept": false}

Eight scenarios crossed with three profiles

All three profiles are pre-registered. Differences are sensitivity evidence, not permission to choose whichever looks most predictive.

<!-- ENHANCEMENT:F03-INTERPRETATION -->

Interpretation ladder

  1. Name the estimand: What contemporaneous midpoint-change slope is associated with one declared unit of signed flow?
  2. Check the nearest non-equivalent method: Hasbrouck Price Impact traces dynamic feedback; this package estimates a contemporaneous empirical slope.
  3. Audit the diagnostics before the headline value:
DiagnosticQuestion
UnitsIs the coefficient labeled in midpoint bps per thousand signed-volume units?
SpecificationAre intercept and signed-flow transformation declared?
InfluenceDo leverage points or sign errors dominate the fitted slope?
  1. Reproduce the independent identity: the exact synthetic linear relation recovers a slope of 2.
  2. Stop at the evidence boundary: The fitted slope is sample-, unit-, side-, and transformation-specific—not a universal structural constant.

Implementation walkthrough

The Python and TypeScript implementations share the same fixtures and result matrix. Both validate inputs, use explicit loops and linear algebra, preserve null states, and normalize stored results to ten decimal places. The package avoids external numerical dependencies so the teaching algorithm remains inspectable.

Run the examples in examples/python/ or examples/typescript/, then inspect tests/. For this topic, the independent acceptance identity is: the exact synthetic linear relation recovers a slope of 2.

Guided lab

Open kyle-lambda-lab.html. Choose a scenario and profile, step through the trace, compare diagnostics, and test reduced motion. The lab begins in an informative canonical state and keeps the full dataset.

Failure and evidence boundary

What Kyle Lambda can and cannot establish

A named historical example is not useful for this canonical package without licensed point-in-time feeds, correction lineage, exact venue semantics, and independent trade-side truth labels. The synthetic suite is more reproducible and covers boundaries deliberately.

The output is descriptive under a declared model. It does not identify informed traders, prove motive or private information, establish causality, guarantee prediction, or demonstrate profits after fees, latency, queue position, and market impact.

What to do next

The next tutorial is Hasbrouck Price Impact. Keep this package as the factual source; the video script is a visual derivative, not a second definition.

Sources

Kyle Lambda Calculation Flow

Rendering system map…

The diagram keeps evidence, measurement, and interpretation separate.

ReferencesPrimary sources and evidence notes

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

Accessed 2026-07-29. Protocol, paper, and methodology statements support only their named definitions and settings. They do not validate the synthetic fixtures or imply trading usefulness.

KYLE — Continuous Auctions and Insider Trading

  • Authors or organization: Albert S. Kyle
  • Source type: Original peer-reviewed paper
  • Publication or effective date: 1985-11
  • Version: Econometrica 53(6), 1315–1335
  • URL or DOI: https://doi.org/10.2307/1913210
  • Accessed: 2026-07-29
  • Jurisdiction or setting: venue/model-specific as described by the source
  • Supports: The structural meaning of lambda as price sensitivity to net order flow and reciprocal market depth.
  • Limitations: The empirical regression in this package is a declared measurement convention, not the full equilibrium model.
  • Evidence role: sourced fact only; synthetic fixtures and package calculations remain author-created.
  • Redistribution: this package redistributes no licensed market observations.

ITCH — Nasdaq TotalView-ITCH 5.0 Specification

  • Authors or organization: Nasdaq
  • Source type: Official exchange technical specification
  • Publication or effective date: 2015-03-06
  • Version: TotalView-ITCH 5.0
  • URL or DOI: https://nasdaqtrader.com/content/technicalsupport/specifications/dataproducts/NQTVITCHSpecification.pdf
  • Accessed: 2026-07-29
  • Jurisdiction or setting: venue/model-specific as described by the source
  • Supports: Nanosecond timestamps and add, execute, cancel, delete, replace, trade, and broken-trade semantics.
  • Limitations: Messages and queue visibility are venue-specific; the non-cross trade side field is not a universal aggressor label.
  • Evidence role: sourced fact only; synthetic fixtures and package calculations remain author-created.
  • Redistribution: this package redistributes no licensed market observations.
order-flow-impact.ts
export type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
export type Row = Record<string, unknown>;
export type Config = Record<string, unknown>;

function numberValue(value: unknown, name: string, mode: "any" | "positive" | "nonnegative" = "any"): number {
  if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${name} must be a finite number`);
  if (mode === "positive" && value <= 0) throw new Error(`${name} must be greater than zero`);
  if (mode === "nonnegative" && value < 0) throw new Error(`${name} must be nonnegative`);
  return value;
}

function rowsValue(value: unknown, minimum = 1): Row[] {
  if (!Array.isArray(value) || value.length < minimum || value.some((row) => row === null || typeof row !== "object" || Array.isArray(row))) {
    throw new Error(`rows must contain at least ${minimum} records`);
  }
  return value as Row[];
}

function rounded(value: unknown, digits = 10): any {
  if (typeof value === "number") return Number(value.toFixed(digits));
  if (Array.isArray(value)) return value.map((item) => rounded(item, digits));
  if (value !== null && typeof value === "object") {
    return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, rounded(item, digits)]));
  }
  return value;
}

export function orderFlowImbalance(
  inputRows: unknown,
  resetOnSession = true,
  normalizeByDepth = false,
): Record<string, unknown> {
  const records = rowsValue(inputRows, 2);
  const trace: Record<string, unknown>[] = [];
  let previous: { session: string; bidPrice: number; bidSize: number; askPrice: number; askSize: number } | null = null;
  let cumulative = 0;
  records.forEach((row, index) => {
    const bid = numberValue(row.bid_price, `rows[${index}].bid_price`, "positive");
    const ask = numberValue(row.ask_price, `rows[${index}].ask_price`, "positive");
    const bidSize = numberValue(row.bid_size, `rows[${index}].bid_size`, "nonnegative");
    const askSize = numberValue(row.ask_size, `rows[${index}].ask_size`, "nonnegative");
    if (ask < bid) throw new Error(`rows[${index}] is crossed`);
    const session = String(row.session ?? "S1");
    let eventOfi = 0;
    let reason = "seed";
    if (previous !== null && (!resetOnSession || session === previous.session)) {
      const raw =
        (bid >= previous.bidPrice ? bidSize : 0) -
        (bid <= previous.bidPrice ? previous.bidSize : 0) -
        (ask <= previous.askPrice ? askSize : 0) +
        (ask >= previous.askPrice ? previous.askSize : 0);
      if (normalizeByDepth) {
        const depth = (bidSize + askSize + previous.bidSize + previous.askSize) / 4;
        eventOfi = depth > 0 ? raw / depth : 0;
      } else eventOfi = raw;
      reason = "best-quote-event";
    }
    cumulative += eventOfi;
    trace.push({
      id: String(row.id ?? `E${String(index + 1).padStart(3, "0")}`),
      index,
      session,
      bid_price: bid,
      bid_size: bidSize,
      ask_price: ask,
      ask_size: askSize,
      event_ofi: eventOfi,
      cumulative_ofi: cumulative,
      side: eventOfi > 0 ? "buy-pressure" : eventOfi < 0 ? "sell-pressure" : "balanced",
      reason,
    });
    previous = { session, bidPrice: bid, bidSize, askPrice: ask, askSize };
  });
  const nonseed = trace.filter((row) => row.reason !== "seed");
  return rounded({
    model: "cont-best-quote-ofi",
    state: "estimated",
    reset_on_session: resetOnSession,
    normalize_by_depth: normalizeByDepth,
    event_count: trace.length,
    classified_event_count: nonseed.length,
    positive_event_count: nonseed.filter((row) => (row.event_ofi as number) > 0).length,
    negative_event_count: nonseed.filter((row) => (row.event_ofi as number) < 0).length,
    zero_event_count: nonseed.filter((row) => row.event_ofi === 0).length,
    cumulative_ofi: cumulative,
    mean_absolute_event_ofi: nonseed.length
      ? nonseed.reduce((sum, row) => sum + Math.abs(row.event_ofi as number), 0) / nonseed.length
      : 0,
    trace,
  });
}

export function queueImbalance(inputRows: unknown, levels = 1, decay = 1): Record<string, unknown> {
  const records = rowsValue(inputRows);
  if (!Number.isInteger(levels) || levels < 1) throw new Error("levels must be a positive integer");
  const decayValue = numberValue(decay, "decay", "positive");
  const trace = records.map((row, index) => {
    const bids = row.bid_sizes;
    const asks = row.ask_sizes;
    if (!Array.isArray(bids) || !Array.isArray(asks) || bids.length < levels || asks.length < levels) {
      throw new Error(`rows[${index}] requires at least ${levels} bid and ask levels`);
    }
    let bidDepth = 0;
    let askDepth = 0;
    for (let level = 0; level < levels; level += 1) {
      const weight = decayValue ** level;
      bidDepth += weight * numberValue(bids[level], `rows[${index}].bid_sizes[${level}]`, "nonnegative");
      askDepth += weight * numberValue(asks[level], `rows[${index}].ask_sizes[${level}]`, "nonnegative");
    }
    const total = bidDepth + askDepth;
    const imbalance = total === 0 ? null : (bidDepth - askDepth) / total;
    return {
      id: String(row.id ?? `Q${String(index + 1).padStart(3, "0")}`),
      index,
      bid_depth: bidDepth,
      ask_depth: askDepth,
      total_depth: total,
      imbalance,
      side: imbalance === null ? "unknown" : imbalance > 0 ? "bid-heavy" : imbalance < 0 ? "ask-heavy" : "balanced",
      reason: imbalance === null ? "zero-total-depth" : "normalized-depth",
    };
  });
  const valid = trace.map((row) => row.imbalance).filter((value): value is number => value !== null);
  return rounded({
    model: "signed-queue-imbalance",
    state: valid.length ? "estimated" : "no-valid-depth",
    levels,
    decay: decayValue,
    observation_count: trace.length,
    valid_count: valid.length,
    unknown_count: trace.length - valid.length,
    mean_imbalance: valid.length ? valid.reduce((sum, value) => sum + value, 0) / valid.length : null,
    last_imbalance: valid.length ? valid[valid.length - 1] : null,
    max_absolute_imbalance: valid.length ? Math.max(...valid.map(Math.abs)) : null,
    trace,
  });
}

function ols(x: number[], y: number[], intercept: boolean): Record<string, any> {
  if (x.length !== y.length || x.length < 3) throw new Error("OLS requires equal series with at least three observations");
  let alpha = 0;
  let slope: number;
  if (intercept) {
    const meanX = x.reduce((sum, value) => sum + value, 0) / x.length;
    const meanY = y.reduce((sum, value) => sum + value, 0) / y.length;
    const denominator = x.reduce((sum, value) => sum + (value - meanX) ** 2, 0);
    if (denominator <= 1e-15) throw new Error("regressor variance must be positive");
    slope = x.reduce((sum, value, index) => sum + (value - meanX) * (y[index] - meanY), 0) / denominator;
    alpha = meanY - slope * meanX;
  } else {
    const denominator = x.reduce((sum, value) => sum + value * value, 0);
    if (denominator <= 1e-15) throw new Error("regressor energy must be positive");
    slope = x.reduce((sum, value, index) => sum + value * y[index], 0) / denominator;
  }
  const predicted = x.map((value) => alpha + slope * value);
  const residuals = y.map((value, index) => value - predicted[index]);
  const meanY = y.reduce((sum, value) => sum + value, 0) / y.length;
  const ssTotal = y.reduce((sum, value) => sum + (value - meanY) ** 2, 0);
  const ssError = residuals.reduce((sum, value) => sum + value * value, 0);
  return {
    alpha,
    slope,
    r_squared: ssTotal <= 1e-15 ? null : 1 - ssError / ssTotal,
    residual_std: Math.sqrt(ssError / Math.max(1, y.length - (intercept ? 2 : 1))),
    predicted,
    residuals,
  };
}

export function kyleLambda(
  inputRows: unknown,
  volumeTransform: "linear" | "signed-sqrt" = "linear",
  intercept = true,
): Record<string, unknown> {
  const records = rowsValue(inputRows, 3);
  const x: number[] = [];
  const y: number[] = [];
  records.forEach((row, index) => {
    const volume = numberValue(row.signed_volume_k, `rows[${index}].signed_volume_k`);
    y.push(numberValue(row.mid_change_bps, `rows[${index}].mid_change_bps`));
    if (volumeTransform === "linear") x.push(volume);
    else if (volumeTransform === "signed-sqrt") x.push(volume === 0 ? 0 : Math.sign(volume) * Math.sqrt(Math.abs(volume)));
    else throw new Error("volume_transform must be linear or signed-sqrt");
  });
  const fit = ols(x, y, intercept);
  const trace = records.map((row, index) => ({
    id: String(row.id ?? `K${String(index + 1).padStart(3, "0")}`),
    index,
    signed_volume_k: row.signed_volume_k,
    regressor: x[index],
    mid_change_bps: y[index],
    predicted_change_bps: fit.predicted[index],
    residual_bps: fit.residuals[index],
    side: fit.predicted[index] > 0 ? "positive-impact" : fit.predicted[index] < 0 ? "negative-impact" : "zero-impact",
    reason: volumeTransform,
  }));
  return rounded({
    model: "empirical-kyle-lambda",
    state: "estimated",
    volume_transform: volumeTransform,
    intercept,
    observation_count: records.length,
    alpha_bps: fit.alpha,
    lambda_bps_per_regressor_unit: fit.slope,
    r_squared: fit.r_squared,
    residual_std_bps: fit.residual_std,
    trace,
  });
}

function solve(matrix: number[][], vector: number[]): number[] {
  const size = vector.length;
  const augmented = matrix.map((row, index) => [...row, vector[index]]);
  for (let column = 0; column < size; column += 1) {
    let pivot = column;
    for (let row = column + 1; row < size; row += 1) {
      if (Math.abs(augmented[row][column]) > Math.abs(augmented[pivot][column])) pivot = row;
    }
    if (Math.abs(augmented[pivot][column]) <= 1e-12) throw new Error("regression design is singular");
    [augmented[column], augmented[pivot]] = [augmented[pivot], augmented[column]];
    const scale = augmented[column][column];
    augmented[column] = augmented[column].map((value) => value / scale);
    for (let row = 0; row < size; row += 1) {
      if (row === column) continue;
      const factor = augmented[row][column];
      augmented[row] = augmented[row].map((value, item) => value - factor * augmented[column][item]);
    }
  }
  return augmented.map((row) => row[row.length - 1]);
}

function multipleOls(design: number[][], target: number[]): [number[], number[]] {
  const width = design[0].length;
  const xtx = Array.from({ length: width }, (_, i) =>
    Array.from({ length: width }, (_, j) => design.reduce((sum, row) => sum + row[i] * row[j], 0)),
  );
  const xty = Array.from({ length: width }, (_, i) =>
    design.reduce((sum, row, index) => sum + row[i] * target[index], 0),
  );
  const coefficients = solve(xtx, xty);
  const residuals = target.map(
    (value, index) => value - coefficients.reduce((sum, coefficient, item) => sum + coefficient * design[index][item], 0),
  );
  return [coefficients, residuals];
}

export function hasbrouckPriceImpact(inputRows: unknown, horizon = 10): Record<string, unknown> {
  const records = rowsValue(inputRows, 8);
  if (!Number.isInteger(horizon) || horizon < 0 || horizon > 100) throw new Error("horizon must be an integer from 0 through 100");
  const flow = records.map((row, index) => numberValue(row.signed_flow, `rows[${index}].signed_flow`));
  const returns = records.map((row, index) => numberValue(row.mid_change_bps, `rows[${index}].mid_change_bps`));
  const design = Array.from({ length: records.length - 1 }, (_, index) => [1, flow[index], returns[index]]);
  const [flowCoef, flowResidual] = multipleOls(design, flow.slice(1));
  const [returnCoef, returnResidual] = multipleOls(design, returns.slice(1));
  const dof = design.length - design[0].length;
  if (dof <= 0) throw new Error("insufficient residual degrees of freedom");
  const sigmaXX = flowResidual.reduce((sum, value) => sum + value * value, 0) / dof;
  const sigmaRX = returnResidual.reduce((sum, value, index) => sum + value * flowResidual[index], 0) / dof;
  if (sigmaXX <= 1e-15) throw new Error("flow innovation variance must be positive");
  let state = [1, sigmaRX / sigmaXX];
  const matrix = [
    [flowCoef[1], flowCoef[2]],
    [returnCoef[1], returnCoef[2]],
  ];
  let cumulative = 0;
  const trace: Record<string, unknown>[] = [];
  for (let step = 0; step <= horizon; step += 1) {
    cumulative += state[1];
    trace.push({
      id: `H${String(step).padStart(3, "0")}`,
      index: step,
      flow_response: state[0],
      return_response_bps: state[1],
      cumulative_impact_bps: cumulative,
      side: cumulative > 0 ? "positive-impact" : cumulative < 0 ? "negative-impact" : "zero-impact",
      reason: "recursive-var1-response",
    });
    state = [
      matrix[0][0] * state[0] + matrix[0][1] * state[1],
      matrix[1][0] * state[0] + matrix[1][1] * state[1],
    ];
  }
  return rounded({
    model: "hasbrouck-var1-flow-first",
    state: "estimated",
    observation_count: records.length,
    horizon,
    flow_equation: { intercept: flowCoef[0], flow_lag: flowCoef[1], return_lag: flowCoef[2] },
    return_equation: { intercept: returnCoef[0], flow_lag: returnCoef[1], return_lag: returnCoef[2] },
    flow_innovation_variance: sigmaXX,
    return_flow_innovation_covariance: sigmaRX,
    contemporaneous_return_response_bps: trace[0].return_response_bps,
    cumulative_price_impact_bps: cumulative,
    trace,
  });
}

function logFactorial(value: number): number {
  let total = 0;
  for (let item = 2; item <= value; item += 1) total += Math.log(item);
  return total;
}

function poissonLog(count: number, cachedLogFactorial: number, rate: number): number {
  return count * Math.log(rate) - rate - cachedLogFactorial;
}

function logSumExp(values: number[]): number {
  const anchor = Math.max(...values);
  return anchor + Math.log(values.reduce((sum, value) => sum + Math.exp(value - anchor), 0));
}

type PinDay = { id: string; buys: number; sells: number; logFactorialBuys: number; logFactorialSells: number };

function pinLogLikelihood(days: PinDay[], params: number[], balancedNoise: boolean): number {
  const [alpha, delta, mu] = params;
  const epsB = params[3];
  const epsS = balancedNoise ? params[3] : params[4];
  if (!(alpha > 0 && alpha < 1 && delta > 0 && delta < 1 && mu > 0 && epsB > 0 && epsS > 0)) return -Infinity;
  return days.reduce((total, day) => {
    const components = [
      Math.log(alpha) + Math.log(delta) +
        poissonLog(day.buys, day.logFactorialBuys, epsB) +
        poissonLog(day.sells, day.logFactorialSells, mu + epsS),
      Math.log(alpha) + Math.log(1 - delta) +
        poissonLog(day.buys, day.logFactorialBuys, mu + epsB) +
        poissonLog(day.sells, day.logFactorialSells, epsS),
      Math.log(1 - alpha) +
        poissonLog(day.buys, day.logFactorialBuys, epsB) +
        poissonLog(day.sells, day.logFactorialSells, epsS),
    ];
    return total + logSumExp(components);
  }, 0);
}

function patternSearch(
  objective: (values: number[]) => number,
  start: number[],
  initialSteps: number[],
  iterations: number,
): [number[], number] {
  let current = [...start];
  let steps = [...initialSteps];
  let score = objective(current);
  for (let iteration = 0; iteration < iterations; iteration += 1) {
    let improved = false;
    for (let index = 0; index < current.length; index += 1) {
      let bestParams = current;
      let bestScore = score;
      for (const direction of [-1, 1]) {
        const candidate = [...current];
        candidate[index] += direction * steps[index];
        candidate[index] = index < 2
          ? Math.min(0.999, Math.max(0.001, candidate[index]))
          : Math.max(0.001, candidate[index]);
        const candidateScore = objective(candidate);
        if (candidateScore > bestScore + 1e-12) {
          bestParams = candidate;
          bestScore = candidateScore;
        }
      }
      if (bestParams !== current) {
        current = bestParams;
        score = bestScore;
        improved = true;
      }
    }
    if (!improved) steps = steps.map((value) => value / 2);
    if (Math.max(...steps) < 1e-6) break;
  }
  return [current, score];
}

export function pin(
  inputRows: unknown,
  starts = 3,
  balancedNoise = false,
  iterations = 120,
): Record<string, unknown> {
  const records = rowsValue(inputRows, 10);
  if (!Number.isInteger(starts) || starts < 1 || starts > 5) throw new Error("starts must be an integer from 1 through 5");
  const days: PinDay[] = records.map((row, index) => {
    if (!Number.isInteger(row.buys) || (row.buys as number) < 0) throw new Error(`rows[${index}].buys must be a nonnegative integer`);
    if (!Number.isInteger(row.sells) || (row.sells as number) < 0) throw new Error(`rows[${index}].sells must be a nonnegative integer`);
    const buys = row.buys as number;
    const sells = row.sells as number;
    return {
      id: String(row.id ?? `D${String(index + 1).padStart(3, "0")}`),
      buys,
      sells,
      logFactorialBuys: logFactorial(buys),
      logFactorialSells: logFactorial(sells),
    };
  });
  const meanB = days.reduce((sum, day) => sum + day.buys, 0) / days.length;
  const meanS = days.reduce((sum, day) => sum + day.sells, 0) / days.length;
  const meanTotal = (meanB + meanS) / 2;
  const seeds = [
    [0.25, 0.50, Math.max(1, meanTotal * 0.80), Math.max(0.1, meanB * 0.65), Math.max(0.1, meanS * 0.65)],
    [0.45, 0.35, Math.max(1, meanTotal * 1.10), Math.max(0.1, meanB * 0.50), Math.max(0.1, meanS * 0.50)],
    [0.15, 0.70, Math.max(1, meanTotal * 1.40), Math.max(0.1, meanB * 0.75), Math.max(0.1, meanS * 0.75)],
    [0.65, 0.50, Math.max(1, meanTotal * 0.55), Math.max(0.1, meanB * 0.40), Math.max(0.1, meanS * 0.40)],
    [0.35, 0.20, Math.max(1, meanTotal * 1.80), Math.max(0.1, meanB * 0.80), Math.max(0.1, meanS * 0.80)],
  ];
  let bestParams: number[] | null = null;
  let bestScore = -Infinity;
  const dimension = balancedNoise ? 4 : 5;
  for (const seed of seeds.slice(0, starts)) {
    const candidate = seed.slice(0, dimension);
    const steps = [0.15, 0.15, ...Array.from({ length: dimension - 2 }, () => Math.max(0.5, meanTotal * 0.20))];
    const [params, score] = patternSearch(
      (values) => pinLogLikelihood(days, values, balancedNoise),
      candidate,
      steps,
      iterations,
    );
    if (score > bestScore) {
      bestParams = params;
      bestScore = score;
    }
  }
  if (bestParams === null) throw new Error("PIN optimization failed");
  const [alpha, delta, mu] = bestParams;
  const epsB = bestParams[3];
  const epsS = balancedNoise ? bestParams[3] : bestParams[4];
  const probability = (alpha * mu) / (alpha * mu + epsB + epsS);
  const trace = days.map((day, index) => ({
    id: day.id,
    index,
    buys: day.buys,
    sells: day.sells,
    imbalance: day.buys - day.sells,
    side: day.buys > day.sells ? "buy-heavy" : day.sells > day.buys ? "sell-heavy" : "balanced",
    reason: "daily-count-input",
  }));
  return rounded({
    model: "ekop-pin",
    state: "estimated",
    observation_count: days.length,
    starts,
    balanced_noise: balancedNoise,
    alpha,
    delta,
    mu,
    epsilon_buy: epsB,
    epsilon_sell: epsS,
    log_likelihood: bestScore,
    pin: probability,
    trace,
  });
}

export function vpin(
  inputRows: unknown,
  bucketVolume = 100,
  windowBuckets = 10,
  includePartial = false,
): Record<string, unknown> {
  const records = rowsValue(inputRows);
  const target = numberValue(bucketVolume, "bucket_volume", "positive");
  if (!Number.isInteger(windowBuckets) || windowBuckets < 1) throw new Error("window_buckets must be a positive integer");
  const buckets: Record<string, any>[] = [];
  let currentBuy = 0;
  let currentSell = 0;
  let bucketIndex = 0;
  records.forEach((row, eventIndex) => {
    const buy = numberValue(row.buy_volume, `rows[${eventIndex}].buy_volume`, "nonnegative");
    const sell = numberValue(row.sell_volume, `rows[${eventIndex}].sell_volume`, "nonnegative");
    let remaining = buy + sell;
    if (remaining <= 0) return;
    const buyFraction = buy / remaining;
    while (remaining > 1e-12) {
      const capacity = target - currentBuy - currentSell;
      const allocation = Math.min(capacity, remaining);
      currentBuy += allocation * buyFraction;
      currentSell += allocation * (1 - buyFraction);
      remaining -= allocation;
      if (currentBuy + currentSell >= target - 1e-10) {
        const imbalance = Math.abs(currentBuy - currentSell);
        buckets.push({
          id: `V${String(bucketIndex + 1).padStart(3, "0")}`,
          index: bucketIndex,
          buy_volume: currentBuy,
          sell_volume: currentSell,
          total_volume: currentBuy + currentSell,
          absolute_imbalance: imbalance,
          imbalance_fraction: imbalance / target,
          source_event_index: eventIndex,
        });
        bucketIndex += 1;
        currentBuy = 0;
        currentSell = 0;
      }
    }
  });
  const partialVolume = currentBuy + currentSell;
  if (includePartial && partialVolume > 1e-12) {
    const imbalance = Math.abs(currentBuy - currentSell);
    buckets.push({
      id: `V${String(bucketIndex + 1).padStart(3, "0")}`,
      index: bucketIndex,
      buy_volume: currentBuy,
      sell_volume: currentSell,
      total_volume: partialVolume,
      absolute_imbalance: imbalance,
      imbalance_fraction: imbalance / partialVolume,
      source_event_index: records.length - 1,
    });
  }
  const trace = buckets.map((bucket, index) => {
    const window = buckets.slice(Math.max(0, index - windowBuckets + 1), index + 1);
    const denominator = window.reduce((sum, item) => sum + item.total_volume, 0);
    const value = window.length === windowBuckets && denominator > 0
      ? window.reduce((sum, item) => sum + item.absolute_imbalance, 0) / denominator
      : null;
    return {
      ...bucket,
      vpin: value,
      window_count: window.length,
      side: value === null ? "warm-up" : "ready",
      reason: value === null ? "insufficient-buckets" : "rolling-volume-imbalance",
    };
  });
  const valid = trace.map((row) => row.vpin).filter((value): value is number => value !== null);
  return rounded({
    model: "volume-synchronized-probability-of-informed-trading",
    state: valid.length ? "estimated" : "warm-up",
    bucket_volume: target,
    window_buckets: windowBuckets,
    include_partial: includePartial,
    input_event_count: records.length,
    bucket_count: buckets.length,
    dropped_partial_volume: includePartial ? 0 : partialVolume,
    valid_vpin_count: valid.length,
    last_vpin: valid.length ? valid[valid.length - 1] : null,
    mean_vpin: valid.length ? valid.reduce((sum, value) => sum + value, 0) / valid.length : null,
    max_vpin: valid.length ? Math.max(...valid) : null,
    trace,
  });
}

export function runTopic(kind: string, inputs: Record<string, unknown>, config: Config = {}): Record<string, unknown> {
  if (inputs === null || typeof inputs !== "object" || Array.isArray(inputs)) throw new Error("inputs must be an object");
  if (kind === "ofi") return orderFlowImbalance(
    inputs.rows,
    (config.reset_on_session ?? true) as boolean,
    (config.normalize_by_depth ?? false) as boolean,
  );
  if (kind === "queue") return queueImbalance(
    inputs.rows,
    (config.levels ?? 1) as number,
    (config.decay ?? 1) as number,
  );
  if (kind === "kyle") return kyleLambda(
    inputs.rows,
    (config.volume_transform ?? "linear") as "linear" | "signed-sqrt",
    (config.intercept ?? true) as boolean,
  );
  if (kind === "hasbrouck") return hasbrouckPriceImpact(inputs.rows, (config.horizon ?? 10) as number);
  if (kind === "pin") return pin(
    inputs.rows,
    (config.starts ?? 3) as number,
    (config.balanced_noise ?? false) as boolean,
    (config.iterations ?? 120) as number,
  );
  if (kind === "vpin") return vpin(
    inputs.rows,
    (config.bucket_volume ?? 100) as number,
    (config.window_buckets ?? 10) as number,
    (config.include_partial ?? false) as boolean,
  );
  throw new Error(`unsupported kind: ${kind}`);
}
Full-height labkyle lambda labOpen full screen