phase-10.5: screener enhancements
This commit is contained in:
@@ -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 §2–5 — 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) ────────────────────────
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user