D01-F04-A02 / Complete engineering topic

Feed-Latency Monitor: Separate Delay from Clock Error

A production-minded guide to Feed-Latency Monitor.

D01 · MARKET DATA ENGINEERING
D01-F04-A02Canonical / Tested / Open
D01 / D01-F04
Key concepts

The governed definitions this build depends on. Read them first if a term is unfamiliar.

A latency number is meaningful only when you can name who created each timestamp, where it was captured, and whether the clocks were comparable. This tutorial builds that evidence into the result.

Three timestamp owners flow into clock-quality and latency states

Imagine a market-data record stamped at the source at 13:30:00.950000 UTC and captured at your network interface at 13:30:00.970842 UTC. Subtraction gives 20.842 ms. That arithmetic is easy. The hard questions are operational:

  • Did the source stamp transmission, matching, publication, or vendor receipt?
  • Did your consumer stamp the NIC, kernel, parser, or strategy queue?
  • Were those two clocks synchronized, and what was their error bound then?
  • Does a negative result mean “faster than zero,” or does it invalidate the measurement?

A useful feed-latency monitor keeps those questions beside the number. It reports observed delay, clock quality, threshold uncertainty, and sequence continuity as separate evidence.

Start with ownership, not subtraction

This package uses three timestamps:

TimestampOwnerBoundaryPurpose
event_timesource venue or vendor encodersource event or message transmissionstart of observed source-to-ingress duration
receive_timeconsumerfirst trusted ingress capture, such as NIC hardwareend of transport observation; start of local processing
process_timeconsumer feed handlerrecord available to the named downstream consumerend of processing observation

These names are a local data contract, not universal market terminology. For example, FIX Session Layer gives SendingTime(52) a precise owner: the session processor sets it when transmitting the message, not when queuing it, uses UTC, and expects comparison to a synchronized receiver clock (FIX Session Layer, section 4.2.3). A feed timestamp with another definition must retain that other definition.

The subtraction from event_time to receive_time does not isolate network transit. It includes every unobserved stage between the two capture boundaries. Calling it “exchange latency” or “network latency” would attribute cause the data cannot establish. The package calls it observed source-to-ingress latency.

UTC is a format; synchronization is evidence

Two timestamps can both end in Z and still come from clocks with different offsets. Let the physical path duration be LiphysicalL_i^{physical}. If the source and receiver clocks have offsets os(t)o_s(t) and or(t)o_r(t) from a common reference, then the measured difference is:

Liobs=Liphysical+or(ti)os(ti)L_i^{obs}=L_i^{physical}+o_r(t_i)-o_s(t_i)

The offset difference looks exactly like latency. Clock skew is that offset at a moment. Clock drift is how the offset changes over time. A clock that passed a morning check can move outside its tolerance later.

That is why the input has a timestamped clock_profile, not a Boolean named synced. It names the source, ingress, and ready clock domains; the owners of the three timestamps; synchronization status; and the maximum absolute cross-domain error. FINRA Rule 4590 offers a useful operational example: its clock tolerance accounts for the reference difference, transmission delay, and drift, and it requires checks and logs throughout the day (FINRA Rule 4590). Its regulatory tolerances are context, not this lab’s alert thresholds.

NIST describes offset and uncertainty as explicit outputs of time comparison, including for financial-market and data-center users (NIST Time Measurement and Analysis Service). Precision without an uncertainty statement is not proof of accuracy.

Three durations, one uncertainty interval

For event ii, define:

Liobs=tiingresstisourceL_i^{obs}=t_i^{ingress}-t_i^{source} Pi=tireadytiingressP_i=t_i^{ready}-t_i^{ingress} Eiobs=Liobs+PiE_i^{obs}=L_i^{obs}+P_i

L_obs measures across source and consumer clock domains. P uses the two consumer timestamps and is valid as an ordinary duration only when those timestamps share a clock domain or have another declared comparison contract. E_obs is the sum of the two observed components, not a claim about a single owner.

If the declared maximum absolute cross-domain error is ϵ\epsilon, retain:

Liphysical[Liobsϵ, Liobs+ϵ]L_i^{physical}\in[L_i^{obs}-\epsilon,\ L_i^{obs}+\epsilon]

The interval does not “correct” the observation. It shows what the synchronization evidence permits. If a 5 ms warning threshold sits inside that interval, the correct state is threshold_uncertain, not an arbitrary ok or warning.

