Library/Market Data Engineering/Cleaning and Validation

D01-F02-A06 / Complete engineering topic

Crossed/Locked Market Detector: Geometry Is a Diagnostic, Not a Verdict

A production-minded guide to Crossed/Locked Market Detector.

D01 · MARKET DATA ENGINEERING
D01-F02-A06Canonical / Tested / Open
D01 / D01-F02
Key concepts

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

Practical promise. You will learn to classify normal, locked, and crossed top-of-book snapshots without losing their feed, venue, timestamp, tick-size, or side-lineage context - and without turning a geometric observation into an unsupported claim about bad data.

Normal, locked, and crossed quote geometry with the required audit context

Suppose a row says the best bid is 100.03100.03 and the best ask is 100.02100.02. The subtraction is easy: ask minus bid is 0.01-0.01, so the selected snapshot is crossed. The difficult part begins after that calculation.

Were both sides for the same instrument and currency? Did they describe one venue or a consolidated market? Were their event times comparable? Was a sequence gap present? Which feed and rule version applied? Was the tick size one cent at that time? A detector that returns only true or false cannot answer any of those questions.

This tutorial builds the detector as an audit-friendly diagnostic. It does not delete the row, de-cross the quote, allege misconduct, or claim a regulatory violation. A locked market can be a valid observable state. A crossed state can require attention while still being insufficient evidence of corrupt data.

All worked data and playground events in this package are synthetic. They establish implementation mechanics, not historical market behavior, predictive value, or investment advice.

First separate three decisions

Market-data pipelines often collapse three independent decisions into one:

  1. Geometry: Is ask minus bid positive, approximately zero under a declared comparison rule, or negative?
  2. Evidence quality: Are the two sides comparable, timely, correctly sequenced, and attached to a precise market scope?
  3. Policy: What should a specific downstream calculation, venue rule, reporting regime, or compliance process do with the observation?

This article implements the first decision and preserves enough context for the next two. It never pretends that the first decision answers all three.

The distinction is not academic. The SEC published Rule 605 staff FAQs on April 1, 2026, but the page expressly schedules those FAQs to become effective August 1, 2026. In that scheduled guidance, a locked NBBO remains usable for the FAQ's stated reporting purposes while a crossed NBBO receives a separate time-aware procedure. On this article's July 22 review date, that treatment was not yet effective. The page also states that it represents staff views and has no legal force or effect. It is a future, context-specific reporting interpretation, not a universal instruction to clean every dataset the same way.

Define the market object before classifying it

A bid and ask do not become comparable merely because they share a row. At minimum, the input must declare:

FieldWhy it matters
InstrumentPrevents comparing different securities
Market scopeDistinguishes a single-venue top from a consolidated best quote
Feed and versionMakes the record reproducible
Event and receive timesSeparates source time from transport and clock effects
SequenceExposes gaps, duplicates, and ordering questions
Bid and ask side sourcesPreserves venue lineage in a consolidated snapshot
Tick sizeGives price differences an instrument- and time-specific scale
Price unit and currencyPrevents unit mismatches

Call a record an NBBO only when the upstream construction actually satisfies the applicable National Best Bid and Offer definition. A local best-of-two-feed calculation should be labeled as such.

The reference implementation requires instrument, market_scope, feed, event_time, receive_time, sequence, bid, ask, and tick_size. Missing or unusable fields produce INVALID with a reason. The original row remains visible.

The signed spread is the core geometry

For bid bib_i and ask aia_i in the same price units, define the signed spread

si=aibi.s_i = a_i - b_i.

The sign has a direct meaning:

  • si>0s_i > 0: the ask sits above the bid, a normal observation;
  • si=0s_i = 0: the two prices coincide, a locked observation;
  • si<0s_i < 0: the bid sits above the ask, a crossed observation.

That exact rule is best for exact decimal or integer-tick data. Binary floating-point inputs can introduce tiny representation differences, and rounded feeds can have declared precision. If a tolerance is necessary, it must be tied to the instrument's tick size and price scale rather than typed as an unexplained constant.

Let qiq_i be tick size, τ\tau a tolerance in ticks, ρ\rho a tolerance in parts per million, and pi=max(ai,bi,1)p_i=\max(|a_i|,|b_i|,1). The package uses

ϵi=max(qiτ, piρ×106).\epsilon_i = \max(q_i\tau,\ p_i\rho\times10^{-6}).

The languages also add a disclosed double-precision guard gi=8upig_i = 8u p_i, where uu is machine epsilon. Classification becomes

