news screen enhancement - 1
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import type { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { ScreenerEngine } from './ScreenerEngine';
|
||||
import { CatalystCache, SignalSnapshotRepository } from '../../domains/shared';
|
||||
import type { LiveAssetResult, ScreenerResult } from '../../domains/shared';
|
||||
import type { DataHealth, LiveAssetResult, ScreenerResult } from '../../domains/shared';
|
||||
import { screenSchema } from '../../domains/shared/types/schemas';
|
||||
|
||||
export class ScreenerController {
|
||||
@@ -65,11 +65,48 @@ 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);
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -151,6 +151,20 @@ export const WATCHLIST_QUERIES = {
|
||||
`,
|
||||
};
|
||||
|
||||
// ── Screening Universe Queries (bin/daily-screen.ts) ────────────────────────
|
||||
|
||||
export const UNIVERSE_QUERIES = {
|
||||
// Every ticker pinned by any user
|
||||
DISTINCT_WATCHLIST_TICKERS: 'SELECT DISTINCT ticker FROM watchlist ORDER BY ticker',
|
||||
|
||||
// Every ticker held by any user (crypto excluded — not fundamentally scored)
|
||||
DISTINCT_HOLDING_TICKERS: `
|
||||
SELECT DISTINCT ticker FROM holdings
|
||||
WHERE type != 'crypto'
|
||||
ORDER BY ticker
|
||||
`,
|
||||
};
|
||||
|
||||
// ── Signal Snapshot Queries (P0.1 — signal track record) ────────────────────
|
||||
|
||||
export const SIGNAL_SNAPSHOT_QUERIES = {
|
||||
|
||||
@@ -87,10 +87,26 @@ export interface AssetResult {
|
||||
fundamental: ScoreResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Data-source health for one screen batch (PRODUCT.md P0.4).
|
||||
* Degraded = a large share of stocks came back without core fundamentals,
|
||||
* which usually means the upstream data source changed or is throttling —
|
||||
* not that the companies are actually missing data.
|
||||
*/
|
||||
export interface DataHealth {
|
||||
degraded: boolean;
|
||||
stocksChecked: number;
|
||||
nullPeRatio: number;
|
||||
nullRoe: number;
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
export interface ScreenerResult {
|
||||
STOCK: AssetResult[];
|
||||
ETF: AssetResult[];
|
||||
BOND: AssetResult[];
|
||||
ERROR: Array<{ ticker: string; message: string }>;
|
||||
marketContext: import('./market.model.js').MarketContext;
|
||||
/** Set by the screener controller on API responses, not by the engine. */
|
||||
dataHealth?: DataHealth;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ export type {
|
||||
ScoringRules,
|
||||
ScoreAudit,
|
||||
ScoreResult,
|
||||
VerdictTier,
|
||||
DataHealth,
|
||||
AssetResult,
|
||||
LiveAssetResult,
|
||||
ScreenerResult,
|
||||
|
||||
Reference in New Issue
Block a user