phase-10.5: screener enhancements
This commit is contained in:
@@ -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'));
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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 & B say "hi"</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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user