Ticker Symbols Have Expiration Dates


Ticker Symbols Have Expiration Dates

August 29, 2019

Ticker symbols look like primary keys because quote APIs put them in URLs and spreadsheets put them in the first column. They are venue-assigned labels. Every assignment has an effective date, an exchange, a security, and an issuer.

A database keyed by ticker can join the price history of one company to the financial statements of another. It can preserve a symbol while silently changing share class. It can also collapse two simultaneous listings into one row. The resulting records remain internally plausible, which makes the failure expensive to detect.

A stock screener needs a point-in-time security master before it needs another valuation ratio.

The timeline contains three separate identity failures. V moved from a Vivendi ADR to Visa after an unassigned interval. GOOG continued through the 2014 share distribution while the security behind the symbol changed from voting Class A shares to non-voting Class C shares. UA later moved from Under Armour Class A to Class C while Class A began trading as UAA.

The symbol survived. The security did not always survive with it.

A ticker is a time-bounded assignment

The minimum useful key for a displayed stock symbol is (symbol, venue, valid_from, valid_to).

That tuple still points to several different objects:

Layer Meaning Typical external identifier
Issuer Legal entity that files accounts CIK, LEI
Security Economic instrument or share class FIGI, ISIN, CUSIP
Listing A security admitted to a trading venue Venue-level FIGI, internal listing ID
Symbol assignment Venue label used during an interval Ticker plus MIC plus dates

These layers have different lifetimes. This separation is the core of a security master database and the market data normalization layer behind scalable stock screener architecture.

A CIK follows an SEC filer. It does not distinguish common stock, preferred stock, an ADR, or parallel share classes. An ISIN identifies a security, but it does not identify a specific execution venue. A ticker identifies a local trading label, but only after the venue and date are supplied.

The internal IDs should be boring integers with no financial meaning. Vendor identifiers belong in versioned mapping tables. Symbols belong in an interval table. Human-readable codes make poor primary keys because operations teams are allowed to change them.

Model the assignment explicitly

Use half-open intervals. An assignment is valid on valid_from and invalid on valid_to. Adjacent assignments can meet at the same date without ambiguity.

from dataclasses import dataclass
from datetime import date
import unicodedata


OPEN_END = date.max


def normalize_symbol(raw: str) -> str:
    symbol = unicodedata.normalize("NFKC", raw).strip().upper()
    if not symbol:
        raise ValueError("empty symbol")
    return symbol


@dataclass(frozen=True, order=True)
class SymbolKey:
    mic: str
    symbol: str

    @classmethod
    def from_raw(cls, mic: str, symbol: str) -> "SymbolKey":
        mic = mic.strip().upper()
        if len(mic) != 4:
            raise ValueError("MIC must contain four characters")
        return cls(mic=mic, symbol=normalize_symbol(symbol))


@dataclass(frozen=True)
class SymbolAssignment:
    key: SymbolKey
    issuer_id: int
    security_id: int
    listing_id: int
    valid_from: date
    valid_to: date = OPEN_END

    def __post_init__(self) -> None:
        if self.valid_from >= self.valid_to:
            raise ValueError("assignment interval must be non-empty")

    def contains(self, session_date: date) -> bool:
        return self.valid_from <= session_date < self.valid_to

The normalization function deliberately preserves punctuation. Removing dots, slashes, hyphens, or class suffixes can merge distinct securities. BRK.A, BRK/A, and BRK A may be vendor renderings of the same local symbol, or they may be unrelated values in another market. That conversion requires a vendor-specific mapping rule with tests and effective dates. A global regular expression cannot infer it.

Store the raw vendor value beside the normalized value. When a mapping fails, the raw field is the evidence.

Resolve by venue and date

Historical resolution belongs on the ingestion path. Quotes should be written with listing_id already attached. Filing facts should be written with issuer_id. Query-time symbol joins turn every valuation request into a reference-data experiment.

A compact interval index is sufficient for an in-memory resolver. Each (MIC, symbol) bucket is sorted once. Lookup is logarithmic in the number of assignments for that bucket.

from bisect import bisect_right
from collections import defaultdict
from datetime import date
from typing import Dict, Iterable, List


