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
@@ -1,5 +1,5 @@
import YahooFinance from 'yahoo-finance2';
import type { YahooNewsItem, YahooSearchOptions, YahooFinanceLib } from '../types';
import type { YahooNewsItem, YahooSearchOptions, YahooFinanceLib, PricePoint } from '../types';
import { YAHOO_MODULES } from '../config/constants';
export class YahooFinanceClient {
@@ -49,4 +49,71 @@ export class YahooFinanceClient {
const { news = [] } = await this.lib.search(query, opts);
return news;
}
/**
* Top holdings of an ETF (ticker symbols, largest weight first).
* Used for sector drill-down. Returns [] on any failure.
*/
async fetchTopHoldings(etf: string, limit = 10): Promise<string[]> {
try {
const result = await this.lib.quoteSummary(
YahooFinanceClient.normalise(etf),
{ modules: ['topHoldings'] },
{ validateResult: false },
);
const holdings = (result?.topHoldings?.holdings ?? []) as Array<{ symbol?: string }>;
return holdings
.map((h) => h.symbol)
.filter((s): s is string => Boolean(s))
.slice(0, limit)
.map((s) => s.toUpperCase());
} catch {
return [];
}
}
/** Chart range presets — Robinhood/Yahoo-style. Intraday for short ranges. */
static readonly CHART_RANGES: Record<string, { days: number; interval: string }> = {
'1d': { days: 1, interval: '5m' },
'5d': { days: 5, interval: '30m' },
'1mo': { days: 30, interval: '1d' },
'3mo': { days: 91, interval: '1d' },
'6mo': { days: 182, interval: '1d' },
ytd: { days: 0, interval: '1d' }, // days computed dynamically (Jan 1 → now)
'1y': { days: 365, interval: '1d' },
'5y': { days: 1826, interval: '1wk' }, // weekly bars keep ~260 points
};
/**
* Closing prices for a named range (ticker modal chart). Intraday ranges
* keep the full timestamp; daily ranges keep the date only.
* Returns [] on any failure — the chart is a nice-to-have, never a blocker.
*/
async fetchCloses(ticker: string, range = '6mo'): Promise<PricePoint[]> {
const preset = YahooFinanceClient.CHART_RANGES[range] ?? YahooFinanceClient.CHART_RANGES['6mo'];
try {
const period1 =
range === 'ytd'
? new Date(Date.UTC(new Date().getUTCFullYear(), 0, 1))
: new Date(Date.now() - preset.days * 24 * 60 * 60 * 1000);
const result = await this.lib.chart(
YahooFinanceClient.normalise(ticker),
{ period1, interval: preset.interval },
{ validateResult: false },
);
const quotes = (result?.quotes ?? []) as Array<{ date?: string | Date; close?: number }>;
const intraday = preset.interval !== '1d';
return quotes
.filter((q) => q.close != null && q.date != null)
.map((q) => {
const iso = new Date(q.date as string | Date).toISOString();
return {
date: intraday ? iso : iso.slice(0, 10),
close: +(q.close as number).toFixed(2),
};
});
} catch {
return [];
}
}
}
@@ -163,6 +163,68 @@ export const UNIVERSE_QUERIES = {
WHERE type != 'crypto'
ORDER BY ticker
`,
// Every ticker screened recently (snapshot ledger) — part of the news universe
DISTINCT_SNAPSHOT_TICKERS_SINCE: `
SELECT DISTINCT ticker FROM signal_snapshots
WHERE snapshot_date >= ?
ORDER BY ticker
`,
};
// ── News Queries (FREE-DATA-STACK §25 — free-tier news pipeline) ───────────
export const NEWS_QUERIES = {
// INSERT OR IGNORE — url_hash PK is the first dedupe line (returns 0 changes on dup)
INSERT_ARTICLE: `
INSERT OR IGNORE INTO news_articles
(url_hash, title_hash, ticker_list, headline, body, source, catalyst, url, published_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
// Second dedupe line: same (normalized) title seen recently → syndicated copy
TITLE_SEEN_SINCE: `
SELECT 1 FROM news_articles
WHERE title_hash = ? AND published_at >= ?
LIMIT 1
`,
INSERT_CATALYST_LINK: `
INSERT OR IGNORE INTO ticker_catalysts (ticker, day, url_hash)
VALUES (?, ?, ?)
`,
// Per-ticker daily cap check (FREE-DATA-STACK §4.4)
COUNT_TICKER_DAY: `
SELECT COUNT(*) AS n FROM ticker_catalysts
WHERE ticker = ? AND day = ?
`,
// Stories for one ticker since a given day — what the UI reads (never Yahoo live)
SELECT_TICKER_NEWS: `
SELECT a.* FROM ticker_catalysts c
JOIN news_articles a ON a.url_hash = c.url_hash
WHERE c.ticker = ? AND c.day >= ?
ORDER BY a.published_at DESC
`,
SELECT_RECENT: `
SELECT * FROM news_articles
ORDER BY published_at DESC
LIMIT ?
`,
// Retention (FREE-DATA-STACK §5): purge bodies after 90d, drop unreferenced after 18mo
PURGE_BODIES_BEFORE: `
UPDATE news_articles SET body = NULL
WHERE body IS NOT NULL AND published_at < ?
`,
DELETE_UNREFERENCED_BEFORE: `
DELETE FROM news_articles
WHERE published_at < ?
AND url_hash NOT IN (SELECT url_hash FROM ticker_catalysts)
`,
};
// ── Signal Snapshot Queries (P0.1 — signal track record) ────────────────────
@@ -287,6 +349,31 @@ export const DDL = `
CREATE INDEX IF NOT EXISTS idx_snapshots_date ON signal_snapshots(snapshot_date);
CREATE INDEX IF NOT EXISTS idx_snapshots_signal ON signal_snapshots(signal, snapshot_date);
CREATE TABLE IF NOT EXISTS news_articles (
url_hash TEXT PRIMARY KEY, -- sha256(url)
title_hash TEXT NOT NULL, -- sha256(normalized headline) — syndication dedupe
ticker_list TEXT NOT NULL, -- JSON array of matched universe tickers
headline TEXT NOT NULL,
body TEXT, -- nullable; purged after 90 days (retention job)
source TEXT NOT NULL, -- 'edgar' | 'prwire' | 'yahoo'
catalyst TEXT, -- 'earnings'|'ma'|'guidance'|'regulatory'|'macro'|NULL
url TEXT NOT NULL,
published_at TEXT NOT NULL, -- ISO timestamp
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_news_published ON news_articles(published_at DESC);
CREATE INDEX IF NOT EXISTS idx_news_title ON news_articles(title_hash, published_at);
CREATE TABLE IF NOT EXISTS ticker_catalysts (
ticker TEXT NOT NULL,
day TEXT NOT NULL, -- YYYY-MM-DD (published date)
url_hash TEXT NOT NULL REFERENCES news_articles(url_hash),
PRIMARY KEY (ticker, day, url_hash)
);
CREATE INDEX IF NOT EXISTS idx_catalysts_ticker ON ticker_catalysts(ticker, day DESC);
`;
// ── Runtime migrations (ALTER TABLE for existing DBs) ────────────────────────
+3 -1
View File
@@ -34,6 +34,7 @@ export class Stock extends Asset {
pFFO: data.pFFO ?? null,
dividendYield: data.dividendYield ?? null,
beta: data.beta ?? null,
dayChangePct: data.dayChangePct ?? null,
week52High: data.week52High ?? null,
week52Low: data.week52Low ?? null,
week52Change: data.week52Change ?? null,
@@ -192,7 +193,8 @@ export class Stock extends Asset {
if (m.quickRatio != null) display['Quick'] = fmt(m.quickRatio, 2);
if (m.beta != null) display['Beta'] = fmt(m.beta, 2);
// 52-week movement
// Movement
if (m.dayChangePct != null) display['Day %'] = fmtSign(m.dayChangePct, '%');
if (w52pos != null) display['52W Pos'] = w52pos;
if (m.week52Change != null) display['52W Chg'] = fmtSign(m.week52Change, '%');
if (m.week52FromHigh != null) display['From High'] = fmtSign(m.week52FromHigh, '%');
@@ -85,6 +85,12 @@ export interface AssetResult {
signal: Signal;
inflated: ScoreResult;
fundamental: ScoreResult;
/**
* Turnaround-watch highlight: style is Turnaround AND the fundamental
* score improved vs the previous snapshot. A candidate flag, not a
* prediction — set by the screener controller, absent for ETFs/bonds.
*/
turnaroundWatch?: boolean;
}
/**
@@ -0,0 +1,30 @@
/**
* Daily change digest types (PRODUCT.md P1.1).
*/
export interface DigestCatalyst {
headline: string;
catalyst: string | null; // 'earnings' | 'ma' | 'guidance' | 'regulatory' | 'macro' | null
source: string; // 'edgar' | 'prwire' | 'yahoo'
url: string;
publishedAt: string;
}
/** A ticker whose signal changed since the previous snapshot. */
export interface DigestChange {
ticker: string;
previousSignal: string;
newSignal: string;
previousDate: string; // day of the previous snapshot
scoreDelta: number | null; // fundamental score change, when both sides have one
price: number | null;
catalysts: DigestCatalyst[]; // recent stories for this ticker (the "why", maybe)
}
export interface DigestReport {
date: string; // YYYY-MM-DD the digest covers
changes: DigestChange[]; // signal flips, strongest-impact first
newTickers: string[]; // first-ever snapshot today (no baseline to diff)
maStories: DigestCatalyst[]; // all M&A-classified stories in the window, always surfaced
snapshotCount: number; // tickers snapshotted today
}
@@ -50,6 +50,7 @@ export interface YahooNewsItem {
publisher: string;
link: string;
relatedTickers?: string[];
providerPublishTime?: string | number | Date;
}
export interface YahooSearchOptions {
@@ -66,6 +67,17 @@ export interface YahooFinanceLib {
queryOpts?: { validateResult?: boolean },
): Promise<any>;
search(query: string, opts?: YahooSearchOptions): Promise<{ news?: YahooNewsItem[] }>;
chart(
ticker: string,
opts: { period1: Date | string; interval?: string },
queryOpts?: { validateResult?: boolean },
): Promise<any>;
}
/** One point of daily price history (ticker modal chart). */
export interface PricePoint {
date: string; // YYYY-MM-DD
close: number;
}
// ── SimpleFIN client types ─────────────────────────────────────────────────
+9
View File
@@ -32,6 +32,7 @@ export type {
YahooNewsItem,
YahooSearchOptions,
YahooFinanceLib,
PricePoint,
SimpleFINOptions,
SimpleFINTransaction,
SimpleFINAccount,
@@ -55,6 +56,14 @@ export type {
HoldingRow,
SignalSnapshotRow,
} from './repositories.model';
export type {
NewsSource,
CatalystType,
NormalizedStory,
NewsArticleRow,
IngestStats,
} from './news.model';
export type { DigestCatalyst, DigestChange, DigestReport } from './digest.model';
export type { NumVal, SanitizedMetrics, SanitizedBondMetrics } from './scorers.model';
export type {
BenchmarkProviderOptions,
@@ -32,6 +32,7 @@ export interface StockData {
pFFO?: number | null;
dividendYield?: number | null;
beta?: number | null;
dayChangePct?: number | null;
week52High?: number | null;
week52Low?: number | null;
week52Change?: number | null;
@@ -66,6 +67,7 @@ export interface StockMetrics {
pFFO: number | null;
dividendYield: number | null;
beta: number | null;
dayChangePct: number | null;
week52High: number | null;
week52Low: number | null;
week52Change: number | null;
+43
View File
@@ -0,0 +1,43 @@
/**
* News pipeline types (FREE-DATA-STACK.md).
*/
export type NewsSource = 'edgar' | 'prwire' | 'yahoo';
export type CatalystType = 'earnings' | 'ma' | 'guidance' | 'regulatory' | 'macro';
/** One story after a poller has normalized it — the only shape the pipeline accepts. */
export interface NormalizedStory {
tickers: string[];
headline: string;
body?: string | null;
source: NewsSource;
url: string;
publishedAt: string; // ISO timestamp
/** Poller-supplied classification (e.g. EDGAR form type); overrides keyword classify. */
catalystHint?: CatalystType | null;
}
/** Raw row from news_articles (snake_case, as stored). */
export interface NewsArticleRow {
url_hash: string;
title_hash: string;
ticker_list: string; // JSON array stringified
headline: string;
body: string | null;
source: string;
catalyst: string | null;
url: string;
published_at: string;
created_at: string;
}
/** What one ingest run did — logged by pollers and bin/poll-news. */
export interface IngestStats {
fetched: number;
stored: number;
droppedNoUniverseTicker: number;
droppedNoise: number;
droppedDuplicate: number;
droppedCapped: number;
}