Negative latency is a clock-quality state

A monitor sometimes receives receive_time < event_time. Taking the absolute value, clamping to zero, or dropping the row hides the best evidence that the measurement contract failed.

This package emits clock_error_negative_latency, keeps the signed value and raw timestamps, and excludes it from the distribution. The state says only what the evidence proves: the observed cross-domain ordering is impossible as a physical one-way duration. Possible causes include clock offset, drift, an incorrectly named source timestamp, a mismatched capture layer, correction error, or reordered data. The monitor cannot choose among them without more evidence.

A reversed local pair, process_time < receive_time, receives a different state: processing_order_error. That distinction matters because it points to a consumer-side ordering or clock-domain problem rather than the cross-domain path.

Decision sequence

Rendering system map…

Notice the order: clock quality gates the distribution. A percentile built first and annotated later has already admitted observations that may not be comparable.

The sequence check is separate. A gap can coexist with an ok latency, and a clock error can occur with continuous sequences. Do not collapse completeness and timing into one traffic-light state.

Verify one event by hand

Synthetic event E0039 contains:

BoundaryUTC timestamp
source13:30:00.950000Z
ingress13:30:00.970842Z
ready13:30:00.971776Z

The observed source-to-ingress duration is 20.842 ms. Local processing adds 0.934 ms, for 21.776 ms observed end to end. The synthetic clock profile declares ϵ=0.05\epsilon=0.05 ms, so the bounded transport interval is 20.792–20.892 ms. Every value in that interval exceeds the pedagogical 15 ms critical threshold.

Exact E0039 timestamps, components, error bound, and negative-latency rule

The 0.05 ms bound is part of the synthetic fixture. It is not a claim about a venue, vendor, network, or NIST service.

At the other edge, E0113 has an observed latency of -0.700 ms. The row remains visible as clock_error_negative_latency, and it never enters the p50, p95, p99, or maximum calculation.

Summarize only eligible observations

For sorted eligible values x(0),,x(n1)x_{(0)},\ldots,x_{(n-1)}, the implementation names its percentile convention. At probability pp, use h=(n1)ph=(n-1)p, j=hj=\lfloor h\rfloor, then linearly interpolate:

Qp=x(j)+(hj)(x(j+1)x(j))Q_p=x_{(j)}+(h-j)(x_{(j+1)}-x_{(j)})

On the 180-event corpus, 174 events are ok, two are warning, three are critical, and one is clock_error_negative_latency. The eligible distribution is:

MetricObserved source-to-ingress latency
p502.312 ms
p953.8309 ms
p9921.84784 ms
maximum31.901 ms

The stream also jumps from sequence 10075 to 10077. That is evidence of a discontinuity, not proof of permanent loss; retransmission and recovery rules are protocol-specific.

Use the lab to see the boundary move

Open the interactive feed-latency lab. It has three deterministic scenarios:

  • Canonical burst stream replays the 180-event fixture and exposes the p99 tail.
  • Threshold edge puts the clock-error interval across the warning boundary, producing threshold_uncertain.
  • Clock failure shows negative cross-domain latency and a reversed consumer pair as explicit diagnostic states.

Choose a scenario, Step forward, Back up, or Play through the same transition function. Pause stops without changing state. Reset restores the scenario’s exact starting state. Changing thresholds recomputes all visible classifications rather than recoloring a cached result. Reduced-motion mode makes Play advance one complete step.

Implementation details that prevent hidden errors

The Python implementation uses timezone-aware datetime. The TypeScript implementation converts timestamps to integer epoch microseconds with bigint before subtraction. Subtracting large JavaScript epoch millisecond floats first can erase part of a microsecond-scale difference.

Both implementations:

  1. validate thresholds and the clock profile;
  2. preserve signed observed durations;
  3. assign the ordered diagnostic state;
  4. retain lower and upper transport bounds;
  5. exclude clock and boundary-uncertain states from summaries;
  6. detect sequence discontinuities; and
  7. use the same linear percentile rule.

The tests assert exact fixture statuses and summaries, the E0039 arithmetic, negative latency, unknown clock synchronization, a threshold crossed by uncertainty, consumer timestamp reversal, malformed UTC, duplicate IDs, invalid thresholds, and empty input. Passing these tests establishes definition-level and cross-language parity. It does not prove a production deployment’s clocks are trustworthy.

