From 78b8c528a906125a305d2dcb87015f91a24c6b86 Mon Sep 17 00:00:00 2001 From: Maksim Kostromin Date: Mon, 26 Oct 2026 03:35:58 -0400 Subject: [PATCH] fix(updater): n-port name key from the shared template 3380d91 --- scripts/update-data.test.ts | 8 ++++++++ scripts/update-data.ts | 28 ++++++++++++++++++---------- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/scripts/update-data.test.ts b/scripts/update-data.test.ts index ecebdf4..696c904 100644 --- a/scripts/update-data.test.ts +++ b/scripts/update-data.test.ts @@ -328,6 +328,14 @@ describe('parsing 3', () => { expect(looksLikeProductPage('Title: Just a moment...\n\n## Performing security verification')).toBe(false); expect(looksLikeProductPage(usfrPage)).toBe(true); }); + + test('N-PORT issuer names match SEC titles across initials, a leading US and backslash EDGAR suffixes', () => { + const stock = (name: string, cusip: string) => `${name}${cusip}1USD11EC`; + const filing = parseNportFiling(`S000099999${stock('US Bancorp', '902973304')}${stock('Bancorp Inc/The', '05969A105')}${stock('CH Robinson Worldwide Inc', '12541W209')}`); + const names = parseSecCompanyNames({ 0: { ticker: 'USB', title: 'US BANCORP \\DE\\' }, 1: { ticker: 'TBBK', title: 'Bancorp, Inc.' }, 2: { ticker: 'CHRW', title: 'C H ROBINSON WORLDWIDE, INC.' } }); + // U.S. Bancorp is not The Bancorp, and C.H. Robinson is one issuer however its initials are spaced + expect(nportTickers(filing.positions, names)).toEqual(['USB', 'TBBK', 'CHRW']); + }); }); // =========================================================================== diff --git a/scripts/update-data.ts b/scripts/update-data.ts index 0a62f4f..02341c5 100755 --- a/scripts/update-data.ts +++ b/scripts/update-data.ts @@ -2745,20 +2745,28 @@ export function nportIdentifier(position: NportPosition): string { } /** - * A company name as a lookup key: upper case, punctuation and legal suffixes dropped, `CLASS A` -> `CL A`. Dotted - * abbreviations collapse like the undotted forms (`N.V.` = `NV`, `S.p.A.` = `SPA`, `A/S` = `A S` = `AS`, `S.A.B. de C.V.` = `SAB DE CV`) - * and `PUBLIC LIMITED COMPANY` is `plc`, so both spellings of one issuer give one key. + * A company name as a lookup key: upper case, punctuation and legal suffixes dropped, `CLASS A` -> `CL A`. Initials are one + * word whether written with dots, spaces or a slash (`N.V.` = `N V` = `NV`, `C.H. ROBINSON` = `C H ROBINSON`, `A/S` = `A S`, + * `S.A.B. de C.V.` = `SAB DE CV`), `PUBLIC LIMITED COMPANY` is `plc`, and a leading `US` stays (`U.S. BANCORP` is not + * `BANCORP, INC.`), so both spellings of one issuer give one key. */ export function nportNameKey(value: unknown): string { - // EDGAR adds a state or qualifier suffix to company titles ("COSTCO WHOLESALE CORP /NEW", "BANK OF AMERICA CORP /DE/"): drop a trailing one - const raw = String(value === null || value === undefined ? '' : value).replace(/(?:\s*\/\s*[A-Za-z]{2,3}\s*\/?)+\s*$/, '') - .replace(/\b[A-Za-z](?:\.[A-Za-z])+\.?(?![A-Za-z])/g, (dotted) => dotted.replace(/\./g, '')) - .replace(/\bA\/S\b/gi, 'AS'); - let text = nportText(raw).toUpperCase().replace(/['’]/g, '').replace(/&/g, ' AND ').replace(/[^A-Z0-9]+/g, ' '); - text = text.replace(/ A S ?$/, ' AS').replace(/\bPUBLIC (?:(?:LIMITED|LTD)(?: (?:COMP[A-Z]*|CO))?|(?:COMPANY|CO) (?:LIMITED|LTD))\b/g, 'PLC').replace(/\b(?:S DE RL )?DE CV\b/g, ' '); + // EDGAR adds a state or qualifier suffix to company titles ("COSTCO WHOLESALE CORP /NEW", "BANK OF AMERICA CORP /DE/", "US BANCORP \DE\"): drop a trailing one + const raw = String(value === null || value === undefined ? '' : value).replace(/(?:\s*[\/\\]\s*[A-Za-z]{2,3}\s*[\/\\]?)+\s*$/, ''); + let text = nportText(raw).toUpperCase().replace(/['’]/g, '').replace(/&/g, ' AND ').replace(/[^A-Z0-9]+/g, ' ').trim(); text = text.replace(/\bCLASS\s+([A-Z])\b/g, 'CL $1').replace(/\bCL\s+([A-Z])\b/g, 'CL $1'); + // a run of single letters is one word, never the class letter after CL + const words: string[] = []; + let joining = false; + text.split(' ').forEach((word) => { + const letter = /^[A-Z]$/.test(word); + if (letter && joining) words[words.length - 1] += word; + else words.push(word); + joining = letter && (joining || words[words.length - 2] !== 'CL'); + }); + text = words.join(' ').replace(/\bPUBLIC (?:(?:LIMITED|LTD)(?: (?:COMP[A-Z]*|CO))?|(?:COMPANY|CO) (?:LIMITED|LTD))\b/g, 'PLC').replace(/\b(?:S DE RL )?DE CV\b/g, ' '); const share = /\bCL [A-Z]\b/.exec(text); - text = text.replace(/\b(THE|INC|INCORPORATED|CORP|CORPORATION|CO|COMPANY|LTD|LIMITED|PLC|SA|SAB|NV|AG|SE|SPA|AS|ASA|ORDINARY|COMMON|STOCK|SHS|SHARES|ADR|DEPOSITARY|RECEIPT|USD|US|REG|REGISTERED)\b/g, ' ').replace(/\s+/g, ' ').trim(); + text = text.replace(/\b(THE|INC|INCORPORATED|CORP|CORPORATION|CO|COMPANY|LTD|LIMITED|PLC|SA|SAB|NV|AG|SE|SPA|AS|ASA|ORDINARY|COMMON|STOCK|SHS|SHARES|ADR|DEPOSITARY|RECEIPT|USD|US|REG|REGISTERED)\b/g, (word: string, _name: string, at: number) => (word === 'US' && at === 0 ? word : ' ')).replace(/\s+/g, ' ').trim(); if (share && !/\bCL [A-Z]\b/.test(text)) text = `${text} ${share[0]}`; return text; }