import { readFileSync, writeFileSync, existsSync } from 'fs'; import { ScreenerEngine } from '../../screener/ScreenerEngine.js'; import { PersonalFinanceAnalyzer } from '../../finance/PersonalFinanceAnalyzer.js'; import { PortfolioAdvisor } from '../../finance/PortfolioAdvisor.js'; import { SimpleFINClient } from '../../finance/clients/SimpleFINClient.js'; import { noopLogger } from '../utils/logger.js'; const PORTFOLIO_PATH = './portfolio.json'; export default async function financeRoutes(app) { // GET /api/finance/portfolio // Returns: { advice, personalFinance, marketContext } app.get('/api/finance/portfolio', async (req, reply) => { if (!existsSync(PORTFOLIO_PATH)) { return reply.code(404).send({ error: 'portfolio.json not found' }); } const { holdings } = JSON.parse(readFileSync(PORTFOLIO_PATH, 'utf8')); // SimpleFIN is optional — omit if not configured let personalFinance = null; if (process.env.SIMPLEFIN_ACCESS_URL) { const client = new SimpleFINClient({ logger: noopLogger }); const { accounts } = await client.getAccounts(); personalFinance = new PersonalFinanceAnalyzer().analyse(accounts); } // Normalize dot-notation tickers to Yahoo Finance format (BRK.B → BRK-B) const normalizeYahoo = (t) => t.toUpperCase().replace(/\./g, '-'); const screenable = holdings .filter((h) => (h.type ?? 'stock') !== 'crypto') .map((h) => normalizeYahoo(h.ticker)); const engine = new ScreenerEngine({ logger: noopLogger }); const results = screenable.length > 0 ? await engine.screenTickers(screenable) : { STOCK: [], ETF: [], BOND: [], ERROR: [], marketContext: {} }; const advice = await new PortfolioAdvisor().advise(holdings, results); return { advice, personalFinance, marketContext: results.marketContext }; }); // POST /api/finance/holdings // Add or update a single holding in portfolio.json. // Body: { ticker, shares, costBasis, type, source } app.post('/api/finance/holdings', { schema: { body: { type: 'object', required: ['ticker', 'shares'], properties: { ticker: { type: 'string', minLength: 1, maxLength: 10 }, shares: { type: 'number', exclusiveMinimum: 0 }, costBasis: { type: 'number', minimum: 0 }, type: { type: 'string', enum: ['stock', 'etf', 'bond', 'crypto'] }, source: { type: 'string' }, }, }, }, handler: async (req, reply) => { const { ticker, shares, costBasis = 0, type = 'stock', source = 'Manual' } = req.body; const normalized = ticker.toUpperCase().trim(); const portfolio = existsSync(PORTFOLIO_PATH) ? JSON.parse(readFileSync(PORTFOLIO_PATH, 'utf8')) : { holdings: [] }; const idx = portfolio.holdings.findIndex((h) => h.ticker.toUpperCase() === normalized); const entry = { ticker: normalized, shares, costBasis, type, source }; if (idx >= 0) { portfolio.holdings[idx] = entry; // update existing } else { portfolio.holdings.push(entry); // add new } writeFileSync(PORTFOLIO_PATH, JSON.stringify(portfolio, null, 2), 'utf8'); return reply.code(201).send(entry); }, }); // DELETE /api/finance/holdings/:ticker // Remove a holding from portfolio.json. app.delete('/api/finance/holdings/:ticker', async (req, reply) => { const ticker = req.params.ticker.toUpperCase(); if (!existsSync(PORTFOLIO_PATH)) return reply.code(404).send({ error: 'portfolio.json not found' }); const portfolio = JSON.parse(readFileSync(PORTFOLIO_PATH, 'utf8')); const before = portfolio.holdings.length; portfolio.holdings = portfolio.holdings.filter((h) => h.ticker.toUpperCase() !== ticker); if (portfolio.holdings.length === before) return reply.code(404).send({ error: 'Holding not found' }); writeFileSync(PORTFOLIO_PATH, JSON.stringify(portfolio, null, 2), 'utf8'); return { ok: true }; }); // GET /api/finance/market-context // Returns live benchmark data without running a full screen app.get('/api/finance/market-context', async () => { const engine = new ScreenerEngine({ logger: noopLogger }); return engine.benchmarkProvider.getMarketContext(); }); }