D02-F03-A02 / Complete engineering topic

Ticker-Change Chain Resolution

A production-minded guide to Ticker-Change Chain Resolution.

D02 · CORPORATE ACTIONS AND SECURIT…
D02-F03-A02Canonical / Tested / Open
D02 / D02-F03
Key concepts

The governed definitions this build depends on. Read them first if a term is unfamiliar.

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:

Plain text
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.

Issuer, instrument, listing, venue, and time-valid symbol assignment

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

ObjectExample package fieldWhat it means
IssuerissuerIdThe organization that issued the instrument.
InstrumentinstrumentId, shareClassIdThe financial instrument and an opaque class relationship key. Descriptive class names require their own time-valid history.
Listing or trading linelistingIdA specific venue-facing admission or line.
VenuevenueMicThe market context for the listing and symbol.
Symbol assignmentassignmentId, revisionA 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:

  • queryAt asks when the symbol assignment should be effective;
  • asOf asks 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 τ\tau:

va(τ)=arg maxr{r:availableAta,rτ}.v_a(\tau)=\operatorname*{arg\,max}_{r}\{r:availableAt_{a,r}\le\tau\}.

If the latest visible revision is cancelled, the assignment is absent. If it is active, the assignment covers the half-open interval

Ia=[effectiveAta,validToa).I_a=[effectiveAt_a,validTo_a).

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.

Effective time and knowledge time in the synthetic chain

The two resolution directions

Listing to symbol

Given listing LL, effective time tt, and knowledge cutoff τ\tau, collect:

RL(t,τ)={a:listingIda=L, tIa, a=va(τ), statusa=active}.R_L(t,\tau)=\{a:listingId_a=L,\ t\in I_a,\ a=v_a(\tau),\ status_a=active\}.

The answer is valid only when RL=1|R_L|=1.

Symbol and venue to listing

Reverse lookup must include a venue. For symbol ss and MIC mm:

RS(s,m,t,τ)={a:symbola=s, MIC(listinga)=m, tIa, a=va(τ), statusa=active}.R_S(s,m,t,\tau)=\{a:symbol_a=s,\ MIC(listing_a)=m,\ t\in I_a,\ a=v_a(\tau),\ status_a=active\}.

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.

AssignmentSymbolEffective fromValid to, exclusiveAvailable atRevisionClassification
SYN-ASG-100ALP2020-01-02 14:30Z2024-06-10 13:30Z2019-12-18 15:05Z1Initial listing
SYN-ASG-200NXT2024-06-10 13:30Z2025-01-10 14:30Z2024-05-15 12:07Z1Administrative
SYN-ASG-200NXT2024-06-10 13:30Z2025-01-15 14:30Z2024-12-20 16:10Z2Administrative correction
SYN-ASG-300NVA2025-01-15 14:30ZOpen2024-12-20 16:12Z1Corporate 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:

JSON
{
  "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:

  • ALP is assigned to an unrelated listing in a later period;
  • another venue uses NXT at 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

Safe handoff, gap, and overlap diagnostics

The resolver makes uncertainty visible:

  • Clean handoff: old validTo equals new effectiveAt; 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:

  1. Validate and index issuer records.
  2. Validate instruments and their issuer foreign keys.
  3. Validate listings, instrument foreign keys, MIC shape, and currency shape.
  4. Validate assignment IDs, revisions, source IDs, statuses, classifications, timestamps, and half-open intervals.
  5. Select the greatest revision whose availableAt does not exceed asOf.
  6. Remove assignments whose selected revision is cancelled.
  7. Filter by listing or by exact symbol plus venue at queryAt.
  8. Require one candidate and reject overlapping listing intervals.
  9. 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

AssetLearning roleArticle placementVideo sceneSourceStatic fallback
Identity hierarchySeparate the five identity layersCentral idea2–3visuals/static/article-hero.svgText hierarchy and table
Two-clock exampleSeparate effect from knowledgeSynthetic chain4–5visuals/static/worked-example.svgWorked-example table
Failure guardCompare handoff, gap, and overlapAmbiguity7visuals/static/failure-guard.svgRejection list
Calculation flowShow algorithm sequenceImplementation5–6visuals/mermaid/calculation-flow.mdNine-stage list
Revision lifecycleShow announcement through correction/cancellationTwo clocks4visuals/mermaid/state-lifecycle.mdFormula and prose
Guided labCompare six scenariosExploration4–8visuals/animated/playground.htmlThree static SVGs

Primary-source roles and limitations are documented in REFERENCES.md.

Ticker-chain point-in-time resolution flow

This diagram shows why entity lineage, revision visibility, and interval cardinality all precede the answer.

Rendering system map…

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.

Rendering system map…

Takeaway: announcedAt, availableAt, effectiveAt, and validTo answer different questions; no one timestamp can replace the others.

References7 primary sources and evidence notes

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.

ticker_change_chain_resolution.ts
/** 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;
Full-height labplaygroundOpen full screen