Add company overview skeleton and cache
This commit is contained in:
336
lib/server/company-analysis.ts
Normal file
336
lib/server/company-analysis.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
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,
|
||||
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: liveQuote,
|
||||
position: input.localInputs.holding,
|
||||
priceHistory,
|
||||
benchmarkHistory,
|
||||
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
|
||||
};
|
||||
Reference in New Issue
Block a user