fix: analyst ratings timeout + add accession column to institution_filings

- Add 6s server-side timeout to Yahoo Finance quoteSummary (Promise.race) so the backend responds with stale data instead of hanging indefinitely. Stale cache is served on error (router.ts:1895-1898), matching ADR-0009.
- Hoist YahooFinance to a module-level singleton (getYf()) so the crumb/cookie is fetched once, not per call. Matches YFinanceAdapter pattern.
- Add suppressNotices for yahooSurvey to reduce log noise.
- Add accession column to institution_filings (schema.sql + migration in client.ts) and thread it through both secDataFetcher.ts INSERT paths (13D/G and 13F-HR). Fixes recurring `no such column: accession` error in new13daProducer alert tick.
- new13daProducer.ts needs no changes - its SELECT accession query now works.
This commit is contained in:
Investor Flow Build
2026-07-25 11:52:48 -04:00
parent 8b51d16d7b
commit 351c104eba
4 changed files with 29 additions and 15 deletions
+1
View File
@@ -57,6 +57,7 @@ function runMigrations(db: DatabaseSync): void {
`ALTER TABLE adapter_queue ADD COLUMN error TEXT`,
`ALTER TABLE adapter_queue ADD COLUMN scheduled_for TEXT`,
`ALTER TABLE insider_transactions ADD COLUMN accession TEXT`,
`ALTER TABLE institution_filings ADD COLUMN accession TEXT`,
`ALTER TABLE x_credentials ADD COLUMN fred_api_key_enc TEXT`,
`ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'active'`,
// Onboarding / risk posture fields added after early installs (CREATE IF NOT EXISTS does not alter).
+2 -1
View File
@@ -113,7 +113,8 @@ CREATE TABLE IF NOT EXISTS institution_filings (
shares REAL, -- shares reported
value_usd REAL,
reported_quarter TEXT NOT NULL, -- calendar quarter positions are "as of"
filed_at TEXT NOT NULL,
filed_at TEXT NOT NULL, -- when the filing was submitted to SEC
accession TEXT, -- SEC accession number (enables 13D/G dedup)
fetched_at TEXT NOT NULL,
put_call TEXT -- Put|Call|null for common stock (13F only)
);
@@ -21,11 +21,23 @@ export interface AnalystConsensus {
const RATINGS_TTL_MS = 86_400_000; // 24h
const MIN_FETCH_INTERVAL_MS = 5_000; // 5s between fetches per symbol
const RATE_LIMIT_BACKOFF_MS = 120_000; // 2min backoff after rate-limit
const YAHOO_TIMEOUT_MS = 6_000; // 6s — shorter than frontend 8s TRPC timeout
// In-memory per-symbol throttle to prevent stampeding in the absence of queue integration
const lastFetchBySymbol = new Map<string, number>();
const rateLimitUntil = new Map<string, number>();
// Singleton YahooFinance instance (crumb/cookie is fetched once, reused across calls).
let yfInstance: { quoteSummary(...args: unknown[]): Promise<Record<string, unknown>> } | null = null;
async function getYf(): Promise<{ quoteSummary(...args: unknown[]): Promise<Record<string, unknown>> }> {
if (!yfInstance) {
const mod = await import('yahoo-finance2');
yfInstance = new mod.default({ suppressNotices: ['yahooSurvey'] }) as typeof yfInstance;
}
return yfInstance;
}
async function fetchFromYahoo(symbol: string): Promise<{ ratings: AnalystRating[]; consensus: AnalystConsensus } | { error: string }> {
const now = Date.now();
@@ -41,17 +53,17 @@ async function fetchFromYahoo(symbol: string): Promise<{ ratings: AnalystRating[
}
lastFetchBySymbol.set(symbol, now);
let yfMod: { default: new () => { quoteSummary(...args: unknown[]): Promise<Record<string, unknown>> } };
try {
yfMod = await import('yahoo-finance2');
} catch {
return { error: 'yahoo-finance2 not available' };
}
const yf = new yfMod.default();
const yf = await getYf();
let raw: Record<string, unknown>;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
try {
raw = await yf.quoteSummary(symbol, { modules: ['upgradeDowngradeHistory', 'recommendationTrend'] }, { validateResult: false });
const yfPromise = yf.quoteSummary(symbol, { modules: ['upgradeDowngradeHistory', 'recommendationTrend'] }, { validateResult: false });
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => reject(new Error('Yahoo Finance timed out')), YAHOO_TIMEOUT_MS);
});
raw = await Promise.race([yfPromise, timeoutPromise]) as Record<string, unknown>;
} catch (e) {
if (timeoutId) { clearTimeout(timeoutId); timeoutId = null; }
const msg = (e as Error).message;
if (/too many requests|rate[- ]?limit|429|edge:\s*too many/i.test(msg)) {
rateLimitUntil.set(symbol, Date.now() + RATE_LIMIT_BACKOFF_MS);
+6 -6
View File
@@ -402,13 +402,13 @@ async function fetchAndStoreSCFilings(
const insert = db.prepare(`
INSERT OR REPLACE INTO institution_filings
(filer_cik, filer_name, filer_sic, symbol, form, shares, value_usd, reported_quarter, filed_at, fetched_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
(filer_cik, filer_name, filer_sic, symbol, form, shares, value_usd, reported_quarter, filed_at, accession, fetched_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
insert.run(
parsed.filerCik, parsed.filerName, filerSic, symbol,
parsed.formType, parsed.shares, parsed.value, quarter, filedAt, now,
parsed.formType, parsed.shares, parsed.value, quarter, filedAt, filing.accession, now,
);
storedCount++;
} catch {
@@ -469,13 +469,13 @@ async function storeMatchedHoldings(
const now = new Date().toISOString();
const insert = db.prepare(`
INSERT OR REPLACE INTO institution_filings
(filer_cik, filer_name, filer_sic, symbol, form, shares, value_usd, reported_quarter, filed_at, fetched_at, put_call)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
(filer_cik, filer_name, filer_sic, symbol, form, shares, value_usd, reported_quarter, filed_at, accession, fetched_at, put_call)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
let n = 0;
for (const h of matching) {
insert.run(filing.cik, filing.filerName, filerSic, symbol, formType, h.sshPrnamt, h.value, quarter, filing.filedAt, now, h.putCall || null);
insert.run(filing.cik, filing.filerName, filerSic, symbol, formType, h.sshPrnamt, h.value, quarter, filing.filedAt, filing.accession, now, h.putCall || null);
n++;
}
return n;