class _Bucket:
    __slots__ = ("starts", "rows")

    def __init__(self, rows: List[SymbolAssignment]) -> None:
        rows.sort(key=lambda row: row.valid_from)

        previous = None
        for row in rows:
            if previous is not None and previous.valid_to > row.valid_from:
                raise ValueError(
                    "overlapping assignments for {0}: {1} and {2}".format(
                        row.key, previous, row
                    )
                )
            previous = row

        self.starts = tuple(row.valid_from for row in rows)
        self.rows = tuple(rows)


class TemporalSymbolIndex:
    def __init__(self, assignments: Iterable[SymbolAssignment]) -> None:
        grouped = defaultdict(list)  # type: Dict[SymbolKey, List[SymbolAssignment]]

        for assignment in assignments:
            grouped[assignment.key].append(assignment)

        self._buckets = {
            key: _Bucket(rows)
            for key, rows in grouped.items()
        }

    def resolve(
        self,
        mic: str,
        symbol: str,
        session_date: date,
    ) -> SymbolAssignment:
        key = SymbolKey.from_raw(mic, symbol)

        try:
            bucket = self._buckets[key]
        except KeyError:
            raise LookupError(
                "unknown symbol {0} on {1}".format(key.symbol, key.mic)
            )

        position = bisect_right(bucket.starts, session_date) - 1
        if position < 0:
            raise LookupError(
                "symbol {0} on {1} was not assigned on {2}".format(
                    key.symbol, key.mic, session_date
                )
            )

        row = bucket.rows[position]
        if not row.contains(session_date):
            raise LookupError(
                "symbol {0} on {1} was unassigned on {2}".format(
                    key.symbol, key.mic, session_date
                )
            )

        return row

An unassigned interval must raise an error. Returning the nearest assignment creates a temporal foreign-key violation. Forward-filling a ticker mapping across a gap is equivalent to forward-filling an issuer.

The overlap check is equally important. Two active assignments for the same symbol and venue indicate one of four conditions: duplicate ingestion, a bad effective date, a venue-code error, or an incomplete model. Choosing the last row hides the defect.

Symbol reuse can manufacture a return series

Suppose daily prices arrive with only symbol and date. The loader groups all observations for V, sorts by date, and writes one adjusted-close series. The first segment belongs to a Vivendi depositary receipt. The later segment belongs to Visa Class A. A return calculation sees a long suspension followed by a discontinuity and treats both as one instrument.

Historical ticker data requires a dated symbol lookup. No adjustment factor can repair the series. Splits and dividends operate within a security lineage. Symbol reuse crosses lineages.

The same defect contaminates fundamentals in a different direction. A current ticker-to-CIK table can map every historical V observation to Visa because the table contains only the latest assignment. Historical Vivendi prices then acquire Visa revenue, Visa shares outstanding, and Visa sector membership. A screen for long-term revenue growth may rank the synthetic record near the top because the numerator and denominator belong to different issuers.

The rows still pass basic validation:

Identity constraints catch what numeric constraints miss.

The number of assignments for one symbol is usually small. The number of quotes is not. Partition an ingestion batch by (MIC, symbol), convert session dates to integer days, and resolve the entire partition with numpy.searchsorted.

import numpy as np


UNKNOWN_LISTING = np.int64(-1)


def resolve_listing_ids(
    quote_days: np.ndarray,
    assignment_starts: np.ndarray,
    assignment_ends: np.ndarray,
    listing_ids: np.ndarray,
) -> np.ndarray:
    """
    All date arrays contain integer UTC session days.

    assignment_starts must be strictly increasing.
    Intervals are [start, end).
    """
    quote_days = np.asarray(quote_days, dtype=np.int64)
    starts = np.asarray(assignment_starts, dtype=np.int64)
    ends = np.asarray(assignment_ends, dtype=np.int64)
    ids = np.asarray(listing_ids, dtype=np.int64)

    if not (starts.ndim == ends.ndim == ids.ndim == 1):
        raise ValueError("assignment arrays must be one-dimensional")
    if not (len(starts) == len(ends) == len(ids)):
        raise ValueError("assignment arrays must have equal length")
    if len(starts) == 0:
        return np.full(quote_days.shape, UNKNOWN_LISTING, dtype=np.int64)
    if np.any(starts[1:] <= starts[:-1]):
        raise ValueError("assignment starts must be strictly increasing")
    if np.any(starts >= ends):
        raise ValueError("assignment intervals must be non-empty")
    if np.any(ends[:-1] > starts[1:]):
        raise ValueError("assignment intervals overlap")

    positions = np.searchsorted(starts, quote_days, side="right") - 1
    safe_positions = np.clip(positions, 0, len(starts) - 1)

    valid = (
        (positions >= 0)
        & (quote_days < ends[safe_positions])
    )

    result = np.full(quote_days.shape, UNKNOWN_LISTING, dtype=np.int64)
    result[valid] = ids[safe_positions[valid]]
    return result

