diff --git a/src/index.css b/src/index.css index f73c5bd..192f964 100644 --- a/src/index.css +++ b/src/index.css @@ -102,6 +102,9 @@ @media (prefers-color-scheme:dark){.busy-overlay{background:rgba(15,23,42,.7);color:#cbd5e1}} html.dark .busy-overlay{background:rgba(15,23,42,.7);color:#cbd5e1} @media (prefers-reduced-motion:reduce){.busy-spinner{animation-duration:2.4s}} + .closed-badge{display:inline-block;margin-left:.375rem;padding:0 .4rem;border:1px solid #fecaca;border-radius:9999px;background:#fef2f2;color:#b91c1c;font-size:.6875rem;font-weight:600;line-height:1.05rem;white-space:nowrap;vertical-align:middle;text-decoration:none} + a.closed-badge:hover{text-decoration:underline} + .dark .closed-badge{border-color:#7f1d1d;background:#3b1219;color:#fca5a5} /* * Horizontal pinning for the catalog table: the Use checkbox and the diff --git a/src/main.tsx b/src/main.tsx index 60479cc..3adaaa9 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -100,6 +100,7 @@ type FundRow = TableRow & { type WatchlistRow = TableRow & { key: string; symbol: string; + ticker: string; name: string; funds: string[]; fundCount: number; @@ -1029,7 +1030,7 @@ function bindColumnFilterEvents(): void { const FUND_FILTER_COLUMNS: FilterColumn[] = [ { key: 'ticker', label: 'Ticker', numeric: false, text: (fund: any) => String((fund.ticker) ?? ''), extraClass: 'catalog-sticky-col catalog-sticky-ticker' }, - { key: 'name', label: 'Fund Name', numeric: false, text: (fund: any) => String((fund.name) ?? '') }, + { key: 'name', label: 'Fund Name', numeric: false, text: (fund: any) => fundNameText(fund.name, fund) }, { key: 'category', label: 'Type', numeric: false, text: (fund: any) => String((categoryLabel(fund.category)) ?? '') }, { key: 'navValue', label: 'NAV', numeric: true, text: (fund: any) => String((fund.nav || '—') ?? ''), value: (fund: any) => fund.navValue }, { key: 'aumValue', label: 'Net Assets', numeric: true, text: (fund: any) => String((formatMoney(fund.aumValue)) ?? ''), value: (fund: any) => fund.aumValue }, @@ -1824,6 +1825,7 @@ function normalizeFundRow(fund: IndexFund): FundRow { fund.exchange, fund.inceptionDate, fund.asOfDate, monthEnd.asOfDate, fund.distributions && fund.distributions.frequency, metrics.dividendYieldText, metrics.secYieldText, fund.cusip, fund.isin, + closedFundText(row), ].map(value => String(value ?? '').toLowerCase()).join(' '); return row; } @@ -1904,7 +1906,7 @@ async function fetchPage(ticker: string, pagePath: string): Promise<{ headers: s const page = await fetchJson(`./api/wisdomtree/funds/${encodeURIComponent(ticker)}/${path}`); const headers: string[] = Array.isArray(page.headers) ? page.headers : []; const rows: any[] = Array.isArray(page.rows) ? page.rows : []; - return { headers, rows: rows.map(row => headers.map(header => String(row[header] ?? ''))) }; + return { headers, rows: rows.map(row => headers.map((header, index) => String((Array.isArray(row) ? row[index] : row && row[header]) ?? ''))) }; // objects keyed by the header, or arrays in header order (iShares) } /** Loads the first page of a paginated sheet and prepares lazy appending. */ @@ -2679,7 +2681,7 @@ function renderFundsTable(): void { ${escapeHtml(fund.ticker)} - ${escapeHtml(fund.name)} + ${escapeHtml(fund.name)}${closedBadgeHtml(fund)} ${escapeHtml(categoryLabel(fund.category))} ${escapeHtml(fund.nav || '—')} ${formatMoney(fund.aumValue)} @@ -2739,36 +2741,318 @@ function cleanKeyPart(value: unknown): string { return text.toUpperCase(); } +// ---- security identity of a holding (ETF-C3): the Watchlist merges rows of one security, never rows that only share a ticker ---- + +/** Value of an identifier character: 0-9, then A-Z as 10..35, then the CUSIP extras * @ # (-1 for anything else). */ +function idCharValue(c: string): number { + return '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ*@#'.indexOf(c); +} + +/** A 9-character CUSIP (or CINS) with a valid check digit. */ +function isCusip(text: string): boolean { + if (!/^[0-9A-Z*@#]{8}[0-9]$/.test(text)) return false; + let sum = 0; + for (let i = 0; i < 8; i++) { + const value = idCharValue(text.charAt(i)) * (i % 2 ? 2 : 1); + sum += Math.floor(value / 10) + (value % 10); + } + return (10 - (sum % 10)) % 10 === Number(text.charAt(8)); +} + +/** A 12-character ISIN: country, 9-character national code, Luhn check digit over the letters expanded to numbers. */ +function isIsin(text: string): boolean { + if (!/^[A-Z]{2}[0-9A-Z]{9}[0-9]$/.test(text)) return false; + let digits = ''; + for (let i = 0; i < text.length; i++) digits += String(idCharValue(text.charAt(i))); + let sum = 0; + for (let i = 0; i < digits.length; i++) { + let digit = Number(digits.charAt(digits.length - 1 - i)); + if (i % 2) { + digit *= 2; + if (digit > 9) digit -= 9; + } + sum += digit; + } + return sum % 10 === 0; +} + +/** A 7-character SEDOL (no vowels) with a valid check digit. */ +function isSedol(text: string): boolean { + if (!/^[0-9B-DF-HJ-NP-TV-Z]{6}[0-9]$/.test(text)) return false; + const weights = [1, 3, 1, 7, 3, 9, 1]; + let sum = 0; + for (let i = 0; i < 7; i++) sum += idCharValue(text.charAt(i)) * weights[i]; + return sum % 10 === 0; +} + +/** A 12-character FIGI (BBG prefix, no vowels). */ +function isFigi(text: string): boolean { + return /^BBG[0-9B-DF-HJ-NP-TV-Z]{8}[0-9]$/.test(text); +} + +const identifierKeyCache: Map = new Map(); + +/** + * Namespaced keys of one identifier cell (already cleaned and upper case), recognized by format and check digit whatever + * column it came from (Vanguard's CUSIP column also holds SEDOLs and ISINs): `C:` CUSIP, `I:` ISIN, `F:` FIGI, `S:` SEDOL. + * A U.S. or Canadian ISIN, or one built on a CINS, also gives its CUSIP, a British or Irish one its SEDOL, so brands that + * publish different identifiers of one security still meet. All-digit CUSIPs and SEDOLs that lost a leading zero in a + * spreadsheet are padded back. Anything else (cash lines, option symbols, swaps, internal codes) gives none. + */ +function identifierKeys(text: string): string[] { + const cached = identifierKeyCache.get(text); + if (cached) return cached; + let keys: string[] = []; + if (isFigi(text)) keys = [`F:${text}`]; + else if (isIsin(text)) { + keys = [`I:${text}`]; + const national = text.slice(2, 11); + if ((/^(US|CA)/.test(text) || /^[A-Z]/.test(national)) && isCusip(national)) keys.push(`C:${national}`); + if (/^(GB|IE|GG|JE|IM)00/.test(text) && isSedol(text.slice(4, 11))) keys.push(`S:${text.slice(4, 11)}`); + } else if (isCusip(text)) keys = [`C:${text}`]; + else if (/^\d{8}$/.test(text) && isCusip(`0${text}`)) keys = [`C:0${text}`]; + else if (isSedol(text)) keys = [`S:${text}`]; + else if (/^\d{6}$/.test(text) && isSedol(`0${text}`)) keys = [`S:0${text}`]; + identifierKeyCache.set(text, keys); + return keys; +} + +/** Cells that may hold a security identifier, in the order their values are tried. */ +const SECURITY_ID_COLUMNS = ['CUSIP', 'ISIN', 'FIGI', 'SEDOL', 'Identifier', 'Security ID']; + +/** Legal-form and share-class words dropped from a name before the ticker+name match. */ +const NAME_NOISE_WORDS = ['INC', 'INCORPORATED', 'CORP', 'CORPORATION', 'CO', 'COMPANY', 'LTD', 'LIMITED', 'PLC', 'LLC', 'THE', 'CL', 'CLASS', 'COM', 'COMMON', 'STOCK', 'SHS', 'SHARES', 'ORD', 'NPV']; + +const securityNameCache: Map = new Map(); + +/** Name in one spelling for the ticker+name match: upper case, no punctuation or legal-form words (Apple Inc. = APPLE INC). */ +function securityNameKey(name: string): string { + const cached = securityNameCache.get(name); + if (cached !== undefined) return cached; + const key = name.toUpperCase().replace(/&/g, ' AND ').split(/[^A-Z0-9]+/).filter(word => word && NAME_NOISE_WORDS.indexOf(word) < 0).join(' '); + securityNameCache.set(name, key); + return key; +} + +/** Asset classes of debt (bonds, notes, bills, loans, mortgage and asset-backed) in the brands' Asset Class or Asset Category cells, N-PORT codes included. */ +const DEBT_CLASS = /\b(fixed income|bonds?|treasur\w*|debt|dbt|notes?|bills?|govt|government|municipal|muni|loans?|lon|mbs|abs|cmo|remic|agency|sovereign|yankee|mortgage|asset backed|fnma|fhlmc|gnma|umbs)\b/i; + +const debtClassCache: Map = new Map(); + +/** True for a debt row: a debt asset class, a coupon or a maturity date. Its ticker is the issuer's, so it never meets the issuer's stock by ticker and name. */ +function isDebtRow(cells: Record): boolean { + const classes = [cells['Asset Class'], cells['Asset Category'], cells['Security Type'], cells.Type]; + for (let i = 0; i < classes.length; i++) { + const text = String(classes[i] ?? ''); + if (!text) continue; + let debt = debtClassCache.get(text); + if (debt === undefined) { + debt = DEBT_CLASS.test(text); + debtClassCache.set(text, debt); + } + if (debt) return true; + } + const coupon = cleanKeyPart(cells.Coupon) || cleanKeyPart(cells['Coupon (%)']); + if (coupon && Number.isFinite(Number(coupon.replace(/[%,\s]/g, '')))) return true; + return /\d/.test(cleanKeyPart(cells.Maturity) || cleanKeyPart(cells['Maturity Date'])); +} + +/** Ticker in one spelling for the ticker+name match: BRK/B, BRK.B, BRK-B are one; U.S. exchange codes (AAPL UW, NVDA.O) are dropped. */ +function securityTickerKey(ticker: string): string { + return ticker.replace(/\.(O|N|OQ)$/, '').replace(/[\s./-]+/g, ' ').replace(/ (US|UN|UW|UQ|UR|UA|UP|UF|UV)$/, '').trim(); +} + +/** Watchlist identity of one holding row: `key` merges rows, `shown` is the Ticker cell, `ticker` the real ticker for Copy Tickers ('' when none). */ +type SecurityKey = { key: string; shown: string; ticker: string }; + /** - * Documented dedupe key fallback order (docs/ui-contract.md §5): - * Ticker -> CUSIP -> ISIN -> Identifier/Security ID -> SEDOL/FIGI -> Name. - * Keys are namespaced (T:/C:/I:/D:/S:/N:) so identifier values can never - * collide with tickers. Numeric local listing tickers (e.g. 005930) are - * valid tickers, not placeholders. Rows are never aggressively dropped: - * bonds without exchange tickers, cash positions, derivatives and zero-weight - * holdings all resolve to a key (the name being the last resort). + * Groups holding rows (header -> cell, from object and array pages alike) into securities and returns each row's key. + * Rows are one security when they share a CUSIP, ISIN, FIGI or SEDOL (`identifierKeys`). A row without one of those + * is matched by its other identifier text (cash lines, option symbols), or else by ticker and name. + * Ticker and name (plus debt or not: a bond carries its issuer's ticker) also join identified groups that publish + * different identifier kinds (one brand only SEDOLs, another only CUSIPs), but only when no two groups of that ticker + * and name disagree on a CUSIP, ISIN or SEDOL (FIGIs are not compared: VanEck publishes the composite FIGI of a stock, + * Schwab another level of it), and a row without identifiers joins an identified group only when exactly one group has + * its ticker and name. A ticker alone never merges anything: Vanguard's `T` on Treasuries stays apart from AT&T, + * MRK GY from MRK, 000100 of Yuhan from 000100 of TCL. The key is the group's first identifier (CUSIP, else ISIN, + * FIGI, SEDOL), the shown text its first ticker (else the identifier, else the name). */ -function positionDedupeKey(row: Record): { key: string; shown: string } | null { - const first = (...values: unknown[]): string => { - for (const value of values) { - const clean = cleanKeyPart(value); - if (clean) return clean; +function securityKeys(rows: Array>): SecurityKey[] { + const count = rows.length; + const parent: number[] = new Array(count); + // per group root: the first CUSIP, ISIN, SEDOL and FIGI key seen in the group ('' = none) + const cusip: string[] = new Array(count); + const isin: string[] = new Array(count); + const sedol: string[] = new Array(count); + const figi: string[] = new Array(count); + const strong: boolean[] = new Array(count); + const primary: string[] = new Array(count); + const pairs: string[] = new Array(count); + const tickers: string[] = new Array(count); + const names: string[] = new Array(count); + const find = (index: number): number => { + let root = index; + while (parent[root] !== root) root = parent[root]; + let at = index; + while (parent[at] !== root) { + const next = parent[at]; + parent[at] = root; + at = next; } - return ''; + return root; }; - const ticker = first(row.Ticker, row.Symbol); - if (ticker) return { key: `T:${ticker}`, shown: ticker }; - const cusip = first(row.CUSIP); - if (cusip) return { key: `C:${cusip}`, shown: cusip }; - const isin = first(row.ISIN); - if (isin) return { key: `I:${isin}`, shown: isin }; - const identifier = first(row.Identifier, row['Security ID']); - if (identifier) return { key: `D:${identifier}`, shown: identifier }; - const sedol = first(row.SEDOL, row.FIGI); - if (sedol) return { key: `S:${sedol}`, shown: sedol }; - const name = String(row.Name ?? row['Security Name'] ?? '').trim(); - if (name) return { key: `N:${name.toUpperCase()}`, shown: name }; - return null; + const identified = (root: number): boolean => !!(cusip[root] || isin[root] || sedol[root] || figi[root]); + const union = (a: number, b: number): void => { + const rootA = find(a); + const rootB = find(b); + if (rootA === rootB) return; + const low = rootA < rootB ? rootA : rootB; + const high = rootA < rootB ? rootB : rootA; + parent[high] = low; + if (!cusip[low]) cusip[low] = cusip[high]; + if (!isin[low]) isin[low] = isin[high]; + if (!sedol[low]) sedol[low] = sedol[high]; + if (!figi[low]) figi[low] = figi[high]; + }; + const firstByLink: Map = new Map(); + const link = (key: string, index: number): void => { + const first = firstByLink.get(key); + if (first === undefined) firstByLink.set(key, index); + else union(first, index); + }; + for (let i = 0; i < count; i++) { + const cells = rows[i]; + parent[i] = i; + cusip[i] = ''; + isin[i] = ''; + sedol[i] = ''; + figi[i] = ''; + let other = ''; + for (let c = 0; c < SECURITY_ID_COLUMNS.length; c++) { + const raw = cells[SECURITY_ID_COLUMNS[c]]; + if (raw === undefined || raw === null || raw === '') continue; + const value = cleanKeyPart(raw); + if (!value) continue; + const keys = identifierKeys(value); + if (!keys.length && !other) other = value; + for (let k = 0; k < keys.length; k++) { + const key = keys[k]; + const kind = key.charAt(0); + if (kind === 'C' && !cusip[i]) cusip[i] = key; + else if (kind === 'I' && !isin[i]) isin[i] = key; + else if (kind === 'S' && !sedol[i]) sedol[i] = key; + else if (kind === 'F' && !figi[i]) figi[i] = key; + } + } + const ticker = cleanKeyPart(cells.Ticker) || cleanKeyPart(cells.Symbol); + const name = String(cells.Name || cells['Security Name'] || cells.Description || '').trim(); + const nameKey = securityNameKey(name); + strong[i] = identified(i); + tickers[i] = ticker; + names[i] = name; + pairs[i] = ticker ? `P:${securityTickerKey(ticker)}|${nameKey}|${isDebtRow(cells) ? 'debt' : ''}` : ''; + primary[i] = strong[i] ? '' : other ? `D:${other}` : ticker ? '' : nameKey ? `N:${nameKey}` : ''; + if (cusip[i]) link(cusip[i], i); + if (isin[i]) link(isin[i], i); + if (sedol[i]) link(sedol[i], i); + if (figi[i]) link(figi[i], i); + if (primary[i]) link(primary[i], i); + } + // ticker and name join identified groups of different identifier kinds, unless two of them disagree (then none join) + const groupsByPair: Map = new Map(); + for (let i = 0; i < count; i++) { + if (!pairs[i] || !strong[i]) continue; + const list = groupsByPair.get(pairs[i]); + if (list) list.push(i); + else groupsByPair.set(pairs[i], [i]); + } + groupsByPair.forEach(list => { + if (list.length < 2) return; + let seenCusip = ''; + let seenIsin = ''; + let seenSedol = ''; + for (let n = 0; n < list.length; n++) { + const root = find(list[n]); + if (cusip[root] && seenCusip && cusip[root] !== seenCusip) return; + if (isin[root] && seenIsin && isin[root] !== seenIsin) return; + if (sedol[root] && seenSedol && sedol[root] !== seenSedol) return; + seenCusip = seenCusip || cusip[root]; + seenIsin = seenIsin || isin[root]; + seenSedol = seenSedol || sedol[root]; + } + for (let n = 1; n < list.length; n++) union(list[0], list[n]); + }); + // a row without identifiers joins the one identified group of its ticker and name, else the other such rows + const firstWeakByPair: Map = new Map(); + for (let i = 0; i < count; i++) { + if (!pairs[i] || strong[i] || identified(find(i))) continue; // its group may already have joined an identified one + const list = groupsByPair.get(pairs[i]); + const target = list ? find(list[0]) : -1; + let unique = target >= 0; + for (let n = 1; unique && list && n < list.length; n++) unique = find(list[n]) === target; + if (unique) { + union(target, i); + continue; + } + const first = firstWeakByPair.get(pairs[i]); + if (first === undefined) firstWeakByPair.set(pairs[i], i); + else union(first, i); + } + const byRoot: Array = new Array(count); + const groupTicker: string[] = new Array(count); + for (let i = 0; i < count; i++) { + const root = find(i); + if (tickers[i] && !groupTicker[root]) groupTicker[root] = tickers[i]; + } + const out: SecurityKey[] = new Array(count); + for (let i = 0; i < count; i++) { + const root = find(i); + let resolved = byRoot[root]; + if (!resolved) { + const id = cusip[root] || isin[root] || figi[root] || sedol[root] || primary[root]; + const ticker = groupTicker[root] || ''; + resolved = { key: id || pairs[root] || `R:${root}`, shown: ticker || id.replace(/^[A-Z]:/, '') || names[root], ticker }; + byRoot[root] = resolved; + } + out[i] = resolved; + } + return out; +} + +/** Copy Tickers of the Watchlist: the real tickers of the shown rows, each once (two securities may show one ticker), never a CUSIP or name stand-in. */ +function watchlistTickers(rows: Array<{ ticker: string }>): string[] { + const seen: Record = {}; + return rows.map(row => row.ticker).filter(ticker => { + if (!ticker || seen[ticker]) return false; + seen[ticker] = true; + return true; + }); +} + +// ---- closed funds (Q21): index rows with `status: closed`, `closedDate` and `closedSource` stay listed with a badge ---- + +/** `Closed ` for a closed fund's index row, '' for any other fund. */ +function closedFundText(fund: Record | null | undefined): string { + if (!fund || fund.status !== 'closed') return ''; + const date = String(fund.closedDate ?? '').trim(); + return date ? `Closed ${date}` : 'Closed'; +} + +/** Fund Name text for the column filter and the search: the name plus `Closed `, so `closed` finds the closed funds and `!closed` hides them. */ +function fundNameText(name: unknown, fund: Record | null | undefined): string { + const closed = closedFundText(fund); + const text = String(name ?? ''); + return closed ? `${text} ${closed}` : text; +} + +/** Badge after the fund name: links the SEC filing or issuer notice of the closing (`closedSource`, https only). */ +function closedBadgeHtml(fund: Record | null | undefined): string { + const text = closedFundText(fund); + if (!text || !fund) return ''; + const source = String(fund.closedSource ?? '').trim(); + const tip = 'The fund is closed (liquidated): it keeps its last published data'; + if (/^https:\/\//i.test(source)) return ` ${escapeHtml(text)}`; + return ` ${escapeHtml(text)}`; } function sheetPositions(ticker: string): HoldingPosition[] { @@ -2835,14 +3119,16 @@ function getDedupedWatchlistRows(): WatchlistRow[] { if (watchlistCache && watchlistCache.signature === signature) return watchlistCache.rows; const map: Map = new Map(); - getSelectedPositions().forEach(position => { - const resolved = positionDedupeKey(position.cells); - if (!resolved) return; + const positions = getSelectedPositions(); + const resolvedKeys = securityKeys(positions.map(position => position.cells)); + positions.forEach((position, index) => { + const resolved = resolvedKeys[index]; let row = map.get(resolved.key); if (!row) { row = { key: resolved.key, symbol: resolved.shown, + ticker: resolved.ticker, name: position.name, funds: [], fundCount: 0, @@ -2964,7 +3250,7 @@ function renderWatchlistTable(): void { const queryText = currentQuery() ? ` matching “${currentQuery()}”` : ''; - setStatus(`Watchlist built from ${state.selected.size} selected ETF${state.selected.size === 1 ? '' : 's'}: ${rows.length} ticker${rows.length === 1 ? '' : 's'}${queryText}. Deduplicated by ticker, or by identifier for bond rows.${rankHint(rows.length)}`, 'success'); + setStatus(`Watchlist built from ${state.selected.size} selected ETF${state.selected.size === 1 ? '' : 's'}: ${rows.length} ticker${rows.length === 1 ? '' : 's'}${queryText}. One row per security (CUSIP, ISIN, FIGI or SEDOL, else ticker and name), never merged by ticker alone.${rankHint(rows.length)}`, 'success'); el.tickerCount.textContent = `${rows.length} tickers`; renderSubtitle(); } @@ -3701,7 +3987,7 @@ function currentExportRows(): { headers: string[]; rows: string[][]; scope: stri function copyTickers(): void { let values: string[] = []; - if (state.activeTab === 'watchlist') values = getVisibleWatchlistRows().map(row => row.symbol); + if (state.activeTab === 'watchlist') values = watchlistTickers(getVisibleWatchlistRows()); // real tickers only, each once (two securities can show one ticker) else if (isDetailTab(state.activeTab)) values = currentExportRows().rows.map(row => String(row[0] ?? '')).filter(Boolean); else values = filteredCatalogFunds().map(fund => fund.ticker); values = values.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));