D01-F04-A01 / Complete engineering topic

Missing-Bar Gap Classifier: Diagnose Absence Without Inventing Certainty

A production-minded guide to Missing-Bar Gap Classifier.

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

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

Practical promise. Given an empty time-bar slot, determine what the evidence actually supports: closed session, expected halt, feed outage, inactivity, bar-builder error, unknown, or conflict. You will also learn when the honest answer is “we cannot know yet.”

An evidence ladder that stops at unknowns and conflicts before assigning a missing-bar cause

A blank candle is visually simple and operationally ambiguous. It might be absent because the market was closed. Trading might have been halted. The market might have been open but inactive. Messages might have been lost. Or the trades might be present while the bar builder failed.

Those causes lead to different actions. Removing an officially closed interval from an expected grid is sensible. Reconstructing a bar after a confirmed feed outage requires message recovery. Rebuilding a missing bar from independently captured trades addresses a construction defect. Forward-filling every blank interval does none of those things; it only makes uncertainty look numeric.

This article builds a conservative classifier that keeps observation, evidence, diagnosis, and repair separate. It is an operational data-quality method, not a trading signal.

One blank slot, five evidence layers

The first design decision is to stop calling all evidence “market data.” The classifier asks five different questions in order:

  1. Is a validated bar actually present?
  2. Does the versioned session calendar include the slot?
  3. Does authoritative market-status evidence show a halt?
  4. Was the source transport healthy and sequentially complete?
  5. Did a source independent of the missing bar path observe trades?

NYSE, for example, publishes session/calendar information and halt information as distinct products and pages (NYSE hours and calendars, NYSE trading halts). At the transport layer, Nasdaq's MoldUDP64 specification defines sequence numbers and retransmission requests after a receiver detects a sequence-number gap (official specification).

Those sources establish that the evidence layers are real. They do not mandate this package's ordering. The precedence ladder is a conservative engineering policy and is tested as such.

Why unknown and conflicting are real outputs

A common classifier stores booleans such as halt=false or sequence_gap=false. That representation cannot distinguish “checked and false” from “not checked,” “late,” or “two sources disagree.” The result looks decisive even when the system never observed the decisive fact.

This package uses explicit states:

  • a layer can be known and affirmative;
  • known and negative;
  • unknown; or
  • conflicting.

Unknown means the evidence needed at that layer is absent, late, or not observable. Conflicting means retained sources disagree at the same layer. The classifier never treats either state as false.

This distinction matters most for inactivity. If a bar is absent from feed A, then “feed A reported zero trades” is not independent proof of no activity. Feed A may simply be the path that failed. The inactive class requires healthy transport and activity evidence independent of the missing bar path.

The input and output contracts

Each candidate slot supplies six categorical states, one independence flag, and immutable evidence identifiers. The exact schema is in the canonical README. In production, the record should additionally carry instrument, venue, event-time interval, receive and availability times, feed session, calendar version, correction version, and classifier version.

The primary output is an audit record:

OutputWhy it exists
classificationThe terminal diagnostic state.
decisive_layerThe layer that stopped evaluation.
reasonA plain-language explanation.
evidence_idsLineage for every contributing observation.
warningsA caution that does not justify a stronger class.

The eight possible classes are present, closed_session, expected_halt, feed_outage, inactive, bar_builder_error, unknown, and conflicting_evidence.

Notice what is not in the output: a synthetic price. Classification should happen before any repair policy.

The conservative decision ladder

Let BiB_i, SiS_i, HiH_i, TiT_i, and AiA_i denote the consolidated bar, session, halt, transport, and activity states for slot ii. The classifier is an ordered function:

Ci=f(Bi,Si,Hi,Ti,Ai).C_i = f(B_i, S_i, H_i, T_i, A_i).

The notation is deliberately compact because the enum contracts carry the meaning. Evaluation proceeds as follows:

  1. A present bar returns present. Unknown presence returns unknown.
  2. A session conflict returns conflicting_evidence; unknown returns unknown; closed returns closed_session.
  3. A halt conflict or unknown remains explicit; an active halt returns expected_halt.
  4. A transport conflict remains explicit. A lost heartbeat or sequence gap is positive evidence for feed_outage. If neither is positive but continuity is incomplete, the result is unknown.
  5. Activity conflict or unknown remains explicit. Dependent activity evidence returns unknown with a warning.
  6. Independent no-trade evidence returns inactive. Independent trades with no bar return bar_builder_error.
Rendering system map…

Takeaway: unknown at a stronger layer stops descent to a weaker story. A conflict is quarantined, not resolved by whichever source happens to arrive last.

Three cases that look similar on a chart

The static timeline compares evidence, not candle geometry:

Seven empty slots classified by calendar, halt, transport, activity, unknown, and conflicting evidence

Consider three missing in-session bars from the synthetic fixture.

