import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { YahooFinanceClient } from '../clients/YahooFinanceClient'; import { MarketCallRepository } from '../repositories/MarketCallRepository'; import { ScreenerEngine } from '../services/index'; import type { SnapshotEntry } from '../types'; import { callSchema } from '../types/schemas'; import { chunkArray } from '../utils/Chunker'; export class CallsController { constructor( private readonly repo: MarketCallRepository, private readonly engine: ScreenerEngine, private readonly yahoo: YahooFinanceClient, ) {} private static toSnapshot(r: any): SnapshotEntry | null { if (!r) return null; const m = r.asset?.displayMetrics ?? r.asset?.getDisplayMetrics?.() ?? {}; return { price: r.asset?.currentPrice ?? null, signal: r.signal ?? null, inflatedVerdict: r.inflated?.label ?? null, fundamentalVerdict: r.fundamental?.label ?? null, pe: m['P/E'] ?? null, roe: m['ROE%'] ?? null, fcf: m['FCF Yld%'] ?? null, }; } register(app: FastifyInstance): void { app.get('/api/calls', this.list.bind(this)); app.get('/api/calls/calendar', this.calendar.bind(this)); app.get('/api/calls/:id', this.get.bind(this)); app.post('/api/calls', { schema: callSchema }, this.create.bind(this)); app.delete('/api/calls/:id', this.remove.bind(this)); } private async list() { return { calls: this.repo.list() }; } private async get(req: FastifyRequest, reply: FastifyReply) { const call = this.repo.get((req.params as { id: string }).id); if (!call) return reply.code(404).send({ error: 'Call not found' }); const current: Record = {}; if (call.tickers.length > 0) { try { const results = await this.engine.screenTickers(call.tickers); for (const r of [...results.STOCK, ...results.ETF, ...results.BOND]) { current[r.asset.ticker] = CallsController.toSnapshot(r); } } catch { /* non-fatal */ } } return { ...call, current }; } private async create(req: FastifyRequest, reply: FastifyReply) { const { title, quarter, date, thesis, tickers } = req.body as { title: string; quarter: string; date?: string; thesis: string; tickers: string[]; }; const upperTickers = tickers.map((t) => t.toUpperCase()); const snapshot: Record = {}; try { const results = await this.engine.screenTickers(upperTickers); for (const r of [...results.STOCK, ...results.ETF, ...results.BOND]) { snapshot[r.asset.ticker] = CallsController.toSnapshot(r); } } catch (err) { req.log.warn(`Could not snapshot prices for market call: ${(err as Error).message}`); } const call = this.repo.create({ title, quarter, date, thesis, tickers: upperTickers, snapshot: snapshot as any, }); return reply.code(201).send(call); } private async remove(req: FastifyRequest, reply: FastifyReply) { const deleted = this.repo.delete((req.params as { id: string }).id); if (!deleted) return reply.code(404).send({ error: 'Call not found' }); return { ok: true }; } private async calendar(req: FastifyRequest) { let tickers: string[]; if ((req.query as any).tickers) { tickers = String((req.query as any).tickers) .split(',') .map((t) => t.trim().toUpperCase()) .filter(Boolean); } else { const set = new Set(this.repo.list().flatMap((c) => c.tickers)); tickers = [...set]; } if (tickers.length === 0) return { events: [] }; const results: Record = {}; for (const batch of chunkArray(tickers, 5)) { await Promise.all( batch.map(async (ticker) => { const cal = await this.yahoo.fetchCalendarEvents(ticker); if (cal) results[ticker] = cal; }), ); await new Promise((r) => setTimeout(r, 500)); } const events: any[] = []; const now = Date.now(); for (const [ticker, cal] of Object.entries(results)) { for (const dateVal of cal.earnings?.earningsDate ?? []) { const d = new Date(dateVal as string); events.push({ ticker, type: 'earnings', date: d.toISOString().slice(0, 10), label: 'Earnings', detail: cal.earnings.isEarningsDateEstimate ? 'Estimated' : 'Confirmed', epsEstimate: cal.earnings.earningsAverage ?? null, revEstimate: cal.earnings.revenueAverage ?? null, isPast: d.getTime() < now, }); } if (cal.exDividendDate) { const d = new Date(cal.exDividendDate); events.push({ ticker, type: 'exdividend', date: d.toISOString().slice(0, 10), label: 'Ex-Dividend', detail: null, isPast: d.getTime() < now, }); } if (cal.dividendDate) { const d = new Date(cal.dividendDate); events.push({ ticker, type: 'dividend', date: d.toISOString().slice(0, 10), label: 'Dividend', detail: null, isPast: d.getTime() < now, }); } } events.sort((a, b) => { if (a.isPast !== b.isPast) return a.isPast ? 1 : -1; return a.isPast ? new Date(b.date).getTime() - new Date(a.date).getTime() : new Date(a.date).getTime() - new Date(b.date).getTime(); }); return { events, tickers }; } }