85 lines
3.1 KiB
JavaScript
85 lines
3.1 KiB
JavaScript
/**
|
||
* bin/finance.js — Personal Finance CLI
|
||
*
|
||
* Fetches your accounts from SimpleFIN, screens your portfolio holdings,
|
||
* and saves a finance-report.html with:
|
||
* 1. Net worth + account overview (SimpleFIN)
|
||
* 2. Portfolio hold/sell/add advice (screener + crypto prices)
|
||
* 3. Spending breakdown (SimpleFIN)
|
||
*
|
||
* Usage:
|
||
* npm run finance
|
||
*/
|
||
|
||
import 'dotenv/config';
|
||
import { readFileSync, existsSync } from 'fs';
|
||
import { SimpleFINClient, saveAccessUrlToEnv } from '../server/finance/clients/SimpleFINClient.js';
|
||
import { PersonalFinanceAnalyzer } from '../server/finance/PersonalFinanceAnalyzer.js';
|
||
import { PortfolioAdvisor } from '../server/finance/PortfolioAdvisor.js';
|
||
import { ScreenerEngine } from '../server/screener/ScreenerEngine.js';
|
||
import { FinanceReporter } from '../server/reporters/FinanceReporter.js';
|
||
|
||
const PORTFOLIO_PATH = './portfolio.json';
|
||
|
||
async function main() {
|
||
// ── 1. Load portfolio
|
||
if (!existsSync(PORTFOLIO_PATH)) {
|
||
throw new Error('portfolio.json not found — edit it with your holdings and re-run.');
|
||
}
|
||
|
||
const { holdings } = JSON.parse(readFileSync(PORTFOLIO_PATH, 'utf8'));
|
||
const byType = holdings.reduce((acc, h) => {
|
||
const t = h.type ?? 'stock';
|
||
acc[t] = (acc[t] ?? 0) + 1;
|
||
return acc;
|
||
}, {});
|
||
console.log(
|
||
`📋 Portfolio: ${holdings.length} positions — ${Object.entries(byType)
|
||
.map(([t, n]) => `${n} ${t}`)
|
||
.join(', ')}\n`,
|
||
);
|
||
|
||
// ── 2. SimpleFIN accounts (optional)
|
||
let personalFinance = null;
|
||
if (process.env.SIMPLEFIN_ACCESS_URL || process.env.SIMPLEFIN_SETUP_TOKEN) {
|
||
try {
|
||
process.stdout.write('💰 Fetching SimpleFIN accounts...');
|
||
const client = new SimpleFINClient({ onAccessUrlClaimed: saveAccessUrlToEnv });
|
||
await client.init();
|
||
const { accounts } = await client.getAccounts();
|
||
personalFinance = new PersonalFinanceAnalyzer().analyse(accounts);
|
||
process.stdout.write(` ${accounts.length} accounts loaded\n`);
|
||
} catch (err) {
|
||
process.stdout.write(` skipped — ${err.message}\n`);
|
||
}
|
||
} else {
|
||
console.log('ℹ Add SIMPLEFIN_SETUP_TOKEN to .env for account balances & spending data\n');
|
||
}
|
||
|
||
// ── 3. Screen stocks & ETFs
|
||
const screenableTickers = holdings
|
||
.filter((h) => (h.type ?? 'stock') !== 'crypto')
|
||
.map((h) => h.ticker.toUpperCase());
|
||
|
||
let results = { STOCK: [], ETF: [], BOND: [], ERROR: [], marketContext: {} };
|
||
if (screenableTickers.length > 0) {
|
||
process.stdout.write(`📊 Screening ${screenableTickers.length} stock/ETF positions...`);
|
||
results = await new ScreenerEngine().screenTickers(screenableTickers);
|
||
process.stdout.write(' done\n');
|
||
}
|
||
|
||
// ── 4. Portfolio advice + crypto prices
|
||
process.stdout.write('💡 Generating portfolio advice...');
|
||
const advice = await new PortfolioAdvisor().advise(holdings, results);
|
||
process.stdout.write(' done\n');
|
||
|
||
// ── 5. Report
|
||
const reportPath = new FinanceReporter().generate(advice, personalFinance, results.marketContext);
|
||
console.log(`\n✅ Finance report: ${reportPath}\n`);
|
||
}
|
||
|
||
main().catch((err) => {
|
||
console.error('Failed:', err.message);
|
||
process.exit(1);
|
||
});
|