Stores canonical exchange-specific listing identity and the authoritative listing quote unit.
One row per (exchange_id, symbol).
- Snapshot source:
data/pyvalue.dbon2026-07-28 - Row count:
75,680 - Table size:
3,395,584 bytes(3.2 MiB) - Approximate bytes per row:
44.9
| Column | Type | Null | Key | Notes |
|---|---|---|---|---|
listing_id |
INTEGER |
no | PK | canonical listing surrogate key |
issuer_id |
INTEGER |
no | FK | issuer metadata link |
exchange_id |
INTEGER |
no | FK, idx | canonical exchange link; part of composite unique key |
symbol |
TEXT |
no | bare canonical listing symbol such as AAPL; part of composite unique key. CHECK enforces uppercase, no whitespace, and [A-Z0-9.&^*-] characters only |
|
currency |
TEXT |
no | authoritative listing quote unit, including subunits such as GBX, ZAC, and ILA. NOT NULL since migration 069; CHECK enforces 3-char uppercase ASCII letters |
|
isin |
TEXT |
yes | idx | ISO 6166 security identifier. Added by migration 088. Nullable — EODHD publishes none for ~25% of listings and absence is a valid state. Deliberately not UNIQUE: every venue trading the same shares carries the same ISIN, which is what makes it the cross-listing grouping key. CHECK enforces 12 uppercase alphanumerics with a 2-letter country prefix and a numeric check digit |
primary_listing_status |
TEXT |
no | canonical primary-listing classification: unknown, primary, or secondary. CHECK enforces the vocabulary since migration 088 |
- Primary key:
listing_id - Physical foreign keys:
exchange_id->exchange.exchange_idissuer_id->issuer.issuer_id
- Physical references from other tables:
financial_facts.listing_id->listing_idfinancial_facts_refresh_state.listing_id->listing_idmarket_data.listing_id->listing_idmetric_compute_status.listing_id->listing_idmetrics.listing_id->listing_idprovider_listing.listing_id->listing_id
- Unique constraints beyond the primary key:
- (
exchange_id,symbol)
- (
- Main logical refs: canonical root for facts, prices, metrics, and primary-listing status
idx_listing_isin (isin)WHERE isin IS NOT NULLidx_listing_issuer (issuer_id)
- canonical-scope resolution joins
listingtoexchangeand projects the canonical symbollisting.symbol || '.' || exchange.exchange_codeas a display label only — never a filter/join key. Full / by-exchange scope scans the supported universe (list_supported_listings); an explicit--symbolsrequest seeks only the requested rows (list_supported_listings_for_symbols: split the canonical symbol, seekexchangebyexchange_code, thenlistingby the(exchange_id, symbol)UNIQUE index) - downstream joins from facts, market data, metrics, and primary-listing status
- FX currency discovery and currency-scoped data checks
refresh-supported-tickers— the sole runtime writer oflistingrows, oflisting.currency, and oflisting.isin; it never deletes them: a prune removes only the provider layer (provider_listing+ raw/fetch/normalization state), and a listing left with no provider mapping is retained, unreachable through the provider-joined scopes until a provider maps it again (2026-07-11 design)- migration-time backfill from legacy securities
ingest-fundamentals never writes here. It attaches each payload to a listing
that refresh-supported-tickers has already catalogued and skips any symbol
whose listing is absent (creating one would require writing the NOT NULL
listing.currency). Currency therefore has a single source of truth.
- Snapshot source:
data/pyvalue.dbon2026-07-28 - Sample window: first
5rows returned by SQLite ordered bylisting_id ASC
[
{
"listing_id": 1,
"issuer_id": 1,
"exchange_id": 1,
"symbol": "AALB",
"currency": "EUR",
"isin": "NL0000852564",
"primary_listing_status": "primary"
},
{
"listing_id": 2,
"issuer_id": 2,
"exchange_id": 1,
"symbol": "ABN",
"currency": "EUR",
"isin": "NL0011540547",
"primary_listing_status": "primary"
},
{
"listing_id": 3,
"issuer_id": 3,
"exchange_id": 1,
"symbol": "ACOMO",
"currency": "EUR",
"isin": "NL0000313286",
"primary_listing_status": "primary"
},
{
"listing_id": 4,
"issuer_id": 4,
"exchange_id": 1,
"symbol": "AD",
"currency": "EUR",
"isin": "NL0011794037",
"primary_listing_status": "primary"
},
{
"listing_id": 5,
"issuer_id": 5,
"exchange_id": 1,
"symbol": "ADYEN",
"currency": "EUR",
"isin": "NL0012969182",
"primary_listing_status": "primary"
}
]- Canonical user-facing symbols such as
AAPL.USare derived, not stored. isinis identity evidence, not classification: it answers "which security". A depositary receipt is legally a distinct security with its own ISIN, so ISIN groups cross-listings of one security but never an ADR with its underlying.refresh-supported-tickersis the primary source (Isinfrom the provider's exchange symbol list, which covers listings whose fundamentals payload omits it); migration 088 seeded the column from storedGeneral.ISIN. A refresh may correct a stored ISIN but never blanks one — a payload missing the field is treated as a provider gap, not a retraction. Shape normalization lives inpyvalue.identifiers(shaped_isin,shaped_lei) and mirrors the SQL CHECK predicates inmigrations.py; keep the two in step.- There is deliberately no
leicolumn here. Migration 088 added one so issuer grouping could read it cheaply; migration 090 removed it. Oncereconcile-issuer-identityconverges, a listing's LEI is functionally determined by itsissuer_id— the grouper puts every listing sharing an LEI under one issuer, which carries the same value — so storing it here was a transitive dependency and the same fact in two tables. The LEI is now read fromfundamentals_raw.data -> '$.General.LEI', the one place it originates. The cost was accepted knowingly and measured:reconcile-issuer-identitywent from ~3s to ~97s on the live catalog, with every grouping outcome identical. isinstays for the opposite reason, and the contrast is the point. ISIN identifies a security and a listing quotes exactly one, while an issuer may have issued many — Alphabet has one LEI and two ISINs. There is no issuer-level column for it to duplicate and no functional dependency to violate, so listing grain is its correct home.listing.currencyis the only persisted listing-currency truth. It is a quote unit and is not collapsed to base currency at storage time. It is written solely byrefresh-supported-tickers; fundamentals ingestion reads the catalog and never creates or mutates a listing's currency.- Monetary normalization, market-cap calculations, FX discovery, and monetary
metrics derive base currency from
listing.currency. - Unknown primary-listing status is treated as eligible; downstream
primary-only scopes exclude only
secondary. This is load-bearing, not incidental: the classifier returnsunknownwhenever no evidence decides a listing, and 19,389 listings rely on it to stay in the universe — roughly 8,800 on domestic exchanges with no EODHDPrimaryTickercoverage, and roughly 10,600 quote-venue lines that weresecondaryuntil 2026-07, when the venue-based rules were removed for having no support in EODHD's data. Seedocs/providers/eodhd.mdfor the rule set and the accepted cost. primary_listing_statusis written only byingest-fundamentals(as it stores each raw payload) and the two reconcile commands; every other command reads it. All three go through one orchestration (storage/universe_reconcile.py) over one rule set (pyvalue.universe.listing_classification), differing only in scope — so there is no second implementation to drift. Ingest re-evaluates each batch's whole neighbourhood, not just the listings that arrived, which is what makes the result independent of ingestion order. The write is guarded, so a settled catalog issues no updates. A flip tosecondarychanges nothing but this column -- the listing keeps its facts/metrics/market-data and is excluded from universe work solely by the primary-only scope filters. Migration 078 is the one-time backfill that resolved any leftoverunknownlisting with stored fundamentals (it shipped with the eager purge that was policy at the time).