This performs the temporal join without allocating Python objects per quote. The loader should reject UNKNOWN_LISTING rows into a quarantine table with vendor, file, line number, raw symbol, venue, and session date. Do not discard them. Unknown mappings are reference-data work queues.

After resolution, persist listing_id on every price, volume, split, dividend, borrow, and trading-status record. A symbol becomes display metadata.

Corporate actions are identity events

A corporate-action feed commonly describes changes in prose: old symbol, new symbol, effective date, exchange, ratio, and action type. Converting that feed into destructive updates loses lineage.

Represent each event by closing intervals and opening new objects according to explicit rules:

Event Issuer Security Listing Symbol assignment
Symbol change same same usually same close old, open new
Venue transfer same same new or versioned close old, open new
New share class same new new open new
Merger consideration surviving or new old security terminates old listing terminates close old
Ticker reuse different different different open after a gap
ADR program termination same underlying issuer receipt terminates listing terminates close old

The words “usually” and “according to policy” matter. Data vendors differ in their treatment of listing continuity. An internal security master needs a documented identity policy that remains stable across vendors. Otherwise, switching market-data providers changes primary keys throughout the warehouse.

A corporate actions database should emit these identity events instead of mutating the latest row. Corporate actions should also carry two clocks:

  1. Valid time: when the exchange assignment or security change took effect.
  2. Recorded time: when the database learned or corrected the event.

The second clock is required for reproducible backtests. A vendor may publish a correction weeks later. Rebuilding history with the corrected row is appropriate for current analytics. Replaying a strategy requires the mapping that was available at the historical decision time.

A simple versioned assignment adds a recorded interval:

from dataclasses import dataclass
from datetime import date, datetime


@dataclass(frozen=True)
class VersionedSymbolAssignment:
    key: SymbolKey
    issuer_id: int
    security_id: int
    listing_id: int
    valid_from: date
    valid_to: date
    recorded_from: datetime
    recorded_to: datetime

    def visible_at(
        self,
        session_date: date,
        knowledge_time: datetime,
    ) -> bool:
        valid = self.valid_from <= session_date < self.valid_to
        known = self.recorded_from <= knowledge_time < self.recorded_to
        return valid and known

Never overwrite a corrected effective date. Close the recorded interval of the old version and insert a new version. The audit trail is part of the market data.

Keep identifier namespaces separate

A production security master accumulates CIKs, LEIs, FIGIs, ISINs, CUSIPs, SEDOLs, vendor IDs, exchange codes, and local symbols. The common FIGI vs ticker comparison is a scope question: one identifies an instrument at a defined FIGI level, while the other is a dated venue label. A single identifier column with a string value creates accidental collisions and weak constraints.

Use a namespace, value, scope, and validity interval:

from dataclasses import dataclass
from datetime import date
from enum import Enum


class IdentifierNamespace(Enum):
    CIK = "CIK"
    LEI = "LEI"
    FIGI = "FIGI"
    ISIN = "ISIN"
    CUSIP = "CUSIP"
    SEDOL = "SEDOL"
    VENDOR_SECURITY_ID = "VENDOR_SECURITY_ID"


class IdentifierScope(Enum):
    ISSUER = "ISSUER"
    SECURITY = "SECURITY"
    LISTING = "LISTING"


@dataclass(frozen=True)
class ExternalIdentifier:
    namespace: IdentifierNamespace
    scope: IdentifierScope
    value: str
    internal_id: int
    valid_from: date
    valid_to: date
    source: str