Case 1: independent no-trade evidence

At 13:50 UTC, the session is open, no halt is active, heartbeat is healthy, sequence continuity is intact, and an independent activity source reports no trades. The classifier reaches the fifth layer and returns inactive.

Case 2: the same zero comes from the missing path

At 14:20 UTC, the visible states look similar, but the activity evidence is not independent. The classifier returns unknown and warns against using a failed path to prove inactivity.

Case 3: two calendars disagree

At 14:25 UTC, one retained calendar version says open and another says closed. The result is conflicting_evidence at the session layer. Healthy transport or zero activity cannot settle a disagreement about whether the slot belonged in the expected universe.

A historical example: 78 gaps that should never exist

Historical examples add value only when fact, provider observation, derivation, and interpretation stay separate. This one needs no vendor price record.

Sourced fact

On 1 December 2018, ICE/NYSE announced that the named NYSE Group markets would be closed on Wednesday, 5 December 2018 for the National Day of Mourning for President George H. W. Bush (official ICE/NYSE notice).

Provider observation

None. This package did not query or license a provider's historical bars. It therefore makes no claim about whether a particular vendor emitted, omitted, or later corrected records.

Engineering assumption and derivation

To expose the classifier's value, compare the official closure with an intentionally naive weekday template: 09:30–16:00 America/New_York in five-minute half-open slots. On that December date the stored example expresses the interval as [14:30Z, 21:00Z). The arithmetic is:

6.5 hours×60 minutes/hour5 minutes/slot=78 slots.\frac{6.5\ \text{hours} \times 60\ \text{minutes/hour}} {5\ \text{minutes/slot}} = 78\ \text{slots}.

The naive template generates 78 candidate slots. The official closure produces zero expected slots.

Interpretation

A weekday-only grid would manufacture 78 apparent gaps for every instrument in scope. Those absences are not evidence of 78 feed failures. They are one calendar-model defect. With the closure notice attached, each naive candidate terminates at the session layer as closed_session.

The complete case metadata lives in historical-closure-case.json. The example demonstrates calendar diagnosis, not provider accuracy, price behavior, or economic impact.

Implementation: pure logic with auditable output

The Python and TypeScript implementations follow the same pattern:

  1. validate every enum, the literal boolean independence flag, and non-empty evidence identifiers;
  2. evaluate the ladder without mutating input;
  3. copy evidence lineage into the diagnosis; and
  4. return immediately at the first decisive layer.

The work is O(n)O(n) time for nn candidate slots and O(n)O(n) output space when the audit records are retained. The per-slot logic is constant space.

Both languages expose diagnoseGap or diagnose_gap for the audit record and a convenience classifyGap or classify_gap for the label. An audited production pipeline should keep the full result.

Use the lab to challenge the evidence

Open the interactive missing-bar evidence lab. It offers three scenarios:

  • Canonical reaches the six confident outcomes;
  • Unknowns shows where missing or dependent evidence stops evaluation; and
  • Conflicts shows same-layer disagreements that require reconciliation.

Use Step and Back to move one decision at a time. Play and Pause call the same transition used by Step. Reset restores the same first case. The active ladder layer, exact field values, reason, evidence identifiers, and accumulated audit log remain synchronized. Reduced-motion mode converts Play to one deterministic step.

Tests that matter

The shared fixture contains 15 meaningful decision states rather than a long run of visually identical normal bars. Python and TypeScript tests verify:

  • all eight terminal classes;
  • full cross-language fixture parity;
  • evidence lineage and decisive-layer preservation;
  • a positive feed fault when activity is unknown;
  • stronger unknown evidence blocking a weaker no-trade claim;
  • dependent zero activity remaining unknown;
  • invalid enum, type, missing-field, and lineage failures; and
  • the exact 78-slot historical derivation with provider_observation: null.

Passing those tests proves that the implementation matches this package's definition. It does not prove how often each cause occurs in live markets or authorize a repair.

Production failure modes

  • Boolean unknowns: storing absent halt metadata as false creates false confidence.
  • Calendar drift: recomputing old slots with today's calendar silently changes history.
  • Dependent inactivity evidence: zero messages on the failed path are not proof of zero trades.
  • Heartbeat overclaim: a live connection does not prove that every payload arrived; sequence continuity remains separate.
  • Halt timing: late or corrected market-status records require a new point-in-time diagnosis version.
  • Venue consolidation: a consolidated bar can hide a single-venue failure.
  • Repair inside diagnosis: filling a bar before preserving the cause destroys auditability.

Keep a repair decision in a separate field and workflow. closed_session might remove a slot from the expected grid. feed_outage might trigger replay and reconciliation. bar_builder_error might trigger deterministic reconstruction. unknown and conflicting_evidence should block automatic repair unless an explicit risk policy says otherwise.

Summary and next topic

