Library/Market Microstructure/Order-Book Dynamics

D11-F04-A05 / Complete engineering topic

Hawkes Order-Arrival Model

A production-minded guide to Hawkes Order-Arrival Model.

D11 · MARKET MICROSTRUCTURE
D11-F04-A05Canonical / Tested / Open
D11 / D11-F04

How does each order arrival temporarily increase the conditional rate of later arrivals? The useful answer is not just a number. It is a number tied to one instrument, venue scope, sequence, session, clock, and declared measurement convention.

Start with the decision

An arrival creates a temporary intensity jump; clustered arrivals occur when new events arrive before earlier excitation has decayed. This tutorial selects Univariate, unmarked, exponential-kernel Hawkes process with constant baseline; multivariate order types and state dependence are excluded. The choice is explicit because similarly named book measures are often not interchangeable.

Choose the right variant

VariantDefinitionBest useMain limitation
Univariate exponential HawkesOne event type, constant baseline, exponential memoryTeach intensity and likelihoodCannot capture cross-excitation or intraday seasonality
Multivariate HawkesSeveral order/trade event typesCross-excitation analysisMore parameters and identifiability risk
State-dependent/marked HawkesBook state or event size changes intensityRicher microstructure modelingRequires calibrated covariates and validation

Define the measurement

λ(t)=μ+ti<tαeβ(tti),n=α/β,logL=ilogλ(ti)0Tλ(t)dt\lambda(t)=\mu+\sum_{t_i<t}\alpha e^{-\beta(t-t_i)},\quad n=\alpha/\beta,\quad \log L=\sum_i\log\lambda(t_i)-\int_0^T\lambda(t)\,dt

The source method is documented by Hawkes (1971). The exchange-data boundary is grounded in the NYSE Integrated Feed description. The fixture below is synthetic and the arithmetic is author-derived.

Work the canonical example

JSON
{
  "event_times_seconds": [
    0.2,
    0.9,
    1.4,
    2.7
  ],
  "baseline_intensity": 0.5,
  "excitation_jump": 0.8,
  "decay_rate": 1.6,
  "evaluation_time_seconds": 3.0,
  "horizon_seconds": 3.5
}
  1. Four events occur before the 3.5-second observation horizon.
  2. Branching ratio = 0.8 / 1.6 = 0.5; expected stationary cluster multiplier = 2.
  3. Conditional intensity at 3.0 seconds = 1.09372544348 events/second.
  4. Exact finite-horizon log-likelihood = -4.98929383752.

See the state before the code

The visual connects the input state, active relationship, output, and the boundary that prevents a plausible but unsupported result.

Hawkes Order-Arrival Model annotated system map

Open the visual at full size · Open the guided playground

Implement the contract

  1. validate sorted event times and parameters
  2. sum decayed excitation from prior events
  3. calculate branching ratio and stability state
  4. evaluate event-time intensities and compensator
  5. return intensity and log-likelihood

Python and TypeScript use the same JSON fixtures and matching error states. Production systems should add fixed-point prices, feed-gap recovery, sequence checks, timestamp normalization, market-status handling, and licensed source lineage.

Validate what the result means

The tests establish that the implementation matches the selected definition. They do not show that the statistic forecasts returns, improves fills, survives latency and fees, or satisfies best-execution duties.

StateDecision boundaryRequired response
Stationary parametersα/β < 1Finite expected cluster multiplier is defined
Critical/nonstationary parametersα/β ≥ 1Finite-horizon intensity remains computable; stationary-cluster interpretation is blocked
Calibration useparameters are fit to dataRequire seasonality handling, goodness-of-fit, residual, and holdout checks

Compare neighboring methods

A Poisson process keeps intensity fixed; the Hawkes process makes intensity history-dependent. A midpoint is a quote anchor, depth-weighted prices are window-dependent summaries, microprice uses imbalance, resiliency is a recovery path, and Hawkes intensity is a conditional event-rate model.

Evidence boundary

