Files
market_screener/server/domains/finance/finance.controller.ts
T
2026-06-09 01:21:02 -04:00

103 lines
3.6 KiB
TypeScript

import type { FastifyInstance, FastifyReply, FastifyRequest, preHandlerHookHandler } from 'fastify';
import { SimpleFINClient, PortfolioRepository, noopLogger } from '../../domains/shared/index.js';
import { PersonalFinanceAnalyzer, ScreenerEngine } from '../screener/index.js';
import { PortfolioAdvisor } from '../portfolio/PortfolioAdvisor.js';
import type { PortfolioHolding } from '../../domains/shared/index.js';
import { holdingSchema } from '../../domains/shared/types/schemas.js';
import type { TokenPayload } from '../auth/index.js';
interface FinanceControllerOptions {
authGuard?: preHandlerHookHandler;
traderGuard?: preHandlerHookHandler;
}
type AuthRequest = FastifyRequest & { user?: TokenPayload };
function userId(req: FastifyRequest): string {
return (req as AuthRequest).user?.sub ?? '';
}
export class FinanceController {
// All portfolio routes only need a valid login — data is already user-scoped by user_id.
// No role restriction needed; any registered user can manage their own portfolio.
readonly #authGuards: preHandlerHookHandler[];
constructor(
private readonly engine: ScreenerEngine,
private readonly repo: PortfolioRepository,
private readonly advisor: PortfolioAdvisor,
options: FinanceControllerOptions = {},
) {
this.#authGuards = options.authGuard ? [options.authGuard] : [];
}
register(app: FastifyInstance): void {
app.get('/api/finance/portfolio', { preHandler: this.#authGuards }, this.portfolio.bind(this));
app.post(
'/api/finance/holdings',
{
schema: holdingSchema,
preHandler: this.#authGuards,
},
this.addHolding.bind(this),
);
app.delete(
'/api/finance/holdings/:ticker',
{
preHandler: this.#authGuards,
},
this.removeHolding.bind(this),
);
app.get('/api/finance/market-context', this.marketContext.bind(this));
}
private async portfolio(req: FastifyRequest, _reply: FastifyReply) {
const uid = userId(req);
const { holdings } = this.repo.exists(uid) ? this.repo.read(uid) : { holdings: [] };
let personalFinance = null;
if (process.env.SIMPLEFIN_ACCESS_URL) {
const client = new SimpleFINClient({ logger: noopLogger });
const { accounts } = await client.getAccounts();
personalFinance = new PersonalFinanceAnalyzer().analyze(accounts);
}
const screenable = holdings
.filter((h) => (h.type ?? 'stock') !== 'crypto')
.map((h) => h.ticker.toUpperCase());
const results =
screenable.length > 0
? await this.engine.screenTickers(screenable)
: { STOCK: [], ETF: [], BOND: [], ERROR: [], marketContext: {} as any };
const advice = await this.advisor.advise(holdings, results);
return { advice, personalFinance, marketContext: results.marketContext };
}
private async addHolding(req: FastifyRequest, reply: FastifyReply) {
const uid = userId(req);
const {
ticker,
shares,
costBasis = 0,
type = 'stock',
source = 'Manual',
} = req.body as PortfolioHolding;
const entry = this.repo.upsert({ ticker, shares, costBasis, type, source }, uid);
return reply.code(201).send(entry);
}
private async removeHolding(req: FastifyRequest, reply: FastifyReply) {
const uid = userId(req);
const ticker = (req.params as { ticker: string }).ticker.toUpperCase();
const removed = this.repo.remove(ticker, uid);
if (!removed) return reply.code(404).send({ error: 'Holding not found' });
return { ok: true };
}
private async marketContext() {
return this.engine.getMarketContext();
}
}