Library/Market Data Engineering/Cleaning and Validation

D01-F02-A05 / Complete engineering topic

Duplicate-Trade Resolver: Rebuild One Effective Trade Without Losing Its History

A production-minded guide to Duplicate-Trade Resolver.

D01 · MARKET DATA ENGINEERING
D01-F02-A05Canonical / 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 resolve replay, correction, cancel, and error events into one auditable trade state—without deleting the raw evidence or inventing a universal feed rule.

Trade events moving from raw arrivals through identity and lineage checks into active state or a tombstone

Two records can show the same price, size, and timestamp and still be two genuine trades. One trade can also appear several times because a message was replayed, corrected, canceled, or declared erroneous. A row-level drop_duplicates call cannot tell those situations apart.

The useful question is therefore not “Do these rows look equal?” It is:

Does this event have the identity and lineage required to change the current economic trade?

What the source specifications actually establish

The current NYSE Daily TAQ v4.3 specification, dated March 3, 2026, distinguishes regular, corrected, canceled, cancel, and error trade records. It says its Trade ID is unique per participant, symbol, and session within a trading session. The Nasdaq TotalView-ITCH 5.0 specification uses a day-unique Match Number and references it from a Broken Trade message; Nasdaq states that a broken trade is final. FIX post-trade documentation separately defines message, trade, and prior-report identifiers.

That evidence supports three engineering lessons:

  1. Message identity and trade identity are not interchangeable.
  2. Corrections and terminal actions need explicit source linkage.
  3. Identifier scope and action precedence depend on the feed or protocol.

This tutorial’s exact key and “no reinstatement after terminal action” rule are versioned package choices. A real adapter must replace them when its source says otherwise.

Four actions, more than four outcomes

The normalized contract accepts NEW, CORRECT, CANCEL, and ERROR.

  • NEW creates an active trade only when its scoped trade key is absent.
  • CORRECT replaces price and size only when it references the current active version.
  • CANCEL creates a CANCELED tombstone.
  • ERROR creates an ERRORED tombstone. It is the teaching adapter’s terminal error/break action, not a universal exchange term.

An event can also become DROP_REPLAY, DROP_DUPLICATE_KEY, PENDING_REFERENCE, REJECT_STALE_REFERENCE, REJECT_CROSS_KEY_REFERENCE, REJECT_TERMINAL, CONFLICT_EVENT_ID, CONFLICT_SEQUENCE, or REJECT_INVALID. Every outcome remains in the audit.

Identity comes before precedence

The scoped event identity is

Ie=(source,session,event ID),I_e=(\text{source},\text{session},\text{event ID}),

while the economic trade key is

Ke=(source,session,instrument,trade ID).K_e=(\text{source},\text{session},\text{instrument},\text{trade ID}).

The session component matters because an exchange or feed can reuse an identifier after a reset. The instrument component prevents a reference from attaching to the same text ID on a different symbol. These fields still do not prove the key is correct for another venue; the feed specification is authoritative.

Scoped event identity is checked before a linked action may advance the current trade version

Notice the two safeguards in the diagram. First, one scoped event ID with two different payloads quarantines both variants; choosing the first would make state depend on arrival luck. Second, a correction must reference the current head, not merely any ancestor. That prevents two correction branches from silently overwriting one another.

Arrival time is evidence, not always precedence

The resolver keeps original arrival order in raw_history, but it materializes eligible events by source/session sequence. If correction E2 arrives before original E1, the chain can still resolve correctly when the feed supplies unambiguous sequence 1 then 2.

This is not permission to sort every feed blindly. The package quarantines two different event IDs that share a source/session sequence, and it leaves a missing parent pending. Receive time cannot prove which ambiguous event should win.

A worked lifecycle you can audit by hand

The canonical synthetic example begins with an out-of-order arrival:

ArrivalSequenceMessageEffective decision
12E2 CORRECT, ref E1, 101.00 × 12replay after sequence 1
21E1 NEW, 100.00 × 10APPLY_NEW
31exact replay of E1DROP_REPLAY
43E3 CANCEL, ref E2APPLY_CANCEL

Replaying source order gives E1 → E2 → E3. The active notional begins at 100.00×10=1,000.00100.00\times10=1{,}000.00. Correction E2 changes it to 101.00×12=1,212.00101.00\times12=1{,}212.00. Cancel E3 then removes it from active output. The resolver retains both economic versions, the cancel tombstone, the replayed arrival, and every decision.

