The governed definitions this build depends on. Read them first if a term is unfamiliar.
- PrimarySymbol ChangeA symbol change replaces a listing's trading symbol from an effective boundary while preserving the relevant identity history.
- PrimaryTicker SymbolA ticker symbol is a short, human-readable code used to refer to a security or listing within a particular market context.
- PrerequisiteListingA listing is the effective-dated relationship that makes a security available under specified terms on a trading venue.
- ImportantAvailability TimeAvailability time is the earliest evidenced instant at which information was usable by a specified system or decision process.
- ImportantData LineageData lineage records where data came from, how it was transformed, and which entities, activities, and agents affected it.
- ImportantEffective TimeEffective time is the instant or interval boundary from which a rule, status, or fact applies in its modeled business domain.
- ImportantExchangeAn exchange is a governed trading venue that operates markets under its own rules and applicable regulation.
- ImportantPublication TimePublication time is when a source officially released or disseminated information.
- ImportantSecurity MasterA security master is a governed reference-data system that organizes instruments, issuers, identifiers, listings, currencies, statuses, and effective dates.
- ImportantSeries IdentitySeries identity is the complete key and semantic contract that states what a sequence of observations represents.
- ImportantStable Security IdentifierA stable security identifier is a governed key intended to preserve instrument identity across changes in mutable labels such as ticker or name.
- ImportantTrading VenueA trading venue is an organized market, platform, or facility on which financial instruments can be quoted, traded, or reported.
Suppose an analyst asks, “What is this company’s old ticker?” The question sounds simple. It is usually underspecified.
Do they mean the same issuer, the same class relationship, the same financial instrument, the same listing, or simply the same symbol text? Which venue? At what effective time? Using what information cutoff? Was the reported change later corrected or cancelled? Descriptive issuer and class names require their own bitemporal records; this resolver carries only opaque issuerId and shareClassId keys.
Ticker-Change Chain Resolution turns that conversational question into two auditable operations:
- given a listing, find its symbol at a stated time;
- given a symbol and venue, find the listing it represented at a stated time.
Both operations also require an asOf cutoff. That second clock prevents a correction published in December from rewriting an answer that should reflect what the system knew in August.
The central idea: a ticker is an assignment, not an identity
A ticker is a short quote symbol used in a market context. It is valuable for display and routing, but it is not a permanent key for the economic object.
The useful hierarchy is:
Issuer
└── Instrument or security
└── Listing or trading line
├── Venue MIC
├── Trading currency
└── Time-valid symbol assignments
An issuer can have several instruments. An instrument can have several venue-specific listings. A listing can use several symbols through time. The same symbol can later belong to an unrelated listing.
This separation is not merely a database preference. The ANNA ISIN Uniform Guidelines, version 24 explain that an International Securities Identification Number (ISIN) identifies a financial instrument rather than the market where it trades, subject to documented exceptions. The ISO 10383 Registration Authority maintains Market Identifier Codes (MICs) for the venue layer. These are different identification problems.
Five objects that should not be collapsed
| Object | Example package field | What it means |
|---|---|---|
| Issuer | issuerId | The organization that issued the instrument. |
| Instrument | instrumentId, shareClassId | The financial instrument and an opaque class relationship key. Descriptive class names require their own time-valid history. |
| Listing or trading line | listingId | A specific venue-facing admission or line. |
| Venue | venueMic | The market context for the listing and symbol. |
| Symbol assignment | assignmentId, revision | A symbol valid for one listing during one interval, according to one visible source revision. |
Matching symbol text can propose a search. It cannot prove that two observations concern the same instrument. Even a symbol change labelled “corporate action” does not prove continuity. A merger or conversion may create a successor instrument, which belongs in an explicit predecessor-successor map.
Two clocks: effective time and knowledge time
Every query carries two instants:
queryAtasks when the symbol assignment should be effective;asOfasks which source revisions were available to the resolver.
The distinction is visible in operational data. The FINRA OTC Equity Daily List User Guide defines a Daily List publication date/time separately from the effective date/time of a symbol or name change. The Nasdaq Symbol Directory definitions describe a file-creation timestamp that can be used to evaluate directory timeliness. These are market-specific products, not a universal schema, but they show why publication and effect should not share one column.
For assignment (a), choose the greatest revision visible at cutoff :
If the latest visible revision is cancelled, the assignment is absent. If it is active, the assignment covers the half-open interval
The left boundary is included and the right boundary is excluded. Therefore an old assignment can end at exactly the instant the next assignment begins without creating an overlap.
The two resolution directions
Listing to symbol
Given listing , effective time , and knowledge cutoff , collect:
The answer is valid only when .
Symbol and venue to listing
Reverse lookup must include a venue. For symbol and MIC :
Again, exactly one candidate is required. Symbol text without a venue is deliberately not accepted.
A complete synthetic chain
All values below are invented. They demonstrate the resolver without suggesting that an anecdote from a real issuer proves a universal mapping rule.
SYN-LST-001 represents a synthetic XNAS listing in USD for SYN-INS-001, linked to opaque class key SYN-CLS-A and issuer key SYN-ISS-001.
| Assignment | Symbol | Effective from | Valid to, exclusive | Available at | Revision | Classification |
|---|---|---|---|---|---|---|
SYN-ASG-100 | ALP | 2020-01-02 14:30Z | 2024-06-10 13:30Z | 2019-12-18 15:05Z | 1 | Initial listing |
SYN-ASG-200 | NXT | 2024-06-10 13:30Z | 2025-01-10 14:30Z | 2024-05-15 12:07Z | 1 | Administrative |
SYN-ASG-200 | NXT | 2024-06-10 13:30Z | 2025-01-15 14:30Z | 2024-12-20 16:10Z | 2 | Administrative correction |
SYN-ASG-300 | NVA | 2025-01-15 14:30Z | Open | 2024-12-20 16:12Z | 1 | Corporate action |
Ask for the symbol at 2024-07-01T15:00:00Z with asOf = 2024-08-01T12:00:00Z. Only revision 1 of SYN-ASG-200 is visible. The unique answer is NXT.
Now keep the effective time but advance asOf beyond 2024-12-20. Revision 2 becomes visible. The selected symbol remains NXT, but its corrected exclusive end changes to 2025-01-15. That correction must not leak into the August answer.
The result is more than a ticker:
{
"symbol": "NXT",
"listingId": "SYN-LST-001",
"instrumentId": "SYN-INS-001",
"issuerId": "SYN-ISS-001",
"shareClassId": "SYN-CLS-A",
"venueMic": "XNAS",
"currency": "USD",
"assignmentId": "SYN-ASG-200",
"revision": 1,
"sourceId": "SYN-SRC-XNAS-200-R1"
}
The source, revision, and two clocks are part of the answer, not optional debug metadata. Current issuer or class names are not returned because the package does not carry a bitemporal descriptive-name table.
Why a ticker chain is not a graph of ticker strings
It is tempting to store edges such as ALP → NXT and NXT → NVA, then follow the arrows. That structure fails when:
ALPis assigned to an unrelated listing in a later period;- another venue uses
NXTat the same time; - the original listing later returns to
ALP; - a correction changes the end of one interval;
- a planned symbol is cancelled;
- a corporate action changes the instrument as well as the label.
A later return to ALP is not necessarily a cycle. Time has advanced, and the stable listing ID still anchors the history. The package reports the readopted text as a diagnostic. A naive undated string graph would see ALP → NXT → ALP and may loop or deduplicate away valid history.
Revision history is different. Revisions for one assignment must be contiguous from 1, become available in strictly increasing order, and retain the same listing, symbol, and effective start. That monotone lineage prevents a correction from being redirected to another identity.
Reuse and simultaneous venues
The synthetic fixture contains two instructive cases.
First, the same instrument has a second listing on XLON, in GBP, with symbol ALP.L. The XNAS and XLON rows coexist. The resolver does not merge them because their listing and venue contexts differ.
Second, ALP on XNAS is assigned to an unrelated instrument beginning in 2026, long after the first XNAS ALP interval ended. A 2021 reverse query finds SYN-INS-001; a 2026 query finds SYN-INS-002. The text matches. The identity does not.
The U.S. Securities and Exchange Commission's 2008 description of the NMS symbol-selection and reservation plan is useful historical context: symbol allocation is governed infrastructure and scarce short symbols can move through reservation processes. It is not evidence that a symbol is globally unique or permanent.
Gaps, overlaps, and ambiguity
The resolver makes uncertainty visible:
- Clean handoff: old
validToequals neweffectiveAt; resolution succeeds. - Gap: no assignment covers
queryAt; resolution fails instead of carrying a symbol forward. - Listing overlap: two assignments for one listing cover the same instant; the chain is ambiguous and fails.
- Reverse overlap: the same symbol and MIC cover the same instant on two listings; reverse lookup fails.
- Different venue: the same text on a different MIC does not collide with the requested venue.
- Cancellation: the greatest visible revision is cancelled, so the planned assignment disappears.
One source may intentionally publish a gap while an instrument is suspended or a listing is transitioning. Another may later correct it. The resolver records the state; it does not decide what the market “must have meant.”
Implementation walkthrough
The Python and TypeScript implementations follow the same nine stages:
- Validate and index issuer records.
- Validate instruments and their issuer foreign keys.
- Validate listings, instrument foreign keys, MIC shape, and currency shape.
- Validate assignment IDs, revisions, source IDs, statuses, classifications, timestamps, and half-open intervals.
- Select the greatest revision whose
availableAtdoes not exceedasOf. - Remove assignments whose selected revision is cancelled.
- Filter by listing or by exact symbol plus venue at
queryAt. - Require one candidate and reject overlapping listing intervals.
- Return full lineage, the visible chain, gaps, and symbol-readoption diagnostics.
The code intentionally uses explicit loops and maps. A production database may use temporal tables or range joins, but optimization should not hide the two clocks or the cardinality check.
Explore the guided lab
Open the Ticker-Change Chain Resolution lab. Choose a scenario, then use Step to move through source ingestion, revision selection, effective-time filtering, cardinality checking, and final lineage output.
Compare these cases:
- canonical listing-to-symbol resolution;
- reverse symbol-plus-venue lookup;
- a later corrected revision;
- non-overlapping symbol reuse;
- a cancellation-created gap;
- an overlapping ambiguous chain.
Reset always restores the canonical scenario and first stage. The lab is synthetic, deterministic, keyboard operable, responsive, and usable without motion.
Testing the contract
The shared fixture drives both implementations. Tests verify:
- exact canonical output and reverse lookup;
- input immutability;
- older and newer knowledge cutoffs;
- cancellation of a planned assignment;
- symbol reuse on the same venue at different times;
- simultaneous same text on different venues;
- listing overlap and reverse-lookup ambiguity;
- same-listing symbol readoption without a false graph cycle;
- invalid calendar dates and noncanonical timestamps;
- missing entity foreign keys;
- duplicate or identity-changing revisions.
Passing tests show that the two implementations agree with this declared contract. They do not prove that the contract matches every venue or vendor. In particular, composite versus segment MICs, when-issued lines, depositary receipts, vendor revision semantics, market-session boundaries, and non-equity instruments require scoped policies.
Evidence boundary
This article does not include a named real-world ticker chain. A defensible public case would require authoritative evidence for the issuer, instrument and share class, listing/trading line, venue, old and new symbols, exact effective instant, publication or availability instant, revisions or cancellation, and data usage rights. The primary references used here establish identifier roles and event-field semantics, but they do not jointly prove one complete redistributable chain at that granularity.
The synthetic case is therefore a deliberate evidence choice, not a missing anecdote.
Summary
A trustworthy ticker chain is a history of assignments around a stable listing, not a sequence of ticker strings.
Keep issuer ID, opaque class ID, instrument, listing, venue, currency, source, revision, and timestamps separate. Resolve both effective time and knowledge time. Require exactly one match. Report gaps. Reject overlaps. Allow time-bounded reuse without inventing identity continuity.
Continue with Share-Class Relationship Mapping to model relationships between distinct instruments without collapsing them into one ticker history.
Asset map
| Asset | Learning role | Article placement | Video scene | Source | Static fallback |
|---|---|---|---|---|---|
| Identity hierarchy | Separate the five identity layers | Central idea | 2–3 | visuals/static/article-hero.svg | Text hierarchy and table |
| Two-clock example | Separate effect from knowledge | Synthetic chain | 4–5 | visuals/static/worked-example.svg | Worked-example table |
| Failure guard | Compare handoff, gap, and overlap | Ambiguity | 7 | visuals/static/failure-guard.svg | Rejection list |
| Calculation flow | Show algorithm sequence | Implementation | 5–6 | visuals/mermaid/calculation-flow.md | Nine-stage list |
| Revision lifecycle | Show announcement through correction/cancellation | Two clocks | 4 | visuals/mermaid/state-lifecycle.md | Formula and prose |
| Guided lab | Compare six scenarios | Exploration | 4–8 | visuals/animated/playground.html | Three static SVGs |
Primary-source roles and limitations are documented in REFERENCES.md.
Rendered from the canonical Mermaid sources linked by this article.
Ticker-chain point-in-time resolution flow
This diagram shows why entity lineage, revision visibility, and interval cardinality all precede the answer.
Takeaway: a ticker becomes usable only after venue, effective time, knowledge time, and stable identity lineage make it unambiguous.
Symbol-assignment revision lifecycle
This lifecycle separates source publication from assignment effect and shows how a later revision or cancellation changes only later knowledge states.
Takeaway: announcedAt, availableAt, effectiveAt, and validTo answer different questions; no one timestamp can replace the others.
References7 primary sources and evidence notesExpand the source trail, evidence role, and limitations behind the engineering choices.
Expand the source trail, evidence role, and limitations behind the engineering choices.
All sources were accessed on 2026-07-22. All package records are synthetic; no exchange, issuer, regulator, identifier-provider, or commercial-vendor dataset is redistributed.
R1 — ISO 10383 Market Identifier Codes
- Organization or authors: SWIFT as ISO 10383 Registration Authority, published through ISO 20022
- Source type: Official registration-authority page and downloadable MIC register
- Publication or effective date: Current page; register publication dated 2026-07-13 with modifications scheduled for 2026-07-27
- Version: ISO 10383 current register at access
- URL: https://www.iso20022.org/market-identifier-codes
- Jurisdiction: Global
- Supports: MIC as the code layer for exchanges, trading platforms, regulated or non-regulated markets, and reporting facilities; the need to preserve venue context
- Limitations: This package validates MIC shape only and does not embed or redistribute the register; production systems must choose operating versus segment MIC consistently and version the register
R2 — ANNA ISIN Uniform Guidelines
- Organization or authors: Association of National Numbering Agencies (ANNA), ISO 6166 Registration Authority
- Source type: Official identifier-allocation guidelines
- Publication or effective date: June 2025
- Version: Version 24
- URL: https://anna-web.org/wp-content/uploads/2025/06/ISIN-Guidelines-Version-24-Jun-2025.pdf
- Jurisdiction: Global
- Supports: The ISIN identifies a financial instrument rather than the market on which it trades, with documented exceptions; LEI and ISIN operate at entity and security layers respectively
- Limitations: Allocation rules and exceptions are broader than this ticker resolver; the package uses synthetic internal IDs and does not assign or validate ISINs
R3 — ISO 6166:2021
- Organization or authors: International Organization for Standardization
- Source type: International standard public abstract
- Publication or effective date: 2021-02
- Version: Edition 8
- URL: https://www.iso.org/standard/78502.html
- Jurisdiction: Global
- Supports: A uniform identifier structure for financial and referential instruments with associated minimum descriptive data
- Limitations: The complete standard is licensed; only the public scope statement is used here, and an ISIN alone does not encode this package's venue-specific symbol intervals
R4 — Financial Instrument Global Identifier Specification
- Organization or authors: Object Management Group
- Source type: Open normative specification and machine-readable ontology
- Publication or effective date: March 2026
- Version: FIGI 1.2
- URL: https://www.omg.org/spec/FIGI
- Jurisdiction: Global
- Supports: Formal instrument-identifier and pricing-source context; reinforces the need to keep identifier types and scopes explicit
- Limitations: The implementation does not assign, validate, or query FIGIs; a production crosswalk must follow the specification and provider terms
R5 — Nasdaq Symbol Directory Data Fields and Definitions
- Organization or authors: Nasdaq Trader
- Source type: Official exchange directory documentation
- Publication or effective date: Continuously maintained page
- Version: Current at access
- URL: https://www.nasdaqtrader.com/trader.aspx?id=symboldirdefs
- Jurisdiction: United States
- Supports: Venue/product-specific symbol and security fields, update cadence, and file-creation time as a timeliness indicator
- Limitations: Current directory data is not a complete historical symbol chain; usage and redistribution rights vary by dataset; the page does not prove ticker permanence or global uniqueness
R6 — OTC Equity Daily List User Guide
- Organization or authors: Financial Industry Regulatory Authority (FINRA)
- Source type: Official regulator/market-operations product guide
- Publication or effective date: Current file accessed 2026-07-22
- Version: File presented by FINRA as the OTCE Daily List User Guide
- URL: https://www.finra.org/sites/default/files/OTCE_Daily_List_User_Guide.pdf
- Jurisdiction: United States OTC equities
- Supports: Separate Daily List publication date/time and effective/ex-date; old and new symbols; current and previous values; additions, deletions, symbol/name changes, and security attributes
- Limitations: OTC-specific product semantics are not a universal exchange schema; publication time still requires a documented ingestion/availability policy
R7 — SEC approval of the NMS symbol-selection and reservation plan
- Organization or authors: U.S. Securities and Exchange Commission
- Source type: Official regulator press release describing an approved NMS plan
- Publication or effective date: 2008-11-06
- Version: Press release 2008-264
- URL: https://www.sec.gov/news/press/2008/2008-264.htm
- Jurisdiction: United States national market system
- Supports: Securities-symbol reservation and selection are governed market infrastructure; a centralized symbol database was part of the approved plan
- Limitations: Historical U.S. regulatory context only; it does not assert global uniqueness, permanence, or the present implementation details of every market participant
Evidence decision
No real issuer chain is published. A responsible real case would need authoritative evidence for the legal issuer, exact instrument and class, listing/trading line, venue, old and new symbols, effective instant, announcement/publication instant, revision and cancellation history, and redistribution permission. The sources above establish architecture and field semantics but do not jointly prove one complete named chain at that granularity.
Full dependency-light reference implementations in both supported languages.
/** Point-in-time ticker-chain resolution for a normalized security master. */
type RecordMap = Record<string, unknown>;
type Direction = "listing_to_symbol" | "symbol_to_listing";
type ParsedAssignment = RecordMap & {
assignmentId: string;
revision: number;
status: "active" | "cancelled";
changeKind: "initial_listing" | "administrative" | "corporate_action";
listingId: string;
symbol: string;
sourceId: string;
announcedAt: string;
availableAt: string;
effectiveAt: string;
validTo: string | null;
_announced: number;
_available: number;
_effective: number;
_validTo: number | null;
};
const ISO_INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
const MIC = /^[A-Z0-9]{4}$/;
const CURRENCY = /^[A-Z]{3}$/;
const CHANGE_KINDS = new Set(["initial_listing", "administrative", "corporate_action"]);
const STATUSES = new Set(["active", "cancelled"]);
const DIRECTIONS = new Set(["listing_to_symbol", "symbol_to_listing"]);
export class ResolutionError extends Error {
constructor(message: string) {
super(message);
this.name = "ResolutionError";
}
}
function objectValue(value: unknown, name: string): RecordMap {
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new TypeError(`${name} must be an object`);
return value as RecordMap;
}
function objects(value: unknown, name: string): RecordMap[] {
if (!Array.isArray(value)) throw new TypeError(`${name} must be a list`);
return value.map((item, index) => objectValue(item, `${name}[${index}]`));
}
function textValue(value: unknown, name: string): string {
if (typeof value !== "string" || value.length === 0 || value.trim() !== value) throw new TypeError(`${name} must be a non-empty, trimmed string`);
return value;
}
function instant(value: unknown, name: string): [string, number] {
const text = textValue(value, name);
if (!ISO_INSTANT.test(text)) throw new TypeError(`${name} must be an ISO 8601 UTC instant ending in Z`);
const parsed = Date.parse(text);
if (!Number.isFinite(parsed) || new Date(parsed).toISOString() !== text.replace("Z", ".000Z")) {
throw new RangeError(`${name} is not a valid calendar instant`);
}
return [text, parsed];
}
function unique(records: RecordMap[], key: string, name: string): Map<string, RecordMap> {
const indexed = new Map<string, RecordMap>();
records.forEach((record, index) => {
const identifier = textValue(record[key], `${name}[${index}].${key}`);
if (indexed.has(identifier)) throw new RangeError(`duplicate ${key}: ${identifier}`);
indexed.set(identifier, record);
});
return indexed;
}
function normalizeEntities(data: RecordMap): [Map<string, RecordMap>, Map<string, RecordMap>, Map<string, RecordMap>] {
const issuers = unique(objects(data.issuers, "issuers"), "issuerId", "issuers");
const instruments = unique(objects(data.instruments, "instruments"), "instrumentId", "instruments");
const listings = unique(objects(data.listings, "listings"), "listingId", "listings");
for (const [instrumentId, instrument] of instruments) {
const issuerId = textValue(instrument.issuerId, `instrument ${instrumentId}.issuerId`);
textValue(instrument.shareClassId, `instrument ${instrumentId}.shareClassId`);
if (!issuers.has(issuerId)) throw new RangeError(`instrument ${instrumentId} references unknown issuer ${issuerId}`);
}
for (const [listingId, listing] of listings) {
const instrumentId = textValue(listing.instrumentId, `listing ${listingId}.instrumentId`);
const venueMic = textValue(listing.venueMic, `listing ${listingId}.venueMic`);
const currency = textValue(listing.currency, `listing ${listingId}.currency`);
if (!instruments.has(instrumentId)) throw new RangeError(`listing ${listingId} references unknown instrument ${instrumentId}`);
if (!MIC.test(venueMic)) throw new RangeError(`listing ${listingId}.venueMic must be a four-character MIC`);
if (!CURRENCY.test(currency)) throw new RangeError(`listing ${listingId}.currency must be a three-letter code`);
}
return [issuers, instruments, listings];
}
function normalizeAssignments(data: RecordMap, listings: Map<string, RecordMap>): ParsedAssignment[] {
const versions = new Map<string, ParsedAssignment[]>();
const seenVersions = new Set<string>();
const normalized: ParsedAssignment[] = [];
objects(data.assignments, "assignments").forEach((raw, index) => {
const record = structuredClone(raw) as RecordMap;
const assignmentId = textValue(record.assignmentId, `assignments[${index}].assignmentId`);
const revision = record.revision;
if (!Number.isInteger(revision) || (revision as number) < 1) throw new TypeError(`assignments[${index}].revision must be a positive integer`);
const versionKey = `${assignmentId}\u0000${revision}`;
if (seenVersions.has(versionKey)) throw new RangeError(`duplicate assignment revision: ${assignmentId} r${revision}`);
seenVersions.add(versionKey);
const status = textValue(record.status, `assignments[${index}].status`);
const changeKind = textValue(record.changeKind, `assignments[${index}].changeKind`);
const listingId = textValue(record.listingId, `assignments[${index}].listingId`);
const symbol = textValue(record.symbol, `assignments[${index}].symbol`);
const sourceId = textValue(record.sourceId, `assignments[${index}].sourceId`);
if (!STATUSES.has(status)) throw new RangeError(`assignments[${index}].status is unsupported`);
if (!CHANGE_KINDS.has(changeKind)) throw new RangeError(`assignments[${index}].changeKind is unsupported`);
if (!listings.has(listingId)) throw new RangeError(`assignment ${assignmentId} references unknown listing ${listingId}`);
const [announcedText, announcedAt] = instant(record.announcedAt, `assignments[${index}].announcedAt`);
const [availableText, availableAt] = instant(record.availableAt, `assignments[${index}].availableAt`);
const [effectiveText, effectiveAt] = instant(record.effectiveAt, `assignments[${index}].effectiveAt`);
if (availableAt < announcedAt) throw new RangeError(`assignment ${assignmentId} is available before it was announced`);
let validToText: string | null = null;
let validTo: number | null = null;
if (record.validTo !== null) {
[validToText, validTo] = instant(record.validTo, `assignments[${index}].validTo`);
if (validTo <= effectiveAt) throw new RangeError(`assignment ${assignmentId}.validTo must be after effectiveAt`);
}
const parsed = {
...record,
assignmentId,
revision: revision as number,
status: status as ParsedAssignment["status"],
changeKind: changeKind as ParsedAssignment["changeKind"],
listingId,
symbol,
sourceId,
announcedAt: announcedText,
availableAt: availableText,
effectiveAt: effectiveText,
validTo: validToText,
_announced: announcedAt,
_available: availableAt,
_effective: effectiveAt,
_validTo: validTo,
} as ParsedAssignment;
normalized.push(parsed);
versions.set(assignmentId, [...(versions.get(assignmentId) ?? []), parsed]);
});
for (const [assignmentId, records] of versions) {
records.sort((a, b) => a.revision - b.revision);
if (records.some((record, index) => record.revision !== index + 1)) throw new RangeError(`assignment ${assignmentId} revisions must be contiguous from 1`);
const anchor = [records[0].listingId, records[0].symbol, records[0].effectiveAt].join("\u0000");
let previousAvailable: number | null = null;
for (const record of records) {
if ([record.listingId, record.symbol, record.effectiveAt].join("\u0000") !== anchor) throw new RangeError(`assignment ${assignmentId} revision changes immutable identity fields`);
if (previousAvailable !== null && record._available <= previousAvailable) throw new RangeError(`assignment ${assignmentId} revision availability must increase`);
previousAvailable = record._available;
}
}
return normalized;
}
function visibleAssignments(assignments: ParsedAssignment[], asOf: number): ParsedAssignment[] {
const versions = new Map<string, ParsedAssignment[]>();
for (const record of assignments) {
if (record._available <= asOf) versions.set(record.assignmentId, [...(versions.get(record.assignmentId) ?? []), record]);
}
const selected: ParsedAssignment[] = [];
for (const records of versions.values()) {
const latest = records.reduce((left, right) => left.revision > right.revision ? left : right);
if (latest.status === "active") selected.push(latest);
}
return selected;
}
function contains(record: ParsedAssignment, queryAt: number): boolean {
return record._effective <= queryAt && (record._validTo === null || queryAt < record._validTo);
}
function buildChain(records: ParsedAssignment[], listingId: string) {
const chain = records.filter((record) => record.listingId === listingId).sort((a, b) => a._effective - b._effective || a.assignmentId.localeCompare(b.assignmentId));
const gaps: Array<{from: string; to: string}> = [];
const seenSymbols = new Set<string>();
const reusedSymbols = new Set<string>();
chain.forEach((record, index) => {
if (seenSymbols.has(record.symbol)) reusedSymbols.add(record.symbol);
seenSymbols.add(record.symbol);
if (index === 0) return;
const previous = chain[index - 1];
if (previous._validTo === null || previous._validTo > record._effective) throw new ResolutionError(`ambiguous chain for ${listingId}: effective intervals overlap`);
if (previous._validTo < record._effective) gaps.push({from: previous.validTo as string, to: record.effectiveAt});
});
return {
chain: chain.map((record) => ({
assignmentId: record.assignmentId,
revision: record.revision,
symbol: record.symbol,
effectiveAt: record.effectiveAt,
validTo: record.validTo,
changeKind: record.changeKind,
sourceId: record.sourceId,
announcedAt: record.announcedAt,
availableAt: record.availableAt,
})),
gaps,
reusedSymbols: [...reusedSymbols].sort(),
};
}
export function resolveTickerChain(input: unknown) {
const data = objectValue(input, "data");
const [issuers, instruments, listings] = normalizeEntities(data);
const assignments = normalizeAssignments(data, listings);
const query = objectValue(data.query, "query");
const direction = textValue(query.direction, "query.direction") as Direction;
if (!DIRECTIONS.has(direction)) throw new RangeError("query.direction is unsupported");
const [queryAtText, queryAt] = instant(query.queryAt, "query.queryAt");
const [asOfText, asOf] = instant(query.asOf, "query.asOf");
const visible = visibleAssignments(assignments, asOf);
let matches: ParsedAssignment[];
if (direction === "listing_to_symbol") {
const listingId = textValue(query.listingId, "query.listingId");
if (!listings.has(listingId)) throw new ResolutionError(`unknown listing: ${listingId}`);
matches = visible.filter((record) => record.listingId === listingId && contains(record, queryAt));
} else {
const symbol = textValue(query.symbol, "query.symbol");
const venueMic = textValue(query.venueMic, "query.venueMic");
if (!MIC.test(venueMic)) throw new RangeError("query.venueMic must be a four-character MIC");
matches = visible.filter((record) => record.symbol === symbol && listings.get(record.listingId)?.venueMic === venueMic && contains(record, queryAt));
}
if (matches.length === 0) throw new ResolutionError("no visible symbol assignment covers queryAt; the result is a gap, cancellation, or unavailable revision");
if (matches.length !== 1) throw new ResolutionError("ambiguous point-in-time symbol assignment; venue, listing, and interval evidence do not resolve uniquely");
const selected = matches[0];
const listing = listings.get(selected.listingId) as RecordMap;
const instrument = instruments.get(listing.instrumentId as string) as RecordMap;
const {chain, gaps, reusedSymbols} = buildChain(visible, selected.listingId);
return {
status: "resolved",
direction,
queryAt: queryAtText,
asOf: asOfText,
symbol: selected.symbol,
listingId: selected.listingId,
instrumentId: listing.instrumentId,
issuerId: instrument.issuerId,
shareClassId: instrument.shareClassId,
venueMic: listing.venueMic,
currency: listing.currency,
assignmentId: selected.assignmentId,
revision: selected.revision,
changeKind: selected.changeKind,
sourceId: selected.sourceId,
announcedAt: selected.announcedAt,
availableAt: selected.availableAt,
effectiveAt: selected.effectiveAt,
validTo: selected.validTo,
chain,
diagnostics: {
visibleAssignmentCount: visible.length,
chainSegmentCount: chain.length,
gapCount: gaps.length,
gaps,
reusedSymbolsWithinListing: reusedSymbols,
},
};
}
export const calculate = resolveTickerChain;
The embedded lab now expands to its full document height, keeping the article as the only scroll surface.