phase-10.5: screener enhancements
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { NewsRepository } from './NewsRepository';
|
||||
import { YahooFinanceClient } from '../shared';
|
||||
import type { NewsArticleRow } from '../shared/types';
|
||||
|
||||
interface StoryView {
|
||||
headline: string;
|
||||
tickers: string[];
|
||||
source: string;
|
||||
catalyst: string | null;
|
||||
url: string;
|
||||
publishedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read side of the news pipeline. Stored pipeline stories (curated, catalyst-
|
||||
* tagged, historical) are merged with a live per-ticker Yahoo search on
|
||||
* request — stored gives depth, live gives freshness. The RSS firehoses
|
||||
* can't be queried per-ticker on demand, which is why they go through the
|
||||
* polling pipeline instead.
|
||||
*/
|
||||
export class NewsController {
|
||||
constructor(
|
||||
private readonly repo: NewsRepository,
|
||||
private readonly yahoo?: YahooFinanceClient,
|
||||
) {}
|
||||
|
||||
register(app: FastifyInstance): void {
|
||||
app.get('/api/news/recent', this.recent.bind(this));
|
||||
app.get('/api/news/:ticker', this.byTicker.bind(this));
|
||||
}
|
||||
|
||||
/** GET /api/news/:ticker?days=7&live=1 (live Yahoo merge on by default) */
|
||||
private async byTicker(req: FastifyRequest) {
|
||||
const ticker = (req.params as { ticker: string }).ticker.toUpperCase();
|
||||
const query = req.query as { days?: string; live?: string };
|
||||
const days = Math.min(Number(query.days ?? 7) || 7, 90);
|
||||
const live = query.live !== '0';
|
||||
const sinceDay = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||
|
||||
const stored = this.repo.newsForTicker(ticker, sinceDay).map(NewsController.serialize);
|
||||
const fresh = live ? await this.fetchLive(ticker) : [];
|
||||
|
||||
// Merge, dedupe by URL, newest first
|
||||
const byUrl = new Map<string, StoryView>();
|
||||
for (const s of [...stored, ...fresh]) byUrl.set(s.url, byUrl.get(s.url) ?? s);
|
||||
const stories = [...byUrl.values()].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt));
|
||||
|
||||
return { ticker, days, stories };
|
||||
}
|
||||
|
||||
/** Live per-ticker Yahoo news search — freshness layer, best-effort. */
|
||||
private async fetchLive(ticker: string): Promise<StoryView[]> {
|
||||
if (!this.yahoo) return [];
|
||||
try {
|
||||
const items = await this.yahoo.search(ticker, { newsCount: 8 });
|
||||
return items
|
||||
.filter((n) => n.title && n.link)
|
||||
.map((n) => ({
|
||||
headline: n.title as string,
|
||||
tickers: [ticker],
|
||||
source: 'yahoo',
|
||||
catalyst: null,
|
||||
url: n.link as string,
|
||||
publishedAt: n.providerPublishTime
|
||||
? new Date(n.providerPublishTime).toISOString()
|
||||
: new Date().toISOString(),
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /api/news/recent?limit=50 */
|
||||
private async recent(req: FastifyRequest) {
|
||||
const limit = Math.min(Number((req.query as { limit?: string }).limit ?? 50) || 50, 200);
|
||||
return { stories: this.repo.recent(limit).map(NewsController.serialize) };
|
||||
}
|
||||
|
||||
private static serialize(row: NewsArticleRow) {
|
||||
return {
|
||||
headline: row.headline,
|
||||
tickers: JSON.parse(row.ticker_list) as string[],
|
||||
source: row.source,
|
||||
catalyst: row.catalyst,
|
||||
url: row.url,
|
||||
publishedAt: row.published_at,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user