A real incident that motivates—but cannot validate—the monitor

In 2012 the SEC reported that NYSE sent data through two proprietary feeds before sending it to consolidated feeds over an extended period beginning in 2008. The release described different internal paths and a software issue, with disparities ranging from single-digit milliseconds to multiple seconds (SEC release 2012-189).

That is an excellent explanation of why path timing matters. It is not an event-level worked example for this algorithm. The public release does not provide a redistributable set of per-message source, consolidated-path, vendor, and consumer timestamps together with clock ownership, capture layers, and synchronization bounds. Manufacturing those fields would turn a documented incident into fictional precision.

The evidence decision is therefore explicit: retain the case as historical motivation, and defer a factual numerical case until the raw timestamp chain and clock-quality evidence are public and reproducible.

Production checklist

  • Name every timestamp owner and capture layer.
  • Partition summaries by venue, feed, channel, session, and timestamp contract.
  • Record synchronization status, bound, measurement time, holdover, offset, and drift evidence.
  • Keep raw timestamps and signed observations immutable.
  • Make clock_untrusted, threshold_uncertain, and negative latency visible.
  • Keep latency, sequence, completeness, and repair policy separate.
  • Calibrate warning and critical thresholds to an owned service objective; do not copy regulatory tolerances.
  • Alert on the rate of excluded clock states as well as on p99 latency.
  • Treat synthetic results as mechanics validation, never as provider performance evidence.

Summary

A trustworthy latency monitor is a clock-quality monitor first. It can tell you the observed time between named boundaries, the uncertainty allowed by synchronization evidence, the classification that follows, and whether the message sequence stayed continuous. It cannot identify an unobserved hop or convert an outage report into endpoint latency.

Continue with Price-Source Consensus Check to learn how timestamp eligibility and provenance affect agreement across multiple data sources.

Primary references

Detailed source roles and limitations are recorded in REFERENCES.md.

References5 primary sources and evidence notes

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

R01 — FIX Session Layer

  • Organization or authors: FIX Trading Community
  • Source type: Official protocol technical standard
  • Publication or effective date: June 2020
  • Version: FIX Session Layer, June 2020
  • URL or DOI: https://www.fixtrading.org/wp-content/uploads/download-manager-files/FIX_Session_Layer_June_2020.pdf
  • Accessed: 2026-07-22
  • Jurisdiction: Global protocol community
  • Supports: SendingTime(52) is transmission time rather than queue time; it is UTC; receiver comparison assumes a synchronized source; sequence numbers support gap detection.
  • Limitations: FIX semantics do not define every market-data feed or the package’s consumer timestamps. FIX’s example threshold range is not a default for this monitor.

R02 — FINRA Rule 4590, Synchronization of Member Business Clocks

  • Organization or authors: Financial Industry Regulatory Authority
  • Source type: Official rule
  • Publication or effective date: Current rule; amended effective August 15, 2016
  • Version: Page accessed 2026-07-22
  • URL or DOI: https://www.finra.org/rules-guidance/rulebooks/finra-rules/4590
  • Accessed: 2026-07-22
  • Jurisdiction: United States; FINRA members
  • Supports: Synchronization must account for reference difference, transmission delay, and drift; clocks need checks and documented synchronization logs.
  • Limitations: The rule’s 50 ms and one-second tolerances are regulatory requirements for its scope, not latency-alert thresholds or evidence that a particular clock was synchronized.

R03 — NIST Time Measurement and Analysis Service

  • Organization or authors: National Institute of Standards and Technology
  • Source type: Official metrology service documentation
  • Publication or effective date: Current service page
  • Version: Page accessed 2026-07-22
  • URL or DOI: https://www.nist.gov/programs-projects/time-measurement-and-analysis-service-tmas
  • Accessed: 2026-07-22
  • Jurisdiction: United States; time metrology
  • Supports: Clock comparison reports offset and uncertainty with respect to UTC(NIST); financial markets, exchanges, and data centers are among the users described.
  • Limitations: The service does not prescribe this package’s algorithm or validate its synthetic 0.05 ms bound.

