phase-10.5: screener enhancements

This commit is contained in:
Kazuma
2026-06-11 19:18:19 -04:00
parent f0c794f0c0
commit bf2a85b5c4
51 changed files with 3745 additions and 36 deletions
+191
View File
@@ -0,0 +1,191 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { DigestService } from '../server/domains/digest/DigestService.js';
import { DiscordNotifier } from '../server/domains/digest/DiscordNotifier.js';
import type { SignalSnapshotRepository } from '../server/domains/shared/persistence/SignalSnapshotRepository.js';
import type { NewsRepository } from '../server/domains/news/NewsRepository.js';
import type { NewsArticleRow, SignalSnapshotRow } from '../server/domains/shared/types/index.js';
function snap(over: Partial<SignalSnapshotRow>): SignalSnapshotRow {
return {
ticker: 'AAPL',
snapshot_date: '2026-06-09',
asset_type: 'STOCK',
price: 189.5,
signal: '✅ Strong Buy',
fundamental_tier: 'PASS',
fundamental_score: 9,
fundamental_label: '🟢 BUY (High Conviction)',
inflated_tier: 'PASS',
inflated_score: 9,
inflated_label: '🟢 BUY (High Conviction)',
coverage_active: 8,
coverage_total: 11,
risk_flags: null,
rate_regime: 'NORMAL',
created_at: '2026-06-09T21:00:00.000Z',
...over,
};
}
function article(over: Partial<NewsArticleRow>): NewsArticleRow {
return {
url_hash: 'h1',
title_hash: 't1',
ticker_list: '["AAPL"]',
headline: '8-K filing: APPLE INC',
body: null,
source: 'edgar',
catalyst: 'regulatory',
url: 'https://sec.gov/x',
published_at: '2026-06-08T20:00:00.000Z',
created_at: '2026-06-08T20:01:00.000Z',
...over,
};
}
function makeService(
today: SignalSnapshotRow[],
prev: SignalSnapshotRow[],
newsByTicker: Record<string, NewsArticleRow[]> = {},
): DigestService {
const snapshots = {
byDate: () => today,
latestBefore: () => prev,
} as unknown as SignalSnapshotRepository;
const news = {
newsForTicker: (t: string) => newsByTicker[t] ?? [],
} as unknown as NewsRepository;
return new DigestService(snapshots, news);
}
test('DigestService', async (t) => {
await t.test('detects signal change and attaches catalysts', () => {
const service = makeService(
[snap({ signal: '🔄 Neutral', fundamental_score: 2 })],
[snap({ snapshot_date: '2026-06-08', signal: '✅ Strong Buy', fundamental_score: 9 })],
{ AAPL: [article({})] },
);
const report = service.build('2026-06-09');
assert.equal(report.changes.length, 1);
const c = report.changes[0];
assert.equal(c.previousSignal, '✅ Strong Buy');
assert.equal(c.newSignal, '🔄 Neutral');
assert.equal(c.scoreDelta, -7);
assert.equal(c.catalysts.length, 1);
assert.equal(c.catalysts[0].catalyst, 'regulatory');
});
await t.test('no change → empty digest', () => {
const service = makeService([snap({})], [snap({ snapshot_date: '2026-06-08' })]);
const report = service.build('2026-06-09');
assert.equal(report.changes.length, 0);
assert.equal(report.snapshotCount, 1);
});
await t.test('first-ever snapshot lands in newTickers, not changes', () => {
const service = makeService([snap({ ticker: 'NVDA' })], []);
const report = service.build('2026-06-09');
assert.equal(report.changes.length, 0);
assert.deepEqual(report.newTickers, ['NVDA']);
});
await t.test('M&A stories surface even without a signal change', () => {
const service = makeService(
[snap({})],
[snap({ snapshot_date: '2026-06-08' })], // same signal — no change
{
AAPL: [
article({
catalyst: 'ma',
headline: 'SC 13D filing: APPLE INC',
url_hash: 'h2',
url: 'https://sec.gov/13d',
}),
],
},
);
const report = service.build('2026-06-09');
assert.equal(report.changes.length, 0);
assert.equal(report.maStories.length, 1);
assert.ok(report.maStories[0].headline.includes('SC 13D'));
});
await t.test('sorts changes by signal-distance impact', () => {
const service = makeService(
[
snap({ ticker: 'SMALL', signal: '⚡ Momentum' }), // Strong Buy(0) → Momentum(1): impact 1
snap({ ticker: 'BIG', signal: '❌ Avoid' }), // Strong Buy(0) → Avoid(4): impact 4
],
[
snap({ ticker: 'SMALL', snapshot_date: '2026-06-08', signal: '✅ Strong Buy' }),
snap({ ticker: 'BIG', snapshot_date: '2026-06-08', signal: '✅ Strong Buy' }),
],
);
const report = service.build('2026-06-09');
assert.equal(report.changes[0].ticker, 'BIG');
assert.equal(report.changes[1].ticker, 'SMALL');
});
});
test('DiscordNotifier.buildPayload', async (t) => {
await t.test('returns null when nothing to report', () => {
assert.equal(
DiscordNotifier.buildPayload({
date: '2026-06-09',
changes: [],
newTickers: [],
maStories: [],
snapshotCount: 5,
}),
null,
);
});
await t.test('builds embed with change fields and M&A section', () => {
const payload = DiscordNotifier.buildPayload({
date: '2026-06-09',
changes: [
{
ticker: 'AAPL',
previousSignal: '✅ Strong Buy',
newSignal: '🔄 Neutral',
previousDate: '2026-06-08',
scoreDelta: -7,
price: 189.5,
catalysts: [
{
headline: '8-K filing: APPLE INC',
catalyst: 'regulatory',
source: 'edgar',
url: 'https://sec.gov/x',
publishedAt: '2026-06-08T20:00:00.000Z',
},
],
},
],
newTickers: [],
maStories: [
{
headline: 'SC 13D filing: APPLE INC',
catalyst: 'ma',
source: 'edgar',
url: 'https://sec.gov/13d',
publishedAt: '2026-06-08T21:00:00.000Z',
},
],
snapshotCount: 12,
});
assert.ok(payload);
const embed = payload.embeds[0] as {
title: string;
fields: Array<{ name: string; value: string }>;
};
assert.ok(embed.title.includes('2026-06-09'));
assert.equal(embed.fields.length, 2); // 1 change + 1 M&A section
assert.ok(embed.fields[0].name.includes('AAPL'));
assert.ok(embed.fields[0].name.includes('score -7'));
assert.ok(embed.fields[0].value.includes('regulatory'));
assert.ok(embed.fields[1].name.includes('M&A'));
});
});
+129
View File
@@ -0,0 +1,129 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { NewsPipeline } from '../server/domains/news/NewsPipeline.js';
import type { NewsRepository } from '../server/domains/news/NewsRepository.js';
import type { NormalizedStory } from '../server/domains/shared/types/index.js';
/** In-memory stub that records what the pipeline stores. */
class StubRepo {
articles: Array<{ urlHash: string; tickers: string[]; catalyst: string | null }> = [];
links: Array<{ ticker: string; day: string }> = [];
seenTitles = new Set<string>();
capCounts = new Map<string, number>(); // `${ticker}|${day}` → count
insertArticle(a: { urlHash: string; tickers: string[]; catalyst: string | null }): boolean {
if (this.articles.some((x) => x.urlHash === a.urlHash)) return false;
this.articles.push(a);
return true;
}
titleSeenSince(titleHash: string): boolean {
return this.seenTitles.has(titleHash);
}
linkTicker(ticker: string, day: string): void {
this.links.push({ ticker, day });
}
countTickerDay(ticker: string, day: string): number {
return this.capCounts.get(`${ticker}|${day}`) ?? 0;
}
purgeBodiesBefore(): number {
return 0;
}
deleteUnreferencedBefore(): number {
return 0;
}
}
const UNIVERSE = new Set(['AAPL', 'MSFT']);
function story(overrides: Partial<NormalizedStory> = {}): NormalizedStory {
return {
tickers: ['AAPL'],
headline: 'Apple announces quarterly results beat estimates',
source: 'prwire',
url: `https://example.com/${Math.random()}`,
publishedAt: '2026-06-09T14:00:00.000Z',
...overrides,
};
}
function makePipeline(repo: StubRepo): NewsPipeline {
return new NewsPipeline(repo as unknown as NewsRepository);
}
test('NewsPipeline', async (t) => {
await t.test('stores universe stories and links tickers', () => {
const repo = new StubRepo();
const stats = makePipeline(repo).ingest([story()], UNIVERSE);
assert.equal(stats.stored, 1);
assert.equal(repo.links.length, 1);
assert.equal(repo.links[0].ticker, 'AAPL');
assert.equal(repo.links[0].day, '2026-06-09');
});
await t.test('drops stories with no universe ticker (§4.1)', () => {
const repo = new StubRepo();
const stats = makePipeline(repo).ingest([story({ tickers: ['ZZZZ'] })], UNIVERSE);
assert.equal(stats.stored, 0);
assert.equal(stats.droppedNoUniverseTicker, 1);
assert.equal(repo.articles.length, 0);
});
await t.test('drops noise headlines, but never filings (§4.2)', () => {
const repo = new StubRepo();
const noise = story({ headline: '5 best stocks to buy now including Apple' });
const filing = story({
headline: '8-K filing: 5 best stocks edge case',
source: 'edgar',
catalystHint: 'regulatory',
});
const stats = makePipeline(repo).ingest([noise, filing], UNIVERSE);
assert.equal(stats.droppedNoise, 1);
assert.equal(stats.stored, 1);
assert.equal(repo.articles[0].catalyst, 'regulatory');
});
await t.test('drops syndicated duplicates by normalized title (§4.3)', () => {
const repo = new StubRepo();
const pipeline = makePipeline(repo);
// First copy stored; mark its normalized-title hash as seen
pipeline.ingest([story({ headline: 'Apple Beats Q2 Estimates!' })], UNIVERSE);
repo.seenTitles.add(sha256(NewsPipeline.normalizeTitle('Apple Beats Q2 Estimates!')));
// Same story, different casing/punctuation/URL → syndicated copy
const stats = pipeline.ingest(
[story({ headline: 'APPLE BEATS Q2 ESTIMATES', url: 'https://other.com/copy' })],
UNIVERSE,
);
assert.equal(stats.droppedDuplicate, 1);
});
await t.test('enforces per-ticker daily cap, filings exempt (§4.4)', () => {
const repo = new StubRepo();
repo.capCounts.set('AAPL|2026-06-09', 25); // at cap
const wire = story();
const filing = story({ source: 'edgar', catalystHint: 'ma', url: 'https://sec.gov/x' });
const stats = makePipeline(repo).ingest([wire, filing], UNIVERSE);
assert.equal(stats.droppedCapped, 1);
assert.equal(stats.stored, 1); // the filing
});
await t.test('classifies catalysts with M&A taking priority', () => {
assert.equal(NewsPipeline.classify('Acme to be acquired by MegaCorp in Q2 deal'), 'ma');
assert.equal(NewsPipeline.classify('Acme reports record quarterly results'), 'earnings');
assert.equal(NewsPipeline.classify('Acme raises full-year guidance'), 'guidance');
assert.equal(NewsPipeline.classify('FDA approval granted for Acme drug'), 'regulatory');
assert.equal(NewsPipeline.classify('Fed holds rates steady amid CPI data'), 'macro');
assert.equal(NewsPipeline.classify('Acme appoints new CMO'), null);
});
await t.test('noise detector catches listicles and target reiterations', () => {
assert.ok(NewsPipeline.isNoise('3 Top Stocks to Watch This Week'));
assert.ok(NewsPipeline.isNoise('Analyst price target raised on momentum'));
assert.ok(!NewsPipeline.isNoise('Apple announces $90B buyback'));
});
});
// Helper mirroring NewsPipeline's title hashing for the dedupe test
import { createHash } from 'crypto';
function sha256(input: string): string {
return createHash('sha256').update(input).digest('hex');
}
+85
View File
@@ -0,0 +1,85 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { EdgarPoller } from '../server/domains/news/pollers/EdgarPoller.js';
import { PrWirePoller } from '../server/domains/news/pollers/PrWirePoller.js';
import { RssParser } from '../server/domains/news/rss.js';
import { noopLogger } from '../server/domains/shared/utils/logger.js';
const EDGAR_ATOM = `<?xml version="1.0" encoding="ISO-8859-1" ?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Latest Filings</title>
<entry>
<title>8-K - APPLE INC (0000320193) (Filer)</title>
<link rel="alternate" type="text/html" href="https://www.sec.gov/Archives/edgar/data/320193/000032019326000001-index.htm"/>
<updated>2026-06-09T13:01:02-04:00</updated>
<id>urn:tag:sec.gov,2008:accession-number=0000320193-26-000001</id>
</entry>
<entry>
<title>8-K - UNKNOWN CO (0009999999) (Filer)</title>
<link rel="alternate" type="text/html" href="https://www.sec.gov/Archives/edgar/data/9999999/x-index.htm"/>
<updated>2026-06-09T13:05:00-04:00</updated>
<id>urn:tag:sec.gov,2008:accession-number=x</id>
</entry>
</feed>`;
const PRWIRE_RSS = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"><channel>
<item>
<title>Acme Corp (NYSE: ACME) Announces Record Q2 Results</title>
<link>https://www.example.com/acme-q2</link>
<pubDate>Tue, 09 Jun 2026 12:00:00 GMT</pubDate>
<description><![CDATA[Acme Corp (NYSE: ACME) and partner Beta Inc (Nasdaq: BETA) today announced...]]></description>
</item>
<item>
<title>Local bakery wins award</title>
<link>https://www.example.com/bakery</link>
<pubDate>Tue, 09 Jun 2026 11:00:00 GMT</pubDate>
<description>No public companies here.</description>
</item>
</channel></rss>`;
test('news pollers', async (t) => {
await t.test('EdgarPoller maps CIK to ticker and filters by universe', () => {
const poller = new EdgarPoller(noopLogger, 'test-agent');
poller.setTickerMap(new Map([['0000320193', 'AAPL']]));
const stories = poller.parseFeed(EDGAR_ATOM, '8-K', 'regulatory', new Set(['AAPL']));
assert.equal(stories.length, 1); // unknown CIK dropped
assert.deepEqual(stories[0].tickers, ['AAPL']);
assert.equal(stories[0].source, 'edgar');
assert.equal(stories[0].catalystHint, 'regulatory');
assert.ok(stories[0].headline.startsWith('8-K filing:'));
assert.ok(stories[0].headline.includes('APPLE INC'));
assert.ok(stories[0].url.includes('sec.gov'));
});
await t.test('EdgarPoller drops universe misses', () => {
const poller = new EdgarPoller(noopLogger, 'test-agent');
poller.setTickerMap(new Map([['0000320193', 'AAPL']]));
const stories = poller.parseFeed(EDGAR_ATOM, '8-K', 'regulatory', new Set(['MSFT']));
assert.equal(stories.length, 0);
});
await t.test('PrWirePoller extracts exchange-tagged tickers', () => {
const stories = PrWirePoller.parseFeed(PRWIRE_RSS);
assert.equal(stories.length, 1); // bakery story has no tickers → skipped
assert.deepEqual(stories[0].tickers.sort(), ['ACME', 'BETA']);
assert.equal(stories[0].source, 'prwire');
assert.ok(stories[0].publishedAt.startsWith('2026-06-09'));
});
await t.test('extractTickers handles exchange tag variants', () => {
assert.deepEqual(PrWirePoller.extractTickers('(NYSE: ABC)'), ['ABC']);
assert.deepEqual(PrWirePoller.extractTickers('(Nasdaq: xyz)'), ['XYZ']);
assert.deepEqual(PrWirePoller.extractTickers('(NYSE American: BRK.B)'), ['BRK.B']);
assert.deepEqual(PrWirePoller.extractTickers('(OTCQB: TINY)'), ['TINY']);
assert.deepEqual(PrWirePoller.extractTickers('no tags here'), []);
});
await t.test('RssParser decodes entities and strips CDATA', () => {
const block = '<item><title>A &amp; B say &quot;hi&quot;</title></item>';
assert.equal(RssParser.tag(block, 'title'), 'A & B say "hi"');
const cdata = '<item><description><![CDATA[Text <b>bold</b> here]]></description></item>';
assert.equal(RssParser.tag(cdata, 'description'), 'Text bold here');
});
});