A canonical, well-specified, cross-language (Python + TypeScript) reference implementation of point-in-time ticker-chain resolution. A ticker is a label a venue rents out. The same four letters can name one company today, a different company in three years, and nothing at all in between — so this module never treats symbol text as an entity key. It resolves through a normalized master (issuer → instrument → listing → assignment), where a symbol is only ever an attribute of a listing over an interval, under two independent clocks:
queryAtfor what was trading andasOffor what had been published.
📖 Full article (canonical): Ticker-Change Chain Resolution — The Fintech Builder
This repository is the runnable, production-oriented companion to that article. The article teaches the concept; this repo is the code you install and build on.
🧭 Browse all algorithms: Awesome FinTech Algorithms — the full index of the library.
🗂️ This algorithm's domain: Corporate Actions and Security Master Data › Identity Continuity
📥 Just want to call it? It also ships in the fintech-algorithms npm package — see Two ways to use this.
| Catalog topic | D02-F03-A02 |
| Domain | D02 — Corporate Actions and Security Master Data |
| Family | D02-F03 — Identity Continuity |
| Difficulty | 4 / 5 |
| Languages | Python, TypeScript |
- The same four letters, two companies
- Two clocks
- Unresolvable is an error here, not a state
- Two ways to use this
- Install
- Quickstart
- Worked example (exact)
- Views: the analysis surface
- Input shape
- API reference
- Edge cases & limitations
- Testing
- Related algorithms
- License
ALP on XNAS, asked at three different business instants:
2021-06-01T14:30:00Z SYN-ISS-001 SYN-LST-001
2025-06-01T00:00:00Z (nobody) the symbol was dormant
2026-06-01T14:30:00Z SYN-ISS-002 SYN-LST-099
Two unrelated issuers and a hole. A five-year price history keyed on ALP spans all three,
and not one row in it looks wrong. That is what makes symbol reuse expensive: it is not a
data-quality problem you can spot by inspection, it is a join that silently produces a
plausible answer.
So identity here is a chain of three hops — ticker → listing → instrument → issuer — and every
hop is a place the naive answer goes astray. The venue is part of the key, always: XNAS and
XLON rent out their letters independently, and ALP in London has nothing to do with ALP in
New York.
The reverse holds too. One instrument can wear two symbols on the same day — NXT in New
York and ALP.L in London — without anything having changed about the company.
queryAt— the effective instant. Which symbol the listing was actually trading under.asOf— the knowledge instant. Which revisions had been published by the time you asked.
They move independently, and the fixture is built to prove it. Revision 2 of SYN-ASG-200
extends a validity window, but is not published until December:
asked on 2024-08-01T12:00:00Z -> nothing known covers that instant
asked on 2024-12-20T16:11:00Z -> NXT (assignment SYN-ASG-200 r2)
Same question — what was this listing trading as on 12 January 2025? Nothing happened in the market between those two answers. A record was corrected, and an instant that had no symbol acquired one retrospectively.
Effective intervals are half-open: effectiveAt <= queryAt < validTo. validTo is the
first instant a symbol no longer applies, not the last instant it does.
Its sibling in this family,
Permanent Security Identifier Mapping,
returns ambiguous as a business state, because a crosswalk is expected to carry unresolved
overlap between sources.
This module raises ResolutionError instead, and the difference is deliberate. A venue does
not knowingly run two live symbol assignments on one listing. If the evidence says otherwise
the master is broken, and returning something would fabricate an answer. ResolutionError
subclasses ValueError (Python) / RangeError (TypeScript), so it is separable from ordinary
validation failures: one means your request was malformed, the other means the master has a
hole in it.
Three things raise it — no assignment covers queryAt, two do, or a listing's chain
overlaps — and they carry different messages because they need different fixes.
This repo is the production home: the full implementation, the analysis surface below, and 277 tests across two languages.
The fintech-algorithms npm package
ships the same topic as one import among several hundred.
fintech-algorithms/corporate-actions-and-security-master-data/identity-continuity/ticker-change-chain-resolution
Python
cd python
pip install -e ".[dev]"TypeScript
cd typescript
npm install
npm run buildPython
from fintech_ticker_chain import calculate
result = calculate({
"issuers": [{"issuerId": "SYN-ISS-001"}],
"instruments": [{"instrumentId": "SYN-INS-001", "issuerId": "SYN-ISS-001",
"shareClassId": "SYN-CLS-A"}],
"listings": [{"listingId": "SYN-LST-001", "instrumentId": "SYN-INS-001",
"venueMic": "XNAS", "currency": "USD"}],
"assignments": [{
"assignmentId": "SYN-ASG-200",
"revision": 1,
"status": "active",
"listingId": "SYN-LST-001",
"symbol": "NXT",
"effectiveAt": "2024-06-10T13:30:00Z",
"validTo": "2025-01-10T14:30:00Z",
"announcedAt": "2024-05-15T12:00:00Z",
"availableAt": "2024-05-15T12:07:00Z",
"changeKind": "administrative",
"sourceId": "SYN-SRC-XNAS-200-R1",
}],
"query": {
"direction": "listing_to_symbol",
"listingId": "SYN-LST-001",
"queryAt": "2024-07-01T15:00:00Z", # what was trading
"asOf": "2024-08-01T12:00:00Z", # what had been published
},
})
print(result["symbol"]) # NXT
print(result["issuerId"]) # SYN-ISS-001TypeScript
import { calculate } from "fintech-ticker-chain";
const result = calculate({ issuers: [...], listings: [...], query: { ... } });One Nasdaq line through three symbols, one London line sharing its letters, and a symbol reissued to another company in 2026. Asserted verbatim by both test suites.
| Assignment | Symbol | Effective | Available | Kind |
|---|---|---|---|---|
SYN-ASG-100 |
ALP |
2 Jan 2020 → 10 Jun 2024 | 18 Dec 2019 | initial listing |
SYN-ASG-200 r1 |
NXT |
10 Jun 2024 → 10 Jan 2025 | 15 May 2024 | administrative |
SYN-ASG-200 r2 |
NXT |
10 Jun 2024 → 15 Jan 2025 | 20 Dec 2024 | administrative |
SYN-ASG-300 |
NVA |
15 Jan 2025 → open | 20 Dec 2024 | corporate action |
SYN-ASG-900 |
ALP |
2 Mar 2026 → open | 12 Feb 2026 | initial listing (other issuer) |
Asked at queryAt = 2024-07-01T15:00:00Z, asOf = 2024-08-01T12:00:00Z:
| Field | Value |
|---|---|
| Symbol | NXT |
| Listing / instrument / issuer | SYN-LST-001 / SYN-INS-001 / SYN-ISS-001 |
| Assignment | SYN-ASG-200 revision 1 |
| Valid to | 2025-01-10T14:30:00Z |
| Chain | ALP → NXT |
| Gaps | 0 |
Note the two-minute detail in the timestamps: the correction (r2, 16:10) was published
before the successor assignment (SYN-ASG-300, 16:12). The venue fixed the record before the
new symbol went live, so no consumer ever saw a gap — which is what a well-run master
looks like, and is easy to miss unless you go looking.
This is the surface that does not fit in a tutorial. Each of these asks a question that only exists once you look at the whole master rather than one row.
You have historical rows keyed on (symbol, venue). You join them to a security master using
today's symbol table. Nothing errors.
row correct naive join verdict
ALP@XNAS 2021-06-01 SYN-ISS-001 SYN-ISS-002 WRONG ISSUER
ALP@XNAS 2026-06-01 SYN-ISS-002 SYN-ISS-002 ok
NXT@XNAS 2024-07-01 SYN-ISS-001 (none) silently dropped
ALP@XNAS 2025-06-01 (none) SYN-ISS-002 silently invented
Three distinct failures, deliberately counted separately because they are three different repairs:
misattributedCount— both sides resolve, different issuer. Silently wrong data, and the only one no downstream check will notice. Issuer rather than listing, because a listing change within one issuer is usually survivable and a change of issuer never is.naiveDroppedCount— the row has a real owner and the naive join finds none. Silently lost data: an inner join quietly shrinks and the row count still looks plausible.naiveInventedCount— nothing owned that symbol then, and the naive join attaches an owner anyway. Silently invented data.
Point join_at at the right vintage and the same file comes back clean — it is the vintage
that bites, not the file.
Reuse is normal: a venue reclaims a symbol once a line stops using it. What makes it
dangerous is that it is invisible to anything keyed on symbol text. crossesIssuers is the
flag that matters — a symbol moving between listings of one issuer is housekeeping; moving
between issuers changes what the data means.
Simultaneous holders are a separate finding and the only one that fails ok: two live
assignments of one symbol at one venue at one instant is not reuse, it is a broken master.
symbol from to why
ALP 2020-01-02T14:30:00Z 2024-06-10T13:30:00Z initial_listing
NXT 2024-06-10T13:30:00Z 2025-01-15T14:30:00Z administrative
NVA 2025-01-15T14:30:00Z (open) corporate_action
One listing, three symbols, one continuous identity. Unlike the chain inside a resolution,
this needs no successful resolution: a listing with a hole in its history, or no symbol at all
at the instant you care about, still has a timeline — and that is exactly when you need one.
It also renders a broken listing rather than refusing it, reporting overlaps as data,
because a timeline that will not draw a broken history is no help in repairing one.
Two of these are not validation errors at all. The records are perfectly well-formed, which is what makes them worth their own surface:
retroactiveChanges(effectiveAtbeforeannouncedAt) — the symbol changed before anyone was told. Everything published in that window was correct when published and is wrong now, and no amount of care at read time repairs it.latePublications(availableAtaftereffectiveAt) — the change was in force before the record describing it could be read. Anything resolved in that window got the previous symbol, correctly and uselessly. The fixture has one: six months of it.
Overlaps fail the audit because resolution genuinely cannot proceed through them. Gaps do not: a suspended line has no symbol, and that is a fact rather than a fault.
Entities — issuers, instruments, listings. Every reference is checked to exist, MICs
must be four characters, currencies three letters, and ids unique.
assignments[]
| Field | Meaning |
|---|---|
assignmentId, revision |
The revision chain; contiguous from 1 |
listingId, symbol |
What is being named, and by what |
effectiveAt, validTo |
Half-open; validTo: null means open-ended |
announcedAt, availableAt |
When it was said, and when it could be read. availableAt >= announcedAt |
status |
active or cancelled |
changeKind |
initial_listing | administrative | corporate_action |
sourceId |
Required. An assignment without provenance is a rumour |
query — direction (listing_to_symbol or symbol_to_listing), the listingId or the
symbol + venueMic, plus queryAt and asOf.
Three rules govern a revision chain, each because breaking it makes point-in-time selection undefined rather than merely wrong: revisions run contiguously from 1; the identity fields (listing, symbol, effective instant) never change across revisions; and availability strictly increases with the revision number. A cancelled latest revision hides the whole assignment rather than falling back to the previous one — the venue withdrew it, and a superseded record is not a replacement.
Instants are RFC 3339 UTC to the second, ending in Z. A malformed spelling is a
TypeError; an impossible date is a ValueError / RangeError. Strings are never trimmed
for you: a symbol arriving as "ALP " is refused rather than repaired, because repairing it
hides a feed about to emit two keys for one symbol.
| Python | TypeScript | Purpose |
|---|---|---|
calculate(data) |
calculate(...) |
Resolve one symbol assignment at both clocks |
resolve_ticker_chain(data) |
resolveTickerChain(...) |
The same function, named for what it does |
join_audit(data, rows, as_of, join_at) |
joinAudit(...) |
Score a symbol-keyed file against a present-day table |
symbol_reuse_scan(data, as_of) |
symbolReuseScan(...) |
Which symbols have had more than one owner |
symbol_timeline(data, listing_id, as_of) |
symbolTimeline(...) |
A listing's symbols as join keys |
verify_master(data, as_of) |
verifyMaster(...) |
Structural audit of the whole master |
prepare(data) |
prepare(...) |
Validate without resolving |
visible_assignments(assignments, as_of) |
visibleAssignments(...) |
Latest revision published by an instant |
build_chain(records, listing_id) |
buildChain(...) |
One listing's chain, gaps and reuse |
normalize_entities(data) |
normalizeEntities(...) |
Issuer → instrument → listing, references checked |
parse_instant(text, name) / format_instant(seconds) |
parseInstant(...) / formatInstant(...) |
Strict RFC 3339 UTC ↔ epoch seconds |
- A symbol is never an identity, and the API is built so you cannot accidentally use it as one: every answer carries the listing, instrument and issuer.
- A venue is always required for a symbol lookup. Exchanges rent out the same letters independently.
- Unresolvable raises. Three distinct causes, three distinct messages.
- Gaps are data, overlaps are faults. A suspended line legitimately has no symbol.
- Cancellation hides the assignment, it does not fall back to the previous revision.
- Time is UTC seconds only. No offsets, no sub-second precision, no local calendars.
- Nothing is inferred. This module does not guess that
NXTsucceededALPfrom the symbols; succession comes from the listing, which is the only thing that carries identity. - The calendar covers years 0000–9999, proleptic Gregorian throughout.
cd python && pytest -q # 139 tests
cd typescript && npm test # 138 testsBoth suites read the same fixtures.json and assert its exact expected output in each
language. The two implementations were also compared directly across 910 scenarios — every
resolution in both directions, plus every timeline, reuse scan, join audit and master report,
over a fourteen-instant grid and against a deliberately broken master — and their JSON output
is byte-identical, nested key order included. The Python core was separately differentially
tested against the reference implementation on 12,000 randomised inputs with zero divergences,
including which inputs raise and with what exception type.
The suites pin the behaviours most likely to drift: half-open intervals at both ends, a
revision invisible one second early, a correction that turns an unanswerable question into an
answer, a symbol reissued to another issuer, a chain overlap that starts after the queried
instant (so the resolution succeeds and the chain is what catches it), and — as positive
controls — an overlap and a lone collision each failing verify_master.
The instant parser is pinned across the whole range it admits, including before the epoch. The textbook form of this calendar algorithm branches on sign because C's integer division truncates; transliterated into a language whose division floors, that branch silently shifts dates by a day for any year before 1970. Both ports use plain floor division and both test it.
Same family — D02-F03 Identity Continuity
- Permanent Security Identifier Mapping — the single-hop crosswalk this repo's chain sits on top of, and where ambiguity is a state rather than an error.
Related — D02-F01 Adjustment Factors
- CRSP Cumulative Price Adjustment — what you can only compute once identity is settled across the whole history.
Related — D01-F04 Data Quality
- Point-in-Time Availability Guard — the same no-look-ahead discipline applied to observations rather than identity.
MIT — see LICENSE.