When to Use
Use this skill when an instrument-master record is refreshed from a vendor, an exchange directory file, or a golden-source reconciliation, and something downstream — a strategy keyed on a ticker, an OMS that rounds to a lot size, a risk engine keyed on a currency — will act on the new record without being told it changed.
The engine takes the before and after record for one instrument, reports every field that moved, grades each move by what it actually breaks, and hands the resulting notifications to registered consumers filtered by severity.
The grading is the point. Not every changed field is the same kind of accident:
CRITICAL— identity and routing.symbol,exchange,mic,status,currency,isin,cusip,sedol,figi. A stale value here sends an order to the wrong instrument or the wrong venue, or to a symbol that now belongs to somebody else.WARNING— order construction.lot_size,tick_size,contract_multiplier,min_order_qty,price_precision,expiry,strike,settlement_date. A stale value here sends a malformed or mis-sized order to the right instrument: rejection, odd-lot, or an unintended notional.INFO— everything else, e.g. a changed long name or sector label.
A field disappearing is floored at WARNING whatever the field is, because a vendor silently dropping a column is a data-quality incident in its own right.
When NOT to Use
- As a corporate-action calendar or effective-date scheduler. This diffs two states that already exist; it has no notion of an effective date. Reference-data changes routinely carry one — ISO 10383 MIC modifications are published on the second Monday of the month and become effective on the fourth. If your loader writes an announced-but-not-yet-effective value into the snapshot, this correctly reports a change that must not be acted on yet. Sequence effective dates upstream with
corporate-action-event-calendar-integration. - As the reconciler between disagreeing vendors. This compares one source against itself over time. Two vendors disagreeing at the same instant is a golden-source problem — see
reference-data-golden-source-designationandmulti-source-price-reconciliation-tie-breaking. - As symbol translation. Detecting that
symbolmovedFB → METAis not the same as maintaining a vendor↔canonical mapping — seereference-data-symbol-mapping-across-vendorsandisin-cusip-sedol-cross-reference-service. - For universe-level membership changes (instruments appearing in or leaving the tradable set). This is per-instrument, field-level; see
instrument-universe-change-detection-and-alerting. - As the transport.
route_notificationsattempts each delivery exactly once and returns the failures. Retry, backoff, ordering guarantees and dead-lettering belong to the message bus. - On a partial/delta payload without setting
treat_missing_as_removal=False. In the default full-snapshot mode, every field absent fromafteris reported as a removal — feed a delta payload and you get a mass-removal alert storm.
Prerequisites
- A stable
instrument_idthat is not the ticker. The ticker is one of the fields whose change this engine exists to detect, so keying the master on it defeats the purpose. Prefer a persistent identifier: a FIGI never changes and is never reused; a US CUSIP survives a pure ticker rename (Meta's Class A CUSIP was explicitly unchanged whenFBbecameMETAon 2022-06-09). - Before/after snapshots for the same instrument as
Mapping[str, Any], both produced by the same source and the same schema version. - Canonicalized value types. Comparison is plain
==:"100"and100are reported as a change (a real schema change, not something to cast away silently), while100and100.0are not. Normalize types, case and padding in the loader — many vendors pad fixed-width fields, and"AAPL "vs"AAPL"is otherwise aCRITICALalert every cycle. - Python 3.10+. Standard library only.
Workflow
-
Validate the pair before diffing.
detect_changesraisesSnapshotErroron a blankinstrument_id, a non-mapping snapshot, or a non-string field name — including when the engine is disabled, so a misconfigured caller cannot be masked by a disabled engine.- Decision point — is
aftera full snapshot or a delta? Full is the default. A delta payload requirestreat_missing_as_removal=False, which also means removals can never be detected on that path; detect them by periodically diffing full snapshots instead.
- Decision point — is
-
Diff field by field, tracking presence separately from value. Absent and
Noneare different facts: absent means the vendor stopped publishing the field,Nonemeans the vendor published "unknown". Every notification carriesold_present/new_presentalongside the values, andchange_typeisADDED,MODIFIEDorREMOVED.- Decision point — a value that cannot be compared is treated as changed, never as stable. If
__eq__raises, the engine reports a change rather than assuming equality.
- Decision point — a value that cannot be compared is treated as changed, never as stable. If
-
Classify severity.
CRITICAL/WARNING/INFOper the field sets above, matched case-insensitively so a vendor publishingSymbolis not silently downgraded toINFO. Removals are then floored atconfig.removal_min_severity(defaultWARNING). Additions are not escalated — new data arriving is not the same risk as existing data vanishing.- Decision point — an unrecognized field defaults to
INFO. That default is fail-quiet. Any field your OMS or risk engine actually reads must be named incritical_fieldsorwarning_fields; the defaults are a starting point, not a survey of your schema.
- Decision point — an unrecognized field defaults to
-
Route notifications by severity, isolating failures. Register consumers with a
min_severity(risk engine atCRITICAL, data-quality dashboard atINFO). Each(consumer, notification)pair is attempted once, in registration order.- Decision point — a failing sink does not abort the dispatch. A risk engine that is down must not stop the OMS from hearing that a symbol was renamed, so a raising callback is recorded in
NotificationDispatchResult.failuresand dispatch continues. A non-emptyfailureslist means a downstream system did not learn about a change it subscribes to — that is an incident. Checkall_delivered; do not fire-and-forget.
- Decision point — a failing sink does not abort the dispatch. A risk engine that is down must not stop the OMS from hearing that a symbol was renamed, so a raising callback is recorded in
-
Audit.
ReferenceDataChangeReportcarries per-severity counts,max_severity, and the caller-suppliedas_of. Each notification exposes a deterministicchange_key(instrument, field, change type, rendered old/new — deliberately excludingas_of) so a re-run, replay or failover produces identical keys and consumers can de-duplicate without the engine holding state.
Full procedure: see
references/workflows.md. Standards reference: seereferences/standards.md. Printable pre-flight checklist: seeassets/checklist.md.
Common Pitfalls
- Keying the instrument master on the ticker. The one field guaranteed to change is the one used as the primary key, so a rename looks like "old instrument disappeared, new instrument appeared" and every open position, order and historical series keyed on
FBis orphaned. Key on a persistent identifier and letsymbolbe an attribute that changes. - Assuming a ticker change implies an identifier change. It does not. Meta's CUSIP was unchanged across
FB → META, and since a US ISIN is the country code plus the CUSIP plus a check digit, the ISIN was unchanged too. Logic that reacts to a rename by re-resolving every identifier will churn through work that no corporate action justifies — and logic that only watches ISIN will miss the rename entirely. - Reading a snapshot with
dict.get(). It collapses "field absent" and "field present with valueNone" into the sameNone, so a vendor dropping a column reads as no change at all. Test presence within. - Treating everything outside the critical list as harmless. A
lot_sizethat moved 100 → 200 does not misroute an order, it gets the order rejected — for every instrument, for the rest of the session. That belongs in a graded severity bucket, not in the same one as a changed sector label. - Acting on a change before its effective date. Detection is not activation. A MIC modification published on the second Monday is not effective until the fourth; a ticker change announced on 31 May was not effective until 9 June. Applying it early routes orders to a venue code the exchange has not switched on yet.
- Symbol reuse. Exchanges recycle freed tickers. A backtest or reconciliation that joins on ticker without a date qualifier will silently splice two different companies into one series; join on a persistent identifier, or on (ticker, date).
- Comparison noise from uncanonicalized values. Trailing whitespace from a fixed-width feed, a case flip, a vendor switching a numeric field from string to int, or a
NaNpublished for a missing numeric (NaNnever equals itself, so it re-alerts every cycle forever) produces aCRITICALalert on every instrument at once. When a full-universe alert storm fires, suspect the loader before the exchange. - Fire-and-forget dispatch. Ignoring
NotificationDispatchResult.failuresreproduces exactly the failure this pipeline exists to prevent: the change was detected, and the system that needed it never heard. - Running with
enabled=Falseand readingNO_CHANGES. A disabled engine reportsENGINE_DISABLED, notNO_CHANGES. Never collapse the two — "we did not look" is not "there was nothing to find".
Verification
- Instantiate
ReferenceDataChangeNotificationPipelineEngine(). Diff{"symbol": "FB", "lot_size": 100, "cusip": "30303M102"}against{"symbol": "META", "lot_size": 100, "cusip": "30303M102"}$\implies$ exactly one notification,field_name == "symbol",severity == "CRITICAL",max_severity == "CRITICAL", and no notification forcusip. - Diff a
lot_size100 → 200 change $\implies$warning_changes == 1,info_changes == 0(regression: this used to classify asINFO). - Presence checks:
{"isin": None}→{}must yield oneREMOVEDwithold_present=True, new_present=False;{"isin": "US0378331005"}→{"isin": None}must yieldMODIFIEDwithnew_present=True;{"isin": None}→{"isin": None}must yieldNO_CHANGES. - Severity checks:
{"Symbol": "FB"}→{"Symbol": "META"}must beCRITICAL(case-insensitive matching); removing an unrecognized field must be floored atWARNING; adding one must stayINFO. - Routing checks: with a
CRITICAL-only consumer and anINFOconsumer over a 3-change report, verifydelivered == 4andskipped_below_threshold == 2; with one consumer whose callback raises, verify the other consumer still received its notification,all_delivered is False, andfailed_consumers == ["<broken>"]. - Negative checks: a blank
instrument_id, a non-mapping snapshot, a non-string field name, overlappingcritical_fields/warning_fields, a bare-string field set, a duplicate consumer name, and an invalidmin_severitymust each raise. - Run
python -m unittest discover -s skills/reference-data-change-notification-pipeline/scriptsand confirm a 100% pass rate.