Research, Data and Risk Platforms · Technology
7Data Quality and Lineage
On 6 May 2010, between 2:40 and 3:00 in the afternoon, more than 20 000 trades in more than 300 US securities were executed at prices 60% or more away from where they had been minutes before, some at a penny, some at $100 000. After the close the exchanges and their self-regulator agreed to break all of them as clearly erroneous. A tick store that had captured those trades faithfully, and never learned that they had been cancelled, fed them to every backtest, volatility estimate and execution study that touched that afternoon, for as long as it kept the data. Nothing in the store was corrupt; the data were exactly what the feed had sent. What was missing was a layer that checks data against what it should look like, marks what fails instead of deleting it, applies the corrections that arrive later as new versions, and knows which results were built from what. This chapter builds that layer (firm.dataqual) and measures it on a simulated day with planted defects and, just as important, genuine market events that a careless rule mistakes for defects.
7.1 Checks: completeness, validity, consistency, timeliness
Definition 7.1 (Data-quality rule, data contract)
A data-quality rule is an automated check of a dataset against an expectation — that it is complete, that its values are valid, that it is consistent with itself and with other sources, that it is timely — run every time the data are produced, with a severity and an owner who acts on its failures. A data contract is the agreement between a dataset’s producer and its consumers that states its schema, its rules and its delivery times, so that a change on either side is a change to the contract rather than a surprise.
Four families of checks cover most market-data defects. Completeness: no messages missing (the feed’s sequence numbers have no gap, chapter 2), no instrument silent when it should be quoting. Validity: every value in its domain (positive prices and sizes, known instruments, timestamps inside the session). Consistency: the data agree with themselves (no bid at or above the ask, trade prices near the quotes) and with other sources (another feed, a vendor’s copy, the official close). Timeliness: the data arrived when the contract says. Listing 7.1 is the suite of the chapter, written as data: each rule has a kind, a severity, an owner and parameters, and firm.dataqual runs any suite over any partition.
def rules(reference_trades: pd.DataFrame, busted: list, halts=(), aware=True) -> dict:
"""The suite. aware=False is the first version: the spike rule does not check that a
jump comes back, and the staleness rule does not know about trading halts."""
md, big = "market data", 10**9
return {
"events": [Rule("sequence", "sequence", "error", md, {"column": "seq"})],
"quotes": [Rule("positive prices", "bounds", "error", md,
{"column": "bid", "lo": 1, "hi": big}),
Rule("crossed book", "crossed", "error", md, {"bid": "bid", "ask": "ask"}),
Rule("stale quotes", "stale", "warning", md,
{"time": "ts", "max_gap": 30 * SEC, "by": "symbol",
"exempt": halts if aware else ()})],
"trades": [Rule("positive trades", "bounds", "error", md,
{"column": "qty", "lo": 1, "hi": big}),
Rule("price spike", "spike", "error", md,
{"column": "price", "k": 8.0, "window": 50, "floor": 1e-3,
"confirm": aware, "by": "symbol"}),
Rule("broken trades", "busted", "error", "operations",
{"key": "match", "busted": busted}),
Rule("vendor trade count", "cross_source", "warning", md,
{"time": "ts", "bucket": 60 * SEC, "tol": 0.02,
"reference": reference_trades})],
}
The day is the second of the chapter-4 week: two instruments, 47 472 events, 9 258 quotes and 1 190 trades. Two genuine events are part of it: SIM2’s trading is halted for 60 seconds at 40% of the session, and SIM2’s price level jumps by 3% at 60% of it, on news, and stays there. Into copies of the day, twenty times with twenty seeds, the chapter plants six kinds of defect: a gap of 200 events, three crossed quotes, eight trade prices off by a factor of ten (a decimal slip, up or down), a 90-second silence of SIM1’s quotes, four trades broken by the venue after the close, and, separately, a vendor’s later correction of an official close.
7.2 Anomaly flags, not deletions
Definition 7.2 (Anomaly flag, quarantine)
An anomaly flag is a record attached to a row or a partition that names the rule it failed, the severity and the detail, leaving the row itself unchanged; consumers decide whether to exclude flagged rows. A quarantine is the state of a partition that failed an error-severity rule: it is withheld from readers until a person releases it, with a reason, or it is rebuilt.
Deleting a bad row is tempting and wrong. The row may be right (a genuine 3% jump looks like a spike to a naive rule); another consumer may need it (a surveillance system wants the erroneous trades, chapter 30); and a deletion cannot be audited or undone. A flag keeps all three options open: the backtest excludes error-severity rows, the surveillance system reads them, and the rule can be improved and rerun.
fig_dataqual.py.The first version of the suite flagged the planted defects and a good deal more. The spike rule compared each trade price with the median of the previous 50 trades of its symbol, in robust units: the median absolute deviation of the previous deviations, with a floor of 0.1% so that a one-tick move is never a spike. On the day of the jump it flagged every trade for the next few dozen, until the backward median caught up with the new level (Figure 7.3). A spike and a level shift look identical from behind; from ahead they do not, because a spike comes back. The data-quality run happens after the close, so it may look ahead:
Method 7.3 (Telling a spike from a move)
Flag a value when it deviates, by more than robust units, both from the median of the values before it and from the median of the values after it. A decimal slip deviates from both; a genuine move of the level deviates only from the values before. At the start of a series, where there is no history, compare with the values after only.
def _rows_spike(df, p):
"""Deviation of each value from the median of the previous `window` values of its
symbol, in robust units: the median absolute deviation of those previous deviations,
floored at `floor` (a relative move below which nothing is a spike, e.g. a tick). With
`confirm`, the value must also deviate from the median of the next `window` values: a
spike comes back, a genuine move of the level does not (a batch check may look ahead)."""
out = []
w, k, floor = p["window"], p["k"], p.get("floor", 1e-3)
for _, g in df.groupby(p.get("by", "symbol"), sort=False):
x = np.log(g[p["column"]].astype(float))
behind = x.rolling(w, min_periods=5).median().shift(1)
ahead = x[::-1].rolling(w, min_periods=5).median().shift(1)[::-1]
dev = x - behind.fillna(ahead) # a day's first rows have no history: look ahead
mad = dev.abs().rolling(w, min_periods=5).median().shift(1).fillna(0.0)
scale = np.maximum(1.4826 * mad, floor)
z = dev / scale
hit = z.abs() > k
if p.get("confirm"):
hit &= ((x - ahead.fillna(behind)) / scale).abs() > k
for i in g.index[hit]:
out.append((int(i), f"robust z {z[i]:.1f}"))
return out
confirm, from the forward median too. code/firm/dataqual/firm_dataqual.pyfig_dataqual.py (seed 0).The staleness rule failed the same way on the halt: 60 seconds without a quote from SIM2 is a defect in the feed only if the market was open. The rule must know the market’s state — the venue’s trading-action messages (chapter 2’s events include them) — and not count time spent halted (Listing 7.3).
def _rows_stale(df, p):
"""Gaps longer than max_gap between consecutive rows of a symbol, not counting the time
inside the `exempt` windows [(symbol, t0, t1)] in which the market itself was silent (a
trading halt, a scheduled pause)."""
out = []
exempt = p.get("exempt", ())
for sym, g in df.groupby(p.get("by", "symbol"), sort=False):
t = g[p["time"]].to_numpy()
for k in np.flatnonzero(np.diff(t) > p["max_gap"]):
quiet = sum(max(0, min(t[k + 1], t1) - max(t[k], t0))
for s, t0, t1 in exempt if s == sym)
if t[k + 1] - t[k] - quiet <= p["max_gap"]: # the market's state explains it
continue
gap = (t[k + 1] - t[k]) / 1e9
out.append((int(g.index[k + 1]), f"no update for {gap:.1f} s"))
return out
Table 7.1 is the result over twenty seeds. Every planted defect is found by both versions of the suite; the difference is entirely in the false flags: 495 trades wrongly flagged as spikes and 20 halts wrongly flagged as stale feeds by the naive suite, none by the aware one.
| rule | planted | found | false flags, naive | false flags, aware |
|---|---|---|---|---|
| sequence gap | 20 | 20 | 0 | 0 |
| crossed book | 60 | 60 | 0 | 0 |
| price spike | 160 | 160 | 495 | 0 |
| stale quotes | 20 | 20 | 20 | 0 |
| broken trades | 80 | 80 | 0 | 0 |
pl_dataqual.detection.A planted defect that every rule finds is not a test of the rules; the confounders are. A surveillance detector of One Quant Book 9 caught all its planted spoofers until legitimate look-alikes were added (its chapter 29), and the same lesson holds here: plant the genuine events the real market has, and measure the false flags.
7.3 Cross-source checks
Definition 7.4 (Cross-source check)
A cross-source check compares a dataset with an independent source of the same facts — the other feed line, a second vendor, the venue’s end-of-day file, the official close — on an aggregate that both can compute (a count, a volume, a last price per interval), and flags the intervals where they differ by more than a tolerance.
A single source can be complete by its own sequence numbers and still wrong: a normaliser that dropped a message type, a compaction that lost a file. The chapter’s check counts trades per minute in the store and in a vendor’s copy of the day and flags minutes that differ by more than 2%: over the twenty seeds it raised 16 flags, the minutes in which the planted gap of 200 events happened to contain trades. It does not say which trades are missing, only where to look; that is the right division of labour between a cheap check that runs everywhere and an expensive investigation that runs where it points.
7.4 Vendor corrections and versions
Definition 7.5 (Vendor correction)
A vendor correction is a later delivery that changes a value already delivered (a close, a trade condition, a corporate action): it is stored as a new version of the same fact, with its own knowledge time, and never overwrites the earlier version.
The chapter’s vendor delivers SIM2’s official close at 16:30 on the day, and a corrected close at 07:10 the next morning. Both are versions of one fact in Book 7’s bitemporal store: asked at 23:00, the close is the first; asked at 08:00, the second. The broken trades of the hook are the same pattern: a list of cancellations, delivered after the close, applied as flags (busted) on the trades they name. What a correction changes is not only the fact but everything computed from it, and that is lineage.
7.5 Lineage and provenance
Definition 7.6 (Data lineage, provenance record)
The data lineage of a result is the graph of dataset versions and transformations it was derived from, back to its raw inputs. A provenance record is the stored description of how one dataset version was produced: the versions of its inputs (by content hash), the code and its version, and when and by whom it ran.
firm.dataqual’s lineage records each dataset version as a node, named by the content hash of its data (Book 7’s firm.workflow.content_hash), with an edge from each input version it was built from (Listing 7.4). A correction produces a new version of the input; every node downstream of the old version is stale. In the chapter’s example (Figure 7.4), the corrected close makes the marks, the P&L and the research features stale, and leaves the minute bars, built from the trades alone, untouched.
def add(self, name: str, data, inputs=(), code: str = "") -> tuple:
node = (name, content_hash(data))
self.inputs[node] = tuple(inputs)
self.code[node] = code
return node
def downstream(self, node: tuple) -> set:
out, todo = set(), [node]
while todo:
n = todo.pop()
for m, ins in self.inputs.items():
if n in ins and m not in out:
out.add(m)
todo.append(m)
return out
pl_dataqual.correction_and_lineage.As of September 2026 — A standard for provenance
The World Wide Web Consortium’s PROV data model (a W3C Recommendation of 30 April 2013, consulted September 2026) defines provenance as a record that describes the people, institutions, entities and activities involved in producing, influencing or delivering a piece of data; the lineage graph of this chapter is a small instance of its entities (dataset versions) and activities (the jobs that derive them).
What quality is worth shows in the numbers of the day (seed 0). With the planted defects kept, SIM1’s VWAP is 2.69% too high and SIM2’s 1.08%, and SIM2’s realised variance of trade-price changes is 24 000 times its true value: eight decimal slips outweigh a day of genuine trading. With the error-severity rows excluded, both VWAPs are within 0.03% of the truth (the residue is the trades lost in the gap, which no flag can restore) and the realised variance equals the true one.
7.6 Tutorial: plant, flag, correct
Goal. Plant defects and confounders in a simulated day, run the rule suite twice (naive and aware), measure detection and false flags, and trace a correction through the lineage. End state: Table 7.1, Figure 7.3 and the VWAP and variance errors of the last section.
- The day:
pl_dataqual.base()with its halt and its jump. - Plant:
plant(seed)returns the defective tables and the truth of which rows are defects. - Run:
firm_dataqual.run(rules(…), tables)(Listing 7.1); thenscoreanddetectionover twenty seeds, naive and aware. - Effect:
effect(0): VWAP and realised variance kept, flagged out and true. - Correct:
correction_and_lineage(): two versions of the close, and the stale results.
What to change next. Lower the spike threshold from 8 to 4 and count the false flags on the natural day; plant a 2% spike instead of a decimal slip and find the smallest one the rule still catches.
7.7 Build: the data-quality layer
Purpose. The checks every partition of the tick store passes before it is published, the flags consumers filter on, the quarantine that withholds bad partitions, and the lineage that says what a correction invalidates; it runs as jobs of chapter 1’s map and chapter 13’s scheduler.
Interface. Rule(name, kind, severity, owner, params), Flag(table, row, rule, severity, detail); run(rules_by_table, tables), clean(df, flags, table, severities); Quarantine with apply, release, readable, log; Lineage with add, downstream, upstream, stale_after; kinds sequence, bounds, crossed, spike, stale, cross_source, busted.
Rules. Rules are data with an owner; flags never modify rows; an error flag quarantines its partition; a release names a person and a reason; corrections are versions; dataset versions are named by content hash.
Acceptance tests. code/firm/dataqual/tests/: each kind finds its planted defect and nothing else; the confirmed spike rule spares a level shift and the staleness rule a halt; the cross-source count by bucket; quarantine and release; lineage names the stale results of a correction.
Stretch. Rules generated from a data contract; a quality score per partition published with it; flags stored as a table of the tick store, queryable like the data.
Sources and further reading
- U.S. Commodity Futures Trading Commission and Securities and Exchange Commission, Findings regarding the market events of May 6, 2010, 30 September 2010.
- W3C, PROV-DM: The PROV Data Model, W3C Recommendation, 30 April 2013.
7.8 Exercises
Exercise 7.1 ★
A feed’s sequence numbers jump from 91 686 to 91 691. How many messages are missing, and what does the capture of chapter 2 do about them?
Solution
Solution of Exercise 7.1.
Four: 91 687 to 91 690. The capture of chapter 2 waits for the other line to fill them, then records the gap’s range and moves on; the sequence rule of this chapter flags the first row after it, so that consumers know the day is incomplete there.
Exercise 7.2 ★
Why does the spike rule have a floor on its scale? What would happen without one on a large-tick instrument whose trades mostly print at the same price?
Solution
Solution of Exercise 7.2.
On a large-tick instrument most consecutive trades print at the same price, so the median absolute deviation of recent deviations is zero, and any move, even one tick, would be infinitely many robust units away: every tick change would be a spike. The floor (0.1% here) says that nothing smaller than a plausible move is ever an anomaly.
Exercise 7.3 ★
A trade prints at 3 000 000 when the previous trades were at 300 000 and the next ones are too. What kind of defect is it likely to be, and why does the forward median confirm it?
Solution
Solution of Exercise 7.3.
A decimal slip: a factor of exactly ten. The forward median is still 300 000, because the next trades are back at the level, so the print deviates from both medians; a genuine move to 3 000 000 would stay there and deviate only from the backward one.
Exercise 7.4 ★★
Why should a rule flag and not delete? Give one consumer that needs the flagged rows.
Solution
Solution of Exercise 7.4.
A flag can be wrong (the jump), a row that is wrong for one use is needed by another, and a deletion can be neither audited nor undone. Surveillance and trade-reconstruction systems need the erroneous and broken trades: they are part of what happened.
Exercise 7.5 ★★
With the defects kept, SIM2’s VWAP on seed 0 is 1.08% too high; after excluding error rows it is 0.02% too low. Where does each error come from?
Solution
Solution of Exercise 7.5.
Kept: the decimal slips that multiply a price by ten dominate the volume-weighted average (a few trades at ten times the price). With error rows excluded, the remaining comes from the trades lost in the gap of 200 events: the sample is complete except there, and no flag can restore missing data.
Exercise 7.6 ★★
The official close is corrected at 07:10 the next morning. Which of the day’s results must be rebuilt, which need not, and how does the lineage know?
Solution
Solution of Exercise 7.6.
The versions built from the old close: the marks, the P&L and the research features, which used the close. The minute bars, built from trades only, need not be rebuilt. The lineage knows because each version records the content hashes of its inputs: everything downstream of the old close’s version is stale.
Exercise 7.7 ★★★
Coding. Run score for seed 0 with the naive and the aware suite. How many false flags does each rule raise, and which genuine event causes each?
Solution
Solution of Exercise 7.7.
Naive: 24 false spike flags (the trades after SIM2’s 3% jump, until the backward median catches up) and one false staleness flag (SIM2’s 60-second halt). Aware: none; every planted defect is found by both.
Exercise 7.8 ★★★
Find the flaw. “Our data-quality job deletes any trade more than 5% from the previous trade and any gap in quotes longer than 30 seconds is filled by repeating the last quote. The cleaned data go straight to the tick store.”
Solution
Solution of Exercise 7.8.
It deletes genuine moves (a 5% jump is news on a volatile day, and every trade after it until the next trade) and invents quotes the market never showed (a halted instrument did not quote; repeating the last quote makes it look tradeable). The raw data are gone, so neither error can be found later. Flag instead, with rules that know the market’s state and confirm spikes both ways, and keep the data as delivered.
7.9 Problem: The Afternoon That Was Cancelled
Problem 7.1
Weekend problem — a day, its defects and its genuine surprises
The chapter’s simulated day (a halt and a 3% jump of SIM2), its six planted defects and the two rule suites.
Part I — The checks.
- Name the four families of checks and give a rule of each from the chapter’s suite.
- What is a data contract, and what does it add to the rules?
- How many events, quotes and trades does the day have?
- Which of the planted defects can no rule on the day’s own data detect, and what detects it?
- Why is the vendor’s close correction not a data-quality flag?
Part II — False flags.
- Why does the naive spike rule flag trades after the 3% jump, and how many on seed 0?
- How many false spike flags over twenty seeds, naive and aware?
- Why does the naive staleness rule flag the halt, and how does the aware rule avoid it?
- What would a pipeline that deleted flagged rows have done to the day of the jump?
- Why must a test of data-quality rules include genuine events, not only planted defects?
Part III — What quality is worth.
- How far are the two VWAPs from the truth with the defects kept, and with error rows excluded?
- By what factor is SIM2’s realised variance inflated with the defects kept?
- What does the cross-source check find over twenty seeds, and what does it not say?
- How does a quarantine differ from a flag, and who releases it?
- Which results does the corrected close make stale?
Part IV — The verdict.
- State the named result: the detection and false-flag counts of the two suites over twenty seeds, and the VWAP and variance errors with and without flags.
- Which single change to the naive suite removed the most false flags?
- What would the store have held for 6 May 2010 afternoon, with and without the broken-trade rule?
- What must the lineage record so that a correction names every stale result?
- In one sentence: what distinguishes a data-quality layer from a cleaning script?
Solution
Solution of Problem 7.1.
- Completeness (sequence gaps, stale quotes), validity (positive prices and sizes), consistency (crossed book, price spikes, the vendor’s trade count), timeliness (the contract’s delivery times; the staleness rule is its intraday cousin).
- The producer’s and consumers’ agreement on schema, rules and delivery times: a change becomes a negotiated change, not a surprise.
- 47 472 events, 9 258 quotes and 1 190 trades.
- The broken trades: they are valid prices on the day; only the venue’s list of cancellations, delivered later, identifies them.
- It is not a failure of the day’s data but a new version of a fact: it is stored as a version, and the lineage finds what to rebuild.
- The backward median lags the new level, so each new trade deviates by about 3% in robust units of a few basis points: 24 flags on seed 0.
- 495 naive, 0 aware.
- Sixty seconds without a quote exceeds 30 seconds; the aware rule does not count the halted time.
- Deleted two dozen genuine trades after the news, and understated the day’s move.
- A rule that finds every planted defect may still flag hundreds of genuine rows; only genuine look-alikes measure false flags.
- Kept: SIM1 , SIM2 ; excluded: within 0.03% (SIM2 ).
- About 24 000.
- 16 flagged minutes, those where the gap lost trades; it says where to look, not which trades are missing.
- A flag marks rows and leaves the partition readable; a quarantine withholds the partition until a person releases it, with a reason, or it is rebuilt.
- The marks, the P&L and the features.
- Named result. Over twenty seeds both suites find all 340 planted defects; the naive suite also flags 495 genuine trades as spikes and 20 halts as stale feeds, the aware one none. With the defects kept, the VWAPs are 2.69% and 1.08% too high and SIM2’s realised variance 24 000 times too large; with error rows excluded, the VWAPs are within 0.03% and the variance is exact.
- Confirming spikes against the forward median: 495 of the 515 false flags.
- Without the rule, 20 000 trades 60% away from their prices, for years; with it, the same trades flagged as broken, excluded from research and kept for surveillance.
- For each dataset version, the content hashes of the input versions it was built from (and the code), so that a new input version names everything downstream.
- A data-quality layer records what it found and lets people decide; a cleaning script changes the data and leaves no trace.
7.10 Interview questions
Interview question 7.1 ★ developer, researcher
What checks would you run on a day of tick data before researchers use it?
Solution
Solution of Interview question 7.1.
Sequence gaps, validity bounds, crossed or locked quotes, spikes confirmed both ways, stale instruments while the market is open, trades against the venue’s list of cancellations, counts and volumes against a second source and the official close; each with an owner, and results as flags.
What the interviewer is looking for: The four families, the market’s state, flags not deletions.
Interview question 7.2 ★★ developer, researcher
How do you tell a bad print from a genuine price move?
Solution
Solution of Interview question 7.2.
A bad print usually comes back: compare it with the prices before and after, in robust units with a floor; a genuine move persists. Check also against quotes, other venues and the news, and against the venue’s cancellations published later.
What the interviewer is looking for: Look-ahead in a batch check; reversal as the signature.
Interview question 7.3 ★★ developer
Why flag bad data rather than delete it?
Solution
Solution of Interview question 7.3.
The rule may be wrong, other consumers need the rows, and deletion cannot be audited or reversed. Flags let each consumer choose and let the rules improve.
What the interviewer is looking for: Reversibility and different consumers.
Interview question 7.4 ★★ developer, risk
A vendor corrects yesterday’s closing price. What has to happen in the firm’s systems?
Solution
Solution of Interview question 7.4.
Store the correction as a new version of the close, then use the lineage to find every result built from the old version (marks, P&L, risk, features) and rebuild them, with the P&L change reported as a correction, not silently replaced.
What the interviewer is looking for: Versions and lineage; the change reported.
Interview question 7.5 ★★★ developer
How would you test a data-quality system, and how would you know it is not flagging too much?
Solution
Solution of Interview question 7.5.
Plant known defects and, just as important, genuine look-alike events (halts, jumps, auctions, early closes), and measure detection and false flags per rule; track flag rates over time on real data and review a sample of flags each week.
What the interviewer is looking for: Confounders and false-flag rates, not only detection.
Interview question 7.6 ★★★ developer
Design lineage for a research platform so that any published result can be traced to its inputs and recomputed after a correction.
Solution
Solution of Interview question 7.6.
Name every dataset version by content hash; record for each its input versions, code version, parameters and run; store results with the manifest; answer “what is this built from” and “what depends on this” by graph queries; recompute from the manifest after a correction.
What the interviewer is looking for: Content-addressed versions and a queryable graph.