The example is synthetic. A real historical case would be valuable only when an official, publishable sample supplies the original event, the linked correction/cancel/error, and sequence context. Duplicate-looking provider rows alone do not establish intended lifecycle state.

Keep four evidence layers separate

Raw arrivals remain immutable while eligible versions build effective state and terminal tombstones

The resolver’s result has four layers:

  1. raw_history stores every arrival, even invalid or conflicting ones.
  2. versions stores each eligible unique event and its head-before/head-after lineage.
  3. states and active provide the materialized view used downstream.
  4. tombstones and pending explain why a trade is absent or unresolved.

Overwriting the raw row with corrected economics collapses these layers. Deleting a cancel makes reconciliation impossible. Treat the materialized state as a rebuildable view, never as a replacement for source evidence.

The transition rule

Let Sn(K)S_n(K) be the state of trade key KK after eligible source event ene_n, under policy version PP:

Sn(K)=δ ⁣(Sn1(K),en;P).S_n(K)=\delta\!\left(S_{n-1}(K),e_n;P\right).

The function δ\delta mutates effective economics only for an accepted NEW or current-head CORRECT. CANCEL and ERROR advance lineage into distinct terminal states. Every rejection or quarantine keeps the prior effective state unchanged.

Implementation outline

Plain text
preserve every arrival
validate the normalized schema
group by scoped event identity
quarantine conflicting payloads and ambiguous source sequences
order eligible events by source sequence
for each event:
  NEW requires an absent composite trade key
  every other action requires a same-key current-head reference
  CORRECT replaces economics
  CANCEL or ERROR creates a terminal tombstone
record one reasoned decision per arrival

The Python and TypeScript implementations consume the same 24-arrival fixture. The pass is O(nlogn)O(n\log n) because of sorting, with O(n)O(n) retained evidence.

Runnable Python and TypeScript entry points print the active key, tombstones, pending count, and audit count without hiding the underlying result object.

Use the playground to compare failures

Open the interactive lifecycle lab. The initial view already shows the complete canonical preview. Then use Back, Step, Play, Pause, and Reset to inspect four scenarios:

  • clean lifecycle;
  • conflicting corrections from one parent;
  • out-of-order arrival with usable source sequence;
  • failure quarantine for conflict, missing reference, or invalid input.

The lab labels all data as synthetic. Reset is deterministic, reduced-motion mode turns Play into one safe step, and the audit table is the only local horizontal scroll surface on narrow screens.

Tests that protect meaning

The shared tests verify:

  • Python/TypeScript parity on all 24 decisions;
  • out-of-order recovery through source sequence;
  • exact replay idempotence and raw-history preservation;
  • conflict quarantine independent of arrival choice;
  • distinct cancel and error tombstones;
  • stale, terminal, cross-key, and missing-reference handling;
  • identifier reuse across sessions;
  • invalid economics and ambiguous sequence rejection;
  • input immutability.

These tests prove definition-level agreement. They do not prove a target feed uses this identity key or precedence.

Production questions to answer before deployment

  • Which source fields identify a message, a trade, and a prior version?
  • Where do identifiers reset: connection, channel, participant, symbol, day, or session?
  • Is source sequence global, partitioned, gap-free, or only monotonic?
  • Can terminal trades be reinstated, and by which explicit action?
  • How long may a missing reference remain pending?
  • Which raw data license and retention rules apply?
  • How will replays reproduce the same policy and schema version?

If any answer is unclear, quarantine rather than invent precedence.

Summary and next topic

A safe duplicate resolver is a versioned event processor. It separates event identity from trade identity, requires explicit correction lineage, distinguishes cancel from error, preserves tombstones, and keeps raw arrivals immutable. That produces one useful active view without erasing the history needed to trust it.

Next, Crossed/Locked Market Detector applies the same discipline to quote-side state and signed spread geometry.

Resolution state and evidence paths

This state machine separates effective-state mutation from evidence-only outcomes.

Rendering system map…

Takeaway: replay, pending, rejection, and quarantine remain in the audit but do not change effective economics; only a valid linked action advances the current head.

ReferencesPrimary sources and evidence notes

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

