Files
market_screener/src/finance/PortfolioImporter.js
T
Kazuma cd74497de6 refactor: restructure to clean architecture
fix: restore ScoringConfig improvements lost in refactor commit

docs: rewrite README and CLAUDE.md to reflect current architecture

code-format

code fixes
2026-06-03 01:36:21 -04:00

250 lines
8.8 KiB
JavaScript

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;
}
}