A historical case is deferred until instrument and venue identity, complete sequence, session, correction state, visible/hidden scope, timing basis, license, and redistribution permission are archived.

Summary and next topic

You can now evaluate a univariate exponential Hawkes intensity, stability ratio, compensator, and log-likelihood, audit its assumptions, and route unsupported states rather than manufacturing precision. Continue with Order Flow Imbalance.

Primary references

Hawkes Order-Arrival Model validation flow

This diagram separates feed reconstruction, calculation, and interpretation.

Rendering system map…

Takeaway: a calculation begins after data eligibility; it does not repair an unknown or stale book.

ReferencesPrimary sources and evidence notes

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

Source 1 — Hawkes (1971)

  • Organization or authors: Alan G. Hawkes
  • Source type: Original paper
  • Publication or effective date: 1971-04-01
  • Version: accessed 2026-07-29
  • Accessed: 2026-07-29
  • URL or DOI: https://doi.org/10.1093/biomet/58.1.83
  • Jurisdiction: method or venue specific as stated by the source
  • Supports: original self-exciting point-process construction
  • Limitations: Supports the method family; package-specific fixtures and software choices are author-derived. The original process definition does not validate a financial calibration.

Source 2 — NYSE Integrated Feed

  • Organization or authors: NYSE
  • Source type: Official exchange data-product documentation
  • Publication or effective date: Current product page accessed 2026-07-29
  • Version: accessed 2026-07-29
  • Accessed: 2026-07-29
  • URL or DOI: https://www.nyse.com/data-products/catalog/integrated-feed
  • Jurisdiction: method or venue specific as stated by the source
  • Supports: order-by-order depth, trades, imbalances, security status, and sequence-aware book reconstruction
  • Limitations: Product documentation does not grant redistribution rights or validate the synthetic fixture.

Source 3 — Hawkes model for price and trades high-frequency dynamics

  • Organization or authors: Emmanuel Bacry and Jean-François Muzy
  • Source type: Original market-microstructure research paper
  • Publication or effective date: 2013-01-07
  • Version: accessed 2026-07-29
  • Accessed: 2026-07-29
  • URL or DOI: https://arxiv.org/abs/1301.1135
  • Jurisdiction: method or venue specific as stated by the source
  • Supports: market-order arrival self-excitation and the need for multivariate microstructure models
  • Limitations: The paper's multivariate calibrated model is materially richer than this synthetic univariate exponential tutorial.

Publication and data boundary

The worked example and playground data are synthetic teaching inputs. Exchange depth data are venue-, license-, sequence-, session-, and timestamp-specific. No market-data entitlement or redistribution permission is implied.

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

function series(
  name: string,
  value: unknown,
  options: { minimum?: number; positive?: boolean; nonnegative?: boolean } = {},
): number[] {
  if (!Array.isArray(value)) throw new Error(`${name} must be an array`);
  const result = value.map((item, index) =>
    numberValue(`${name}[${index}]`, item, {
      positive: options.positive,
      nonnegative: options.nonnegative,
    }),
  );
  if (result.length < (options.minimum ?? 1)) {
    throw new Error(`${name} must contain at least ${options.minimum ?? 1} values`);
  }
  return result;
}

function bookSide(
  prices: unknown,
  sizes: unknown,
  side: "bid" | "ask",
  minimum = 1,
): [number[], number[]] {
  const priceValues = series(`${side}_prices`, prices, { minimum, positive: true });
  const sizeValues = series(`${side}_sizes`, sizes, { minimum, positive: true });
  if (priceValues.length !== sizeValues.length) {
    throw new Error(`${side}_prices and ${side}_sizes must have equal length`);
  }
  for (let index = 0; index < priceValues.length - 1; index += 1) {
    if (side === "bid" && priceValues[index] <= priceValues[index + 1]) {
      throw new Error("bid_prices must be strictly descending from the best bid");
    }
    if (side === "ask" && priceValues[index] >= priceValues[index + 1]) {
      throw new Error("ask_prices must be strictly ascending from the best ask");
    }
  }
  return [priceValues, sizeValues];
}