Accessed 2026-07-22. Identifiers, lifecycle values, correction linkage, ordering, and terminal behavior must be mapped from the actual source contract.

NYSE-DTAQ-4.3 — Daily TAQ Client Specifications

  • Organization or authors: New York Stock Exchange / Intercontinental Exchange
  • Source type: Official historical market-data specification
  • Publication or effective date: March 3, 2026
  • Version: 4.3
  • URL or DOI: https://www.nyse.com/publicdocs/nyse/data/Daily_TAQ_Client_Spec_v4.3.pdf
  • Jurisdiction: United States equities; CTA and UTP SIP-derived files
  • Supports: Trade Correction Indicator values for regular, late-corrected, erroneous, canceled, cancel, error, and correction records; message sequence; Trade ID scope; source/licensing boundary.
  • Limitations: Describes licensed Daily TAQ files, not this package’s normalized action or reference schema. It does not provide the worked event payload used here.

NASDAQ-ITCH-5.0 — Nasdaq TotalView-ITCH

  • Organization or authors: Nasdaq
  • Source type: Official exchange feed specification
  • Publication or effective date: Revision log current through April 28, 2023 in the accessed document
  • Version: 5.0
  • URL or DOI: https://nasdaqtrader.com/content/technicalsupport/specifications/dataproducts/NQTVITCHspecification.pdf
  • Jurisdiction: Nasdaq U.S. equities
  • Supports: Day-unique Match Number, Broken Trade reference to an earlier execution/trade message, and the rule that a broken trade is final.
  • Limitations: Nasdaq-specific. A Broken Trade is mapped to package action ERROR for teaching; this is not a universal cancel/error definition.

FIX-POSTTRADE — Business Area: Post-Trade

  • Organization or authors: FIX Trading Community
  • Source type: Official maintained protocol documentation
  • Publication or effective date: Maintained online
  • Version: FIX Latest
  • URL or DOI: https://www.fixtrading.org/online-specification/business-area-posttrade/
  • Jurisdiction: Cross-market protocol
  • Supports: Different roles for TradeReportID, TradeID, TradeReportRefID, and SecondaryTradeID; new, replace, and cancel workflows.
  • Limitations: Counterparties and venues select profiles and conventions; the package composite keys and current-head rule are not mandated by FIX.

FIX-487 — TradeReportTransType

  • Organization or authors: FIX Trading Community
  • Source type: Official FIX field reference
  • Publication or effective date: Maintained online
  • Version: FIX 5.0 SP2 field 487
  • URL or DOI: https://fiximate.fixtrading.org/legacy/en/FIX.5.0SP2/tag487.html
  • Jurisdiction: Cross-market protocol
  • Supports: Enumerated New, Cancel, Replace, Release, Reverse, and Cancel Due To Back Out Of Trade transaction types.
  • Limitations: The package intentionally narrows the teaching model to four normalized actions; it does not claim one-to-one coverage of every FIX value.

Dataset and historical-example note

datasets/shared_fixture.json and datasets/rich_trade_events.csv are synthetic, CC0-1.0 teaching data. No licensed NYSE/FMP/provider payload is stored. No real historical lifecycle is claimed because an official, redistribution-cleared original-plus-correction sample with complete linkage was not established.

duplicateResolver.ts
/** Auditable duplicate-trade lifecycle resolver; source messages are never mutated. */

export type Action = "NEW" | "CORRECT" | "CANCEL" | "ERROR";
export type TradeEvent = {
  source_id: string; session_id: string; instrument_id: string;
  event_id: string; trade_id: string; action: string;
  receive_ts: string; sequence: number; ref_event_id?: string;
  price?: number; size?: number; [key: string]: unknown;
};

export const POLICY_VERSION = "synthetic-lifecycle-v2";
const ACTIONS = new Set(["NEW", "CORRECT", "CANCEL", "ERROR"]);
const textFields = ["source_id", "session_id", "instrument_id", "event_id", "trade_id", "action", "receive_ts"] as const;
const eventKey = (e: TradeEvent) => `${e.source_id}|${e.session_id}|${e.event_id}`;
const tradeKey = (e: TradeEvent) => `${e.source_id}|${e.session_id}|${e.instrument_id}|${e.trade_id}`;
const publicEvent = (e: Record<string, unknown>) => Object.fromEntries(Object.entries(e).filter(([k]) => !k.startsWith("_")));
const fingerprint = (e: Record<string, unknown>) => JSON.stringify(Object.fromEntries(Object.entries(publicEvent(e)).sort(([a], [b]) => a.localeCompare(b))));