statei={LOCKED,siϵi+gi,CROSSED,si<(ϵi+gi),NORMAL,si>ϵi+gi.\operatorname{state}_i= \begin{cases} \mathrm{LOCKED}, & |s_i|\le \epsilon_i+g_i,\\ \mathrm{CROSSED}, & s_i<-(\epsilon_i+g_i),\\ \mathrm{NORMAL}, & s_i>\epsilon_i+g_i. \end{cases}

The numerical guard prevents a decimal tick boundary from moving because of binary storage. It is not permission to hide a real spread. The default business tolerance is zero ticks and zero ppm.

Official SEC material is a useful warning against hard-coding one-cent assumptions. Compliance timing for amended U.S. minimum pricing increments was delayed until the first business day of November 2026. The lesson for this detector is narrow: ingest the tick size applicable to the instrument, market, and observation time; do not derive it from a universal constant.

Verify three snapshots by hand

Use a tick size of 0.010.01 and zero business tolerance.

BidAskSigned spreadSpread in ticksState
100.00100.00100.02100.02+0.02+0.02+2+2NORMAL
100.01100.01100.01100.010.000.0000LOCKED
100.03100.03100.02100.020.01-0.011-1CROSSED

The second row is not an error merely because it is locked. The third row is a crossed observation, not proof that the feed is corrupt. In either case, preserve the row and inspect its context.

Now compare an intentionally nonstandard pedagogical case: bid 100.0000100.0000, ask 100.0025100.0025, tick size 0.010.01. With zero tolerance, the positive spread is NORMAL. With a declared quarter-tick tolerance, ϵ=0.0025\epsilon=0.0025, so the row sits at the configured LOCKED boundary. This demonstrates why tolerance is policy: changing it changes classification.

Exact spread geometry, tick tolerance, and context fields

The diagram uses position and labels as well as color. Notice that every state still flows to the same context-review panel.

An auditable algorithm

Rendering system map…

The processing order is deliberately conservative:

  1. Validate tolerance parameters once.
  2. Retain each row in input order.
  3. Require market scope, feed, timestamp, sequence, and instrument context.
  4. Validate finite nonnegative prices and a positive tick size.
  5. Compute signed spread, spread in ticks, configured tolerance, and numerical guard.
  6. Attach a neutral diagnostic such as CROSSED_OBSERVATION.
  7. Add context warnings, including receive time earlier than event time.
  8. Leave escalation, repair, duration, and regulatory treatment to another layer.

Why retain a receive-before-event row instead of automatically rejecting its geometry? Independent clocks can be offset. The warning says “check clocks”; it does not prove the price relationship was false. A stricter ingestion gate may reject that row before this detector, but that must be a named policy.

Explore zero and nonzero tolerance side by side

Open the interactive crossed/locked market laboratory. It starts with 12 of 36 synthetic observations already visible, so the first screen explains the lesson before you press anything.

The stage compares two classifications for every visible row:

  • Exact: zero business tolerance;
  • Configured: the selected tick-fraction tolerance.

Choose the consolidated or single-venue synthetic scenario, then use Back, Step, Play, Pause, and Reset. Adjust tolerance to see precisely which observations change. The status panel always shows feed, market scope, event time, receive time, sequence, side sources, tick size, signed spread, and current rule.

What to notice:

  • A true zero remains locked in both panels.
  • A small positive spread can become tolerance-locked, but the exact classification stays visible.
  • A one-tick cross remains crossed under a quarter-tick tolerance.
  • The configured label never overwrites the raw spread or exact label.

The comparison is educational, not a recommended production threshold.

Python and TypeScript stay in parity

Both implementations expose the same two options:

Plain text
tolerance_ticks
relative_tolerance_ppm

Both return the same snake-case diagnostic fields. Shared tests cover:

  • exact normal, lock, and cross;
  • a quarter-tick boundary;
  • ppm tolerance at different price scales;
  • floating-point behavior at one tick;
  • missing context and malformed timestamps;
  • nonfinite or negative prices and invalid tick sizes;
  • clock-order warnings;
  • a 144-row synthetic geometry corpus.

Passing these tests proves that the two implementations match this package's definition. It does not prove that a chosen tolerance is appropriate for a production feed.

Do not turn state into a verdict

The SEC staff FAQ for Rules 610 and 611 discusses protected quotations, latency, exceptions, and policies. The page also states that its answers are staff views rather than Commission rules. That is exactly why a reusable detector should emit context instead of hard-coding a universal compliance conclusion.

Regulatory status is also time-sensitive. In June 2026, the SEC proposed changes concerning the trade-through rule and locked/crossed provisions. A proposal is not effective law. Any compliance workflow must pin the jurisdiction, instrument scope, rule version, effective date, and applicable exceptions.

For a data-quality workflow, useful follow-up questions include:

  • Did bid and ask arrive from the same feed and market scope?
  • Are event and receive clocks synchronized?
  • Was there a sequence gap or replay?
  • Are either side stale, indicative, manual, or outside the intended session?
  • Were the two prices normalized to the same unit, currency, and adjustment basis?
  • How long did the state persist in event time?
  • Does the downstream use require a reliable benchmark, or only an observed state?

None of those answers can be inferred from the sign alone.

Historical-example evidence gate

This topic is a strong candidate for a real historical example because a synchronized interval can make the value of context obvious. It is also easy to get wrong.

A publishable example would need:

  1. a redistributable primary or officially licensed source;
  2. instrument and exact interval;
  3. feed identity and version;
  4. event and receive timestamp semantics;
  5. bid and ask side lineage;
  6. tick size applicable at that time;
  7. sequence-gap, correction, and session checks;
  8. a reproducible transformation into the displayed snapshot;
  9. careful language that reports the observation without assigning unsupported cause.

No source record meeting that gate is available in this package. Therefore no synthetic row is dressed up as a real NBBO interval. The numerical examples in the SEC Rule 605 staff FAQ are scheduled guidance examples, not claimed historical market events; the FAQ was not yet effective on the July 22 review date and has no legal force or effect. This is a deliberate evidence decision, not a missing citation.

Failure modes to test

  • Absolute epsilon: $0.001 means different things for a one-dollar instrument and a thousand-dollar instrument.
  • Hidden tolerance: a chart labels a state locked but never shows its boundary.
  • Async assembly: bid and ask from different source times are presented as simultaneous.
  • Scope inflation: a local consolidated top is labeled NBBO without eligibility evidence.
  • Lock equals bad: valid locked observations are dropped automatically.
  • Cross equals corruption: an observed cross is treated as proof of bad data or misconduct.
  • Row-count duration: ten packets are mistaken for ten seconds.
  • Repair inside detection: raw evidence is overwritten before it can be audited.
  • Proposal equals law: a proposed rule change is presented as effective.

The safest output is descriptive: raw inputs, signed spread, tick-normalized spread, exact and configured boundaries, neutral state, and full context.

Summary

A crossed/locked detector is mathematically small and operationally consequential. Compute s=askbids=ask-bid, tie any tolerance to tick size and price scale, disclose the machine guard, and retain feed, scope, timestamps, sequence, and side lineage. Report locked and crossed states as observations. Let a separate, versioned policy decide what they mean for a particular use.

Next, continue with Previous-Tick Interpolation. It will reuse event-time and source-age context to carry observations onto a grid without borrowing future values.

Primary references and asset map

Detailed source roles and limitations are in REFERENCES.md. The canonical specification is README.md.

AssetTeaching roleSourceStatus
Article heroThree states plus evidence context../visuals/static/article-hero.svgReady
Spread geometryExact boundary and tick/scale tolerance../visuals/static/spread-geometry.svgReady
Market flowGeometry separated from review policy../visuals/mermaid/market-flow.mdReady
Interactive labExact/configured comparison across 36 synthetic rows../visuals/animated/playground.htmlReady

Geometry-to-review flow

Purpose: keep spread classification separate from evidence review and policy.

Rendering system map…

Takeaway: normal, locked, and crossed are observed geometries. None alone proves data corruption, participant cause, or a regulatory conclusion.

ReferencesPrimary sources and evidence notes

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

Accessed 2026-07-22. U.S. regulatory status is time-sensitive. These sources support context and terminology; none defines the package's engineering tolerance.

SEC-605-FAQ - Frequently Asked Questions: Rule 605 of Regulation NMS

  • Organization or authors: U.S. Securities and Exchange Commission, Division of Trading and Markets staff
  • Source type: Official regulator staff guidance
  • Publication or effective date: Published 2026-04-01; scheduled effective date 2026-08-01
  • Version: Published staff FAQ; not yet effective on the 2026-07-22 access date
  • URL or DOI: https://www.sec.gov/rules-regulations/staff-guidance/trading-markets-frequently-asked-questions/frequently-asked-questions-rule-605-regulation-nms
  • Accessed: 2026-07-22
  • Jurisdiction: United States; Rule 605 reporting
  • Supports: The staff guidance scheduled for August 1 describes, for its stated Rule 605 reporting context, use of locked NBBOs and a separate, time-aware procedure for crossed NBBOs; the page includes constructed examples.
  • Limitations: The FAQ was not yet effective on the July 22 access date. The page states that it represents staff views and has no legal force or effect. It is not a universal market-data cleaning specification, and its examples are not claimed historical market intervals.

SEC-610-611-FAQ - Responses Concerning Rules 610 and 611

  • Organization or authors: U.S. Securities and Exchange Commission, Division of Trading and Markets staff
  • Source type: Official regulator staff FAQ
  • Publication or effective date: 2008 update; page current at access
  • Version: Page current at access
  • URL or DOI: https://www.sec.gov/divisions/marketreg/nmsfaq610-11.htm
  • Accessed: 2026-07-22
  • Jurisdiction: United States; NMS stocks
  • Supports: Protected-quotation, latency, exception, and locked/crossed-rule context; demonstrates that the regulatory question is narrower and richer than sign classification.
  • Limitations: The page explicitly says the answers are staff views, not Commission rules. Always verify current effective law.

SEC-2026-PROPOSAL - Trade-Through Rule and Locked and Crossed Markets Provisions

  • Organization or authors: U.S. Securities and Exchange Commission
  • Source type: Official proposed-rule record
  • Publication or effective date: Issued 2026-06-11; Federal Register publication 2026-06-17
  • Version: Release 34-105655; proposal only
  • URL or DOI: https://www.sec.gov/rules-regulations/2026/06/s7-2026-20
  • Accessed: 2026-07-22
  • Jurisdiction: United States; NMS stocks
  • Supports: Rule 610(e) contains locked/crossed provisions at the review date and the Commission has proposed rescission; current status must be pinned.
  • Limitations: A proposal is not effective law and must never be presented as an adopted change.

SEC-TICK-SIZE-RELIEF - 2025 exemptive order and compliance timing

  • Organization or authors: U.S. Securities and Exchange Commission
  • Source type: Official Commission press release linking the exemptive order
  • Publication or effective date: 2025-10-31
  • Version: Release 2025-130; order 34-104172
  • URL or DOI: https://www.sec.gov/newsroom/press-releases/2025-130-sec-issues-exemptive-order-regarding-compliance-certain-rules-under-regulation-nms
  • Accessed: 2026-07-22
  • Jurisdiction: United States; NMS stocks
  • Supports: Compliance with amended minimum-pricing-increment provisions was delayed until the first business day of November 2026, illustrating why software should receive applicable tick size as data rather than hard-code one cent.
  • Limitations: Compliance timing can change. The detector does not calculate a regulatory tick size.

PY-FLOAT - Python floating-point information

  • Organization or authors: Python Software Foundation
  • Source type: Official language documentation
  • Publication or effective date: Maintained
  • Version: Python 3 standard library
  • URL or DOI: https://docs.python.org/3/library/sys.html#sys.float_info
  • Accessed: 2026-07-22
  • Jurisdiction: Technical documentation
  • Supports: sys.float_info.epsilon used by the disclosed machine-precision guard.
  • Limitations: Machine epsilon is not a market or business tolerance.

ECMASCRIPT-NUMBER - Number.EPSILON

  • Organization or authors: ECMA International
  • Source type: Official language specification
  • Publication or effective date: Maintained
  • Version: ECMA-262 current edition
  • URL or DOI: https://tc39.es/ecma262/multipage/numbers-and-dates.html#sec-number.epsilon
  • Accessed: 2026-07-22
  • Jurisdiction: Technical standard
  • Supports: Number.EPSILON used by the TypeScript implementation's disclosed machine-precision guard.
  • Limitations: Machine epsilon is not a market or business tolerance.

Evidence classification and historical-example decision

  • Sourced facts: the Rule 605 staff FAQ's August 1 scheduled effective date and narrow future reporting treatment, current proposal status, compliance timing, and language machine-epsilon definitions above.
  • Implementation choices: required context fields, neutral diagnostic labels, the maximum of tick-based and ppm tolerances, and the factor-eight numerical guard.
  • Pedagogical simplifications: synthetic feed names, venue labels, observations, and the playground's quarter-tick comparison.
  • Empirical claims: none. The synthetic state counts prove only deterministic mechanics.
  • Historical event: not included. No redistributable synchronized source record meeting the package's evidence gate was available; no synthetic interval is represented as real.
marketState.ts
export type MarketState = "NORMAL" | "LOCKED" | "CROSSED" | "INVALID";

export type QuoteSnapshot = Record<string, unknown> & {
  instrument: string;
  market_scope: string;
  feed: string;
  event_time: string;
  receive_time: string;
  sequence: number;
  bid: number;
  ask: number;
  tick_size: number;
};

export type DetectorOptions = {
  tolerance_ticks?: number;
  relative_tolerance_ppm?: number;
};

const requiredContext = [
  "instrument", "market_scope", "feed", "event_time", "receive_time", "sequence",
];

function invalid(quote: Record<string, unknown>, index: number, reason: string) {
  return {
    ...quote,
    index,
    state: "INVALID" as MarketState,
    spread: null,
    spread_ticks: null,
    effective_tolerance: null,
    numeric_guard: null,
    diagnostic: "CONTEXT_OR_VALUE_INVALID",
    reason,
  };
}

function parseTime(value: unknown): number {
  if (typeof value !== "string" || !/(Z|[+-]\d\d:\d\d)$/.test(value)) {
    throw new Error("timestamp must include an offset or Z");
  }
  const parsed = Date.parse(value);
  if (!Number.isFinite(parsed)) throw new Error("timestamp must be valid ISO-8601");
  return parsed;
}

export function classifyMarkets(
  quotes: Record<string, unknown>[],
  options: DetectorOptions = {},
) {
  const toleranceTicks = options.tolerance_ticks ?? 0;
  const relativeTolerancePpm = options.relative_tolerance_ppm ?? 0;
  for (const [name, value] of [
    ["tolerance_ticks", toleranceTicks],
    ["relative_tolerance_ppm", relativeTolerancePpm],
  ] as const) {
    if (!Number.isFinite(value) || value < 0) {
      throw new Error(`${name} must be finite and nonnegative`);
    }
  }

  return quotes.map((quote, index) => {
    const missing = requiredContext.filter((field) => !(field in quote));
    if (missing.length) return invalid(quote, index, `missing context: ${missing.join(", ")}`);

    const bid = Number(quote.bid);
    const ask = Number(quote.ask);
    const tickSize = Number(quote.tick_size);
    const sequence = Number(quote.sequence);
    let eventTime: number;
    let receiveTime: number;
    try {
      eventTime = parseTime(quote.event_time);
      receiveTime = parseTime(quote.receive_time);
    } catch (error) {
      return invalid(quote, index, `unusable field: ${(error as Error).message}`);
    }
    if (
      quote.bid === null || quote.ask === null || quote.tick_size === null ||
      !Number.isFinite(bid) || !Number.isFinite(ask) || !Number.isFinite(tickSize) ||
      !Number.isInteger(sequence)
    ) return invalid(quote, index, "bid, ask, tick_size, and sequence must be numeric");
    if (bid < 0 || ask < 0 || tickSize <= 0 || sequence < 0) {
      return invalid(quote, index, "prices/sequence must be nonnegative and tick_size positive");
    }

    const spread = ask - bid;
    const priceScale = Math.max(Math.abs(bid), Math.abs(ask), 1);
    const effectiveTolerance = Math.max(
      tickSize * toleranceTicks,
      priceScale * relativeTolerancePpm * 1e-6,
    );
    const numericGuard = priceScale * Number.EPSILON * 8;
    const boundary = effectiveTolerance + numericGuard;
    const state: MarketState = Math.abs(spread) <= boundary
      ? "LOCKED"
      : spread < -boundary ? "CROSSED" : "NORMAL";
    const diagnostic = state === "LOCKED"
      ? "LOCKED_OBSERVATION"
      : state === "CROSSED" ? "CROSSED_OBSERVATION" : "POSITIVE_SPREAD_OBSERVATION";
    const contextWarnings = receiveTime < eventTime ? ["RECEIVE_BEFORE_EVENT_CHECK_CLOCKS"] : [];
    return {
      ...quote,
      index,
      bid,
      ask,
      tick_size: tickSize,
      spread,
      spread_ticks: spread / tickSize,
      effective_tolerance: effectiveTolerance,
      numeric_guard: numericGuard,
      state,
      diagnostic,
      context_warnings: contextWarnings,
      reason: null,
    };
  });
}
Full-height labplaygroundOpen full screen