111 lines
4.2 KiB
JavaScript
111 lines
4.2 KiB
JavaScript
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();
|
|
});
|
|
}
|