function normalize(raw: TradeEvent): {event: TradeEvent & {_receive_sort?: number}; errors: string[]} {
  const event = {...raw, action: String(raw.action ?? "").toUpperCase()} as TradeEvent & {_receive_sort?: number};
  const errors: string[] = [];
  for (const field of textFields) if (typeof event[field] !== "string" || !(event[field] as string).trim()) errors.push(`${field} must be a non-empty string`);
  if (!ACTIONS.has(event.action)) errors.push("action must be NEW, CORRECT, CANCEL, or ERROR");
  if (!Number.isInteger(event.sequence) || event.sequence < 0) errors.push("sequence must be a non-negative integer");
  const parsed = Date.parse(event.receive_ts);
  if (!Number.isFinite(parsed) || !/(Z|[+-]\d\d:\d\d)$/.test(event.receive_ts)) errors.push("receive_ts must be a valid timezone-bearing ISO timestamp");
  else event._receive_sort = parsed;
  if (event.action === "NEW" && event.ref_event_id) errors.push("NEW must not carry ref_event_id");
  if (["CORRECT", "CANCEL", "ERROR"].includes(event.action) && (typeof event.ref_event_id !== "string" || !event.ref_event_id.trim())) errors.push(`${event.action} requires ref_event_id`);
  if (["NEW", "CORRECT"].includes(event.action)) {
    if (!Number.isFinite(event.price) || event.price! <= 0 || !Number.isInteger(event.size) || event.size! <= 0) errors.push(`${event.action} requires finite positive price and integer size`);
  } else if (event.price !== undefined || event.size !== undefined) errors.push(`${event.action} must not replace price or size`);
  return {event, errors};
}

