The governed definitions this build depends on. Read them first if a term is unfamiliar.
- PrimaryBid-Ask SpreadThe bid-ask spread is the difference between the ask price and bid price for a comparable quote state.
- PrerequisiteAsk PriceAsk price is the price associated with displayed or reported selling interest in a quote.
- PrerequisiteBid PriceBid price is the price associated with displayed or reported buying interest in a quote.
- PrerequisiteQuoteA quote is a market expression of available buying or selling interest at stated prices and, when supplied, quantities.
- ImportantEvent TimeEvent time is the timestamp assigned to when a market or business event occurred at its source.
- ImportantMarket Data FeedA market data feed is a source-defined stream or delivery product carrying market events, reference data, or aggregates.
- ImportantNumerical ToleranceNumerical tolerance is a declared bound used to decide when two numeric results are acceptably close for a specific comparison.
- ImportantTrading VenueA trading venue is an organized market, platform, or facility on which financial instruments can be quoted, traded, or reported.
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.
Suppose a row says the best bid is and the best ask is . The subtraction is easy: ask minus bid is , 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:
- Geometry: Is ask minus bid positive, approximately zero under a declared comparison rule, or negative?
- Evidence quality: Are the two sides comparable, timely, correctly sequenced, and attached to a precise market scope?
- 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:
| Field | Why it matters |
|---|---|
| Instrument | Prevents comparing different securities |
| Market scope | Distinguishes a single-venue top from a consolidated best quote |
| Feed and version | Makes the record reproducible |
| Event and receive times | Separates source time from transport and clock effects |
| Sequence | Exposes gaps, duplicates, and ordering questions |
| Bid and ask side sources | Preserves venue lineage in a consolidated snapshot |
| Tick size | Gives price differences an instrument- and time-specific scale |
| Price unit and currency | Prevents 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 and ask in the same price units, define the signed spread
The sign has a direct meaning:
- : the ask sits above the bid, a normal observation;
- : the two prices coincide, a locked observation;
- : 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 be tick size, a tolerance in ticks, a tolerance in parts per million, and . The package uses
The languages also add a disclosed double-precision guard , where is machine epsilon. Classification becomes
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 and zero business tolerance.
| Bid | Ask | Signed spread | Spread in ticks | State |
|---|---|---|---|---|
NORMAL | ||||
LOCKED | ||||
CROSSED |
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 , ask , tick size . With zero tolerance, the positive spread is NORMAL. With a declared quarter-tick tolerance, , so the row sits at the configured LOCKED boundary. This demonstrates why tolerance is policy: changing it changes classification.
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
The processing order is deliberately conservative:
- Validate tolerance parameters once.
- Retain each row in input order.
- Require market scope, feed, timestamp, sequence, and instrument context.
- Validate finite nonnegative prices and a positive tick size.
- Compute signed spread, spread in ticks, configured tolerance, and numerical guard.
- Attach a neutral diagnostic such as
CROSSED_OBSERVATION. - Add context warnings, including receive time earlier than event time.
- 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:
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:
- a redistributable primary or officially licensed source;
- instrument and exact interval;
- feed identity and version;
- event and receive timestamp semantics;
- bid and ask side lineage;
- tick size applicable at that time;
- sequence-gap, correction, and session checks;
- a reproducible transformation into the displayed snapshot;
- 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.001means 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 , 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.
| Asset | Teaching role | Source | Status |
|---|---|---|---|
| Article hero | Three states plus evidence context | ../visuals/static/article-hero.svg | Ready |
| Spread geometry | Exact boundary and tick/scale tolerance | ../visuals/static/spread-geometry.svg | Ready |
| Market flow | Geometry separated from review policy | ../visuals/mermaid/market-flow.md | Ready |
| Interactive lab | Exact/configured comparison across 36 synthetic rows | ../visuals/animated/playground.html | Ready |
Rendered from the canonical Mermaid sources linked by this article.
Geometry-to-review flow
Purpose: keep spread classification separate from evidence review and policy.
Takeaway: normal, locked, and crossed are observed geometries. None alone proves data corruption, participant cause, or a regulatory conclusion.
ReferencesPrimary sources and evidence notesExpand the source trail, evidence role, and limitations behind the engineering choices.
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.epsilonused 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.EPSILONused 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.
Full dependency-light reference implementations in both supported languages.
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,
};
});
}
The embedded lab now expands to its full document height, keeping the article as the only scroll surface.