phase-10.5: screener enhancements

This commit is contained in:
saikiranvella
2026-06-11 19:18:19 -04:00
parent bac00ab5d5
commit e953822bab
51 changed files with 3745 additions and 36 deletions
+213 -1
View File
@@ -1,15 +1,42 @@
import type { FastifyInstance, FastifyRequest } from 'fastify';
import { ScreenerEngine } from './ScreenerEngine';
import { CatalystCache, SignalSnapshotRepository } from '../../domains/shared';
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 {
@@ -24,6 +51,161 @@ export class ScreenerController {
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). */
@@ -65,6 +247,7 @@ export class ScreenerController {
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');
@@ -78,6 +261,35 @@ export class ScreenerController {
};
}
/**
* 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
@@ -40,6 +40,13 @@ export class DataMapper {
const currentPrice = pr.regularMarketPrice ?? 0;
const sharesOutstanding = ks.sharesOutstanding ?? 0;
// Today's % change — powers the sector drill-down "Today" sort
const prevClose = pr.regularMarketPreviousClose ?? null;
const dayChangePct =
prevClose != null && prevClose > 0 && (currentPrice as number) > 0
? +((((currentPrice as number) - prevClose) / prevClose) * 100).toFixed(2)
: null;
const operatingCashflow = fd.operatingCashflow ?? 0;
const freeCashflow = fd.freeCashflow ?? 0;
@@ -131,6 +138,7 @@ export class DataMapper {
? (sd.trailingAnnualDividendYield as number) * 100
: null,
beta: sd.beta ?? null,
dayChangePct,
week52High,
week52Low,
week52Change,