A missing bar is a question. The evidence ladder answers only as strongly as the retained session, market-status, transport, and independent activity records allow. It can return a useful cause, but it can also preserve unknown or conflicting evidence without cosmetic certainty.

Continue with Feed-Latency Monitor to distinguish data that is absent from data that has merely not arrived yet.

The primary source roles, dates, and limitations are recorded in REFERENCES.md. The canonical README owns the contracts, and the implementations and shared fixture make them executable.

ReferencesPrimary sources and evidence notes

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

NYSE-HOURS — Holidays and trading hours

  • Organization or authors: New York Stock Exchange
  • Source type: Official exchange calendar and session page
  • Publication or effective date: Current page; future calendars are versioned on the site
  • Version: Page retrieved 2026-07-22
  • URL or DOI: https://www.nyse.com/trade/hours-calendars
  • Accessed: 2026-07-22
  • Jurisdiction: United States
  • Supports: Session membership is venue-, product-, date-, and hours-scope-dependent; current NYSE Tape A core-session information is published separately from halt status.
  • Limitations: This current page is not used as proof of a 2018 regular-session template. Production systems need the calendar version applicable at the historical knowledge cutoff.

NYSE-HALTS — Trading halts

  • Organization or authors: New York Stock Exchange
  • Source type: Official exchange market-status page
  • Publication or effective date: Continuously updated
  • Version: Page retrieved 2026-07-22
  • URL or DOI: https://www.nyse.com/trade/trading-halts
  • Accessed: 2026-07-22
  • Jurisdiction: United States
  • Supports: Halt state is an operational evidence layer distinct from session membership and trade activity.
  • Limitations: The page is not a redistributable complete historical halt archive. Retention, late updates, and product scope require production controls.

NASDAQ-MOLDUDP64 — MoldUDP64 Protocol Specification

  • Organization or authors: Nasdaq
  • Source type: Official market-data transport protocol specification
  • Publication or effective date: Document current at retrieval
  • Version: 1.00
  • URL or DOI: https://nasdaqtrader.com/content/technicalsupport/specifications/dataproducts/moldudp64.pdf
  • Accessed: 2026-07-22
  • Jurisdiction: United States / protocol-specific
  • Supports: Downstream packets carry sequence numbers; a receiver can detect a sequence-number gap and request retransmission.
  • Limitations: The package uses protocol sequence gaps only as an example of positive transport evidence. Recovery procedures and product applicability depend on the subscribed feed.

NASDAQ-ITCH — Nasdaq TotalView-ITCH 5.0

  • Organization or authors: Nasdaq
  • Source type: Official market-data message specification
  • Publication or effective date: Document current at retrieval
  • Version: 5.0
  • URL or DOI: https://www.nasdaqtrader.com/content/technicalsupport/specifications/dataproducts/NQTVITCHSpecification.pdf
  • Accessed: 2026-07-22
  • Jurisdiction: United States / Nasdaq TotalView-ITCH
  • Supports: Market-data records include event timestamps and defined trade-message semantics, illustrating why activity evidence has a documented protocol meaning.
  • Limitations: ITCH tracking numbers are not substituted for MoldUDP64 transport sequence numbers. A subscriber's capture completeness remains an operational fact to establish.

ICE-NYSE-BUSH-CLOSURE — NYSE Group closure for National Day of Mourning

  • Organization or authors: Intercontinental Exchange / New York Stock Exchange
  • Source type: Official exchange press release
  • Publication or effective date: Published 2018-12-01; closure on 2018-12-05
  • Version: Archived official notice retrieved 2026-07-22
  • URL or DOI: https://ir.theice.com/press/news-details/2018/New-York-Stock-Exchange-to-Honor-President-George-H-W-Bush/default.aspx
  • Accessed: 2026-07-22
  • Jurisdiction: United States
  • Supports: The named NYSE Group markets were closed on Wednesday, 5 December 2018 for the National Day of Mourning.
  • Limitations: It does not provide security-level bars, prove a vendor's coverage, or establish this package's intentionally naive regular-session template. Those distinctions remain explicit in the historical case.

Package conventions — engineering choices, not external facts

  • Unknown at a stronger evidence layer stops descent to weaker explanations.
  • Same-layer disagreement becomes conflicting_evidence rather than a tie-break.
  • A lost heartbeat or sequence gap is positive transport-fault evidence.
  • inactive and bar_builder_error require activity evidence independent of the missing bar path.
  • These are conservative reference-implementation policies. A production owner may choose a different auditable policy but must not present it as an exchange rule.
missingBarGapClassifier.ts
export type GapClass =
  | "present"
  | "closed_session"
  | "expected_halt"
  | "feed_outage"
  | "inactive"
  | "bar_builder_error"
  | "unknown"
  | "conflicting_evidence";

