smart_money · 13F institutional holdings pipeline2026-09-01

The Security Master & CUSIP Succession Build

A status report on closing the gap between a raw 13F extract and what a client evaluating us against FactSet actually asked for: a CUSIP that can be trusted this quarter, and traced to the same economic position next quarter through a split, a merger, or a CUSIP change.

PHASES 0–4 · SHIPPED PHASE 5 (LIVE QoQ WIRING) · NOT STARTED PIPELINE HEALTH · HEALTHY, 0 ALERTS

The client's framing, in their own words: “FactSet’s edge is that layer, not the raw XML parse.” Everything below is built against that standard — not against “can we parse the filing,” which we already could.


00Where this stood yesterday

Every CUSIP in holdings was stored exactly as a filer typed it: no case normalization, no zero-padding, no check-digit validation, and — because the join between last quarter and this quarter keys on the raw string — no way to tell a real trade from a corporate action. A merger, a reverse split, or a reticker read as a full exit on the old CUSIP and a brand-new position on the new one. We had a downstream statistical flag (corporate_action_suspect) that could tell you something happened to roughly 80–150 CUSIP clusters a quarter; it could not tell you what, or link the two sides.

That is the exact gap FactSet's CUSIP Global Services relationship fills, and the exact gap this client is pricing us against.


01What shipped

Phases 0–4 of the plan · additive only · zero changes to the live quarter-over-quarter join

77,153,771
holdings rows normalized
100%
normalized_cusip coverage
0.08%
check-digit fail, equity/debt
30 qtrs
SEC 13(f) list, 2019Q1–2026Q2
621,632
official-list rows ingested
9,837
candidate succession pairs found

Phase 0 — validate and normalize every CUSIP

cusip_validate.py implements the ANSI X9.6 / CGS modulus-10 check digit — public arithmetic, no CUSIP Global Services license required — plus 9-character normalization (uppercase, zero-pad the classic “leading zero dropped” case). Two new columns ride alongside the original, unmutated cusip so the audit trail the client asked for (reported → normalized → canonical → event) exists from the first column, not bolted on later. Wired into the live parser so every new filing gets it for free; the 77.2M existing rows were backfilled in one pass.

Specimen — check digit, real security
037833100
PosCharValue×2 (even pos)Digit sum
1000
23366
3777
488167
5333
63366
7111
80000
sum = 30  →  check = (10 − 30 mod 10) mod 10 = 0  —  matches filed check digit

This isn't a toy example — it's Apple's actual CUSIP, hand-verified before anything was trusted to run against 77 million rows. The same routine was independently verified against Berkshire Hathaway B, Microsoft, and a real CINS-format identifier pulled from our own 13D data (G13311108, discussed below) before it touched production.

Phase 1 — close a tracked-schema gap

cusip_mapping, the table every ticker lookup depends on, existed only in the live database — it was never in schema.sql. A clean rebuild from source could not have reproduced it. Fixed; no shape change.

Phase 2 — the SEC's own list of what a CUSIP is allowed to be

Ingested the SEC's Official List of Section 13(f) Securities for every quarter back to our data's start — the list SEC itself publishes for exactly the validation use the client is asking for (“on_13f_list_for_period”, “list_status”). Three real obstacles surfaced and were resolved before trusting the pipeline, detailed below.

Phase 3 — a place to record that a CUSIP changed

cusip_succession_events is the append-only evidence ledger (old CUSIP, new CUSIP, event type, effective date, split ratio, source) — the exact shape the client's spec asks for. cusip_canonical is the flat, always-one-hop lookup a join would actually use, derived from the ledger by chain-collapsing so a security that changed labels twice still resolves in one step. Both mirror cik_canonical, the manager-identity table this pipeline already trusts for the same class of problem on the filer side.

Phase 4 — find the candidates, don't guess the answer

detect_cusip_successions.py proposes candidates from three angles — a paired mass-exit/mass-new CUSIP under one issuer in the same quarter, corroborated against SEC name-change history where available; and a separate review list of issuers whose 13D/13G filings carry more than one CUSIP over time. It writes nothing. Every candidate is a starting point for a human to verify, the same discipline the existing manager-identity detector already uses.


What we learned building it

Five things that only surfaced by testing against real filings, not by reading the spec

Finding

The SEC moved the list's URL mid-history

Everything through 2021Q1 lives under one path; 2021Q2 onward moved to another. A hardcoded template would have silently 404’d every quarter for the last five years. Fixed by discovering URLs from the SEC's own index page instead of guessing a pattern.

Finding

Listed options carry a different, non-CUSIP numbering scheme