R04 — SEC approval of the Consolidated Audit Trail plan

  • Organization or authors: U.S. Securities and Exchange Commission
  • Source type: Official press release and fact sheet
  • Publication or effective date: November 15, 2016
  • Version: Release 2016-240
  • URL or DOI: https://www.sec.gov/newsroom/press-releases/2016-240
  • Accessed: 2026-07-22
  • Jurisdiction: United States securities markets
  • Supports: Timestamp granularity and clock synchronization are distinct; the approved plan described different clock tolerances for SRO and broker-dealer contexts to help sequence events.
  • Limitations: This is regulatory context, not a current complete CAT compliance guide and not an engineering threshold recommendation.

R05 — SEC action on NYSE market-data distribution

  • Organization or authors: U.S. Securities and Exchange Commission
  • Source type: Official enforcement release
  • Publication or effective date: September 14, 2012
  • Version: Release 2012-189
  • URL or DOI: https://www.sec.gov/newsroom/press-releases/2012-2012-189htm
  • Accessed: 2026-07-22
  • Jurisdiction: United States securities markets
  • Supports: Historical motivation that differing internal paths and a software issue produced release-time disparities from single-digit milliseconds to multiple seconds between proprietary and consolidated feeds.
  • Limitations: The public release does not provide redistributable per-message endpoint timestamps, all timestamp owners, clock domains, capture layers, or synchronization bounds. It is therefore not used as a numerical fixture or proof of the monitor.

Historical evidence disposition

The NYSE case was reviewed as a possible real-world worked example and deliberately deferred. It makes the operational value clear, but converting its reported range into event-level calculations would manufacture missing timestamp ownership and uncertainty. A future case must preserve raw evidence and separate sourced fact, provider observation, derivation, and interpretation.

feedLatencyMonitor.ts
export interface LatencyEvent {
  event_id: string;
  sequence: number;
  event_time: string;
  receive_time: string;
  process_time: string;
  [key: string]: unknown;
}

export interface ClockProfile {
  source_clock_domain: string;
  ingress_clock_domain: string;
  ready_clock_domain: string;
  source_timestamp_owner: string;
  ingress_timestamp_owner: string;
  ready_timestamp_owner: string;
  cross_domain_sync_status: "synchronized" | "degraded" | "unknown";
  max_abs_offset_ms: number | null;
  [key: string]: unknown;
}

function epochUs(value: string): bigint {
  const match = value.match(/^(.*?)(?:\.(\d+))?Z$/);
  if (!match) throw new Error(`timestamp must be UTC ISO-8601 ending in Z: ${value}`);
  const base = Date.parse(`${match[1]}Z`);
  if (!Number.isFinite(base)) throw new Error(`invalid timestamp: ${value}`);
  const fraction = (match[2] ?? "").padEnd(6, "0").slice(0, 6);
  return BigInt(base) * 1000n + BigInt(fraction);
}

function validateThresholds(warningMs: number, criticalMs: number): void {
  if (![warningMs, criticalMs].every(Number.isFinite)) throw new Error("thresholds must be finite");
  if (warningMs < 0 || criticalMs <= warningMs) throw new Error("require 0 <= warningMs < criticalMs");
}

export function validateClockProfile(profile: ClockProfile): ClockProfile {
  const required: (keyof ClockProfile)[] = [
    "source_clock_domain", "ingress_clock_domain", "ready_clock_domain",
    "source_timestamp_owner", "ingress_timestamp_owner", "ready_timestamp_owner",
    "cross_domain_sync_status", "max_abs_offset_ms",
  ];
  const missing = required.filter((field) => !(field in profile));
  if (missing.length) throw new Error(`clock_profile missing fields: ${missing.join(", ")}`);
  if (!["synchronized", "degraded", "unknown"].includes(profile.cross_domain_sync_status)) {
    throw new Error("cross_domain_sync_status must be synchronized, degraded, or unknown");
  }
  const bound = profile.max_abs_offset_ms;
  if (bound !== null && (!Number.isFinite(bound) || bound < 0)) {
    throw new Error("max_abs_offset_ms must be null or a finite non-negative number");
  }
  if (profile.cross_domain_sync_status === "synchronized" && bound === null) {
    throw new Error("a synchronized profile needs max_abs_offset_ms");
  }
  return { ...profile };
}

