news screen enhancement - 1

This commit is contained in:
saikiranvella
2026-06-09 20:11:10 -04:00
parent 662a717916
commit bac00ab5d5
13 changed files with 250 additions and 35 deletions
+92
View File
@@ -0,0 +1,92 @@
/**
* Daily screening job — keeps the signal snapshot ledger (PRODUCT.md P0.1)
* accumulating even when nobody opens the UI.
*
* Universe = union of all users' watchlist tickers + all non-crypto holdings,
* or an explicit list passed on the command line.
*
* Usage:
* npm run screen:daily # watchlist + holdings universe
* npm run screen:daily -- AAPL MSFT # explicit tickers
*
* Schedule for market close, e.g. crontab (4:30pm ET weekdays):
* 30 16 * * 1-5 cd /path/to/market_screener && npm run screen:daily
*/
import 'dotenv/config';
import {
YahooFinanceClient,
BenchmarkProvider,
SignalSnapshotRepository,
createDb,
DatabaseConnection,
QueryAudit,
} from '../server/domains/shared';
import { QueryBuilder } from '../server/domains/shared/utils/QueryBuilder';
import { ScreenerEngine } from '../server/domains/screener';
import type { AssetResult } from '../server/domains/shared';
function universeFromDb(db: DatabaseConnection): string[] {
const watchlist = db
.all<{ ticker: string }>(new QueryBuilder('UNIVERSE_QUERIES.DISTINCT_WATCHLIST_TICKERS'))
.map((r) => r.ticker);
const holdings = db
.all<{ ticker: string }>(new QueryBuilder('UNIVERSE_QUERIES.DISTINCT_HOLDING_TICKERS'))
.map((r) => r.ticker);
return [...new Set([...watchlist, ...holdings])].sort();
}
const db = new DatabaseConnection(createDb(process.env.DB_PATH ?? './market-screener.db'), {
audit: new QueryAudit(),
logSlowQueries: 100,
});
const cliTickers = process.argv.slice(2).map((t) => t.toUpperCase());
const tickers = cliTickers.length > 0 ? cliTickers : universeFromDb(db);
if (tickers.length === 0) {
console.log('No tickers to screen — watchlist and holdings are empty.');
console.log('Pass tickers explicitly: npm run screen:daily -- AAPL MSFT');
process.exit(0);
}
console.log(`Screening ${tickers.length} tickers: ${tickers.join(', ')}`);
const yahoo = new YahooFinanceClient();
const benchmark = new BenchmarkProvider(yahoo);
const engine = new ScreenerEngine(yahoo, benchmark);
const snapshots = new SignalSnapshotRepository(db);
try {
const results = await engine.screenWithProgress(tickers);
const rateRegime = results.marketContext?.rateRegime ?? null;
const assets = [...results.STOCK, ...results.ETF, ...results.BOND] as AssetResult[];
const written = snapshots.recordBatch(
assets.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,
})),
);
const bySignal = new Map<string, number>();
for (const a of assets) bySignal.set(a.signal, (bySignal.get(a.signal) ?? 0) + 1);
console.log(`\nSnapshots written: ${written}`);
for (const [signal, count] of [...bySignal.entries()].sort()) {
console.log(` ${signal}: ${count}`);
}
if (results.ERROR.length > 0) {
console.log(`Errors (${results.ERROR.length}):`);
for (const e of results.ERROR) console.log(` ${e.ticker}: ${e.message}`);
}
process.exit(0);
} catch (err) {
console.error('Daily screen failed:', (err as Error).message);
process.exit(1);
}
+1
View File
@@ -11,6 +11,7 @@
"test:watch": "tsx --test --watch --test-reporter=spec tests/*.test.ts", "test:watch": "tsx --test --watch --test-reporter=spec tests/*.test.ts",
"lint": "eslint . --ext .ts,.js", "lint": "eslint . --ext .ts,.js",
"lint:fix": "eslint . --ext .ts,.js --fix", "lint:fix": "eslint . --ext .ts,.js --fix",
"screen:daily": "tsx bin/daily-screen.ts",
"format": "prettier --write \"server/**/*.ts\" \"bin/**/*.ts\" \"tests/**/*.ts\"", "format": "prettier --write \"server/**/*.ts\" \"bin/**/*.ts\" \"tests/**/*.ts\"",
"format:check": "prettier --check \"server/**/*.ts\" \"bin/**/*.ts\" \"tests/**/*.ts\"", "format:check": "prettier --check \"server/**/*.ts\" \"bin/**/*.ts\" \"tests/**/*.ts\"",
"prepare": "husky" "prepare": "husky"
+38 -1
View File
@@ -1,7 +1,7 @@
import type { FastifyInstance, FastifyRequest } from 'fastify'; import type { FastifyInstance, FastifyRequest } from 'fastify';
import { ScreenerEngine } from './ScreenerEngine'; import { ScreenerEngine } from './ScreenerEngine';
import { CatalystCache, SignalSnapshotRepository } from '../../domains/shared'; 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'; import { screenSchema } from '../../domains/shared/types/schemas';
export class ScreenerController { export class ScreenerController {
@@ -65,11 +65,48 @@ export class ScreenerController {
const tickers = (req.body as { tickers: string[] }).tickers.map((t) => t.toUpperCase()); const tickers = (req.body as { tickers: string[] }).tickers.map((t) => t.toUpperCase());
const results = await this.engine.screenTickers(tickers); const results = await this.engine.screenTickers(tickers);
this.recordSnapshots(results, req); this.recordSnapshots(results, req);
const dataHealth = ScreenerController.assessDataHealth(results);
if (dataHealth.degraded) {
req.log?.warn?.({ dataHealth }, 'screen batch returned degraded fundamentals data');
}
return { return {
...results, ...results,
STOCK: ScreenerController.serializeAssets(results.STOCK as LiveAssetResult[]), STOCK: ScreenerController.serializeAssets(results.STOCK as LiveAssetResult[]),
ETF: ScreenerController.serializeAssets(results.ETF as LiveAssetResult[]), ETF: ScreenerController.serializeAssets(results.ETF as LiveAssetResult[]),
BOND: ScreenerController.serializeAssets(results.BOND 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) ──────────────────── // ── Signal Snapshot Queries (P0.1 — signal track record) ────────────────────
export const SIGNAL_SNAPSHOT_QUERIES = { export const SIGNAL_SNAPSHOT_QUERIES = {
@@ -87,10 +87,26 @@ export interface AssetResult {
fundamental: ScoreResult; 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 { export interface ScreenerResult {
STOCK: AssetResult[]; STOCK: AssetResult[];
ETF: AssetResult[]; ETF: AssetResult[];
BOND: AssetResult[]; BOND: AssetResult[];
ERROR: Array<{ ticker: string; message: string }>; ERROR: Array<{ ticker: string; message: string }>;
marketContext: import('./market.model.js').MarketContext; marketContext: import('./market.model.js').MarketContext;
/** Set by the screener controller on API responses, not by the engine. */
dataHealth?: DataHealth;
} }
+2
View File
@@ -8,6 +8,8 @@ export type {
ScoringRules, ScoringRules,
ScoreAudit, ScoreAudit,
ScoreResult, ScoreResult,
VerdictTier,
DataHealth,
AssetResult, AssetResult,
LiveAssetResult, LiveAssetResult,
ScreenerResult, ScreenerResult,
+2 -2
View File
@@ -85,12 +85,12 @@ test('BondScorer', async (t) => {
}); });
await t.test('handles null/undefined metrics gracefully', () => { await t.test('handles null/undefined metrics gracefully', () => {
const metrics: BondMetrics = { const metrics = {
ytm: null, ytm: null,
duration: 5, duration: 5,
creditRating: null, creditRating: null,
creditRatingNumeric: null, creditRatingNumeric: null,
}; } as unknown as BondMetrics;
const result = BondScorer.score(metrics, DEFAULT_RULES); const result = BondScorer.score(metrics, DEFAULT_RULES);
// Should not crash // Should not crash
+22 -20
View File
@@ -12,13 +12,13 @@ class MockMarketCallRepository {
quarter: 'Q2 2024', quarter: 'Q2 2024',
thesis: 'Strong iPhone sales cycle', thesis: 'Strong iPhone sales cycle',
tickers: ['AAPL'], tickers: ['AAPL'],
date: new Date('2024-05-01'), date: '2024-05-01',
snapshots: [{ ticker: 'AAPL', price: 180, date: new Date('2024-05-01') }], snapshot: {},
}, },
]; ];
async list(): Promise<(MarketCall & { id: string })[]> { async list(): Promise<(MarketCall & { id: string })[]> {
return this.calls.sort((a, b) => b.date.getTime() - a.date.getTime()); return this.calls.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
} }
async get(id: string): Promise<(MarketCall & { id: string }) | null> { async get(id: string): Promise<(MarketCall & { id: string }) | null> {
@@ -27,7 +27,7 @@ class MockMarketCallRepository {
async create(call: MarketCall): Promise<MarketCall & { id: string }> { async create(call: MarketCall): Promise<MarketCall & { id: string }> {
const id = String(this.calls.length + 1); const id = String(this.calls.length + 1);
const newCall = { id, ...call }; const newCall = { ...call, id };
this.calls.push(newCall); this.calls.push(newCall);
return newCall; return newCall;
} }
@@ -152,7 +152,7 @@ test('CallsController', async (t) => {
const calls = await repository.list(); const calls = await repository.list();
assert.ok(Array.isArray(calls)); assert.ok(Array.isArray(calls));
assert.equal(calls.length, 1); assert.equal(calls.length, 1);
assert.equal(calls[0].ticker || calls[0].title, 'AAPL Post-Earnings' || 'AAPL'); assert.equal(calls[0].title, 'AAPL Post-Earnings');
}); });
await t.test('returns calls sorted by date (newest first)', async () => { await t.test('returns calls sorted by date (newest first)', async () => {
@@ -164,8 +164,8 @@ test('CallsController', async (t) => {
quarter: 'Q1 2024', quarter: 'Q1 2024',
thesis: 'Old thesis', thesis: 'Old thesis',
tickers: ['AAPL'], tickers: ['AAPL'],
date: new Date('2024-01-01'), date: '2024-01-01',
snapshots: [], snapshot: {},
}, },
{ {
id: '2', id: '2',
@@ -173,13 +173,13 @@ test('CallsController', async (t) => {
quarter: 'Q2 2024', quarter: 'Q2 2024',
thesis: 'New thesis', thesis: 'New thesis',
tickers: ['MSFT'], tickers: ['MSFT'],
date: new Date('2024-05-01'), date: '2024-05-01',
snapshots: [], snapshot: {},
}, },
]; ];
async list() { async list() {
return this.calls.sort((a, b) => b.date.getTime() - a.date.getTime()); return this.calls.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
} }
async get(id: string) { async get(id: string) {
@@ -205,14 +205,14 @@ test('CallsController', async (t) => {
await t.test('creates new market call', async () => { await t.test('creates new market call', async () => {
const repository = new MockMarketCallRepository() as any; const repository = new MockMarketCallRepository() as any;
const newCall: MarketCall = { const newCall = {
title: 'MSFT Q3 2024', title: 'MSFT Q3 2024',
quarter: 'Q3 2024', quarter: 'Q3 2024',
thesis: 'Cloud growth acceleration', thesis: 'Cloud growth acceleration',
tickers: ['MSFT'], tickers: ['MSFT'],
date: new Date('2024-07-01'), date: '2024-07-01',
snapshots: [], snapshot: {},
}; } as MarketCall;
const created = await repository.create(newCall); const created = await repository.create(newCall);
assert.ok(created.id); assert.ok(created.id);
@@ -261,14 +261,14 @@ test('CallsController', async (t) => {
const repository = new MockMarketCallRepository() as any; const repository = new MockMarketCallRepository() as any;
const engine = new MockScreenerEngine() as any; const engine = new MockScreenerEngine() as any;
const newCall: MarketCall = { const newCall = {
title: 'Tech Quartet', title: 'Tech Quartet',
quarter: 'Q3 2024', quarter: 'Q3 2024',
thesis: 'All tech leaders', thesis: 'All tech leaders',
tickers: ['AAPL', 'MSFT', 'NVDA', 'GOOG'], tickers: ['AAPL', 'MSFT', 'NVDA', 'GOOG'],
date: new Date('2024-07-01'), date: '2024-07-01',
snapshots: [], snapshot: {},
}; } as MarketCall;
const created = await repository.create(newCall); const created = await repository.create(newCall);
const results = await engine.screenTickers(created.tickers); const results = await engine.screenTickers(created.tickers);
@@ -290,11 +290,13 @@ test('CallsController', async (t) => {
} }
}); });
await t.test('call includes snapshots of entry prices', async () => { await t.test('call includes a snapshot of entry prices', async () => {
const repository = new MockMarketCallRepository() as any; const repository = new MockMarketCallRepository() as any;
const call = await repository.get('1'); const call = await repository.get('1');
assert.ok(call); assert.ok(call);
assert.ok(Array.isArray(call.snapshots)); // MarketCall.snapshot is Record<ticker, TickerSnapshot>, not an array
assert.equal(typeof call.snapshot, 'object');
assert.ok(!Array.isArray(call.snapshot));
}); });
}); });
+2 -2
View File
@@ -142,7 +142,7 @@ test('PortfolioAdvisor', async (t) => {
displayMetrics: {}, displayMetrics: {},
} as any, } as any,
{ {
signal: SIGNAL.BUY, signal: SIGNAL.STRONG_BUY,
fundamental: { label: 'pass', scoreSummary: '', audit: { passedGates: true } }, fundamental: { label: 'pass', scoreSummary: '', audit: { passedGates: true } },
inflated: { label: 'pass', scoreSummary: '', audit: { passedGates: true } }, inflated: { label: 'pass', scoreSummary: '', audit: { passedGates: true } },
asset: { asset: {
@@ -239,7 +239,7 @@ test('PortfolioAdvisor', async (t) => {
displayMetrics: {}, displayMetrics: {},
} as any, } as any,
{ {
signal: SIGNAL.BUY, signal: SIGNAL.STRONG_BUY,
fundamental: { label: 'pass', scoreSummary: '', audit: { passedGates: true } }, fundamental: { label: 'pass', scoreSummary: '', audit: { passedGates: true } },
inflated: { label: 'pass', scoreSummary: '', audit: { passedGates: true } }, inflated: { label: 'pass', scoreSummary: '', audit: { passedGates: true } },
asset: { asset: {
+18 -10
View File
@@ -3,11 +3,7 @@ import assert from 'node:assert/strict';
import { ScreenerController } from '../server/domains/screener/screener.controller.js'; import { ScreenerController } from '../server/domains/screener/screener.controller.js';
import { ScreenerEngine } from '../server/domains/screener/ScreenerEngine.js'; import { ScreenerEngine } from '../server/domains/screener/ScreenerEngine.js';
import type { import type { LiveAssetResult, MarketContext } from '../server/domains/shared/types/index.js';
LiveAssetResult,
MarketContext,
Stock,
} from '../server/domains/shared/types/index.js';
import { ASSET_TYPE, SIGNAL } from '../server/domains/shared/config/constants.js'; import { ASSET_TYPE, SIGNAL } from '../server/domains/shared/config/constants.js';
// Mock implementations // Mock implementations
@@ -43,12 +39,24 @@ class MockScreenerEngine extends ScreenerEngine {
returnOnEquity: 95.2, returnOnEquity: 95.2,
freeCashFlow: 100000000, freeCashFlow: 100000000,
}), }),
} as unknown as Stock; } as unknown as LiveAssetResult['asset'];
const mockResult: LiveAssetResult = { const mockResult: LiveAssetResult = {
asset: mockStock, asset: mockStock,
fundamentalScore: { label: '✓ BUY', scoreSummary: 'Quality gate PASS' }, fundamental: {
inflatedScore: { label: ' BUY', scoreSummary: 'Market adjusted gate PASS' }, label: '🟢 BUY (High Conviction)',
tier: 'PASS',
score: 9,
scoreSummary: 'Quality gate PASS',
audit: { passedGates: true },
},
inflated: {
label: '🟢 BUY (High Conviction)',
tier: 'PASS',
score: 9,
scoreSummary: 'Market adjusted gate PASS',
audit: { passedGates: true },
},
signal: SIGNAL.STRONG_BUY, signal: SIGNAL.STRONG_BUY,
}; };
@@ -190,7 +198,7 @@ test('ScreenerController', async (t) => {
assert.equal(results.STOCK.length, 1); assert.equal(results.STOCK.length, 1);
const result = results.STOCK[0]; const result = results.STOCK[0];
assert.ok(result.signal); assert.ok(result.signal);
assert.ok(result.fundamentalScore); assert.ok(result.fundamental);
assert.ok(result.inflatedScore); assert.ok(result.inflated);
}); });
}); });
@@ -21,6 +21,12 @@ class ScreenerStore {
// ── Derived ──────────────────────────────────────────────────────── // ── Derived ────────────────────────────────────────────────────────
ctx = $derived(this.results?.marketContext ?? null); ctx = $derived(this.results?.marketContext ?? null);
/** P0.4 data-sanity sentinel — dismissible per screen run. */
healthDismissed = $state(false);
dataHealth = $derived(
!this.healthDismissed && this.results?.dataHealth?.degraded ? this.results.dataHealth : null,
);
allAssets = $derived( allAssets = $derived(
this.results ? sorted([...this.results.STOCK, ...this.results.ETF, ...this.results.BOND]) : [], this.results ? sorted([...this.results.STOCK, ...this.results.ETF, ...this.results.BOND]) : [],
); );
@@ -28,6 +34,7 @@ class ScreenerStore {
// ── Actions ──────────────────────────────────────────────────────── // ── Actions ────────────────────────────────────────────────────────
async screen(): Promise<void> { async screen(): Promise<void> {
this.error = null; this.error = null;
this.healthDismissed = false;
this.loading = true; this.loading = true;
try { try {
const tickers = this.input const tickers = this.input
@@ -46,6 +53,7 @@ class ScreenerStore {
async reloadCatalysts(): Promise<void> { async reloadCatalysts(): Promise<void> {
this.loadingCats = true; this.loadingCats = true;
this.error = null; this.error = null;
this.healthDismissed = false;
try { try {
const cat = await fetchCatalysts(); const cat = await fetchCatalysts();
this.input = cat.tickers.join(', '); this.input = cat.tickers.join(', ');
+7
View File
@@ -62,6 +62,13 @@
<div class="error-banner">{s.error}</div> <div class="error-banner">{s.error}</div>
{/if} {/if}
{#if s.dataHealth}
<div class="warn-banner" role="alert">
<span>{s.dataHealth.message}</span>
<button class="warn-dismiss" onclick={() => s.healthDismissed = true} title="Dismiss"></button>
</div>
{/if}
{#if s.loading || s.loadingCats} {#if s.loading || s.loadingCats}
<div class="loading-area"> <div class="loading-area">
<Spinner size="lg" label={s.loadingCats ? 'Fetching news catalysts…' : 'Screening tickers…'} /> <Spinner size="lg" label={s.loadingCats ? 'Fetching news catalysts…' : 'Screening tickers…'} />
+28
View File
@@ -76,3 +76,31 @@
margin-bottom: 16px; margin-bottom: 16px;
font-size: var(--fs-md); font-size: var(--fs-md);
} }
// ── Warning banner (data-sanity sentinel, P0.4) ───────────────────────────
.warn-banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
background: var(--amber-dim);
border: 1px solid var(--amber);
border-radius: var(--radius-md);
color: var(--amber);
padding: 10px var(--space-lg);
margin-bottom: 16px;
font-size: var(--fs-md);
}
.warn-dismiss {
background: none;
border: none;
color: var(--amber);
cursor: pointer;
font-size: 14px;
padding: 2px 6px;
line-height: 1;
&:hover { filter: brightness(1.3); }
}