import { existsSync, readFileSync, writeFileSync } from 'fs'; // PortfolioImporter // // Reads a holdings CSV exported from Robinhood, Vanguard, or Fidelity // and merges the positions into portfolio.json. // // How to export: // Robinhood → Account → Statements & History → Export CSV (choose Holdings) // Vanguard → My Accounts → Holdings → Download (top-right icon) // Fidelity → Accounts & Trade → Portfolio → Positions → Download CSV // // Broker is auto-detected from the CSV headers. // Existing portfolio.json entries are updated in-place; new tickers are added. // Positions with zero shares are removed. export class PortfolioImporter { // ── Broker column maps ────────────────────────────────────────────────────── // Each broker uses different header names for the same data. // Listed in priority order — first match wins. static BROKERS = [ { name: 'Robinhood', detect: (headers) => headers.some((h) => /average.?cost/i.test(h) && headers.some((h2) => /quantity/i.test(h2))), ticker: ['Symbol'], shares: ['Quantity'], costBasis: ['Average Cost', 'Average Buy Price', 'Avg Cost'], }, { name: 'Vanguard', // Vanguard exports use "Ticker Symbol" and "Shares" — cost basis not always present detect: (headers) => headers.some((h) => /ticker.? symbol|ticker symbol/i.test(h)) && headers.some((h) => /^shares$/i.test(h)), ticker: ['Ticker Symbol', 'Ticker Symbol', 'Symbol', 'Ticker'], shares: ['Shares', 'Quantity'], costBasis: [ 'Average Cost Basis', 'Cost Basis Per Share', 'Avg Cost Basis/Share', 'Average Cost', ], }, { name: 'Fidelity', detect: (headers) => headers.some((h) => /account.?name/i.test(h)) && headers.some((h) => /symbol/i.test(h)) && headers.some((h) => /cost.?basis/i.test(h)), ticker: ['Symbol'], shares: ['Quantity', 'Shares'], costBasis: ['Cost Basis Per Share', 'Average Cost Basis', 'Cost Basis'], }, { name: 'Generic', detect: () => true, // fallback ticker: ['Symbol', 'Ticker', 'ticker', 'symbol', 'SYMBOL'], shares: ['Quantity', 'Shares', 'shares', 'quantity', 'QTY', 'Qty'], costBasis: [ 'Average Cost', 'Cost Basis', 'Avg Cost', 'Average Buy Price', 'Cost Per Share', 'cost_basis', ], }, ]; // ── Public API ────────────────────────────────────────────────────────────── import(csvPath, portfolioPath = './portfolio.json', source = null) { if (!existsSync(csvPath)) { throw new Error(`File not found: ${csvPath}`); } const raw = readFileSync(csvPath, 'utf8'); const parsed = this._parseCSV(raw); if (parsed.length === 0) { throw new Error('CSV is empty or could not be parsed.'); } const broker = this._detectBroker(parsed[0]); const brokerName = source ?? broker.name; console.log(`\n🔍 Detected broker: ${brokerName}`); const holdings = this._extractHoldings(parsed, broker, brokerName); if (holdings.length === 0) { throw new Error( `No valid holdings found.\n` + `Headers detected: ${Object.keys(parsed[0]).join(', ')}\n` + `Tip: use --broker to specify manually if auto-detection failed.`, ); } const merged = this._mergeIntoPortfolio(holdings, portfolioPath); console.log(`✅ Imported ${holdings.length} positions from ${broker.name}`); console.log(` portfolio.json now has ${merged.holdings.length} holdings\n`); holdings.forEach((h) => { const cb = h.costBasis != null ? ` @ $${h.costBasis.toFixed(2)}` : ' (no cost basis)'; console.log(` ${h.ticker.padEnd(6)} ${h.shares} shares${cb}`); }); return merged; } // ── CSV parser (no external deps) ────────────────────────────────────────── _parseCSV(raw) { const lines = raw.split(/\r?\n/).filter((l) => l.trim()); if (lines.length < 2) return []; // Find the header row — skip metadata rows at the top (Vanguard has these) // A valid header row has at least one of these keywords const headerKeywords = /symbol|ticker|shares|quantity|cost|price/i; let headerIdx = 0; for (let i = 0; i < Math.min(lines.length, 10); i++) { if (headerKeywords.test(lines[i])) { headerIdx = i; break; } } const headers = this._splitRow(lines[headerIdx]).map((h) => h.trim().replace(/^"|"$/g, '')); const rows = []; for (let i = headerIdx + 1; i < lines.length; i++) { const values = this._splitRow(lines[i]).map((v) => v.trim().replace(/^"|"$/g, '')); if (values.length < 2 || !values[0]) continue; const row = {}; headers.forEach((h, idx) => { row[h] = values[idx] ?? ''; }); rows.push(row); } return rows; } _splitRow(line) { // Handle quoted CSV fields that may contain commas const result = []; let current = ''; let inQuotes = false; for (const ch of line) { if (ch === '"') { inQuotes = !inQuotes; } else if (ch === ',' && !inQuotes) { result.push(current); current = ''; } else { current += ch; } } result.push(current); return result; } // ── Broker detection ──────────────────────────────────────────────────────── _detectBroker(sampleRow) { const headers = Object.keys(sampleRow); return PortfolioImporter.BROKERS.find((b) => b.detect(headers)); } // ── Holdings extraction ───────────────────────────────────────────────────── _extractHoldings(rows, broker, source = null) { const holdings = []; for (const row of rows) { const ticker = this._getField(row, broker.ticker); const sharesRaw = this._getField(row, broker.shares); const costRaw = this._getField(row, broker.costBasis); // Skip non-ticker rows (totals, cash, blanks, fund names) if (!ticker || !/^[A-Z]{1,6}$/.test(ticker.toUpperCase().trim())) continue; const shares = parseFloat(sharesRaw?.replace(/[,$]/g, '') ?? '0'); const costBasis = costRaw ? parseFloat(costRaw.replace(/[,$]/g, '')) : null; if (isNaN(shares) || shares <= 0) continue; // skip zero/empty positions holdings.push({ ticker: ticker.toUpperCase().trim(), shares: +shares.toFixed(6), costBasis: costBasis != null && !isNaN(costBasis) ? +costBasis.toFixed(4) : null, source: source ?? broker.name, type: 'stock', // default; user can change to 'etf' or 'crypto' in portfolio.json }); } return holdings; } _getField(row, candidates) { const rowKeys = Object.keys(row); for (const key of candidates) { // 1. Exact match if (row[key] !== undefined && row[key] !== '') return row[key]; // 2. Case-insensitive exact match const exact = rowKeys.find((k) => k.toLowerCase() === key.toLowerCase()); if (exact && row[exact] !== '') return row[exact]; // 3. Normalised match — collapse whitespace and compare const norm = (s) => s.toLowerCase().replace(/\s+/g, ' ').trim(); const fuzzy = rowKeys.find((k) => norm(k) === norm(key)); if (fuzzy && row[fuzzy] !== '') return row[fuzzy]; } return null; } // ── Merge into portfolio.json ─────────────────────────────────────────────── _mergeIntoPortfolio(newHoldings, portfolioPath) { const existing = existsSync(portfolioPath) ? JSON.parse(readFileSync(portfolioPath, 'utf8')) : { holdings: [] }; const holdingMap = Object.fromEntries( (existing.holdings ?? []).map((h) => [h.ticker.toUpperCase(), h]), ); for (const h of newHoldings) { if (holdingMap[h.ticker]) { // Update existing entry — preserve manually set costBasis if CSV has none holdingMap[h.ticker].shares = h.shares; if (h.costBasis != null) holdingMap[h.ticker].costBasis = h.costBasis; } else { holdingMap[h.ticker] = { ticker: h.ticker, shares: h.shares, costBasis: h.costBasis ?? 0, }; } } const merged = { holdings: Object.values(holdingMap) }; writeFileSync(portfolioPath, JSON.stringify(merged, null, 2), 'utf8'); return merged; } }