export function resolveTrades(input: TradeEvent[]) {
  const rows = input.map(e => ({...e}));
  const auditByIndex = new Map<number, any>();
  const raw_history: any[] = [];
  const valid: Array<[number, TradeEvent & {_receive_sort?: number}]> = [];
  rows.forEach((raw, arrival_index) => {
    const {event, errors} = normalize(raw);
    raw_history.push({arrival_index, event: {...raw}, validation_errors: errors});
    if (errors.length) auditByIndex.set(arrival_index, {arrival_index, event_id: raw.event_id, trade_id: raw.trade_id, decision: "REJECT_INVALID", reason: errors.join("; ")});
    else valid.push([arrival_index, event]);
  });

  const grouped = new Map<string, Array<[number, TradeEvent & {_receive_sort?: number}]>>();
  for (const item of valid) grouped.set(eventKey(item[1]), [...(grouped.get(eventKey(item[1])) ?? []), item]);
  const canonical: Array<[number, TradeEvent & {_receive_sort?: number}]> = [];
  for (const group of grouped.values()) {
    if (new Set(group.map(([, e]) => fingerprint(e))).size > 1) {
      for (const [i, e] of group) auditByIndex.set(i, {arrival_index:i,event_id:e.event_id,trade_id:e.trade_id,decision:"CONFLICT_EVENT_ID",reason:"same scoped event_id arrived with different payloads; all variants quarantined"});
      continue;
    }
    const first = [...group].sort((a,b)=>a[0]-b[0])[0]; canonical.push(first);
    for (const [i,e] of group) if (i !== first[0]) auditByIndex.set(i,{arrival_index:i,event_id:e.event_id,trade_id:e.trade_id,decision:"DROP_REPLAY",reason:"byte-equivalent normalized event_id already retained"});
  }

  const sequenceGroups = new Map<string, typeof canonical>();
  for (const item of canonical) { const e=item[1], k=`${e.source_id}|${e.session_id}|${e.sequence}`; sequenceGroups.set(k,[...(sequenceGroups.get(k)??[]),item]); }
  const eligible: typeof canonical = [];
  for (const group of sequenceGroups.values()) {
    if (group.length > 1) for (const [i,e] of group) auditByIndex.set(i,{arrival_index:i,event_id:e.event_id,trade_id:e.trade_id,decision:"CONFLICT_SEQUENCE",reason:"two event IDs share one source/session sequence; ordering is ambiguous"});
    else eligible.push(group[0]);
  }
  eligible.sort((a,b)=>a[1].source_id.localeCompare(b[1].source_id)||a[1].session_id.localeCompare(b[1].session_id)||a[1].sequence-b[1].sequence||a[1]._receive_sort!-b[1]._receive_sort!||a[1].event_id.localeCompare(b[1].event_id));
  const eligibleByKey = new Map(eligible.map(([,e])=>[eventKey(e),e]));
  const eventTradeKeys = new Map<string,string>();
  const states: Record<string,any> = {}; const versions:any[]=[]; const pending:any[]=[];
  const record=(i:number,e:TradeEvent,decision:string,reason:string,before:string|null,after:string|null)=>{
    auditByIndex.set(i,{arrival_index:i,event_id:e.event_id,trade_id:e.trade_id,decision,reason});
    versions.push({event_key:eventKey(e),trade_key:tradeKey(e),event_id:e.event_id,action:e.action,ref_event_id:e.ref_event_id,sequence:e.sequence,price:e.price,size:e.size,decision,head_before:before,head_after:after});
  };
  for (const [i,e] of eligible) {
    const ek=eventKey(e), tk=tradeKey(e), action=e.action as Action; eventTradeKeys.set(ek,tk);
    const current=states[tk], before=current?.head_event_id??null;
    if(action==="NEW"){
      if(current){record(i,e,"DROP_DUPLICATE_KEY","trade key already has an active or terminal chain",before,before);continue;}
      states[tk]={trade_key:tk,source_id:e.source_id,session_id:e.session_id,instrument_id:e.instrument_id,trade_id:e.trade_id,status:"ACTIVE",head_event_id:e.event_id,head_event_key:ek,price:e.price,size:e.size,chain:[e.event_id]};
      record(i,e,"APPLY_NEW","first valid NEW for this scoped trade key",null,e.event_id);continue;
    }
    const refKey=`${e.source_id}|${e.session_id}|${e.ref_event_id}`;
    if(!eligibleByKey.has(refKey)||!eventTradeKeys.has(refKey)){
      pending.push({event_key:ek,trade_key:tk,ref_event_id:e.ref_event_id,action});record(i,e,"PENDING_REFERENCE","referenced event is missing, quarantined, or not yet valid in source sequence",before,before);continue;
    }
    if(eventTradeKeys.get(refKey)!==tk){record(i,e,"REJECT_CROSS_KEY_REFERENCE","reference crosses instrument, trade, source, or session boundary",before,before);continue;}
    if(!current){pending.push({event_key:ek,trade_key:tk,ref_event_id:e.ref_event_id,action});record(i,e,"PENDING_REFERENCE","referenced event did not produce an effective trade",null,null);continue;}
    if(current.status!=="ACTIVE"){record(i,e,"REJECT_TERMINAL","package policy does not reinstate canceled or erroneous trades",before,before);continue;}
    if(current.head_event_key!==refKey){record(i,e,"REJECT_STALE_REFERENCE","reference is not the current applied version",before,before);continue;}
    const next={...current,head_event_id:e.event_id,head_event_key:ek,chain:[...current.chain,e.event_id]};
    if(action==="CORRECT"){next.price=e.price;next.size=e.size;states[tk]=next;record(i,e,"APPLY_CORRECTION","correction references the current active version",before,e.event_id);}
    else {next.status=action==="CANCEL"?"CANCELED":"ERRORED";next.tombstone={action,event_id:e.event_id,ref_event_id:e.ref_event_id};states[tk]=next;record(i,e,action==="CANCEL"?"APPLY_CANCEL":"APPLY_ERROR",`${action} creates a terminal tombstone under package policy`,before,e.event_id);}
  }
  const orderedStates=Object.fromEntries(Object.entries(states).sort(([a],[b])=>a.localeCompare(b)));
  const values=Object.values(orderedStates);
  return {policy_version:POLICY_VERSION,active:values.filter((s:any)=>s.status==="ACTIVE"),states:orderedStates,tombstones:values.filter((s:any)=>s.status!=="ACTIVE"),pending,versions,raw_history,audit:rows.map((_,i)=>auditByIndex.get(i))};
}
Full-height labplaygroundOpen full screen