function validateBook(
  bidPrices: unknown,
  bidSizes: unknown,
  askPrices: unknown,
  askSizes: unknown,
  minimum = 1,
): [number[], number[], number[], number[], number] {
  const [bids, bidDepth] = bookSide(bidPrices, bidSizes, "bid", minimum);
  const [asks, askDepth] = bookSide(askPrices, askSizes, "ask", minimum);
  if (bids[0] >= asks[0]) throw new Error("best bid must be below best ask");
  return [bids, bidDepth, asks, askDepth, (bids[0] + asks[0]) / 2];
}

function cumulative(values: number[]): number[] {
  let total = 0;
  return values.map((value) => {
    total += value;
    return total;
  });
}

function naesSideSlope(
  prices: number[],
  sizes: number[],
  midpoint: number,
): [number, number[]] {
  const logDepth = cumulative(sizes).map(Math.log);
  const firstDistance = Math.abs(prices[0] / midpoint - 1);
  if (firstDistance <= 0) throw new Error("best quote must differ from midpoint");
  const local = [logDepth[0] / firstDistance];
  for (let index = 0; index < prices.length - 1; index += 1) {
    const priceChange = Math.abs(prices[index + 1] / prices[index] - 1);
    if (priceChange <= 0) throw new Error("adjacent price levels must differ");
    local.push((logDepth[index + 1] / logDepth[index] - 1) / priceChange);
  }
  return [local.reduce((sum, value) => sum + value, 0) / local.length, local];
}

export function orderBookSlope(
  bidPrices: unknown,
  bidSizes: unknown,
  askPrices: unknown,
  askSizes: unknown,
): Record<string, unknown> {
  const [bids, bidDepth, asks, askDepth, midpoint] = validateBook(
    bidPrices,
    bidSizes,
    askPrices,
    askSizes,
    2,
  );
  const [bidSlope, bidLocal] = naesSideSlope(bids, bidDepth, midpoint);
  const [askSlope, askLocal] = naesSideSlope(asks, askDepth, midpoint);
  const combined = (bidSlope + askSlope) / 2;
  const slopeTolerance = 1e-12 * Math.max(1, Math.abs(bidSlope), Math.abs(askSlope));
  const slopeDifference = bidSlope - askSlope;
  return {
    model: "naes-skjeltorp-snapshot-slope",
    level_count: bids.length === asks.length ? bids.length : null,
    bid_level_count: bids.length,
    ask_level_count: asks.length,
    midpoint,
    bid_cumulative_depth: cumulative(bidDepth),
    ask_cumulative_depth: cumulative(askDepth),
    bid_local_slopes: bidLocal,
    ask_local_slopes: askLocal,
    bid_slope: bidSlope,
    ask_slope: askSlope,
    order_book_slope: combined,
    slope_tolerance: slopeTolerance,
    state: Math.abs(slopeDifference) <= slopeTolerance ? "balanced" : slopeDifference > 0 ? "steeper-bid" : "steeper-ask",
  };
}

export function depthWeightedMidprice(
  bidPrices: unknown,
  bidSizes: unknown,
  askPrices: unknown,
  askSizes: unknown,
): Record<string, unknown> {
  const [bids, bidDepth, asks, askDepth, midpoint] = validateBook(
    bidPrices,
    bidSizes,
    askPrices,
    askSizes,
  );
  const bidTotal = bidDepth.reduce((sum, value) => sum + value, 0);
  const askTotal = askDepth.reduce((sum, value) => sum + value, 0);
  const weightedBid = bids.reduce((sum, price, index) => sum + price * bidDepth[index], 0) / bidTotal;
  const weightedAsk = asks.reduce((sum, price, index) => sum + price * askDepth[index], 0) / askTotal;
  const depthMidpoint = (weightedBid + weightedAsk) / 2;
  const shift = depthMidpoint - midpoint;
  return {
    model: "same-side-depth-weighted-midpoint",
    level_count: bids.length === asks.length ? bids.length : null,
    bid_level_count: bids.length,
    ask_level_count: asks.length,
    top_midpoint: midpoint,
    depth_weighted_bid: weightedBid,
    depth_weighted_ask: weightedAsk,
    depth_weighted_midprice: depthMidpoint,
    shift,
    shift_bps: (shift / midpoint) * 10_000,
    bid_total_depth: bidTotal,
    ask_total_depth: askTotal,
    state: shift > 1e-15 ? "above-top-mid" : shift < -1e-15 ? "below-top-mid" : "at-top-mid",
  };
}

