Rebuild company overview analysis page

This commit is contained in:
2026-03-12 20:39:30 -04:00
parent b9a1d8ba40
commit ba385586bc
29 changed files with 2040 additions and 888 deletions

View File

@@ -70,6 +70,10 @@ import {
upsertWatchlistItemRecord
} from '@/lib/server/repos/watchlist';
import { getPriceHistory, getQuote } from '@/lib/server/prices';
import { synthesizeCompanyOverview } from '@/lib/server/company-overview-synthesis';
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 { answerSearchQuery, searchKnowledgeBase } from '@/lib/server/search';
import {
enqueueTask,
@@ -1362,13 +1366,15 @@ export const app = new Elysia({ prefix: '/api' })
return jsonError('ticker is required');
}
const [filings, holding, watchlistItem, liveQuote, priceHistory, journalPreview] = await Promise.all([
const [filings, holding, watchlistItem, liveQuote, priceHistory, journalPreview, memo, secProfile] = await Promise.all([
listFilingsRecords({ ticker, limit: 40 }),
getHoldingByTicker(session.user.id, ticker),
getWatchlistItemByTicker(session.user.id, ticker),
getQuote(ticker),
getPriceHistory(ticker),
listResearchJournalEntries(session.user.id, ticker, 6)
listResearchJournalEntries(session.user.id, ticker, 6),
getResearchMemoByTicker(session.user.id, ticker),
getSecCompanyProfile(ticker)
]);
const redactedFilings = filings
.map(redactInternalFilingAnalysisFields)
@@ -1376,6 +1382,7 @@ export const app = new Elysia({ prefix: '/api' })
const latestFiling = redactedFilings[0] ?? null;
const companyName = latestFiling?.company_name
?? secProfile?.companyName
?? holding?.company_name
?? watchlistItem?.company_name
?? ticker;
@@ -1416,6 +1423,11 @@ export const app = new Elysia({ prefix: '/api' })
? (referenceMetrics.netIncome / referenceMetrics.revenue) * 100
: null
};
const annualFiling = redactedFilings.find((entry) => entry.filing_type === '10-K') ?? null;
const [description, synthesizedDevelopments] = await Promise.all([
getCompanyDescription(annualFiling),
getRecentDevelopments(ticker, { filings: redactedFilings })
]);
const latestFilingSummary = latestFiling
? {
accessionNumber: latestFiling.accession_number,
@@ -1427,6 +1439,31 @@ export const app = new Elysia({ prefix: '/api' })
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,
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 Response.json({
analysis: {
@@ -1453,7 +1490,11 @@ export const app = new Elysia({ prefix: '/api' })
journalPreview,
recentAiReports: aiReports.slice(0, 5),
latestFilingSummary,
keyMetrics
keyMetrics,
companyProfile,
valuationSnapshot,
bullBear: synthesis.bullBear,
recentDevelopments
}
});
}, {

View File

@@ -485,6 +485,14 @@ if (process.env.RUN_TASK_WORKFLOW_E2E === '1') {
latestFilingSummary: { accessionNumber: string; summary: string | null } | null;
keyMetrics: { revenue: number | null; netMargin: number | null };
position: { company_name: string | null } | null;
companyProfile: { source: string; description: string | null };
valuationSnapshot: { source: string; marketCap: number | null; evToRevenue: number | null };
bullBear: { source: string; bull: string[]; bear: string[] };
recentDevelopments: {
status: string;
items: Array<{ kind: string; accessionNumber: string | null }>;
weeklySnapshot: { source: string; itemCount: number } | null;
};
};
}).analysis;
@@ -499,6 +507,12 @@ if (process.env.RUN_TASK_WORKFLOW_E2E === '1') {
expect(payload.keyMetrics.revenue).toBe(41000000000);
expect(payload.keyMetrics.netMargin).not.toBeNull();
expect(payload.position?.company_name).toBe('Netflix, Inc.');
expect(['sec_derived', 'unavailable']).toContain(payload.companyProfile.source);
expect(['derived', 'partial', 'unavailable']).toContain(payload.valuationSnapshot.source);
expect(['ai_synthesized', 'memo_fallback', 'unavailable']).toContain(payload.bullBear.source);
expect(['ready', 'partial', 'unavailable']).toContain(payload.recentDevelopments.status);
expect(payload.recentDevelopments.items[0]?.accessionNumber).toBe('0000000000-26-000777');
expect(payload.recentDevelopments.weeklySnapshot?.itemCount ?? 0).toBeGreaterThanOrEqual(1);
const updatedEntry = await jsonRequest('PATCH', `/api/research/journal/${entryId}`, {
title: 'Thesis refresh v2',

View File

@@ -0,0 +1,85 @@
import { describe, expect, it, mock } from 'bun:test';
import { __companyOverviewSynthesisInternals, synthesizeCompanyOverview } from './company-overview-synthesis';
describe('company overview synthesis', () => {
it('parses strict json AI responses', () => {
const parsed = __companyOverviewSynthesisInternals.parseAiJson(JSON.stringify({
bull: ['Demand remains durable'],
bear: ['Valuation is demanding'],
weeklySummary: 'The week centered on enterprise demand and new disclosures.',
weeklyHighlights: ['8-K signaled a new contract']
}));
expect(parsed.bull).toEqual(['Demand remains durable']);
expect(parsed.bear).toEqual(['Valuation is demanding']);
expect(parsed.weeklyHighlights).toEqual(['8-K signaled a new contract']);
});
it('falls back to memo-backed bullets when AI is unavailable', async () => {
const result = await synthesizeCompanyOverview({
ticker: 'MSFT',
companyName: 'Microsoft Corp',
description: 'Microsoft builds software and cloud infrastructure.',
memo: {
id: 1,
user_id: 'u1',
organization_id: null,
ticker: 'MSFT',
rating: 'buy',
conviction: 'high',
time_horizon_months: 24,
packet_title: null,
packet_subtitle: null,
thesis_markdown: 'Azure demand remains durable.',
variant_view_markdown: '',
catalysts_markdown: 'Copilot monetization can expand ARPU.',
risks_markdown: 'Competition may compress pricing.',
disconfirming_evidence_markdown: 'Enterprise optimization could slow seat growth.',
next_actions_markdown: '',
created_at: '2026-03-01T00:00:00.000Z',
updated_at: '2026-03-10T00:00:00.000Z'
},
latestFilingSummary: null,
recentAiReports: [],
recentDevelopments: []
}, {
aiConfigured: false
});
expect(result.bullBear.source).toBe('memo_fallback');
expect(result.bullBear.bull[0]).toContain('Azure demand');
expect(result.bullBear.bear[0]).toContain('Competition');
expect(result.weeklySnapshot?.source).toBe('heuristic');
});
it('uses AI output when available', async () => {
const runAnalysis = mock(async () => ({
provider: 'zhipu' as const,
model: 'glm-5',
text: JSON.stringify({
bull: ['Demand remains durable'],
bear: ['Spending could normalize'],
weeklySummary: 'The week was defined by a new contract disclosure.',
weeklyHighlights: ['8-K disclosed a new enterprise customer']
})
}));
const result = await synthesizeCompanyOverview({
ticker: 'MSFT',
companyName: 'Microsoft Corp',
description: 'Microsoft builds software and cloud infrastructure.',
memo: null,
latestFilingSummary: null,
recentAiReports: [],
recentDevelopments: []
}, {
aiConfigured: true,
runAnalysis
});
expect(runAnalysis).toHaveBeenCalledTimes(1);
expect(result.bullBear.source).toBe('ai_synthesized');
expect(result.bullBear.bull).toEqual(['Demand remains durable']);
expect(result.weeklySnapshot?.source).toBe('ai_synthesized');
});
});

View File

@@ -0,0 +1,273 @@
import { isAiConfigured, runAiAnalysis } from '@/lib/server/ai';
import type {
CompanyAiReport,
CompanyBullBear,
RecentDevelopmentItem,
RecentDevelopmentsWeeklySnapshot,
ResearchMemo
} from '@/lib/types';
type SynthesisResult = {
bullBear: CompanyBullBear;
weeklySnapshot: RecentDevelopmentsWeeklySnapshot | null;
};
type SynthesisOptions = {
now?: Date;
runAnalysis?: typeof runAiAnalysis;
aiConfigured?: boolean;
};
type CacheEntry<T> = {
expiresAt: number;
value: T;
};
const SYNTHESIS_CACHE_TTL_MS = 1000 * 60 * 30;
const synthesisCache = new Map<string, CacheEntry<SynthesisResult>>();
function normalizeLine(line: string) {
return line
.replace(/^[-*+]\s+/, '')
.replace(/^\d+\.\s+/, '')
.replace(/[`#>*_~]/g, ' ')
.replace(/\[(.*?)\]\((.*?)\)/g, '$1')
.replace(/\s+/g, ' ')
.trim();
}
function bulletizeText(value: string | null | undefined, fallback: string[] = []) {
if (!value) {
return fallback;
}
const lines = value
.split(/\n+/)
.map(normalizeLine)
.filter((line) => line.length >= 18);
if (lines.length > 0) {
return lines;
}
return value
.split(/(?<=[.!?])\s+/)
.map(normalizeLine)
.filter((sentence) => sentence.length >= 18);
}
function dedupe(items: string[], limit = 5) {
const seen = new Set<string>();
const result: string[] = [];
for (const item of items) {
const normalized = item.trim();
if (!normalized) {
continue;
}
const key = normalized.toLowerCase();
if (seen.has(key)) {
continue;
}
seen.add(key);
result.push(normalized);
if (result.length >= limit) {
break;
}
}
return result;
}
function fallbackBullBear(input: {
memo: ResearchMemo | null;
latestFilingSummary: { summary: string | null } | null;
recentDevelopments: RecentDevelopmentItem[];
}) {
const bull = dedupe([
...bulletizeText(input.memo?.thesis_markdown),
...bulletizeText(input.memo?.catalysts_markdown),
...bulletizeText(input.latestFilingSummary?.summary)
]);
const bear = dedupe([
...bulletizeText(input.memo?.risks_markdown),
...bulletizeText(input.memo?.disconfirming_evidence_markdown),
...bulletizeText(input.memo?.variant_view_markdown)
]);
const highlights = dedupe(
input.recentDevelopments
.slice(0, 3)
.map((item) => item.summary ?? item.title),
3
);
const summary = highlights.length > 0
? `Recent developments centered on ${highlights.join(' ')}`
: 'No recent SEC development summaries are available yet.';
return {
bullBear: {
source: bull.length > 0 || bear.length > 0 ? 'memo_fallback' : 'unavailable',
bull,
bear,
updatedAt: new Date().toISOString()
} satisfies CompanyBullBear,
weeklySummary: {
summary,
highlights
}
};
}
function parseAiJson(text: string) {
const fenced = text.match(/```json\s*([\s\S]*?)```/i);
const raw = fenced?.[1] ?? text;
const parsed = JSON.parse(raw) as {
bull?: unknown;
bear?: unknown;
weeklySummary?: unknown;
weeklyHighlights?: unknown;
};
const asItems = (value: unknown) => Array.isArray(value)
? dedupe(value.filter((item): item is string => typeof item === 'string'), 5)
: [];
return {
bull: asItems(parsed.bull),
bear: asItems(parsed.bear),
weeklySummary: typeof parsed.weeklySummary === 'string' ? parsed.weeklySummary.trim() : '',
weeklyHighlights: asItems(parsed.weeklyHighlights).slice(0, 3)
};
}
function buildPrompt(input: {
ticker: string;
companyName: string;
description: string | null;
memo: ResearchMemo | null;
latestFilingSummary: { summary: string | null; filingType: string; filingDate: string } | null;
recentAiReports: CompanyAiReport[];
recentDevelopments: RecentDevelopmentItem[];
}) {
return [
'You are synthesizing a public-equity company overview.',
'Return strict JSON with keys: bull, bear, weeklySummary, weeklyHighlights.',
'bull and bear must each be arrays of 3 to 5 concise strings.',
'weeklyHighlights must be an array of up to 3 concise strings.',
'Do not include markdown, prose before JSON, or code fences unless absolutely necessary.',
'',
`Ticker: ${input.ticker}`,
`Company: ${input.companyName}`,
`Business description: ${input.description ?? 'n/a'}`,
`Memo thesis: ${input.memo?.thesis_markdown ?? 'n/a'}`,
`Memo catalysts: ${input.memo?.catalysts_markdown ?? 'n/a'}`,
`Memo risks: ${input.memo?.risks_markdown ?? 'n/a'}`,
`Memo disconfirming evidence: ${input.memo?.disconfirming_evidence_markdown ?? 'n/a'}`,
`Memo variant view: ${input.memo?.variant_view_markdown ?? 'n/a'}`,
`Latest filing summary: ${input.latestFilingSummary?.summary ?? 'n/a'}`,
`Recent AI report summaries: ${input.recentAiReports.map((report) => `${report.filingType} ${report.filingDate}: ${report.summary}`).join(' | ') || 'n/a'}`,
`Recent developments: ${input.recentDevelopments.map((item) => `${item.kind} ${item.publishedAt}: ${item.summary ?? item.title}`).join(' | ') || 'n/a'}`
].join('\n');
}
export async function synthesizeCompanyOverview(
input: {
ticker: string;
companyName: string;
description: string | null;
memo: ResearchMemo | null;
latestFilingSummary: {
accessionNumber: string;
filingDate: string;
filingType: string;
summary: string | null;
} | null;
recentAiReports: CompanyAiReport[];
recentDevelopments: RecentDevelopmentItem[];
},
options?: SynthesisOptions
): Promise<SynthesisResult> {
const now = options?.now ?? new Date();
const cacheKey = JSON.stringify({
ticker: input.ticker,
description: input.description,
memoUpdatedAt: input.memo?.updated_at ?? null,
latestFilingSummary: input.latestFilingSummary?.accessionNumber ?? null,
recentAiReports: input.recentAiReports.map((report) => report.accessionNumber),
recentDevelopments: input.recentDevelopments.map((item) => item.id)
});
const cached = synthesisCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return cached.value;
}
const fallback = fallbackBullBear(input);
const buildWeeklySnapshot = (source: 'ai_synthesized' | 'heuristic', summary: string, highlights: string[]) => ({
summary,
highlights,
itemCount: input.recentDevelopments.length,
startDate: new Date(now.getTime() - 6 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10),
endDate: now.toISOString().slice(0, 10),
updatedAt: now.toISOString(),
source
} satisfies RecentDevelopmentsWeeklySnapshot);
const aiEnabled = options?.aiConfigured ?? isAiConfigured();
if (!aiEnabled) {
const result = {
bullBear: fallback.bullBear,
weeklySnapshot: buildWeeklySnapshot('heuristic', fallback.weeklySummary.summary, fallback.weeklySummary.highlights)
};
synthesisCache.set(cacheKey, { value: result, expiresAt: Date.now() + SYNTHESIS_CACHE_TTL_MS });
return result;
}
try {
const runAnalysis = options?.runAnalysis ?? runAiAnalysis;
const aiResult = await runAnalysis(
buildPrompt(input),
'Respond with strict JSON only.',
{ workload: 'report' }
);
const parsed = parseAiJson(aiResult.text);
const bull = parsed.bull.length > 0 ? parsed.bull : fallback.bullBear.bull;
const bear = parsed.bear.length > 0 ? parsed.bear : fallback.bullBear.bear;
const summary = parsed.weeklySummary || fallback.weeklySummary.summary;
const highlights = parsed.weeklyHighlights.length > 0 ? parsed.weeklyHighlights : fallback.weeklySummary.highlights;
const result = {
bullBear: {
source: bull.length > 0 || bear.length > 0 ? 'ai_synthesized' : fallback.bullBear.source,
bull,
bear,
updatedAt: now.toISOString()
} satisfies CompanyBullBear,
weeklySnapshot: buildWeeklySnapshot(
summary || highlights.length > 0 ? 'ai_synthesized' : 'heuristic',
summary || fallback.weeklySummary.summary,
highlights
)
};
synthesisCache.set(cacheKey, { value: result, expiresAt: Date.now() + SYNTHESIS_CACHE_TTL_MS });
return result;
} catch {
const result = {
bullBear: fallback.bullBear,
weeklySnapshot: buildWeeklySnapshot('heuristic', fallback.weeklySummary.summary, fallback.weeklySummary.highlights)
};
synthesisCache.set(cacheKey, { value: result, expiresAt: Date.now() + SYNTHESIS_CACHE_TTL_MS });
return result;
}
}
export const __companyOverviewSynthesisInternals = {
bulletizeText,
dedupe,
fallbackBullBear,
parseAiJson
};

View File

@@ -0,0 +1,86 @@
import { describe, expect, it } from 'bun:test';
import type { Filing } from '@/lib/types';
import { __recentDevelopmentsInternals, getRecentDevelopments, secFilingsDevelopmentSource } from './recent-developments';
function filing(input: Partial<Filing> & Pick<Filing, 'accession_number' | 'filing_type' | 'filing_date' | 'ticker' | 'cik' | 'company_name'>): Filing {
return {
id: 1,
filing_url: 'https://www.sec.gov/Archives/example.htm',
submission_url: null,
primary_document: 'example.htm',
metrics: null,
analysis: null,
created_at: '2026-03-01T00:00:00.000Z',
updated_at: '2026-03-01T00:00:00.000Z',
...input
};
}
describe('recent developments', () => {
it('prioritizes 8-K items ahead of periodic filings', async () => {
const items = await secFilingsDevelopmentSource.fetch('MSFT', {
now: new Date('2026-03-12T12:00:00.000Z'),
filings: [
filing({
accession_number: '0001',
filing_type: '10-Q',
filing_date: '2026-03-09',
ticker: 'MSFT',
cik: '0000789019',
company_name: 'Microsoft Corp'
}),
filing({
accession_number: '0002',
filing_type: '8-K',
filing_date: '2026-03-10',
ticker: 'MSFT',
cik: '0000789019',
company_name: 'Microsoft Corp'
})
]
});
expect(items[0]?.kind).toBe('8-K');
expect(items[0]?.title).toContain('8-K');
});
it('builds a ready recent developments payload from filing records', async () => {
const developments = await getRecentDevelopments('MSFT', {
now: new Date('2026-03-12T12:00:00.000Z'),
filings: [
filing({
accession_number: '0002',
filing_type: '8-K',
filing_date: '2026-03-10',
ticker: 'MSFT',
cik: '0000789019',
company_name: 'Microsoft Corp',
analysis: {
text: 'The company announced a new enterprise AI contract.',
provider: 'zhipu',
model: 'glm-5'
}
})
]
});
expect(developments.status).toBe('ready');
expect(developments.items).toHaveLength(1);
expect(developments.items[0]?.summary).toContain('enterprise AI contract');
});
it('creates heuristic summaries when filing analysis is unavailable', () => {
const summary = __recentDevelopmentsInternals.buildSummary(
filing({
accession_number: '0003',
filing_type: '10-K',
filing_date: '2026-03-02',
ticker: 'MSFT',
cik: '0000789019',
company_name: 'Microsoft Corp'
})
);
expect(summary).toContain('10-K');
});
});

View File

@@ -0,0 +1,161 @@
import { format } from 'date-fns';
import type { Filing, RecentDevelopmentItem, RecentDevelopments } from '@/lib/types';
export type RecentDevelopmentSourceContext = {
filings: Filing[];
now?: Date;
};
export type RecentDevelopmentSource = {
name: string;
fetch: (ticker: string, context: RecentDevelopmentSourceContext) => Promise<RecentDevelopmentItem[]>;
};
type CacheEntry<T> = {
expiresAt: number;
value: T;
};
const RECENT_DEVELOPMENTS_CACHE_TTL_MS = 1000 * 60 * 10;
const recentDevelopmentsCache = new Map<string, CacheEntry<RecentDevelopments>>();
function filingPriority(filing: Filing) {
switch (filing.filing_type) {
case '8-K':
return 0;
case '10-Q':
return 1;
case '10-K':
return 2;
default:
return 3;
}
}
function sortFilings(filings: Filing[]) {
return [...filings].sort((left, right) => {
const dateDelta = Date.parse(right.filing_date) - Date.parse(left.filing_date);
if (dateDelta !== 0) {
return dateDelta;
}
return filingPriority(left) - filingPriority(right);
});
}
function buildTitle(filing: Filing) {
switch (filing.filing_type) {
case '8-K':
return `${filing.company_name} filed an 8-K`;
case '10-K':
return `${filing.company_name} annual filing`;
case '10-Q':
return `${filing.company_name} quarterly filing`;
default:
return `${filing.company_name} filing update`;
}
}
function buildSummary(filing: Filing) {
const analysisSummary = filing.analysis?.text ?? filing.analysis?.legacyInsights ?? null;
if (analysisSummary) {
return analysisSummary;
}
const formattedDate = format(new Date(filing.filing_date), 'MMM dd, yyyy');
if (filing.filing_type === '8-K') {
return `The company disclosed a current report on ${formattedDate}. Review the filing for event-specific detail and attached exhibits.`;
}
return `The company published a ${filing.filing_type} on ${formattedDate}. Review the filing for the latest reported business and financial changes.`;
}
export const secFilingsDevelopmentSource: RecentDevelopmentSource = {
name: 'SEC filings',
async fetch(_ticker, context) {
const now = context.now ?? new Date();
const nowEpoch = now.getTime();
const recentFilings = sortFilings(context.filings)
.filter((filing) => {
const filedAt = Date.parse(filing.filing_date);
if (!Number.isFinite(filedAt)) {
return false;
}
const ageInDays = (nowEpoch - filedAt) / (1000 * 60 * 60 * 24);
if (ageInDays > 14) {
return false;
}
return filing.filing_type === '8-K' || filing.filing_type === '10-K' || filing.filing_type === '10-Q';
})
.slice(0, 8);
return recentFilings.map((filing) => ({
id: `${filing.ticker}-${filing.accession_number}`,
kind: filing.filing_type,
title: buildTitle(filing),
url: filing.filing_url,
source: 'SEC filings',
publishedAt: filing.filing_date,
summary: buildSummary(filing),
accessionNumber: filing.accession_number
}));
}
};
export const yahooDevelopmentSource: RecentDevelopmentSource | null = null;
export const investorRelationsRssSource: RecentDevelopmentSource | null = null;
export async function getRecentDevelopments(
ticker: string,
context: RecentDevelopmentSourceContext,
options?: {
sources?: RecentDevelopmentSource[];
limit?: number;
}
): Promise<RecentDevelopments> {
const normalizedTicker = ticker.trim().toUpperCase();
const limit = options?.limit ?? 6;
const cacheKey = `${normalizedTicker}:${context.filings.map((filing) => filing.accession_number).join(',')}`;
const cached = recentDevelopmentsCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return cached.value;
}
const sources = options?.sources ?? [secFilingsDevelopmentSource];
const itemCollections = await Promise.all(
sources.map(async (source) => {
try {
return await source.fetch(normalizedTicker, context);
} catch {
return [] satisfies RecentDevelopmentItem[];
}
})
);
const items = itemCollections
.flat()
.sort((left, right) => Date.parse(right.publishedAt) - Date.parse(left.publishedAt))
.slice(0, limit);
const result: RecentDevelopments = {
status: items.length > 0 ? 'ready' : 'unavailable',
items,
weeklySnapshot: null
};
recentDevelopmentsCache.set(cacheKey, {
value: result,
expiresAt: Date.now() + RECENT_DEVELOPMENTS_CACHE_TTL_MS
});
return result;
}
export const __recentDevelopmentsInternals = {
buildSummary,
buildTitle,
sortFilings
};

View File

@@ -0,0 +1,67 @@
import { describe, expect, it } from 'bun:test';
import { __secCompanyProfileInternals, deriveValuationSnapshot } from './sec-company-profile';
describe('sec company profile helpers', () => {
it('formats fiscal year end values', () => {
expect(__secCompanyProfileInternals.formatFiscalYearEnd('0630')).toBe('06/30');
expect(__secCompanyProfileInternals.formatFiscalYearEnd('1231')).toBe('12/31');
expect(__secCompanyProfileInternals.formatFiscalYearEnd('')).toBeNull();
});
it('picks the latest numeric fact across supported namespaces', () => {
const payload = {
facts: {
dei: {
EntityCommonStockSharesOutstanding: {
units: {
shares: [
{ val: 7_400_000_000, filed: '2025-10-31' },
{ val: 7_500_000_000, filed: '2026-01-31' }
]
}
}
}
}
};
expect(
__secCompanyProfileInternals.pickLatestNumericFact(
payload,
['dei'],
['EntityCommonStockSharesOutstanding']
)
).toBe(7_500_000_000);
});
it('derives valuation metrics from free inputs only', () => {
const snapshot = deriveValuationSnapshot({
quote: 200,
sharesOutstanding: 1_000_000,
revenue: 50_000_000,
cash: 5_000_000,
debt: 15_000_000,
netIncome: 10_000_000
});
expect(snapshot.marketCap).toBe(200_000_000);
expect(snapshot.enterpriseValue).toBe(210_000_000);
expect(snapshot.evToRevenue).toBe(4.2);
expect(snapshot.trailingPe).toBe(20);
expect(snapshot.source).toBe('derived');
});
it('marks valuation as unavailable when core inputs are missing', () => {
const snapshot = deriveValuationSnapshot({
quote: null,
sharesOutstanding: null,
revenue: null,
cash: null,
debt: null,
netIncome: null
});
expect(snapshot.marketCap).toBeNull();
expect(snapshot.enterpriseValue).toBeNull();
expect(snapshot.source).toBe('unavailable');
});
});

View File

@@ -0,0 +1,380 @@
import type { CompanyProfile, CompanyValuationSnapshot } from '@/lib/types';
type FetchImpl = typeof fetch;
type SubmissionPayload = {
cik?: string;
name?: string;
tickers?: string[];
exchanges?: string[];
sicDescription?: string;
fiscalYearEnd?: string;
website?: string;
addresses?: {
business?: {
country?: string | null;
countryCode?: string | null;
stateOrCountryDescription?: string | null;
};
};
};
type CompanyFactsPayload = {
facts?: Record<string, Record<string, { units?: Record<string, FactPoint[]> }>>;
};
type FactPoint = {
val?: number;
filed?: string;
end?: string;
};
type ExchangeDirectoryPayload = {
fields?: string[];
data?: Array<Array<string | number | null>>;
};
type ExchangeDirectoryRecord = {
cik: string;
name: string;
ticker: string;
exchange: string | null;
};
export type SecCompanyProfileResult = {
ticker: string;
cik: string | null;
companyName: string | null;
exchange: string | null;
industry: string | null;
country: string | null;
website: string | null;
fiscalYearEnd: string | null;
employeeCount: number | null;
sharesOutstanding: number | null;
};
type CacheEntry<T> = {
expiresAt: number;
value: T;
};
const EXCHANGE_DIRECTORY_URL = 'https://www.sec.gov/files/company_tickers_exchange.json';
const SEC_SUBMISSIONS_BASE = 'https://data.sec.gov/submissions';
const SEC_COMPANY_FACTS_BASE = 'https://data.sec.gov/api/xbrl/companyfacts';
const EXCHANGE_CACHE_TTL_MS = 1000 * 60 * 30;
const SUBMISSIONS_CACHE_TTL_MS = 1000 * 60 * 30;
const COMPANY_FACTS_CACHE_TTL_MS = 1000 * 60 * 30;
let exchangeDirectoryCache: CacheEntry<Map<string, ExchangeDirectoryRecord>> | null = null;
const submissionsCache = new Map<string, CacheEntry<SubmissionPayload>>();
const companyFactsCache = new Map<string, CacheEntry<CompanyFactsPayload>>();
function envUserAgent() {
return process.env.SEC_USER_AGENT || 'Fiscal Clone <support@fiscal.local>';
}
async function fetchJson<T>(url: string, fetchImpl: FetchImpl = fetch): Promise<T> {
const response = await fetchImpl(url, {
headers: {
'User-Agent': envUserAgent(),
Accept: 'application/json'
},
cache: 'no-store'
});
if (!response.ok) {
throw new Error(`SEC request failed (${response.status})`);
}
return await response.json() as T;
}
function asNormalizedString(value: unknown) {
if (typeof value !== 'string') {
return null;
}
const normalized = value.trim();
return normalized.length > 0 ? normalized : null;
}
function normalizeCik(value: string | number | null | undefined) {
const digits = String(value ?? '').replace(/\D/g, '');
return digits.length > 0 ? digits : null;
}
function toPaddedCik(value: string | null) {
return value ? value.padStart(10, '0') : null;
}
function formatFiscalYearEnd(value: string | null | undefined) {
const normalized = asNormalizedString(value);
if (!normalized) {
return null;
}
const digits = normalized.replace(/\D/g, '');
if (digits.length !== 4) {
return normalized;
}
return `${digits.slice(0, 2)}/${digits.slice(2)}`;
}
function pointDate(point: FactPoint) {
return Date.parse(point.filed ?? point.end ?? '');
}
function pickLatestNumericFact(payload: CompanyFactsPayload, namespaces: string[], tags: string[]) {
const points: FactPoint[] = [];
for (const namespace of namespaces) {
const facts = payload.facts?.[namespace] ?? {};
for (const tag of tags) {
const entry = facts[tag];
if (!entry?.units) {
continue;
}
for (const series of Object.values(entry.units)) {
if (!Array.isArray(series)) {
continue;
}
for (const point of series) {
if (typeof point.val === 'number' && Number.isFinite(point.val)) {
points.push(point);
}
}
}
}
}
if (points.length === 0) {
return null;
}
const sorted = [...points].sort((left, right) => {
const leftDate = pointDate(left);
const rightDate = pointDate(right);
if (Number.isFinite(leftDate) && Number.isFinite(rightDate)) {
return rightDate - leftDate;
}
if (Number.isFinite(rightDate)) {
return 1;
}
if (Number.isFinite(leftDate)) {
return -1;
}
return 0;
});
return sorted[0]?.val ?? null;
}
async function getExchangeDirectory(fetchImpl?: FetchImpl) {
if (exchangeDirectoryCache && exchangeDirectoryCache.expiresAt > Date.now()) {
return exchangeDirectoryCache.value;
}
const payload = await fetchJson<ExchangeDirectoryPayload>(EXCHANGE_DIRECTORY_URL, fetchImpl);
const fields = payload.fields ?? [];
const cikIndex = fields.indexOf('cik');
const nameIndex = fields.indexOf('name');
const tickerIndex = fields.indexOf('ticker');
const exchangeIndex = fields.indexOf('exchange');
const directory = new Map<string, ExchangeDirectoryRecord>();
for (const row of payload.data ?? []) {
const ticker = asNormalizedString(row[tickerIndex]);
const cik = normalizeCik(row[cikIndex]);
const name = asNormalizedString(row[nameIndex]);
const exchange = asNormalizedString(row[exchangeIndex]);
if (!ticker || !cik || !name) {
continue;
}
directory.set(ticker.toUpperCase(), {
cik,
name,
ticker: ticker.toUpperCase(),
exchange
});
}
exchangeDirectoryCache = {
value: directory,
expiresAt: Date.now() + EXCHANGE_CACHE_TTL_MS
};
return directory;
}
async function getSubmissionByCik(cik: string, fetchImpl?: FetchImpl) {
const padded = toPaddedCik(cik);
if (!padded) {
return null;
}
const cached = submissionsCache.get(padded);
if (cached && cached.expiresAt > Date.now()) {
return cached.value;
}
const payload = await fetchJson<SubmissionPayload>(`${SEC_SUBMISSIONS_BASE}/CIK${padded}.json`, fetchImpl);
submissionsCache.set(padded, {
value: payload,
expiresAt: Date.now() + SUBMISSIONS_CACHE_TTL_MS
});
return payload;
}
async function getCompanyFactsByCik(cik: string, fetchImpl?: FetchImpl) {
const padded = toPaddedCik(cik);
if (!padded) {
return null;
}
const cached = companyFactsCache.get(padded);
if (cached && cached.expiresAt > Date.now()) {
return cached.value;
}
const payload = await fetchJson<CompanyFactsPayload>(`${SEC_COMPANY_FACTS_BASE}/CIK${padded}.json`, fetchImpl);
companyFactsCache.set(padded, {
value: payload,
expiresAt: Date.now() + COMPANY_FACTS_CACHE_TTL_MS
});
return payload;
}
export async function getSecCompanyProfile(
ticker: string,
options?: { fetchImpl?: FetchImpl }
): Promise<SecCompanyProfileResult | null> {
const normalizedTicker = ticker.trim().toUpperCase();
if (!normalizedTicker) {
return null;
}
try {
const directory = await getExchangeDirectory(options?.fetchImpl);
const directoryRecord = directory.get(normalizedTicker) ?? null;
const cik = directoryRecord?.cik ?? null;
const [submission, companyFacts] = await Promise.all([
cik ? getSubmissionByCik(cik, options?.fetchImpl) : Promise.resolve(null),
cik ? getCompanyFactsByCik(cik, options?.fetchImpl) : Promise.resolve(null)
]);
const employeeCount = companyFacts
? pickLatestNumericFact(companyFacts, ['dei'], ['EntityNumberOfEmployees'])
: null;
const sharesOutstanding = companyFacts
? pickLatestNumericFact(companyFacts, ['dei'], ['EntityCommonStockSharesOutstanding', 'CommonStockSharesOutstanding'])
: null;
return {
ticker: normalizedTicker,
cik,
companyName: asNormalizedString(submission?.name) ?? directoryRecord?.name ?? null,
exchange: asNormalizedString(submission?.exchanges?.[0]) ?? directoryRecord?.exchange ?? null,
industry: asNormalizedString(submission?.sicDescription),
country: asNormalizedString(submission?.addresses?.business?.country)
?? asNormalizedString(submission?.addresses?.business?.stateOrCountryDescription),
website: asNormalizedString(submission?.website),
fiscalYearEnd: formatFiscalYearEnd(submission?.fiscalYearEnd ?? null),
employeeCount,
sharesOutstanding
};
} catch {
return null;
}
}
export function toCompanyProfile(input: SecCompanyProfileResult | null, description: string | null): CompanyProfile {
if (!input && !description) {
return {
description: null,
exchange: null,
industry: null,
country: null,
website: null,
fiscalYearEnd: null,
employeeCount: null,
source: 'unavailable'
};
}
return {
description,
exchange: input?.exchange ?? null,
industry: input?.industry ?? null,
country: input?.country ?? null,
website: input?.website ?? null,
fiscalYearEnd: input?.fiscalYearEnd ?? null,
employeeCount: input?.employeeCount ?? null,
source: 'sec_derived'
};
}
export function deriveValuationSnapshot(input: {
quote: number | null;
sharesOutstanding: number | null;
revenue: number | null;
cash: number | null;
debt: number | null;
netIncome: number | null;
}): CompanyValuationSnapshot {
const hasPrice = typeof input.quote === 'number' && Number.isFinite(input.quote) && input.quote > 0;
const hasShares = typeof input.sharesOutstanding === 'number' && Number.isFinite(input.sharesOutstanding) && input.sharesOutstanding > 0;
const marketCap = hasPrice && hasShares ? input.quote! * input.sharesOutstanding! : null;
const hasCash = typeof input.cash === 'number' && Number.isFinite(input.cash);
const hasDebt = typeof input.debt === 'number' && Number.isFinite(input.debt);
const enterpriseValue = marketCap !== null && hasCash && hasDebt
? marketCap + input.debt! - input.cash!
: null;
const hasRevenue = typeof input.revenue === 'number' && Number.isFinite(input.revenue) && input.revenue > 0;
const hasNetIncome = typeof input.netIncome === 'number' && Number.isFinite(input.netIncome) && input.netIncome > 0;
const trailingPe = marketCap !== null && hasNetIncome
? marketCap / input.netIncome!
: null;
const evToRevenue = enterpriseValue !== null && hasRevenue
? enterpriseValue / input.revenue!
: null;
const availableCount = [
input.sharesOutstanding,
marketCap,
enterpriseValue,
trailingPe,
evToRevenue
].filter((value) => typeof value === 'number' && Number.isFinite(value)).length;
return {
sharesOutstanding: input.sharesOutstanding,
marketCap,
enterpriseValue,
trailingPe,
evToRevenue,
evToEbitda: null,
source: availableCount === 0
? 'unavailable'
: availableCount >= 3
? 'derived'
: 'partial'
};
}
export const __secCompanyProfileInternals = {
formatFiscalYearEnd,
pickLatestNumericFact
};

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from 'bun:test';
import { __secDescriptionInternals, extractBusinessDescription } from './sec-description';
describe('sec description extraction', () => {
it('extracts Item 1 Business content from normalized filing text', () => {
const text = `
PART I
ITEM 1. BUSINESS
Microsoft develops and supports software, services, devices, and solutions worldwide. The company operates through productivity, cloud, and personal computing franchises. Its strategy centers on platform breadth, recurring commercial relationships, and enterprise adoption.
ITEM 1A. RISK FACTORS
Competition remains intense.
`.trim();
const description = extractBusinessDescription(text);
expect(description).toContain('Microsoft develops and supports software');
expect(description).not.toContain('RISK FACTORS');
});
it('falls back to the first meaningful paragraph when Item 1 is missing', () => {
const text = `
Forward-looking statements
This company designs semiconductors for accelerated computing workloads and sells related systems, networking products, and software. It serves hyperscale, enterprise, and sovereign demand across several end markets.
Additional introductory text.
`.trim();
expect(extractBusinessDescription(text)).toContain('designs semiconductors');
});
it('clips long extracted text on sentence boundaries', () => {
const clipped = __secDescriptionInternals.clipAtSentenceBoundary(`${'A short sentence. '.repeat(80)}`, 200);
expect(clipped.length).toBeLessThanOrEqual(200);
expect(clipped.endsWith('.')).toBe(true);
});
});

View File

@@ -0,0 +1,134 @@
import type { Filing } from '@/lib/types';
import { fetchPrimaryFilingText } from '@/lib/server/sec';
type CacheEntry<T> = {
expiresAt: number;
value: T;
};
const DESCRIPTION_CACHE_TTL_MS = 1000 * 60 * 60 * 6;
const DESCRIPTION_MAX_CHARS = 1_600;
const descriptionCache = new Map<string, CacheEntry<string | null>>();
function normalizeWhitespace(value: string) {
return value
.replace(/[ \t]+/g, ' ')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
function clipAtSentenceBoundary(value: string, maxChars = DESCRIPTION_MAX_CHARS) {
if (value.length <= maxChars) {
return value;
}
const slice = value.slice(0, maxChars);
const sentenceBoundary = Math.max(
slice.lastIndexOf('. '),
slice.lastIndexOf('! '),
slice.lastIndexOf('? ')
);
if (sentenceBoundary > maxChars * 0.6) {
return slice.slice(0, sentenceBoundary + 1).trim();
}
const wordBoundary = slice.lastIndexOf(' ');
return (wordBoundary > maxChars * 0.7 ? slice.slice(0, wordBoundary) : slice).trim();
}
function cleanupExtractedSection(value: string) {
return clipAtSentenceBoundary(
normalizeWhitespace(
value
.replace(/\btable of contents\b/gi, ' ')
.replace(/\bitem\s+1\.?\s+business\b/gi, ' ')
.replace(/\bpart\s+i\b/gi, ' ')
)
);
}
function fallbackDescription(text: string) {
const paragraphs = text
.split(/\n{2,}/)
.map((paragraph) => normalizeWhitespace(paragraph))
.filter((paragraph) => paragraph.length >= 80)
.filter((paragraph) => !/^item\s+\d+[a-z]?\.?/i.test(paragraph))
.slice(0, 3);
if (paragraphs.length === 0) {
return null;
}
return clipAtSentenceBoundary(paragraphs.join(' '));
}
export function extractBusinessDescription(text: string) {
const normalized = normalizeWhitespace(text);
if (!normalized) {
return null;
}
const startMatch = /\bitem\s+1\.?\s+business\b/i.exec(normalized);
if (!startMatch || startMatch.index < 0) {
return fallbackDescription(normalized);
}
const afterStart = normalized.slice(startMatch.index + startMatch[0].length);
const endMatch = /\bitem\s+1a\.?\s+risk factors\b|\bitem\s+2\.?\s+properties\b|\bitem\s+2\.?\b/i.exec(afterStart);
const section = endMatch
? afterStart.slice(0, endMatch.index)
: afterStart;
const extracted = cleanupExtractedSection(section);
if (extracted.length >= 120) {
return extracted;
}
return fallbackDescription(normalized);
}
export async function getCompanyDescription(
filing: Pick<Filing, 'accession_number' | 'cik' | 'filing_url' | 'primary_document'> | null
) {
if (!filing) {
return null;
}
const cached = descriptionCache.get(filing.accession_number);
if (cached && cached.expiresAt > Date.now()) {
return cached.value;
}
try {
const document = await fetchPrimaryFilingText({
filingUrl: filing.filing_url,
cik: filing.cik,
accessionNumber: filing.accession_number,
primaryDocument: filing.primary_document ?? null
}, {
maxChars: 40_000
});
const description = document ? extractBusinessDescription(document.text) : null;
descriptionCache.set(filing.accession_number, {
value: description,
expiresAt: Date.now() + DESCRIPTION_CACHE_TTL_MS
});
return description;
} catch {
descriptionCache.set(filing.accession_number, {
value: null,
expiresAt: Date.now() + DESCRIPTION_CACHE_TTL_MS
});
return null;
}
}
export const __secDescriptionInternals = {
cleanupExtractedSection,
clipAtSentenceBoundary,
fallbackDescription
};

View File

@@ -672,6 +672,63 @@ export type CompanyAiReportDetail = CompanyAiReport & {
primaryDocument: string | null;
};
export type CompanyProfile = {
description: string | null;
exchange: string | null;
industry: string | null;
country: string | null;
website: string | null;
fiscalYearEnd: string | null;
employeeCount: number | null;
source: 'sec_derived' | 'unavailable';
};
export type CompanyValuationSnapshot = {
sharesOutstanding: number | null;
marketCap: number | null;
enterpriseValue: number | null;
trailingPe: number | null;
evToRevenue: number | null;
evToEbitda: number | null;
source: 'derived' | 'partial' | 'unavailable';
};
export type CompanyBullBear = {
source: 'ai_synthesized' | 'memo_fallback' | 'unavailable';
bull: string[];
bear: string[];
updatedAt: string | null;
};
export type RecentDevelopmentKind = '8-K' | '10-K' | '10-Q' | 'press_release' | 'news';
export type RecentDevelopmentItem = {
id: string;
kind: RecentDevelopmentKind;
title: string;
url: string | null;
source: string;
publishedAt: string;
summary: string | null;
accessionNumber: string | null;
};
export type RecentDevelopmentsWeeklySnapshot = {
summary: string;
highlights: string[];
itemCount: number;
startDate: string;
endDate: string;
updatedAt: string;
source: 'ai_synthesized' | 'heuristic';
};
export type RecentDevelopments = {
status: 'ready' | 'partial' | 'unavailable';
items: RecentDevelopmentItem[];
weeklySnapshot: RecentDevelopmentsWeeklySnapshot | null;
};
export type CompanyAnalysis = {
company: {
ticker: string;
@@ -708,6 +765,10 @@ export type CompanyAnalysis = {
debt: number | null;
netMargin: number | null;
};
companyProfile: CompanyProfile;
valuationSnapshot: CompanyValuationSnapshot;
bullBear: CompanyBullBear;
recentDevelopments: RecentDevelopments;
};
export type NavGroup = 'overview' | 'research' | 'portfolio';