CALL/PUT rows on the SEC's own list fail our check digit ~99% of the time — not a bug. They're OCC-style option symbols sharing the 9-character slot, not ANSI X9.6 CUSIPs. Confirmed against 19,022 real rows before writing this off as noise; the health check now excludes options from the check-digit alert so it never cries wolf on data that's supposed to look this way.

Finding

The SEC's own list contains duplicate rows

~2–8% of rows per quarter repeat the same CUSIP, almost always identical option contract rows. A handful disagree only on whether the security was flagged ADDED that quarter. Resolved by keeping the more informative copy; logged, not hidden.

Bug caught

A two-security cycle would have silently mismapped both sides

Synthetic test: CUSIP A renamed to B, B renamed back to A. The first version of the chain-resolver detected no problem and pointed each one at the other — wrong, and wrong silently. Caught in testing before any real data touched it; the resolver now recognizes the cycle and excludes both sides for manual review instead of guessing.

Bug caught

An off-by-one on the price feed was silently shifting every quarter-end mark by one trading day

While rebuilding the manager-performance price layer for speed (an unrelated but adjacent fix), a boundary check let a quarter-end's mark fall on the next day's close. SPY's 2019-03-31 mark came back as $285.83 instead of the correct $282.48. Caught by comparing against a known-good run before trusting the rebuild; fixed and reverified across all 30 quarters.


The acceptance test

The client's spec warns that a CUSIP can go through multiple, overlapping, non-sequential changes — not a clean A → B cutover. We didn't have to invent a test case for this: it's already in our data.

Issuer CIK 1939965 · Brera Holdings PLC · ticker SLMT

Three distinct CUSIPs, all filed against the same issuer, in overlapping windows — not sequential replacement:

CUSIPFirst seenLast seenFilings
G133111082026-04-072026-08-219
G133111162026-05-152026-08-143
G133111322026-05-292026-05-292

This is exactly why the detector reports candidates rather than auto-pairing “earliest” against “latest.” detect_cusip_successions.py surfaces all three under one issuer for a human to resolve — the honest output, given the real data doesn't cooperate with a simple sequential-chain assumption.


One thing worth flagging before this goes near a client conversation

The SEC's own published list carries this notice on every page: “CUSIP Numbers and descriptions are used with permission by Standard & Poor's CUSIP Service Bureau… No redistribution without permission.” Using the list to validate our own already-filed CUSIPs — which is all Phases 0–4 do — is exactly the use the SEC publishes it for. Re-exposing the raw issuer names and descriptions as a bulk feed to an external consumer is a different question, and one this report is flagging rather than deciding.


Gap analysis against the client's spec

Section numbers match the client's own document

Spec sectionAskStatusNote
§1Extraction fields (CUSIP, value, shares, filing metadata)donePre-existing; this was never the gap.
§2CUSIP validation — check digit, 13(f)-list status, exception fieldsdoneCheck digit + normalization live on every row. List-status join is data-ready; the consumer-facing exception view is the one piece of §2 still in Phase 5.
§3Succession semantics (name change / split / merger / reorg)doneLedger schema covers every event type the client listed, including the “CUSIP usually stays” vs. “new CUSIP assigned” distinction.
§4ASecurity master (issuer, type, linked identifiers)in queueDeliberately thin: we reuse the existing ticker/CIK table rather than a second master, per the client's own “could Claude+web produce this” test for what's worth building new.
§4BCUSIP succession / event tabledonecusip_succession_events + cusip_canonical, live, empty pending curation of the 9,837 candidates.
§4CHoldings joined to a permanent security keyin queuePhase 5 — wiring cusip_canonical into the live quarter-over-quarter join. Not started on purpose: this touches every downstream consumer (Qdrant, MCP, the mars mirror, Raul's front end) and should wait for a human to curate real candidates first.
§4DQoQ logic that doesn't invent trades on a split/mergerin queueSame Phase 5 dependency, plus split-ratio share adjustment specifically for reverse splits.
§5CGS / paid data sourcesout of scopeBy design. Built the free-source event pipeline instead; a licensing decision if the client needs recall a free pipeline can't reach.
§6Pipeline: ingest → validate → resolve → adjust → publish exceptionsin queueSteps 1–2 (ingest, validate) live. Steps 3–6 depend on Phase 5.
§7“Done” = one time series across a CUSIP change, audit trail intactin queueThe audit trail (reported → normalized → canonical → event) already exists end to end. The time-series continuity itself is exactly what Phase 5 delivers.

What's next

Curate a first batch of the 297 highest-confidence candidate pairs (corroborated against SEC name-change history, unambiguous mass-exit/mass-new signature) into cusip_succession_events. Re-run the chain resolver. Then — and only then — wire cusip_canonical into the live join, the same order cik_canonical itself was built in: detector first, curated seed second, production wiring last.