Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Fintech Ticker-Change Chain Resolution — Corporate Actions Algorithm

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: queryAt for what was trading and asOf for what had been published.

Python TypeScript License Tests

📖 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 DataIdentity 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

Table of contents


The same four letters, two companies

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 dayNXT in New York and ALP.L in London — without anything having changed about the company.


Two clocks

  • 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.


Unresolvable is an error here, not a state

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.


Two ways to use this

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

Install

Python

cd python
pip install -e ".[dev]"

TypeScript

cd typescript
npm install
npm run build

Quickstart

Python

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-001

TypeScript

import { calculate } from "fintech-ticker-chain";

const result = calculate({ issuers: [...], listings: [...], query: { ... } });

Worked example (exact)

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 ALPNXT
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.


Views: the analysis surface

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.

join_audit — the one that costs money

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.

symbol_reuse_scan — which symbols have had more than one owner

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_timeline — the join keys, in order

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.

verify_master — faults that no single query can show you

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 (effectiveAt before announcedAt) — 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 (availableAt after effectiveAt) — 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.


Input shape

Entitiesissuers, 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

querydirection (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.


API reference

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

Edge cases & limitations

  • 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 NXT succeeded ALP from the symbols; succession comes from the listing, which is the only thing that carries identity.
  • The calendar covers years 0000–9999, proleptic Gregorian throughout.

Testing

cd python && pytest -q          # 139 tests
cd typescript && npm test       # 138 tests

Both 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.


Related algorithms

Same family — D02-F03 Identity Continuity

Related — D02-F01 Adjustment Factors

Related — D01-F04 Data Quality

🧭 Browse all algorithms →


License

MIT — see LICENSE.