Scope should be enforced by code and database constraints. A CIK to ticker mapping joins an issuer identifier to a symbol assignment. It is a many-to-many temporal relationship once share classes, ADRs, venue moves, and historical ticker changes are included.

Avoid deriving one identifier from another unless the issuing authority defines the transformation. A vendor’s ticker convention is presentation logic. It is not a reversible encoding of venue, class, and security identity.

The screener query should cross layers deliberately

A fundamental stock screener usually combines data from at least three grains:

A safe valuation pipeline names the crossing points.

For an enterprise-value screen:

  1. Select the issuer and filing version available at the as-of time.
  2. Select eligible common-equity securities for that issuer.
  3. Select the primary or most liquid listing for each security under an explicit venue policy.
  4. Resolve the listing price at the market timestamp.
  5. Aggregate all relevant share classes and other claims under the capital-structure policy.
  6. attach the current display symbol only after the calculation.

The display ticker does not participate in the arithmetic.

This structure also handles dual listings without duplicating the issuer’s revenue. It handles multiple share classes without pretending their prices are interchangeable. It allows a listing to move venues without rewriting issuer history.

Tests should attack identity, not formatting

Most security-master tests check whether a symbol contains legal characters. The valuable tests construct adversarial histories.

At minimum, the suite should cover:

The central invariant is simple: every fact resolves to one internal object at its own timestamp and under the knowledge state requested by the caller.

Using only the current map creates identifier survivorship bias. A current stock screener may use the latest corrected security master. A backtest may use the point-in-time version. Both should address the same internal IDs.

The chart

The chart below is intentionally driven by interval data. It uses the same shape as the security-master table: venue-qualified symbol, effective dates, internal security identity, and explanatory event text.

import React, { useMemo, useState } from "react";
import { ParentSize } from "@visx/responsive";
import { Group } from "@visx/group";
import { AxisBottom } from "@visx/axis";
import { scaleBand, scaleTime } from "@visx/scale";
import { LinearGradient } from "@visx/gradient";
import { localPoint } from "@visx/event";
import { TooltipWithBounds } from "@visx/tooltip";

const END = "2019-08-29";

const rows = [
  {
    key: "V · XNYS",
    segments: [
      {
        start: "2000-12-11",
        end: "2006-08-03",
        label: "Vivendi ADR",
        detail: "Issuer 101 · security 4001",
        color: "#a78bfa",
      },
      {
        start: "2006-08-03",
        end: "2008-03-19",
        label: "unassigned",
        detail: "No valid symbol assignment",
        color: "transparent",
        gap: true,
      },
      {
        start: "2008-03-19",
        end: END,
        label: "Visa Class A",
        detail: "Issuer 882 · security 9104",
        color: "#22d3ee",
      },
    ],
  },
  {
    key: "GOOG · XNAS",
    segments: [
      {
        start: "2004-08-19",
        end: "2014-04-03",
        label: "Class A voting",
        detail: "Security 7001",
        color: "#bef264",
      },
      {
        start: "2014-04-03",
        end: END,
        label: "Class C non-voting",
        detail: "Security 7002 · symbol retained",
        color: "#60a5fa",
      },
    ],
  },
  {
    key: "GOOGL · XNAS",
    segments: [
      {
        start: "2014-04-03",
        end: END,
        label: "Class A voting",
        detail: "Security 7001 · new symbol",
        color: "#bef264",
      },
    ],
  },
  {
    key: "UA · XNYS",
    segments: [
      {
        start: "2005-11-18",
        end: "2016-12-07",
        label: "Under Armour Class A",
        detail: "Security 8101",
        color: "#fbbf24",
      },
      {
        start: "2016-12-07",
        end: END,
        label: "Under Armour Class C",
        detail: "Security 8102 · symbol retained",
        color: "#fb7185",
      },
    ],
  },
  {
    key: "UAA · XNYS",
    segments: [
      {
        start: "2016-12-07",
        end: END,
        label: "Under Armour Class A",
        detail: "Security 8101 · new symbol",
        color: "#fbbf24",
      },
    ],
  },
];

