337 lines
11 KiB
TypeScript
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,
|
|
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
|
|
};
|