import fs from 'node:fs/promises'; import path from 'node:path'; const sourcePath = path.resolve('data/brk/rawData/brk_holdings_1988_1997_export.csv'); const outputPath = path.resolve('data/brk/brk_holdings.csv'); const COMPANY_NAMES = [ ['american express|美国运通', '美国运通(American Express)'], ['coca-cola|可口可乐', '可口可乐(Coca-Cola)'], ['disney|迪士尼', '华特迪士尼(Walt Disney)'], ['freddie mac|房地美|联邦住', '房地美(Freddie Mac)'], ['gillette|吉列', '吉列(Gillette)'], ['travelers|旅行家', '旅行家集团(Travelers Group)'], ['washington post|华盛顿邮报', '华盛顿邮报(Washington Post)'], ['wells fargo|富国银行', '富国银行(Wells Fargo)'], ['capital cities|大都会', '大都会/ABC(Capital Cities/ABC)'], ['geico|盖可', '盖可保险(GEICO)'], ['gannett|甘尼特', '甘尼特(Gannett)'], ['pnc', 'PNC 银行(PNC Bank)'], ['general dynamics|通用动力', '通用动力(General Dynamics)'], ['guinness|健力士', '健力士(Guinness)'], ["mcdonald|麦当劳", '麦当劳(McDonald\'s)'], ].map(([pattern, name]) => ({ pattern: new RegExp(pattern, 'i'), name })); function parseCSV(text) { const rows = []; let row = []; let value = ''; let quoted = false; for (let i = 0; i < text.length; i += 1) { const char = text[i]; if (char === '"') { if (quoted && text[i + 1] === '"') { value += '"'; i += 1; } else { quoted = !quoted; } } else if (char === ',' && !quoted) { row.push(value.trim()); value = ''; } else if ((char === '\n' || char === '\r') && !quoted) { if (char === '\r' && text[i + 1] === '\n') i += 1; row.push(value.trim()); rows.push(row); row = []; value = ''; } else { value += char; } } if (value || row.length) rows.push([...row, value.trim()]); return rows; } function canonicalName(value) { return COMPANY_NAMES.find(({ pattern }) => pattern.test(value))?.name || value.trim(); } function parseShares(value) { const digits = value.replace(/[^\d]/g, ''); return digits ? Number(digits) : NaN; } const rows = parseCSV(await fs.readFile(sourcePath, 'utf8')); const holdings = []; for (let index = 0; index < rows.length; index += 1) { const dateMatch = rows[index][0]?.match(/^(\d{4})\/12\/31$/); if (!dateMatch) continue; const year = Number(dateMatch[1]); const header = rows[index + 1] || []; const sharesFirst = /股(份)?数量|股数|持股数量/.test(header[0] || ''); const companyIndex = sharesFirst ? 1 : 0; const sharesIndex = sharesFirst ? 0 : 1; for (let rowIndex = index + 2; rowIndex < rows.length; rowIndex += 1) { const record = rows[rowIndex]; if (!record.some(Boolean)) break; if (/^\d{4}\/12\/31$/.test(record[0] || '')) break; const company = record[companyIndex]; const shares = parseShares(record[sharesIndex] || ''); if (!company || !Number.isFinite(shares)) continue; holdings.push({ year, company: canonicalName(company), shares }); } } holdings.sort((a, b) => a.year - b.year || a.company.localeCompare(b.company, 'zh-CN')); const lines = ['year,company,shares', ...holdings.map((row) => `${row.year},${row.company},${row.shares}`)]; await fs.mkdir(path.dirname(outputPath), { recursive: true }); await fs.writeFile(outputPath, `${lines.join('\n')}\n`, 'utf8'); console.log(`Normalized ${holdings.length} holdings across ${new Set(holdings.map((row) => row.year)).size} years.`);