export function microprice(
  bid: unknown,
  bidSize: unknown,
  ask: unknown,
  askSize: unknown,
): Record<string, unknown> {
  const bidValue = numberValue("bid", bid, { positive: true });
  const askValue = numberValue("ask", ask, { positive: true });
  const bidDepth = numberValue("bid_size", bidSize, { positive: true });
  const askDepth = numberValue("ask_size", askSize, { positive: true });
  if (bidValue >= askValue) throw new Error("bid must be below ask");
  const totalDepth = bidDepth + askDepth;
  const midpoint = (bidValue + askValue) / 2;
  const spread = askValue - bidValue;
  const imbalance = (bidDepth - askDepth) / totalDepth;
  const value = (askValue * bidDepth + bidValue * askDepth) / totalDepth;
  const shift = value - midpoint;
  return {
    model: "top-of-book-imbalance-weighted-quote",
    bid: bidValue,
    ask: askValue,
    bid_size: bidDepth,
    ask_size: askDepth,
    midpoint,
    spread,
    queue_imbalance: imbalance,
    microprice: value,
    shift,
    shift_bps: (shift / midpoint) * 10_000,
    identity_error: value - (midpoint + (imbalance * spread) / 2),
    state: imbalance > 1e-15 ? "bid-pressure" : imbalance < -1e-15 ? "ask-pressure" : "balanced",
  };
}

export function orderBookResiliency(
  timesSeconds: unknown,
  displacementBps: unknown,
  forecastSeconds: unknown,
): Record<string, unknown> {
  const times = series("times_seconds", timesSeconds, { minimum: 3, nonnegative: true });
  const gaps = series("displacement_bps", displacementBps, { minimum: 3, positive: true });
  if (times.length !== gaps.length) throw new Error("times_seconds and displacement_bps must have equal length");
  if (Math.abs(times[0]) > 1e-15) throw new Error("times_seconds must start at zero");
  for (let index = 0; index < times.length - 1; index += 1) {
    if (times[index] >= times[index + 1]) throw new Error("times_seconds must be strictly increasing");
  }
  const initialGap = gaps[0];
  if (gaps.slice(1).some((gap) => gap > initialGap * (1 + 1e-12))) {
    throw new Error("canonical exponential recovery requires displacement not to exceed its initial shock");
  }
  const forecast = numberValue("forecast_seconds", forecastSeconds, { nonnegative: true });
  const denominator = times.slice(1).reduce((sum, time) => sum + time * time, 0);
  const numerator = times.slice(1).reduce(
    (sum, time, index) => sum - time * Math.log(gaps[index + 1] / initialGap),
    0,
  );
  const rate = Math.max(0, numerator / denominator);
  const estimated = rate > 1e-12;
  const halfLife = estimated ? Math.log(2) / rate : null;
  const forecastGap = estimated ? initialGap * Math.exp(-rate * forecast) : initialGap;
  const fitted = times.map((time) => initialGap * Math.exp(-rate * time));
  const rmse = Math.sqrt(
    gaps.reduce((sum, value, index) => sum + (value - fitted[index]) ** 2, 0) / gaps.length,
  );
  return {
    model: "exponential-displacement-recovery",
    observation_count: times.length,
    initial_displacement_bps: initialGap,
    recovery_rate_per_second: rate,
    half_life_seconds: halfLife,
    forecast_seconds: forecast,
    forecast_displacement_bps: forecastGap,
    recovered_fraction: estimated ? 1 - forecastGap / initialGap : 0,
    fitted_displacement_bps: fitted,
    rmse_bps: rmse,
    state: estimated ? "estimated" : "not-recovering",
  };
}

