353 lines
14 KiB
TypeScript
353 lines
14 KiB
TypeScript
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
|
import { ScreenerEngine } from './ScreenerEngine';
|
|
import { CatalystCache, SignalSnapshotRepository, YahooFinanceClient } from '../../domains/shared';
|
|
import type { DataHealth, LiveAssetResult, ScreenerResult } from '../../domains/shared';
|
|
import type { NewsRepository } from '../news/NewsRepository';
|
|
import { screenSchema } from '../../domains/shared/types/schemas';
|
|
|
|
export class ScreenerController {
|
|
/** Company profiles change rarely — cache for an hour. */
|
|
private static readonly PROFILE_TTL_MS = 60 * 60 * 1000;
|
|
private profileCache = new Map<string, { data: unknown; expiresAt: number }>();
|
|
|
|
/** Sector pulse — SPDR sector ETFs as the standard proxy, cached 15 min. */
|
|
private static readonly SECTOR_TTL_MS = 15 * 60 * 1000;
|
|
private static readonly SECTOR_ETFS: Array<{ etf: string; sector: string; name: string }> = [
|
|
{ etf: 'XLK', sector: 'TECHNOLOGY', name: 'Technology' },
|
|
{ etf: 'XLF', sector: 'FINANCIAL', name: 'Financials' },
|
|
{ etf: 'XLE', sector: 'ENERGY', name: 'Energy' },
|
|
{ etf: 'XLV', sector: 'HEALTHCARE', name: 'Healthcare' },
|
|
{ etf: 'XLC', sector: 'COMMUNICATION', name: 'Communication' },
|
|
{ etf: 'XLP', sector: 'CONSUMER_STAPLES', name: 'Staples' },
|
|
{ etf: 'XLY', sector: 'CONSUMER_DISCRETIONARY', name: 'Discretionary' },
|
|
{ etf: 'XLRE', sector: 'REIT', name: 'Real Estate' },
|
|
{ etf: 'XLI', sector: 'GENERAL', name: 'Industrials' },
|
|
{ etf: 'XLU', sector: 'GENERAL', name: 'Utilities' },
|
|
];
|
|
private sectorCache: { data: unknown; expiresAt: number } | null = null;
|
|
|
|
/** Sector drill-down (holdings + screen + news) — cached 30 min per sector. */
|
|
private static readonly SECTOR_DETAIL_TTL_MS = 30 * 60 * 1000;
|
|
private sectorDetailCache = new Map<string, { data: unknown; expiresAt: number }>();
|
|
|
|
constructor(
|
|
private readonly engine: ScreenerEngine,
|
|
private readonly catalystCache: CatalystCache,
|
|
// Optional so tests and minimal setups work without a database.
|
|
private readonly snapshots?: SignalSnapshotRepository,
|
|
private readonly yahoo?: YahooFinanceClient,
|
|
private readonly news?: NewsRepository,
|
|
) {}
|
|
|
|
register(app: FastifyInstance): void {
|
|
app.post(
|
|
'/api/screen',
|
|
{ schema: screenSchema, config: { rateLimit: { max: 10, timeWindow: '1 minute' } } },
|
|
this.screen.bind(this),
|
|
);
|
|
app.get(
|
|
'/api/screen/catalysts',
|
|
{ config: { rateLimit: { max: 10, timeWindow: '1 minute' } } },
|
|
this.catalysts.bind(this),
|
|
);
|
|
app.get('/api/screen/history/:ticker', this.history.bind(this));
|
|
app.get('/api/screen/profile/:ticker', this.profile.bind(this));
|
|
app.get('/api/screen/chart/:ticker', this.chart.bind(this));
|
|
app.get('/api/screen/sectors', this.sectors.bind(this));
|
|
app.get('/api/screen/sector/:sector', this.sectorDetail.bind(this));
|
|
}
|
|
|
|
/**
|
|
* Sector drill-down: the sector ETF's top 10 holdings, freshly screened
|
|
* (signal + advice-ready rows), plus recent news for those tickers and
|
|
* macro stories — "what's in this sector and why is it moving".
|
|
*/
|
|
private async sectorDetail(req: FastifyRequest) {
|
|
const sector = (req.params as { sector: string }).sector.toUpperCase();
|
|
const entry = ScreenerController.SECTOR_ETFS.find((s) => s.sector === sector);
|
|
if (!entry || !this.yahoo) return { sector, etf: null, stocks: [], news: [] };
|
|
|
|
const cached = this.sectorDetailCache.get(sector);
|
|
if (cached && Date.now() < cached.expiresAt) return cached.data;
|
|
|
|
const holdings = await this.yahoo.fetchTopHoldings(entry.etf, 10);
|
|
const results = holdings.length > 0 ? await this.engine.screenTickers(holdings) : null;
|
|
const stocks = results
|
|
? ScreenerController.serializeAssets(results.STOCK as LiveAssetResult[])
|
|
: [];
|
|
|
|
// News: stored stories for these tickers (last 3 days), deduped by URL
|
|
const newsSince = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
|
const byUrl = new Map<string, unknown>();
|
|
if (this.news) {
|
|
for (const ticker of holdings) {
|
|
for (const row of this.news.newsForTicker(ticker, newsSince)) {
|
|
byUrl.set(row.url, {
|
|
headline: row.headline,
|
|
tickers: JSON.parse(row.ticker_list),
|
|
source: row.source,
|
|
catalyst: row.catalyst,
|
|
url: row.url,
|
|
publishedAt: row.published_at,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
const data = {
|
|
sector,
|
|
etf: entry.etf,
|
|
name: entry.name,
|
|
stocks,
|
|
news: [...byUrl.values()],
|
|
};
|
|
this.sectorDetailCache.set(sector, {
|
|
data,
|
|
expiresAt: Date.now() + ScreenerController.SECTOR_DETAIL_TTL_MS,
|
|
});
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* Sector pulse — today's % change per sector via SPDR sector ETFs (the
|
|
* standard proxy). Returns sectors sorted best→worst plus the leader.
|
|
*/
|
|
private async sectors() {
|
|
if (this.sectorCache && Date.now() < this.sectorCache.expiresAt) {
|
|
return this.sectorCache.data;
|
|
}
|
|
if (!this.yahoo) return { asOf: null, leader: null, sectors: [] };
|
|
|
|
const results = await Promise.all(
|
|
ScreenerController.SECTOR_ETFS.map(async ({ etf, sector, name }) => {
|
|
try {
|
|
const summary = await this.yahoo!.fetchSummary(etf);
|
|
const pr = summary?.price ?? {};
|
|
const price = pr.regularMarketPrice ?? null;
|
|
const prev = pr.regularMarketPreviousClose ?? null;
|
|
const changePct =
|
|
price != null && prev != null && prev > 0
|
|
? +(((price - prev) / prev) * 100).toFixed(2)
|
|
: null;
|
|
return { etf, sector, name, changePct };
|
|
} catch {
|
|
return { etf, sector, name, changePct: null };
|
|
}
|
|
}),
|
|
);
|
|
|
|
const sectors = results
|
|
.filter((s) => s.changePct != null)
|
|
.sort((a, b) => (b.changePct as number) - (a.changePct as number));
|
|
|
|
const data = {
|
|
asOf: new Date().toISOString(),
|
|
leader: sectors[0] ?? null,
|
|
sectors,
|
|
};
|
|
this.sectorCache = { data, expiresAt: Date.now() + ScreenerController.SECTOR_TTL_MS };
|
|
return data;
|
|
}
|
|
|
|
/** Company profile for the ticker modal — name, description, sector. */
|
|
private async profile(req: FastifyRequest) {
|
|
const ticker = (req.params as { ticker: string }).ticker.toUpperCase();
|
|
if (!this.yahoo) return { ticker, profile: null };
|
|
|
|
const cached = this.profileCache.get(ticker);
|
|
if (cached && Date.now() < cached.expiresAt) return cached.data;
|
|
|
|
try {
|
|
const summary = await this.yahoo.fetchSummary(ticker);
|
|
const ap = summary?.assetProfile ?? {};
|
|
const pr = summary?.price ?? {};
|
|
const fd = summary?.financialData ?? {};
|
|
const price = pr.regularMarketPrice ?? null;
|
|
const targetMean = fd.targetMeanPrice ?? null;
|
|
const data = {
|
|
ticker,
|
|
profile: {
|
|
name: pr.longName ?? pr.shortName ?? ticker,
|
|
summary: ap.longBusinessSummary ?? null,
|
|
sector: ap.sector ?? null,
|
|
industry: ap.industry ?? null,
|
|
website: ap.website ?? null,
|
|
employees: ap.fullTimeEmployees ?? null,
|
|
marketCap: pr.marketCap ?? null,
|
|
currentPrice: price,
|
|
// Analyst price targets (Yahoo sell-side consensus)
|
|
targets: {
|
|
mean: targetMean,
|
|
high: fd.targetHighPrice ?? null,
|
|
low: fd.targetLowPrice ?? null,
|
|
analysts: fd.numberOfAnalystOpinions ?? null,
|
|
recommendationMean: fd.recommendationMean ?? null, // 1=Strong Buy … 5=Strong Sell
|
|
upsidePct:
|
|
targetMean != null && price != null && price > 0
|
|
? +(((targetMean - price) / price) * 100).toFixed(1)
|
|
: null,
|
|
},
|
|
},
|
|
};
|
|
this.profileCache.set(ticker, {
|
|
data,
|
|
expiresAt: Date.now() + ScreenerController.PROFILE_TTL_MS,
|
|
});
|
|
return data;
|
|
} catch {
|
|
return { ticker, profile: null };
|
|
}
|
|
}
|
|
|
|
/** Closes for the ticker modal chart. ?range=1d|5d|1mo|3mo|6mo|1y. */
|
|
private async chart(req: FastifyRequest) {
|
|
const ticker = (req.params as { ticker: string }).ticker.toUpperCase();
|
|
const raw = (req.query as { range?: string }).range ?? '6mo';
|
|
const range = raw in YahooFinanceClient.CHART_RANGES ? raw : '6mo';
|
|
if (!this.yahoo) return { ticker, range, points: [] };
|
|
return { ticker, range, points: await this.yahoo.fetchCloses(ticker, range) };
|
|
}
|
|
|
|
/** Signal snapshot history for one ticker (P0.1 ledger read side). */
|
|
private async history(req: FastifyRequest) {
|
|
if (!this.snapshots) return { ticker: null, snapshots: [] };
|
|
const { ticker } = req.params as { ticker: string };
|
|
return {
|
|
ticker: ticker.toUpperCase(),
|
|
snapshots: this.snapshots.history(ticker).map((row) => ({
|
|
date: row.snapshot_date,
|
|
signal: row.signal,
|
|
price: row.price,
|
|
fundamental: { tier: row.fundamental_tier, score: row.fundamental_score },
|
|
inflated: { tier: row.inflated_tier, score: row.inflated_score },
|
|
coverage:
|
|
row.coverage_active != null
|
|
? { active: row.coverage_active, total: row.coverage_total }
|
|
: null,
|
|
riskFlags: row.risk_flags ? JSON.parse(row.risk_flags) : [],
|
|
rateRegime: row.rate_regime,
|
|
})),
|
|
};
|
|
}
|
|
|
|
private static serializeAssets(arr: LiveAssetResult[]) {
|
|
return arr.map((r) => ({
|
|
...r,
|
|
asset: {
|
|
ticker: r.asset.ticker,
|
|
type: r.asset.type,
|
|
currentPrice: r.asset.currentPrice,
|
|
metrics: r.asset.metrics,
|
|
displayMetrics: r.asset.getDisplayMetrics(),
|
|
},
|
|
}));
|
|
}
|
|
|
|
private async screen(req: FastifyRequest) {
|
|
const tickers = (req.body as { tickers: string[] }).tickers.map((t) => t.toUpperCase());
|
|
const results = await this.engine.screenTickers(tickers);
|
|
this.recordSnapshots(results, req);
|
|
this.flagTurnarounds(results);
|
|
const dataHealth = ScreenerController.assessDataHealth(results);
|
|
if (dataHealth.degraded) {
|
|
req.log?.warn?.({ dataHealth }, 'screen batch returned degraded fundamentals data');
|
|
}
|
|
return {
|
|
...results,
|
|
STOCK: ScreenerController.serializeAssets(results.STOCK as LiveAssetResult[]),
|
|
ETF: ScreenerController.serializeAssets(results.ETF as LiveAssetResult[]),
|
|
BOND: ScreenerController.serializeAssets(results.BOND as LiveAssetResult[]),
|
|
dataHealth,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Turnaround-watch (candidate flag, NOT a prediction): the stock's style is
|
|
* already Turnaround (earnings down, revenue holding) AND its fundamental
|
|
* score improved vs the previous snapshot in the ledger. Both legs must
|
|
* hold — style alone is static, improvement alone is noise.
|
|
*/
|
|
private flagTurnarounds(results: ScreenerResult): void {
|
|
if (!this.snapshots) return;
|
|
for (const row of results.STOCK as LiveAssetResult[]) {
|
|
const metrics = row.asset.metrics as { growthCategory?: string };
|
|
if (metrics?.growthCategory !== 'Turnaround') continue;
|
|
if (row.fundamental.tier === 'REJECT' || row.fundamental.score == null) continue;
|
|
|
|
try {
|
|
// History includes today's snapshot (recorded just above) — compare
|
|
// today's score against the most recent prior day with a score.
|
|
const history = this.snapshots.history(row.asset.ticker);
|
|
const prior = [...history]
|
|
.reverse()
|
|
.find((h) => h.snapshot_date < history[history.length - 1]?.snapshot_date);
|
|
if (prior?.fundamental_score != null && row.fundamental.score > prior.fundamental_score) {
|
|
row.turnaroundWatch = true;
|
|
}
|
|
} catch {
|
|
// best-effort — never fail the screen for a highlight
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* P0.4 data-sanity sentinel — if a large share of screened stocks come back
|
|
* with null core fundamentals (P/E, ROE), the upstream source has likely
|
|
* changed schema or is throttling. Surface it loudly instead of letting
|
|
* everything silently degrade to "No Data" rows.
|
|
*/
|
|
private static assessDataHealth(results: ScreenerResult): DataHealth {
|
|
const THRESHOLD = 0.3; // >30% nulls = degraded
|
|
const MIN_SAMPLE = 3; // don't alarm on tiny batches
|
|
|
|
const stocks = results.STOCK as LiveAssetResult[];
|
|
const metrics = stocks.map(
|
|
(r) => r.asset.metrics as { peRatio?: number | null; returnOnEquity?: number | null },
|
|
);
|
|
const nullPeRatio = metrics.filter((m) => m.peRatio == null).length;
|
|
const nullRoe = metrics.filter((m) => m.returnOnEquity == null).length;
|
|
const total = metrics.length;
|
|
|
|
const degraded =
|
|
total >= MIN_SAMPLE && (nullPeRatio / total > THRESHOLD || nullRoe / total > THRESHOLD);
|
|
|
|
return {
|
|
degraded,
|
|
stocksChecked: total,
|
|
nullPeRatio,
|
|
nullRoe,
|
|
message: degraded
|
|
? `${Math.max(nullPeRatio, nullRoe)} of ${total} stocks returned no core fundamentals — data source may be degraded; treat this screen with caution`
|
|
: null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* P0.1 signal track record — persist one snapshot per asset per day.
|
|
* Best-effort: a snapshot failure must never fail the screen response.
|
|
*/
|
|
private recordSnapshots(results: ScreenerResult, req: FastifyRequest): void {
|
|
if (!this.snapshots) return;
|
|
try {
|
|
const rateRegime = results.marketContext?.rateRegime ?? null;
|
|
const inputs = [...results.STOCK, ...results.ETF, ...results.BOND].map((r) => ({
|
|
ticker: r.asset.ticker,
|
|
assetType: r.asset.type,
|
|
price: r.asset.currentPrice ?? null,
|
|
signal: r.signal,
|
|
fundamental: r.fundamental,
|
|
inflated: r.inflated,
|
|
rateRegime,
|
|
}));
|
|
this.snapshots.recordBatch(inputs);
|
|
} catch (err) {
|
|
req.log?.warn?.({ err }, 'signal snapshot recording failed');
|
|
}
|
|
}
|
|
|
|
private async catalysts() {
|
|
const { tickers, stories } = await this.catalystCache.get();
|
|
return { tickers, stories };
|
|
}
|
|
}
|