const parseDate = value => new Date(value + "T00:00:00Z");
const formatDay = value => value.toISOString().slice(0, 10);

function Timeline({ width }) {
  const compact = width < 640;
  const height = compact ? 510 : 430;
  const margin = {
    top: compact ? 92 : 82,
    right: 20,
    bottom: 48,
    left: compact ? 92 : 142,
  };

  const innerWidth = Math.max(0, width - margin.left - margin.right);
  const innerHeight = height - margin.top - margin.bottom;

  const [tooltip, setTooltip] = useState(null);

  const x = useMemo(
    () =>
      scaleTime({
        domain: [parseDate("2000-01-01"), parseDate(END)],
        range: [0, innerWidth],
      }),
    [innerWidth]
  );

  const y = useMemo(
    () =>
      scaleBand({
        domain: rows.map(row => row.key),
        range: [0, innerHeight],
        padding: 0.28,
      }),
    [innerHeight]
  );

  const showTooltip = (event, row, segment) => {
    const point = localPoint(event);
    if (!point) return;

    setTooltip({
      left: point.x + 12,
      top: point.y + 12,
      row,
      segment,
    });
  };

  const hideTooltip = () => setTooltip(null);

  if (width < 40) return null;

  return (
    <div
      style={{
        position: "relative",
        width: "100%",
        borderRadius: 18,
        overflow: "hidden",
        background: "#07111f",
        boxShadow: "0 24px 70px rgba(2, 8, 23, 0.42)",
      }}
    >
      <svg
        width={width}
        height={height}
        role="img"
        aria-labelledby="ticker-timeline-title ticker-timeline-desc"
      >
        <title id="ticker-timeline-title">
          Ticker symbols resolve to different securities over time
        </title>
        <desc id="ticker-timeline-desc">
          A timeline showing symbol reuse, share-class substitution, and
          parallel ticker assignments on NYSE and Nasdaq from 2000 through
          August 2019.
        </desc>

        <LinearGradient
          id="panel-gradient"
          from="#07111f"
          to="#10233d"
          vertical={false}
        />
        <rect width={width} height={height} fill="url(#panel-gradient)" />

        <text
          x={20}
          y={30}
          fill="#f8fafc"
          fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
          fontSize={compact ? 15 : 18}
          fontWeight={700}
        >
          SYMBOL ASSIGNMENTS ARE INTERVALS
        </text>
        <text
          x={20}
          y={compact ? 54 : 55}
          fill="#94a3b8"
          fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
          fontSize={compact ? 10 : 12}
        >
          same label ≠ same security
        </text>

        <Group left={margin.left} top={margin.top}>
          {[2000, 2004, 2008, 2012, 2016].map(year => {
            const xValue = x(parseDate(year + "-01-01"));
            return (
              <line
                key={year}
                x1={xValue}
                x2={xValue}
                y1={0}
                y2={innerHeight}
                stroke="rgba(148, 163, 184, 0.15)"
              />
            );
          })}

          {rows.map(row => {
            const rowY = y(row.key);
            const band = y.bandwidth();

            return (
              <Group key={row.key}>
                <text
                  x={-12}
                  y={rowY + band / 2}
                  dy="0.35em"
                  textAnchor="end"
                  fill="#cbd5e1"
                  fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
                  fontSize={compact ? 10 : 12}
                  fontWeight={700}
                >
                  {row.key}
                </text>

                <line
                  x1={0}
                  x2={innerWidth}
                  y1={rowY + band / 2}
                  y2={rowY + band / 2}
                  stroke="rgba(148, 163, 184, 0.17)"
                />

                {row.segments.map(segment => {
                  const start = parseDate(segment.start);
                  const end = parseDate(segment.end);
                  const left = x(start);
                  const segmentWidth = Math.max(2, x(end) - left);
                  const showLabel = segmentWidth > (compact ? 82 : 120);

                  return (
                    <Group
                      key={row.key + segment.start}
                      left={left}
                      top={rowY}
                    >
                      <rect
                        width={segmentWidth}
                        height={band}
                        rx={8}
                        fill={segment.color}
                        fillOpacity={segment.gap ? 0.04 : 0.9}
                        stroke={
                          segment.gap
                            ? "rgba(203, 213, 225, 0.65)"
                            : "rgba(255, 255, 255, 0.24)"
                        }
                        strokeDasharray={segment.gap ? "5 5" : undefined}
                        tabIndex="0"
                        aria-label={
                          row.key +
                          ", " +
                          segment.label +
                          ", " +
                          segment.start +
                          " through " +
                          segment.end
                        }
                        onMouseMove={event =>
                          showTooltip(event, row, segment)
                        }
                        onMouseLeave={hideTooltip}
                        onTouchStart={event =>
                          showTooltip(event, row, segment)
                        }
                        onFocus={() =>
                          setTooltip({
                            left: margin.left + left + 12,
                            top: margin.top + rowY + band + 8,
                            row,
                            segment,
                          })
                        }
                        onBlur={hideTooltip}
                        style={{ cursor: "crosshair", outline: "none" }}
                      />

                      {showLabel && (
                        <text
                          x={12}
                          y={band / 2}
                          dy="0.35em"
                          pointerEvents="none"
                          fill={segment.gap ? "#cbd5e1" : "#07111f"}
                          fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
                          fontSize={compact ? 9 : 10}
                          fontWeight={800}
                        >
                          {segment.label}
                        </text>
                      )}
                    </Group>
                  );
                })}
              </Group>
            );
          })}

          <AxisBottom
            top={innerHeight}
            scale={x}
            numTicks={compact ? 5 : 8}
            stroke="rgba(203, 213, 225, 0.45)"
            tickStroke="rgba(203, 213, 225, 0.35)"
            tickFormat={value =>
              String(new Date(value).getUTCFullYear())
            }
            tickLabelProps={() => ({
              fill: "#94a3b8",
              fontFamily:
                "ui-monospace, SFMono-Regular, Menlo, monospace",
              fontSize: compact ? 9 : 10,
              textAnchor: "middle",
            })}
          />
        </Group>
      </svg>

      {tooltip && (
        <TooltipWithBounds
          left={tooltip.left}
          top={tooltip.top}
          style={{
            position: "absolute",
            maxWidth: 260,
            padding: "10px 12px",
            border: "1px solid rgba(148, 163, 184, 0.28)",
            borderRadius: 10,
            background: "rgba(2, 8, 23, 0.96)",
            color: "#e2e8f0",
            fontFamily:
              "ui-monospace, SFMono-Regular, Menlo, monospace",
            fontSize: 11,
            lineHeight: 1.45,
            boxShadow: "0 16px 40px rgba(0, 0, 0, 0.35)",
          }}
        >
          <div style={{ color: "#ffffff", fontWeight: 800 }}>
            {tooltip.row.key} · {tooltip.segment.label}
          </div>
          <div>{tooltip.segment.detail}</div>
          <div style={{ color: "#94a3b8", marginTop: 4 }}>
            {formatDay(parseDate(tooltip.segment.start))}
            {" → "}
            {formatDay(parseDate(tooltip.segment.end))}
          </div>
        </TooltipWithBounds>
      )}
    </div>
  );
}

export default function TickerIdentityTimeline() {
  return (
    <div style={{ width: "100%", minHeight: 430 }}>
      <ParentSize>
        {({ width }) => <Timeline width={width} />}
      </ParentSize>
    </div>
  );
}

The visual encoding is intentionally literal. A colored interval represents one security assignment. Repeated colors across different symbols indicate the same security moving to a new symbol. A symbol changing color indicates a different security inheriting the label. A dashed interval indicates that no assignment exists.

The chart can be generated from production security-master rows. Hardcoded examples are useful for documentation. The live screener should render the same component from audited reference data.

The primary key should survive the story

Companies rename themselves. Exchanges reassign symbols. Securities split into classes. ADR programs terminate. Listings move. Vendors revise effective dates.

A durable stock screener preserves those events instead of flattening them into the latest ticker map.

Use internal IDs for storage. Resolve symbols with venue and time. Keep issuer, security, and listing grain separate. Preserve both valid time and recorded time. Reject gaps and overlaps. Attach symbols at the presentation boundary.

Ticker symbols are excellent labels. Labels have expiration dates.