Files
Neon-Desk/lib/server/company-analysis.ts
francy51 529437c760 Stop substituting synthetic market data when providers fail
- Replace synthetic fallback in getQuote()/getPriceHistory() with null returns
- Add QuoteResult/PriceHistoryResult types with { value, stale } structure
- Implement stale-while-revalidate: return cached value with stale=true on live fetch failure
- Cache failures for 30s to avoid hammering provider
- Update CompanyAnalysis type to use PriceData<T> wrapper
- Update task-processors to track failed/stale tickers explicitly
- Update price-history-card UI to show unavailable state and stale indicator
- Add comprehensive tests for failure cases
- Add e2e tests for null data, stale data, and live data scenarios

Resolves #14
2026-03-14 23:37:12 -04:00

337 lines
11 KiB
TypeScript

import { createHash } from 'node:crypto';
import type {
CompanyAiReport,
CompanyAnalysis,
Filing,
Holding,
ResearchJournalEntry,
ResearchMemo,
WatchlistItem
} from '@/lib/types';
import { synthesizeCompanyOverview } from '@/lib/server/company-overview-synthesis';
import { getPriceHistory, getQuote } from '@/lib/server/prices';
import { getRecentDevelopments } from '@/lib/server/recent-developments';
import { deriveValuationSnapshot, getSecCompanyProfile, toCompanyProfile } from '@/lib/server/sec-company-profile';
import { getCompanyDescription } from '@/lib/server/sec-description';
import { getYahooCompanyDescription } from '@/lib/server/yahoo-company-profile';
import { redactInternalFilingAnalysisFields } from '@/lib/server/api/filing-redaction';
import { listFilingsRecords } from '@/lib/server/repos/filings';
import { getHoldingByTicker } from '@/lib/server/repos/holdings';
import { getResearchMemoByTicker } from '@/lib/server/repos/research-library';
import { listResearchJournalEntries } from '@/lib/server/repos/research-journal';
import { getWatchlistItemByTicker } from '@/lib/server/repos/watchlist';
import {
CURRENT_COMPANY_OVERVIEW_CACHE_VERSION,
getCompanyOverviewCache,
upsertCompanyOverviewCache
} from '@/lib/server/repos/company-overview-cache';
const FINANCIAL_FORMS = new Set<Filing['filing_type']>(['10-K', '10-Q']);
const COMPANY_OVERVIEW_CACHE_TTL_MS = 1000 * 60 * 15;
export type CompanyAnalysisLocalInputs = {
filings: Filing[];
holding: Holding | null;
watchlistItem: WatchlistItem | null;
journalPreview: ResearchJournalEntry[];
memo: ResearchMemo | null;
};
function withFinancialMetricsPolicy(filing: Filing): Filing {
if (FINANCIAL_FORMS.has(filing.filing_type)) {
return filing;
}
return {
...filing,
metrics: null
};
}
export function buildCompanyAnalysisSourceSignature(input: {
ticker: string;
localInputs: CompanyAnalysisLocalInputs;
cacheVersion?: number;
}) {
const payload = {
ticker: input.ticker.trim().toUpperCase(),
cacheVersion: input.cacheVersion ?? CURRENT_COMPANY_OVERVIEW_CACHE_VERSION,
filings: input.localInputs.filings.map((filing) => ({
accessionNumber: filing.accession_number,
updatedAt: filing.updated_at
})),
memoUpdatedAt: input.localInputs.memo?.updated_at ?? null,
watchlistUpdatedAt: input.localInputs.watchlistItem?.updated_at ?? null,
holdingUpdatedAt: input.localInputs.holding?.updated_at ?? null,
journalPreview: input.localInputs.journalPreview.map((entry) => ({
id: entry.id,
updatedAt: entry.updated_at
}))
};
return createHash('sha256').update(JSON.stringify(payload)).digest('hex');
}
export function isCompanyOverviewCacheFresh(input: {
updatedAt: string;
sourceSignature: string;
expectedSourceSignature: string;
cacheVersion: number;
refresh?: boolean;
ttlMs?: number;
now?: Date;
}) {
if (input.refresh) {
return false;
}
if (input.cacheVersion !== CURRENT_COMPANY_OVERVIEW_CACHE_VERSION) {
return false;
}
if (input.sourceSignature !== input.expectedSourceSignature) {
return false;
}
const now = input.now ?? new Date();
const updatedAt = Date.parse(input.updatedAt);
if (!Number.isFinite(updatedAt)) {
return false;
}
return now.getTime() - updatedAt <= (input.ttlMs ?? COMPANY_OVERVIEW_CACHE_TTL_MS);
}
async function getCompanyAnalysisLocalInputs(input: { userId: string; ticker: string }): Promise<CompanyAnalysisLocalInputs> {
const ticker = input.ticker.trim().toUpperCase();
const [filings, holding, watchlistItem, journalPreview, memo] = await Promise.all([
listFilingsRecords({ ticker, limit: 40 }),
getHoldingByTicker(input.userId, ticker),
getWatchlistItemByTicker(input.userId, ticker),
listResearchJournalEntries(input.userId, ticker, 6),
getResearchMemoByTicker(input.userId, ticker)
]);
return {
filings,
holding,
watchlistItem,
journalPreview,
memo
};
}
async function buildCompanyAnalysisPayload(input: {
userId: string;
ticker: string;
localInputs: CompanyAnalysisLocalInputs;
}): Promise<CompanyAnalysis> {
const ticker = input.ticker.trim().toUpperCase();
const redactedFilings = input.localInputs.filings
.map(redactInternalFilingAnalysisFields)
.map(withFinancialMetricsPolicy);
const [liveQuote, priceHistory, benchmarkHistory, secProfile] = await Promise.all([
getQuote(ticker),
getPriceHistory(ticker),
getPriceHistory('^GSPC'),
getSecCompanyProfile(ticker)
]);
const latestFiling = redactedFilings[0] ?? null;
const companyName = latestFiling?.company_name
?? secProfile?.companyName
?? input.localInputs.holding?.company_name
?? input.localInputs.watchlistItem?.company_name
?? ticker;
const financials = redactedFilings
.filter((entry) => entry.metrics && FINANCIAL_FORMS.has(entry.filing_type))
.map((entry) => ({
filingDate: entry.filing_date,
filingType: entry.filing_type,
revenue: entry.metrics?.revenue ?? null,
netIncome: entry.metrics?.netIncome ?? null,
totalAssets: entry.metrics?.totalAssets ?? null,
cash: entry.metrics?.cash ?? null,
debt: entry.metrics?.debt ?? null
}));
const aiReports: CompanyAiReport[] = redactedFilings
.filter((entry) => entry.analysis?.text || entry.analysis?.legacyInsights)
.slice(0, 8)
.map((entry) => ({
accessionNumber: entry.accession_number,
filingDate: entry.filing_date,
filingType: entry.filing_type,
provider: entry.analysis?.provider ?? 'unknown',
model: entry.analysis?.model ?? 'unknown',
summary: entry.analysis?.text ?? entry.analysis?.legacyInsights ?? ''
}));
const latestMetricsFiling = redactedFilings.find((entry) => entry.metrics) ?? null;
const referenceMetrics = latestMetricsFiling?.metrics ?? null;
const keyMetrics = {
referenceDate: latestMetricsFiling?.filing_date ?? latestFiling?.filing_date ?? null,
revenue: referenceMetrics?.revenue ?? null,
netIncome: referenceMetrics?.netIncome ?? null,
totalAssets: referenceMetrics?.totalAssets ?? null,
cash: referenceMetrics?.cash ?? null,
debt: referenceMetrics?.debt ?? null,
netMargin: referenceMetrics?.revenue && referenceMetrics.netIncome !== null
? (referenceMetrics.netIncome / referenceMetrics.revenue) * 100
: null
};
const annualFiling = redactedFilings.find((entry) => entry.filing_type === '10-K') ?? null;
const [secDescription, yahooDescription, synthesizedDevelopments] = await Promise.all([
getCompanyDescription(annualFiling),
getYahooCompanyDescription(ticker),
getRecentDevelopments(ticker, { filings: redactedFilings })
]);
const description = yahooDescription ?? secDescription;
const latestFilingSummary = latestFiling
? {
accessionNumber: latestFiling.accession_number,
filingDate: latestFiling.filing_date,
filingType: latestFiling.filing_type,
filingUrl: latestFiling.filing_url,
submissionUrl: latestFiling.submission_url ?? null,
summary: latestFiling.analysis?.text ?? latestFiling.analysis?.legacyInsights ?? null,
hasAnalysis: Boolean(latestFiling.analysis?.text || latestFiling.analysis?.legacyInsights)
}
: null;
const companyProfile = toCompanyProfile(secProfile, description);
const valuationSnapshot = deriveValuationSnapshot({
quote: liveQuote.value,
sharesOutstanding: secProfile?.sharesOutstanding ?? null,
revenue: keyMetrics.revenue,
cash: keyMetrics.cash,
debt: keyMetrics.debt,
netIncome: keyMetrics.netIncome
});
const synthesis = await synthesizeCompanyOverview({
ticker,
companyName,
description,
memo: input.localInputs.memo,
latestFilingSummary,
recentAiReports: aiReports.slice(0, 5),
recentDevelopments: synthesizedDevelopments.items
});
const recentDevelopments = {
...synthesizedDevelopments,
weeklySnapshot: synthesis.weeklySnapshot,
status: synthesizedDevelopments.items.length > 0
? synthesis.weeklySnapshot ? 'ready' : 'partial'
: synthesis.weeklySnapshot ? 'partial' : 'unavailable'
} as const;
return {
company: {
ticker,
companyName,
sector: input.localInputs.watchlistItem?.sector ?? null,
category: input.localInputs.watchlistItem?.category ?? null,
tags: input.localInputs.watchlistItem?.tags ?? [],
cik: latestFiling?.cik ?? null
},
quote: { value: liveQuote.value, stale: liveQuote.stale },
position: input.localInputs.holding,
priceHistory: { value: priceHistory.value, stale: priceHistory.stale },
benchmarkHistory: { value: benchmarkHistory.value, stale: benchmarkHistory.stale },
financials,
filings: redactedFilings.slice(0, 20),
aiReports,
coverage: input.localInputs.watchlistItem
? {
...input.localInputs.watchlistItem,
latest_filing_date: latestFiling?.filing_date ?? input.localInputs.watchlistItem.latest_filing_date ?? null
}
: null,
journalPreview: input.localInputs.journalPreview,
recentAiReports: aiReports.slice(0, 5),
latestFilingSummary,
keyMetrics,
companyProfile,
valuationSnapshot,
bullBear: synthesis.bullBear,
recentDevelopments
};
}
type GetCompanyAnalysisPayloadOptions = {
userId: string;
ticker: string;
refresh?: boolean;
now?: Date;
};
type GetCompanyAnalysisPayloadDeps = {
getLocalInputs?: (input: { userId: string; ticker: string }) => Promise<CompanyAnalysisLocalInputs>;
getCachedOverview?: typeof getCompanyOverviewCache;
upsertCachedOverview?: typeof upsertCompanyOverviewCache;
buildOverview?: (input: {
userId: string;
ticker: string;
localInputs: CompanyAnalysisLocalInputs;
}) => Promise<CompanyAnalysis>;
};
export async function getCompanyAnalysisPayload(
input: GetCompanyAnalysisPayloadOptions,
deps?: GetCompanyAnalysisPayloadDeps
): Promise<CompanyAnalysis> {
const ticker = input.ticker.trim().toUpperCase();
const now = input.now ?? new Date();
const localInputs = await (deps?.getLocalInputs ?? getCompanyAnalysisLocalInputs)({
userId: input.userId,
ticker
});
const sourceSignature = buildCompanyAnalysisSourceSignature({
ticker,
localInputs
});
const cached = await (deps?.getCachedOverview ?? getCompanyOverviewCache)({
userId: input.userId,
ticker
});
if (cached && isCompanyOverviewCacheFresh({
updatedAt: cached.updated_at,
sourceSignature: cached.source_signature,
expectedSourceSignature: sourceSignature,
cacheVersion: cached.cache_version,
refresh: input.refresh,
now
})) {
return cached.payload;
}
const analysis = await (deps?.buildOverview ?? buildCompanyAnalysisPayload)({
userId: input.userId,
ticker,
localInputs
});
await (deps?.upsertCachedOverview ?? upsertCompanyOverviewCache)({
userId: input.userId,
ticker,
sourceSignature,
payload: analysis
});
return analysis;
}
export const __companyAnalysisInternals = {
COMPANY_OVERVIEW_CACHE_TTL_MS,
buildCompanyAnalysisPayload,
buildCompanyAnalysisSourceSignature,
getCompanyAnalysisLocalInputs,
isCompanyOverviewCacheFresh,
withFinancialMetricsPolicy
};