Files
market_screener/server/server/routes/finance.js
T
2026-06-06 22:55:43 -04:00

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