export function hawkesOrderArrival(
  eventTimesSeconds: unknown,
  baselineIntensity: unknown,
  excitationJump: unknown,
  decayRate: unknown,
  evaluationTimeSeconds: unknown,
  horizonSeconds: unknown,
): Record<string, unknown> {
  const events = series("event_times_seconds", eventTimesSeconds, { minimum: 1, nonnegative: true });
  for (let index = 0; index < events.length - 1; index += 1) {
    if (events[index] >= events[index + 1]) throw new Error("event_times_seconds must be strictly increasing");
  }
  const mu = numberValue("baseline_intensity", baselineIntensity, { positive: true });
  const alpha = numberValue("excitation_jump", excitationJump, { nonnegative: true });
  const beta = numberValue("decay_rate", decayRate, { positive: true });
  const evaluationTime = numberValue("evaluation_time_seconds", evaluationTimeSeconds, { nonnegative: true });
  const horizon = numberValue("horizon_seconds", horizonSeconds, { positive: true });
  if (events[events.length - 1] >= horizon) throw new Error("all events must occur strictly before horizon_seconds");
  if (evaluationTime > horizon) throw new Error("evaluation_time_seconds must not exceed horizon_seconds");
  const branchingRatio = alpha / beta;
  const contributing = events.filter((event) => event < evaluationTime);
  const intensity = mu + contributing.reduce(
    (sum, event) => sum + alpha * Math.exp(-beta * (evaluationTime - event)),
    0,
  );
  const eventIntensities = events.map((event, index) =>
    mu + events.slice(0, index).reduce(
      (sum, earlier) => sum + alpha * Math.exp(-beta * (event - earlier)),
      0,
    ),
  );
  const compensator = mu * horizon + (alpha / beta) * events.reduce(
    (sum, event) => sum + 1 - Math.exp(-beta * (horizon - event)),
    0,
  );
  const logLikelihood = eventIntensities.reduce((sum, value) => sum + Math.log(value), 0) - compensator;
  const stationary = branchingRatio < 1;
  return {
    model: "univariate-exponential-hawkes",
    event_count: events.length,
    baseline_intensity: mu,
    excitation_jump: alpha,
    decay_rate: beta,
    branching_ratio: branchingRatio,
    stationary,
    expected_cluster_multiplier: stationary ? 1 / (1 - branchingRatio) : null,
    evaluation_time_seconds: evaluationTime,
    intensity_at_evaluation: intensity,
    event_intensities: eventIntensities,
    horizon_seconds: horizon,
    compensator,
    log_likelihood: logLikelihood,
    state: stationary ? "stationary" : "nonstationary-parameters",
  };
}

export function calculate(topicId: string, inputs: Record<string, unknown>): Record<string, unknown> {
  if (topicId === "D11-F04-A01") {
    return orderBookSlope(inputs.bid_prices, inputs.bid_sizes, inputs.ask_prices, inputs.ask_sizes);
  }
  if (topicId === "D11-F04-A02") {
    return depthWeightedMidprice(inputs.bid_prices, inputs.bid_sizes, inputs.ask_prices, inputs.ask_sizes);
  }
  if (topicId === "D11-F04-A03") {
    return microprice(inputs.bid, inputs.bid_size, inputs.ask, inputs.ask_size);
  }
  if (topicId === "D11-F04-A04") {
    return orderBookResiliency(inputs.times_seconds, inputs.displacement_bps, inputs.forecast_seconds);
  }
  if (topicId === "D11-F04-A05") {
    return hawkesOrderArrival(
      inputs.event_times_seconds,
      inputs.baseline_intensity,
      inputs.excitation_jump,
      inputs.decay_rate,
      inputs.evaluation_time_seconds,
      inputs.horizon_seconds,
    );
  }
  throw new Error(`unsupported topic_id: ${topicId}`);
}
Full-height labplaygroundOpen full screen