phase-10.5: screener enhancements
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user