export function percentile(values: number[], probability: number): number {
  if (values.length === 0) throw new Error("percentile needs at least one value");
  if (!(probability >= 0 && probability <= 1)) throw new Error("probability must be in [0, 1]");
  const ordered = [...values].sort((a, b) => a - b);
  const position = (ordered.length - 1) * probability;
  const lower = Math.floor(position);
  const upper = Math.min(lower + 1, ordered.length - 1);
  const weight = position - lower;
  return ordered[lower] * (1 - weight) + ordered[upper] * weight;
}

export function diagnoseEvent(
  event: LatencyEvent,
  clockProfile: ClockProfile,
  warningMs = 5,
  criticalMs = 15,
) {
  validateThresholds(warningMs, criticalMs);
  const profile = validateClockProfile(clockProfile);
  const observedTransport = Number(epochUs(event.receive_time) - epochUs(event.event_time)) / 1000;
  const processing = Number(epochUs(event.process_time) - epochUs(event.receive_time)) / 1000;
  const bound = profile.max_abs_offset_ms;
  const clockTrusted = profile.cross_domain_sync_status === "synchronized" && bound !== null;
  const sameConsumerClock = profile.ingress_clock_domain === profile.ready_clock_domain;
  let status: string;
  let clockQualityState: string;
  if (processing < 0) {
    status = "processing_order_error";
    clockQualityState = "consumer_timestamp_order_reversed";
  } else if (!sameConsumerClock) {
    status = "clock_untrusted";
    clockQualityState = "consumer_clock_domains_not_comparable";
  } else if (!clockTrusted) {
    status = "clock_untrusted";
    clockQualityState = "source_ingress_clocks_not_trusted";
  } else if (observedTransport < 0) {
    status = "clock_error_negative_latency";
    clockQualityState = "negative_observed_latency";
  } else if ([warningMs, criticalMs].some((threshold) => observedTransport - bound < threshold && threshold <= observedTransport + bound)) {
    status = "threshold_uncertain";
    clockQualityState = "synchronized_but_threshold_within_clock_error_bound";
  } else if (observedTransport >= criticalMs) {
    status = "critical";
    clockQualityState = "synchronized";
  } else if (observedTransport >= warningMs) {
    status = "warning";
    clockQualityState = "synchronized";
  } else {
    status = "ok";
    clockQualityState = "synchronized";
  }
  const eligible = ["ok", "warning", "critical"].includes(status);
  return {
    ...event,
    observed_transport_latency_ms: observedTransport,
    processing_latency_ms: processing,
    observed_end_to_end_latency_ms: observedTransport + processing,
    transport_lower_bound_ms: bound === null ? null : observedTransport - bound,
    transport_upper_bound_ms: bound === null ? null : observedTransport + bound,
    clock_quality_state: clockQualityState,
    eligible_for_transport_summary: eligible,
    status,
  };
}

export function monitor(
  events: LatencyEvent[],
  clockProfile: ClockProfile,
  warningMs = 5,
  criticalMs = 15,
) {
  validateThresholds(warningMs, criticalMs);
  const profile = validateClockProfile(clockProfile);
  if (events.length === 0) throw new Error("events must not be empty");
  if (new Set(events.map((event) => event.event_id)).size !== events.length) throw new Error("event_id must be unique");
  const rows = events.map((event) => diagnoseEvent(event, profile, warningMs, criticalMs));
  const valid = rows.filter((row) => row.eligible_for_transport_summary).map((row) => row.observed_transport_latency_ms);
  const sequence_gaps = rows.slice(1).flatMap((row, index) =>
    row.sequence === rows[index].sequence + 1 ? [] : [{ after: rows[index].sequence, before: row.sequence }],
  );
  const statuses = [
    "ok", "warning", "critical", "threshold_uncertain", "clock_error_negative_latency",
    "clock_untrusted", "processing_order_error",
  ];
  const counts = Object.fromEntries(statuses.map((status) => [status, rows.filter((row) => row.status === status).length]));
  return {
    events: rows,
    clock_profile: profile,
    counts,
    p50_ms: valid.length ? percentile(valid, 0.5) : null,
    p95_ms: valid.length ? percentile(valid, 0.95) : null,
    p99_ms: valid.length ? percentile(valid, 0.99) : null,
    max_ms: valid.length ? Math.max(...valid) : null,
    sequence_gaps,
  };
}
Full-height labfeed latency playgroundOpen full screen