export type BarState = "present" | "absent" | "unknown";
export type SessionStatus = "open" | "closed" | "unknown" | "conflicting";
export type HaltStatus = "active" | "inactive" | "unknown" | "conflicting";
export type HeartbeatStatus = "healthy" | "lost" | "unknown" | "conflicting";
export type SequenceStatus = "continuous" | "gap" | "unknown" | "conflicting";
export type ActivityStatus = "trades" | "no_trades" | "unknown" | "conflicting";

export interface GapRow {
  timestamp?: string;
  bar_state: BarState;
  session_status: SessionStatus;
  halt_status: HaltStatus;
  heartbeat_status: HeartbeatStatus;
  sequence_status: SequenceStatus;
  activity_status: ActivityStatus;
  activity_independent: boolean;
  evidence_ids: string[];
  [key: string]: unknown;
}

export interface GapDiagnosis {
  classification: GapClass;
  decisive_layer: string;
  reason: string;
  evidence_ids: string[];
  warnings: string[];
}

const allowed: Record<string, Set<string>> = {
  bar_state: new Set(["present", "absent", "unknown"]),
  session_status: new Set(["open", "closed", "unknown", "conflicting"]),
  halt_status: new Set(["active", "inactive", "unknown", "conflicting"]),
  heartbeat_status: new Set(["healthy", "lost", "unknown", "conflicting"]),
  sequence_status: new Set(["continuous", "gap", "unknown", "conflicting"]),
  activity_status: new Set(["trades", "no_trades", "unknown", "conflicting"]),
};

function validate(row: GapRow): void {
  for (const [field, values] of Object.entries(allowed)) {
    if (!values.has(row[field] as string)) {
      throw new Error(`${field} has an invalid value`);
    }
  }
  if (typeof row.activity_independent !== "boolean") {
    throw new Error("activity_independent must be a boolean");
  }
  if (
    !Array.isArray(row.evidence_ids) ||
    row.evidence_ids.length === 0 ||
    row.evidence_ids.some((value) => typeof value !== "string" || value.trim() === "")
  ) {
    throw new Error("evidence_ids must be a non-empty list of strings");
  }
}

function result(
  classification: GapClass,
  decisive_layer: string,
  reason: string,
  row: GapRow,
  warnings: string[] = [],
): GapDiagnosis {
  return { classification, decisive_layer, reason, evidence_ids: [...row.evidence_ids], warnings };
}

export function diagnoseGap(row: GapRow): GapDiagnosis {
  validate(row);
  if (row.bar_state === "present") return result("present", "bar", "A validated bar is materialized.", row);
  if (row.bar_state === "unknown") return result("unknown", "bar", "Bar presence has not been established.", row);

  if (row.session_status === "conflicting") return result("conflicting_evidence", "session", "Calendar sources disagree about whether the slot belongs to a session.", row);
  if (row.session_status === "unknown") return result("unknown", "session", "Session membership is unavailable.", row);
  if (row.session_status === "closed") return result("closed_session", "session", "Authoritative session evidence excludes this slot.", row);

  if (row.halt_status === "conflicting") return result("conflicting_evidence", "market_state", "Market-status sources disagree about an active halt.", row);
  if (row.halt_status === "unknown") return result("unknown", "market_state", "Halt state is not known.", row);
  if (row.halt_status === "active") return result("expected_halt", "market_state", "An authoritative halt interval covers the missing slot.", row);

  if (row.heartbeat_status === "conflicting" || row.sequence_status === "conflicting") return result("conflicting_evidence", "transport", "Transport-health evidence conflicts.", row);
  if (row.heartbeat_status === "lost" || row.sequence_status === "gap") return result("feed_outage", "transport", "A lost heartbeat or sequenced-message gap is positive feed-failure evidence.", row);
  if (row.heartbeat_status === "unknown" || row.sequence_status === "unknown") return result("unknown", "transport", "Feed continuity is not fully observed.", row);

  if (row.activity_status === "conflicting") return result("conflicting_evidence", "activity", "Activity sources disagree about whether trades occurred.", row);
  if (row.activity_status === "unknown") return result("unknown", "activity", "Trade activity is not known.", row);
  if (!row.activity_independent) return result("unknown", "activity", "Activity evidence is not independent of the missing bar path.", row, ["Do not use the failed path to prove that no trades occurred."]);
  if (row.activity_status === "no_trades") return result("inactive", "activity", "Independent activity evidence reports no trades in a healthy, open interval.", row);
  return result("bar_builder_error", "construction", "Independent trade evidence exists, but the expected bar is absent.", row);
}

export function classifyGap(row: GapRow): GapClass {
  return diagnoseGap(row).classification;
}

export function classifyRows(rows: GapRow[]) {
  return rows.map((row) => ({ ...row, ...diagnoseGap(row) }));
}
Full-height labgap classifier playgroundOpen full screen