From 1e1a7bb5499debbb5f1f30a5181b29fabf2a3aeb Mon Sep 17 00:00:00 2001 From: Maksim Kostromin Date: Mon, 26 Oct 2026 04:05:17 -0400 Subject: [PATCH] fix(updater): n-port tickers only for common stock, state suffix in names, no float artifacts in cells --- scripts/update-data.ts | 40 ++++++++++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/scripts/update-data.ts b/scripts/update-data.ts index 02341c5..c2af86a 100755 --- a/scripts/update-data.ts +++ b/scripts/update-data.ts @@ -2585,8 +2585,8 @@ export function withoutClosedFunds(queue: ReadonlyArray, tickerOf: (item: * - identifiers come from the position itself: `` text (or a `value` attribute), ``, ``, * ``; a derivative's reference instrument (inside ``) never lends its identifiers to the position * - CUSIP `000000000` and `N/A` are missing; a missing weight or value is null (an empty cell), never 0 - * - a ticker only for an equity row (assetCat EC or EP) traded in USD with a U.S. ISIN or none, and never one ticker on two - * different securities (`nportTickers`) + * - a ticker only for a common-stock row (assetCat EC, not a CVR, right, warrant or escrow line) traded in USD with a U.S. + * ISIN or none, and never one ticker on two different securities (`nportTickers`) * - identity: the filing must name the fund's own series id, or, with no series id known, the fund's name (`nportIdentity`) * - freshness: the report period must be strictly newer than the published holdings (`nportIsFresh`) * - filings come newest report period first from the registrant's own archive folder, never from the filer agent's CIK @@ -2732,11 +2732,17 @@ export function parseNportFiling(xml: string): NportFiling { }; } -/** A number as sheet text: '' for null, no exponent, at most `digits` decimals (trailing zeros dropped). */ +/** + * A number as sheet text: '' for null, no exponent, at most `digits` decimals (trailing zeros dropped), and no binary float + * artifact: `115797518.01` stays `115797518.01`, not the `115797518.01000001` of `toFixed(8)`. + */ export function nportCell(value: number | null, digits = 8): string { if (value === null || !Number.isFinite(value)) return ''; - const text = value.toFixed(digits); - return text.indexOf('.') < 0 ? text : text.replace(/0+$/, '').replace(/\.$/, ''); + const fixed = value.toFixed(digits); + // the shortest text of the rounded number; toFixed only where that text would be an exponent (very small or large numbers) + const shortest = String(Number(fixed)); + if (!/e/i.test(shortest)) return shortest; + return fixed.indexOf('.') < 0 ? fixed : fixed.replace(/0+$/, '').replace(/\.$/, ''); } /** The position's own identifier for a sheet: CUSIP, else ISIN, else the "other" code (a Bloomberg or internal id), else ''. */ @@ -2751,8 +2757,9 @@ export function nportIdentifier(position: NportPosition): string { * `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/", "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*$/, ''); + // EDGAR adds a state or qualifier suffix to company titles ("COSTCO WHOLESALE CORP /NEW", "BANK OF AMERICA CORP /DE/", + // "US BANCORP \DE\", "ACUITY INC. (DE)"): drop a trailing one + const raw = String(value === null || value === undefined ? '' : value).replace(/(?:\s*(?:[\/\\]\s*[A-Za-z]{2,3}\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 @@ -2808,8 +2815,21 @@ export function parseSecFundTickers(payload: unknown): Map`, else the + * Is the row a contingent value right, a right, a warrant or an escrow line filed as equity? A placeholder CUSIP or ISIN + * (`436CVR021`, `US009CVR0445`, `549ESC028`, `866CNT017`) says so; so does the name or title (`NOVO NORDISK A/S - CVR`, + * `XYZ INC RIGHTS`, `... WARRANT FEB 2026`, `Luby's Inc Escrow`), except on a CUSIP with the common-stock issue number 10 + * (Schwab titles Enovix common stock 293594107 "Enovix Corp Warrants"). `CVR` as the first word is a company (CVR Energy). + */ +function nportIsRight(position: NportPosition): boolean { + if (/^[0-9A-Z]{3}(?:CVR|ESC|CNT)/.test(position.cusip) || /^US[0-9A-Z]{3}(?:CVR|ESC|CNT)/.test(position.isin)) return true; + if (/^[0-9A-Z]{6}10[0-9]$/.test(position.cusip)) return false; + return [position.name, position.title].some((text) => /\S\s*\bCVRS?\b|\bCONTINGENT\b|\bRIGHTS?\b|\bRTS\b|\bWARRANTS?\b|\bWTS\b|\bESCROW\b/i.test(text)); +} + +/** + * Exchange tickers for the positions, in their order ('' for none). A ticker is given only to a common-stock row (assetCat + * EC; a preferred EP, a CVR, right, warrant or escrow line gets none, a name match would give it the issuer's common ticker) + * traded in USD (or with no currency) whose ISIN is U.S. or missing: first the filer's own ``, else the * SEC issuer-name table (`names`) when the row has a CUSIP or a U.S. ISIN. A ticker that lands on two different securities * (Alphabet A and C, an ordinary share and its ADR) is dropped from the name-matched rows; when the filer itself gave it * to two securities it is dropped from all of them. Bonds, cash, funds and derivatives stay identifier-keyed. @@ -2818,7 +2838,7 @@ export function nportTickers(positions: ReadonlyArray, names: Map const fromFiler: boolean[] = []; const tickers = positions.map((position, index) => { fromFiler[index] = false; - const equity = (position.assetCat === 'EC' || position.assetCat === 'EP') && !position.derivative; + const equity = position.assetCat === 'EC' && !position.derivative && !nportIsRight(position); const usd = !position.currency || position.currency === 'USD'; if (!equity || !usd || (position.isin && position.isin.slice(0, 2) !== 'US')) return ''; if (/^[A-Z][A-Z0-9.-]{0,9}$/.test(position.ticker)) {