173 lines
5.5 KiB
TypeScript
173 lines
5.5 KiB
TypeScript
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<string, SnapshotEntry | null> = {};
|
|
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<string, SnapshotEntry | null> = {};
|
|
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<string, any> = {};
|
|
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<void>((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 };
|
|
}
|
|
}
|