diff --git a/app/analysis/page.tsx b/app/analysis/page.tsx index 74ca346..40d8924 100644 --- a/app/analysis/page.tsx +++ b/app/analysis/page.tsx @@ -1,157 +1,32 @@ 'use client'; import { useQueryClient } from '@tanstack/react-query'; -import Link from 'next/link'; -import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { format } from 'date-fns'; -import { - CartesianGrid, - Line, - LineChart, - ResponsiveContainer, - Tooltip, - XAxis, - YAxis -} from 'recharts'; -import { - BrainCircuit, - ChartNoAxesCombined, - NotebookTabs, - NotebookPen, - RefreshCcw, - Search, - SquarePen, - Trash2 -} from 'lucide-react'; +import { Suspense, useCallback, useEffect, useMemo, useState } from 'react'; import { useSearchParams } from 'next/navigation'; import { AppShell } from '@/components/shell/app-shell'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; +import { AnalysisToolbar } from '@/components/analysis/analysis-toolbar'; +import { BullBearPanel } from '@/components/analysis/bull-bear-panel'; +import { CompanyOverviewCard } from '@/components/analysis/company-overview-card'; +import { CompanyProfileFactsTable } from '@/components/analysis/company-profile-facts-table'; +import { PriceHistoryCard } from '@/components/analysis/price-history-card'; +import { RecentDevelopmentsSection } from '@/components/analysis/recent-developments-section'; +import { ValuationFactsTable } from '@/components/analysis/valuation-facts-table'; import { Panel } from '@/components/ui/panel'; import { useAuthGuard } from '@/hooks/use-auth-guard'; import { useLinkPrefetch } from '@/hooks/use-link-prefetch'; import { buildGraphingHref } from '@/lib/graphing/catalog'; -import { - createResearchJournalEntry, - deleteResearchJournalEntry, - updateResearchJournalEntry -} from '@/lib/api'; -import { - asNumber, - formatCurrency, - formatCurrencyByScale, - formatPercent, - type NumberScaleUnit -} from '@/lib/format'; import { queryKeys } from '@/lib/query/keys'; -import { - companyAnalysisQueryOptions, - researchJournalQueryOptions -} from '@/lib/query/options'; -import type { - CompanyAnalysis, - ResearchJournalEntry -} from '@/lib/types'; - -type FinancialPeriodFilter = 'quarterlyAndFiscalYearEnd' | 'quarterlyOnly' | 'fiscalYearEndOnly'; - -type FinancialSeriesPoint = { - filingDate: string; - filingType: '10-K' | '10-Q'; - periodLabel: 'Quarter End' | 'Fiscal Year End'; - revenue: number | null; - netIncome: number | null; - assets: number | null; - netMargin: number | null; -}; - -type JournalFormState = { - title: string; - bodyMarkdown: string; - accessionNumber: string; -}; - -const EMPTY_JOURNAL_FORM: JournalFormState = { - title: '', - bodyMarkdown: '', - accessionNumber: '' -}; - -const FINANCIAL_PERIOD_FILTER_OPTIONS: Array<{ value: FinancialPeriodFilter; label: string }> = [ - { value: 'quarterlyAndFiscalYearEnd', label: 'Quarterly + Fiscal Year End' }, - { value: 'quarterlyOnly', label: 'Quarterly only' }, - { value: 'fiscalYearEndOnly', label: 'Fiscal Year End only' } -]; - -const FINANCIAL_VALUE_SCALE_OPTIONS: Array<{ value: NumberScaleUnit; label: string }> = [ - { value: 'thousands', label: 'Thousands (K)' }, - { value: 'millions', label: 'Millions (M)' }, - { value: 'billions', label: 'Billions (B)' } -]; - -const CHART_TEXT = '#f3f5f7'; -const CHART_MUTED = '#a1a9b3'; -const CHART_GRID = 'rgba(196, 202, 211, 0.18)'; -const CHART_TOOLTIP_BG = 'rgba(31, 34, 39, 0.96)'; -const CHART_TOOLTIP_BORDER = 'rgba(220, 226, 234, 0.24)'; - -function formatShortDate(value: string) { - return format(new Date(value), 'MMM yyyy'); -} - -function formatLongDate(value: string) { - return format(new Date(value), 'MMM dd, yyyy'); -} - -function formatDateTime(value: string) { - return format(new Date(value), 'MMM dd, yyyy · HH:mm'); -} - -function ratioPercent(numerator: number | null, denominator: number | null) { - if (numerator === null || denominator === null || denominator === 0) { - return null; - } - - return (numerator / denominator) * 100; -} +import { companyAnalysisQueryOptions } from '@/lib/query/options'; +import type { CompanyAnalysis } from '@/lib/types'; function normalizeTickerInput(value: string | null) { const normalized = value?.trim().toUpperCase() ?? ''; return normalized || null; } -function isFinancialSnapshotForm( - filingType: CompanyAnalysis['filings'][number]['filing_type'] -): filingType is '10-K' | '10-Q' { - return filingType === '10-K' || filingType === '10-Q'; -} - -function includesFinancialPeriod(filingType: '10-K' | '10-Q', filter: FinancialPeriodFilter) { - if (filter === 'quarterlyOnly') { - return filingType === '10-Q'; - } - - if (filter === 'fiscalYearEndOnly') { - return filingType === '10-K'; - } - - return true; -} - -function asScaledFinancialCurrency( - value: number | null | undefined, - scale: NumberScaleUnit -) { - if (value === null || value === undefined) { - return 'n/a'; - } - - return formatCurrencyByScale(value, scale); -} - export default function AnalysisPage() { return ( - Loading analysis desk...}> + Loading overview...}> ); @@ -161,22 +36,14 @@ function AnalysisPageContent() { const { isPending, isAuthenticated } = useAuthGuard(); const searchParams = useSearchParams(); const queryClient = useQueryClient(); - const { prefetchReport, prefetchResearchTicker } = useLinkPrefetch(); + const { prefetchResearchTicker } = useLinkPrefetch(); const initialTicker = normalizeTickerInput(searchParams.get('ticker')) ?? 'MSFT'; const [tickerInput, setTickerInput] = useState(initialTicker); const [ticker, setTicker] = useState(initialTicker); const [analysis, setAnalysis] = useState(null); - const [journalEntries, setJournalEntries] = useState([]); - const [journalLoading, setJournalLoading] = useState(true); - const [journalForm, setJournalForm] = useState(EMPTY_JOURNAL_FORM); - const [editingJournalId, setEditingJournalId] = useState(null); - const [highlightedJournalId, setHighlightedJournalId] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [financialPeriodFilter, setFinancialPeriodFilter] = useState('quarterlyAndFiscalYearEnd'); - const [financialValueScale, setFinancialValueScale] = useState('millions'); - const journalEntryRefs = useRef(new Map()); useEffect(() => { const normalized = normalizeTickerInput(searchParams.get('ticker')); @@ -186,8 +53,6 @@ function AnalysisPageContent() { setTickerInput(normalized); setTicker(normalized); - const journalId = Number(searchParams.get('journalId')); - setHighlightedJournalId(Number.isInteger(journalId) && journalId > 0 ? journalId : null); }, [searchParams]); const loadAnalysis = useCallback(async (symbol: string) => { @@ -203,745 +68,97 @@ function AnalysisPageContent() { const response = await queryClient.fetchQuery(options); setAnalysis(response.analysis); } catch (err) { - setError(err instanceof Error ? err.message : 'Unable to load company analysis'); + setError(err instanceof Error ? err.message : 'Unable to load company overview'); setAnalysis(null); } finally { setLoading(false); } }, [queryClient]); - const loadJournal = useCallback(async (symbol: string) => { - const options = researchJournalQueryOptions(symbol); - - if (!queryClient.getQueryData(options.queryKey)) { - setJournalLoading(true); - } - - try { - const response = await queryClient.fetchQuery(options); - setJournalEntries(response.entries); - } catch (err) { - setError(err instanceof Error ? err.message : 'Unable to load research journal'); - setJournalEntries([]); - } finally { - setJournalLoading(false); - } - }, [queryClient]); - useEffect(() => { if (!isPending && isAuthenticated) { - void Promise.all([ - loadAnalysis(ticker), - loadJournal(ticker) - ]); + void loadAnalysis(ticker); } - }, [isPending, isAuthenticated, loadAnalysis, loadJournal, ticker]); - - useEffect(() => { - if (!highlightedJournalId) { - return; - } - - const node = journalEntryRefs.current.get(highlightedJournalId); - if (!node) { - return; - } - - node.scrollIntoView({ behavior: 'smooth', block: 'center' }); - const timeoutId = window.setTimeout(() => { - setHighlightedJournalId((current) => (current === highlightedJournalId ? null : current)); - }, 2200); - - return () => { - window.clearTimeout(timeoutId); - }; - }, [highlightedJournalId, journalEntries]); - - const priceSeries = useMemo(() => { - return (analysis?.priceHistory ?? []).map((point) => ({ - ...point, - label: formatShortDate(point.date) - })); - }, [analysis?.priceHistory]); - - const financialSeries = useMemo(() => { - return (analysis?.financials ?? []) - .filter((item): item is CompanyAnalysis['financials'][number] & { filingType: '10-K' | '10-Q' } => { - return isFinancialSnapshotForm(item.filingType); - }) - .slice() - .sort((a, b) => Date.parse(a.filingDate) - Date.parse(b.filingDate)) - .map((item) => ({ - filingDate: item.filingDate, - filingType: item.filingType, - periodLabel: item.filingType === '10-Q' ? 'Quarter End' : 'Fiscal Year End', - revenue: item.revenue, - netIncome: item.netIncome, - assets: item.totalAssets, - netMargin: ratioPercent(item.netIncome ?? null, item.revenue ?? null) - })); - }, [analysis?.financials]); - - const filteredFinancialSeries = useMemo(() => { - return financialSeries.filter((point) => includesFinancialPeriod(point.filingType, financialPeriodFilter)); - }, [financialSeries, financialPeriodFilter]); - - const periodEndFilings = useMemo(() => { - return (analysis?.filings ?? []).filter((filing) => isFinancialSnapshotForm(filing.filing_type)); - }, [analysis?.filings]); - - const selectedFinancialScaleLabel = useMemo(() => { - return FINANCIAL_VALUE_SCALE_OPTIONS.find((option) => option.value === financialValueScale)?.label ?? 'Millions (M)'; - }, [financialValueScale]); - - const resetJournalForm = useCallback(() => { - setEditingJournalId(null); - setJournalForm(EMPTY_JOURNAL_FORM); - }, []); + }, [isPending, isAuthenticated, loadAnalysis, ticker]); const activeTicker = analysis?.company.ticker ?? ticker; - - const saveJournalEntry = async (event: React.FormEvent) => { - event.preventDefault(); - const normalizedTicker = activeTicker.trim().toUpperCase(); - if (!normalizedTicker) { - return; - } - - setError(null); - - try { - if (editingJournalId === null) { - await createResearchJournalEntry({ - ticker: normalizedTicker, - entryType: journalForm.accessionNumber.trim() ? 'filing_note' : 'note', - title: journalForm.title.trim() || undefined, - bodyMarkdown: journalForm.bodyMarkdown, - accessionNumber: journalForm.accessionNumber.trim() || undefined - }); - } else { - await updateResearchJournalEntry(editingJournalId, { - title: journalForm.title.trim() || undefined, - bodyMarkdown: journalForm.bodyMarkdown - }); - } - - void queryClient.invalidateQueries({ queryKey: queryKeys.researchJournal(normalizedTicker) }); - void queryClient.invalidateQueries({ queryKey: queryKeys.companyAnalysis(normalizedTicker) }); - void queryClient.invalidateQueries({ queryKey: queryKeys.watchlist() }); - await Promise.all([ - loadAnalysis(normalizedTicker), - loadJournal(normalizedTicker) - ]); - resetJournalForm(); - } catch (err) { - setError(err instanceof Error ? err.message : 'Unable to save journal entry'); - } - }; - - const beginEditJournalEntry = (entry: ResearchJournalEntry) => { - setEditingJournalId(entry.id); - setJournalForm({ - title: entry.title ?? '', - bodyMarkdown: entry.body_markdown, - accessionNumber: entry.accession_number ?? '' - }); - }; - - const removeJournalEntry = async (entry: ResearchJournalEntry) => { - try { - await deleteResearchJournalEntry(entry.id); - void queryClient.invalidateQueries({ queryKey: queryKeys.researchJournal(entry.ticker) }); - void queryClient.invalidateQueries({ queryKey: queryKeys.companyAnalysis(entry.ticker) }); - await Promise.all([ - loadAnalysis(entry.ticker), - loadJournal(entry.ticker) - ]); - if (editingJournalId === entry.id) { - resetJournalForm(); - } - } catch (err) { - setError(err instanceof Error ? err.message : 'Unable to delete journal entry'); - } - }; + const quickLinks = useMemo(() => ({ + research: `/research?ticker=${encodeURIComponent(activeTicker)}`, + filings: `/filings?ticker=${encodeURIComponent(activeTicker)}`, + financials: `/financials?ticker=${encodeURIComponent(activeTicker)}`, + graphing: buildGraphingHref(activeTicker) + }), [activeTicker]); if (isPending || !isAuthenticated) { - return
Loading analysis desk...
; + return
Loading overview...
; } return ( - - - Ask with RAG - - - - )} + title="Company Overview" + subtitle="A summary-first view of price, business context, valuation, recent developments, and key debate points." + activeTicker={activeTicker} + actions={null} > - -
{ - event.preventDefault(); - const normalized = tickerInput.trim().toUpperCase(); - if (!normalized) { - return; - } - setTicker(normalized); - }} - > - setTickerInput(event.target.value.toUpperCase())} - placeholder="Ticker (AAPL)" - className="w-full sm:max-w-xs" - /> - - {analysis ? ( - <> - prefetchResearchTicker(analysis.company.ticker)} - onFocus={() => prefetchResearchTicker(analysis.company.ticker)} - className="text-sm text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]" - > - Open financials - - prefetchResearchTicker(analysis.company.ticker)} - onFocus={() => prefetchResearchTicker(analysis.company.ticker)} - className="text-sm text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]" - > - Open filing stream - - prefetchResearchTicker(analysis.company.ticker)} - onFocus={() => prefetchResearchTicker(analysis.company.ticker)} - className="text-sm text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]" - > - Open graphing - - - ) : null} -
-
+ { + event.preventDefault(); + const normalized = tickerInput.trim().toUpperCase(); + if (!normalized) { + return; + } + + setTicker(normalized); + }} + onRefresh={() => { + const normalizedTicker = activeTicker.trim().toUpperCase(); + void queryClient.invalidateQueries({ queryKey: queryKeys.companyAnalysis(normalizedTicker) }); + void loadAnalysis(normalizedTicker); + }} + quickLinks={quickLinks} + onLinkPrefetch={() => prefetchResearchTicker(activeTicker)} + /> {error ? ( - +

{error}

) : null} -
- -

{analysis?.company.companyName ?? ticker}

-

{analysis?.company.ticker ?? ticker}

-

{analysis?.company.sector ?? 'Sector unavailable'}

- {analysis?.company.category ? ( -

{analysis.company.category}

- ) : null} - {analysis?.company.tags.length ? ( -
- {analysis.company.tags.map((tag) => ( - - {tag} - - ))} -
- ) : null} + {analysis ? ( + <> +
+ + +
+ +
+ + +
+ + prefetchResearchTicker(activeTicker)} + /> + + + + ) : ( + +

No overview is available for the selected ticker.

- - -

{formatCurrency(analysis?.quote ?? 0)}

-

CIK {analysis?.company.cik ?? 'n/a'}

-
- - -

{formatCurrency(analysis?.position?.market_value)}

-

{analysis?.position ? `${asNumber(analysis.position.shares).toLocaleString()} shares` : 'Not held in portfolio'}

-
- - -

= 0 ? 'text-[#96f5bf]' : 'text-[#ff9f9f]'}`}> - {formatCurrency(analysis?.position?.gain_loss)} -

-

{formatPercent(analysis?.position?.gain_loss_pct)}

-
-
- -
- - {analysis?.coverage ? ( -
-
-
-

Status

-

{analysis.coverage.status}

-
-
-

Priority

-

{analysis.coverage.priority}

-
-
-
-

Last Reviewed

-

{analysis.coverage.last_reviewed_at ? formatDateTime(analysis.coverage.last_reviewed_at) : 'No research review recorded yet'}

-
-
-

Latest Filing

-

{analysis.coverage.latest_filing_date ? formatLongDate(analysis.coverage.latest_filing_date) : 'No filing history loaded yet'}

-
- - Manage coverage - -
- ) : ( -

This company is not yet in your coverage list. Add it from Coverage to track workflow status and review cadence.

- )} -
- - -
-
-

Revenue

-

{asScaledFinancialCurrency(analysis?.keyMetrics.revenue, financialValueScale)}

-
-
-

Net Income

-

{asScaledFinancialCurrency(analysis?.keyMetrics.netIncome, financialValueScale)}

-
-
-

Assets

-

{asScaledFinancialCurrency(analysis?.keyMetrics.totalAssets, financialValueScale)}

-
-
-

Cash / Debt

-

- {analysis?.keyMetrics.cash != null && analysis?.keyMetrics.debt != null - ? `${asScaledFinancialCurrency(analysis.keyMetrics.cash, financialValueScale)} / ${asScaledFinancialCurrency(analysis.keyMetrics.debt, financialValueScale)}` - : 'n/a'} -

-
-
-

- Reference date: {analysis?.keyMetrics.referenceDate ? formatLongDate(analysis.keyMetrics.referenceDate) : 'No financial filing selected'}. - {' '}Net margin: {analysis && analysis.keyMetrics.netMargin !== null ? formatPercent(analysis.keyMetrics.netMargin) : 'n/a'}. -

-
- - - {analysis?.latestFilingSummary ? ( -
-
-

Filing

-

- {analysis.latestFilingSummary.filingType} · {formatLongDate(analysis.latestFilingSummary.filingDate)} -

-
-

- {analysis.latestFilingSummary.summary ?? 'No AI summary stored yet for the most recent filing.'} -

-
- {analysis.latestFilingSummary.hasAnalysis ? ( - - Open latest memo - - ) : null} - - Open filing stream - -
-
- ) : ( -

No filing snapshot available yet for this company.

- )} -
-
- -
- - {loading ? ( -

Loading price history...

- ) : priceSeries.length === 0 ? ( -

No price history available.

- ) : ( -
- - - - - `$${value.toFixed(0)}`} - /> - formatCurrency(Array.isArray(value) ? value[0] : value)} - contentStyle={{ - backgroundColor: CHART_TOOLTIP_BG, - border: `1px solid ${CHART_TOOLTIP_BORDER}`, - borderRadius: '0.75rem' - }} - labelStyle={{ color: CHART_TEXT }} - itemStyle={{ color: CHART_TEXT }} - cursor={{ stroke: 'rgba(220, 226, 234, 0.28)', strokeWidth: 1 }} - /> - - - -
- )} -
- - -
- {FINANCIAL_PERIOD_FILTER_OPTIONS.map((option) => ( - - ))} -
-
- {FINANCIAL_VALUE_SCALE_OPTIONS.map((option) => ( - - ))} -
-
- )} - > - {loading ? ( -

Loading financials...

- ) : filteredFinancialSeries.length === 0 ? ( -

No financial rows match the selected period filter.

- ) : ( -
-
- {filteredFinancialSeries.map((point, index) => ( -
-
-
-

{formatLongDate(point.filingDate)}

-

{point.filingType} · {point.periodLabel}

-
-

= 0 ? 'text-[#96f5bf]' : 'text-[#ff9f9f]'}`}> - {asScaledFinancialCurrency(point.netIncome, financialValueScale)} -

-
-
-
-
Revenue
-
{asScaledFinancialCurrency(point.revenue, financialValueScale)}
-
-
-
Assets
-
{asScaledFinancialCurrency(point.assets, financialValueScale)}
-
-
-
Net Margin
-
{point.netMargin === null ? 'n/a' : formatPercent(point.netMargin)}
-
-
-
- ))} -
- -
- - - - - - - - - - - - - - {filteredFinancialSeries.map((point, index) => ( - - - - - - - - - - ))} - -
FiledPeriodFormRevenueNet IncomeAssetsNet Margin
{formatLongDate(point.filingDate)}{point.periodLabel}{point.filingType}{asScaledFinancialCurrency(point.revenue, financialValueScale)}= 0 ? 'text-[#96f5bf]' : 'text-[#ff9f9f]'}> - {asScaledFinancialCurrency(point.netIncome, financialValueScale)} - {asScaledFinancialCurrency(point.assets, financialValueScale)}{point.netMargin === null ? 'n/a' : formatPercent(point.netMargin)}
-
-
- )} -
- - - - {loading ? ( -

Loading filings...

- ) : periodEndFilings.length === 0 ? ( -

No 10-K or 10-Q filings available for this ticker.

- ) : ( -
-
- {periodEndFilings.map((filing) => ( -
-
-
-

{format(new Date(filing.filing_date), 'MMM dd, yyyy')}

-

{filing.filing_type} · {filing.filing_type === '10-Q' ? 'Quarter End' : 'Fiscal Year End'}

-
- {filing.filing_url ? ( - - SEC filing - - ) : null} -
-
-
-
Revenue
-
{asScaledFinancialCurrency(filing.metrics?.revenue, financialValueScale)}
-
-
-
Net Income
-
{asScaledFinancialCurrency(filing.metrics?.netIncome, financialValueScale)}
-
-
-
Assets
-
{asScaledFinancialCurrency(filing.metrics?.totalAssets, financialValueScale)}
-
-
-
- ))} -
- -
- - - - - - - - - - - - - - {periodEndFilings.map((filing) => ( - - - - - - - - - - ))} - -
FiledPeriodTypeRevenueNet IncomeAssetsDocument
{format(new Date(filing.filing_date), 'MMM dd, yyyy')}{filing.filing_type === '10-Q' ? 'Quarter End' : 'Fiscal Year End'}{filing.filing_type}{asScaledFinancialCurrency(filing.metrics?.revenue, financialValueScale)}{asScaledFinancialCurrency(filing.metrics?.netIncome, financialValueScale)}{asScaledFinancialCurrency(filing.metrics?.totalAssets, financialValueScale)} - {filing.filing_url ? ( - - SEC filing - - ) : ( - 'n/a' - )} -
-
-
- )} -
- - - {loading ? ( -

Loading AI reports...

- ) : !analysis || analysis.recentAiReports.length === 0 ? ( -

No AI reports generated yet. Run filing analysis from the filings stream.

- ) : ( -
- {analysis.recentAiReports.map((report) => ( -
-
-
-

- {report.filingType} · {format(new Date(report.filingDate), 'MMM dd, yyyy')} -

-

{report.provider} / {report.model}

-
- -
-

{report.summary}

-
-

{report.accessionNumber}

- prefetchReport(analysis.company.ticker, report.accessionNumber)} - onFocus={() => prefetchReport(analysis.company.ticker, report.accessionNumber)} - className="text-xs uppercase tracking-[0.12em] text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]" - > - Open summary - -
-
- ))} -
- )} -
- -
- prefetchResearchTicker(activeTicker)} - onFocus={() => prefetchResearchTicker(activeTicker)} - className="inline-flex items-center gap-2 rounded-lg border border-[color:var(--line-weak)] px-3 py-2 text-xs uppercase tracking-[0.12em] text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--accent-strong)]" - > - - Open research - - )} - > -
-
-

Workspace focus

-

- Use the research surface to manage the typed library, attach evidence to memo sections, upload diligence files, and assemble the packet view for investor review. -

-
-
-
-

Stored research entries

-

{journalEntries.length}

-
-
-

Latest update

-

{journalEntries[0] ? formatDateTime(journalEntries[0].updated_at) : 'No research activity yet'}

-
-
-
-
- - - {journalLoading ? ( -

Loading research entries...

- ) : journalEntries.length === 0 ? ( -

No research saved yet. Use AI memo saves from reports or open the Research workspace to start building the thesis.

- ) : ( -
- {journalEntries.slice(0, 4).map((entry) => ( -
-
-
-

- {entry.entry_type.replace('_', ' ')} · {formatDateTime(entry.updated_at)} -

-

{entry.title ?? 'Untitled entry'}

-
- prefetchResearchTicker(activeTicker)} - onFocus={() => prefetchResearchTicker(activeTicker)} - className="text-xs uppercase tracking-[0.12em] text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]" - > - Open workspace - -
-

{entry.body_markdown}

-
- ))} -
- )} -
-
- - -
- - Analysis scope: price + filings + ai synthesis + research workspace -
-
+ )}
); } diff --git a/app/analysis/reports/[ticker]/[accessionNumber]/page.tsx b/app/analysis/reports/[ticker]/[accessionNumber]/page.tsx index c2c64b5..821bdf0 100644 --- a/app/analysis/reports/[ticker]/[accessionNumber]/page.tsx +++ b/app/analysis/reports/[ticker]/[accessionNumber]/page.tsx @@ -96,7 +96,7 @@ export default function AnalysisReportPage() { subtitle={`Detailed filing analysis${resolvedTicker ? ` for ${resolvedTicker}` : ''}.`} activeTicker={resolvedTicker} breadcrumbs={[ - { label: 'Analysis', href: analysisHref }, + { label: 'Overview', href: analysisHref }, { label: 'Reports', href: analysisHref }, { label: resolvedTicker || 'Summary' } ]} diff --git a/app/financials/page.tsx b/app/financials/page.tsx index 68e83d6..fc8430f 100644 --- a/app/financials/page.tsx +++ b/app/financials/page.tsx @@ -926,7 +926,7 @@ function FinancialsPageContent() { onFocus={() => prefetchResearchTicker(financials.company.ticker)} className="text-sm text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]" > - Open analysis + Open overview
-

Analysis

-

Inspect one company across prices, filings, financials, and AI reports.

+

Overview

+

Inspect one company across price, SEC context, valuation, and recent developments.

Financials

diff --git a/app/portfolio/page.tsx b/app/portfolio/page.tsx index 1c33701..5ef604a 100644 --- a/app/portfolio/page.tsx +++ b/app/portfolio/page.tsx @@ -346,7 +346,7 @@ export default function PortfolioPage() { onFocus={() => prefetchResearchTicker(holding.ticker)} className="inline-flex items-center rounded-md border border-[color:var(--line-weak)] px-2 py-1 text-xs text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--accent-strong)]" > - Analysis + Overview prefetchResearchTicker(holding.ticker)} className="text-xs text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]" > - Analysis + Overview prefetchResearchTicker(ticker)} className="inline-flex items-center gap-2 rounded-lg border border-[color:var(--line-weak)] px-3 py-2 text-sm text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--accent-strong)]" > - Open analysis + Open overview ) : null}
)} > {!ticker ? ( - +

This workspace is company-first by design and activates once a ticker is selected.

) : null} diff --git a/app/watchlist/page.tsx b/app/watchlist/page.tsx index 10d086e..38be142 100644 --- a/app/watchlist/page.tsx +++ b/app/watchlist/page.tsx @@ -384,7 +384,7 @@ export default function WatchlistPage() { onFocus={() => prefetchResearchTicker(item.ticker)} className="inline-flex items-center gap-1 rounded-md border border-[color:var(--line-weak)] px-2 py-1 text-xs text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--accent-strong)]" > - Analyze + Open overview prefetchResearchTicker(item.ticker)} className="inline-flex items-center gap-1 rounded-md border border-[color:var(--line-weak)] px-2 py-1 text-xs text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)] hover:text-[color:var(--accent-strong)]" > - Analyze + Open overview void; + onSubmit: (event: React.FormEvent) => void; + onRefresh: () => void; + quickLinks: { + research: string; + filings: string; + financials: string; + graphing: string; + }; + onLinkPrefetch?: () => void; +}; + +export function AnalysisToolbar(props: AnalysisToolbarProps) { + return ( +
+
+
+

Company overview

+

Inspect the latest high-level picture for {props.currentTicker}

+
+ +
+ props.onTickerInputChange(event.target.value.toUpperCase())} + placeholder="Ticker (AAPL)" + className="w-full sm:min-w-[180px]" + /> + + +
+
+ +
+ + Research + + + Filings + + + Financials + + + Graphing + +
+
+ ); +} diff --git a/components/analysis/bull-bear-panel.tsx b/components/analysis/bull-bear-panel.tsx new file mode 100644 index 0000000..1e56aba --- /dev/null +++ b/components/analysis/bull-bear-panel.tsx @@ -0,0 +1,56 @@ +import Link from 'next/link'; +import { Panel } from '@/components/ui/panel'; +import type { CompanyBullBear } from '@/lib/types'; + +type BullBearPanelProps = { + bullBear: CompanyBullBear; + researchHref: string; + onLinkPrefetch?: () => void; +}; + +export function BullBearPanel(props: BullBearPanelProps) { + const hasContent = props.bullBear.bull.length > 0 || props.bullBear.bear.length > 0; + + return ( + + {!hasContent ? ( +
+ No synthesis inputs are available yet. Add memo sections or filing context in Research to populate this debate surface. +
+ + Open research workspace + +
+
+ ) : ( +
+
+

Bull case

+
    + {props.bullBear.bull.map((item) => ( +
  • + {item} +
  • + ))} +
+
+ +
+

Bear case

+
    + {props.bullBear.bear.map((item) => ( +
  • + {item} +
  • + ))} +
+
+
+ )} +
+ ); +} diff --git a/components/analysis/company-overview-card.tsx b/components/analysis/company-overview-card.tsx new file mode 100644 index 0000000..1fb56e2 --- /dev/null +++ b/components/analysis/company-overview-card.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { useState } from "react"; +import { ExternalLink } from "lucide-react"; +import { Panel } from "@/components/ui/panel"; +import type { CompanyAnalysis } from "@/lib/types"; + +type CompanyOverviewCardProps = { + analysis: CompanyAnalysis; +}; + +export function CompanyOverviewCard(props: CompanyOverviewCardProps) { + const [expanded, setExpanded] = useState(false); + const description = + props.analysis.companyProfile.description ?? + "No annual filing business description is available yet."; + const needsClamp = description.length > 320; + + return ( + +
+
+
+

+ {props.analysis.company.companyName} +

+

+ {props.analysis.company.ticker} +

+

+ {props.analysis.company.sector ?? + props.analysis.companyProfile.industry ?? + "Sector unavailable"} + {props.analysis.company.category + ? ` · ${props.analysis.company.category}` + : ""} + {props.analysis.company.cik + ? ` · CIK ${props.analysis.company.cik}` + : ""} +

+
+
+ +
+
+
+

+ Business description +

+

+ {expanded || !needsClamp + ? description + : `${description.slice(0, 320).trimEnd()}...`} +

+
+ {props.analysis.companyProfile.website ? ( + + Website + + + ) : null} +
+ {needsClamp ? ( + + ) : null} +
+
+
+ ); +} diff --git a/components/analysis/company-profile-facts-table.tsx b/components/analysis/company-profile-facts-table.tsx new file mode 100644 index 0000000..290728e --- /dev/null +++ b/components/analysis/company-profile-facts-table.tsx @@ -0,0 +1,70 @@ +import { Fragment } from 'react'; +import { Panel } from '@/components/ui/panel'; +import { formatScaledNumber } from '@/lib/format'; +import type { CompanyAnalysis } from '@/lib/types'; + +type CompanyProfileFactsTableProps = { + analysis: CompanyAnalysis; +}; + +function factValue(value: string | null | undefined) { + return value && value.trim().length > 0 ? value : 'n/a'; +} + +function employeeCountLabel(value: number | null) { + return value === null ? 'n/a' : formatScaledNumber(value, { maximumFractionDigits: 1 }); +} + +export function CompanyProfileFactsTable(props: CompanyProfileFactsTableProps) { + const items = [ + { label: 'Exchange', value: factValue(props.analysis.companyProfile.exchange) }, + { label: 'Industry', value: factValue(props.analysis.companyProfile.industry ?? props.analysis.company.sector) }, + { label: 'Country / state', value: factValue(props.analysis.companyProfile.country) }, + { label: 'Fiscal year end', value: factValue(props.analysis.companyProfile.fiscalYearEnd) }, + { label: 'Employees', value: employeeCountLabel(props.analysis.companyProfile.employeeCount) }, + { label: 'Website', value: factValue(props.analysis.companyProfile.website) }, + { label: 'Category', value: factValue(props.analysis.company.category) }, + { label: 'CIK', value: factValue(props.analysis.company.cik) } + ]; + const rows = Array.from({ length: Math.ceil(items.length / 2) }, (_, index) => items.slice(index * 2, index * 2 + 2)); + + return ( + +
+ + + {rows.map((row) => ( + item.label).join('-')} className="border-t border-[color:var(--line-weak)]"> + {row.map((item) => ( + + + + + ))} + {row.length === 1 ? ( + <> + + ))} + +
+ {item.label} + + {item.label === 'Website' && item.value !== 'n/a' ? ( + + {item.value} + + ) : ( + item.value + )} + + + + ) : null} +
+
+
+ ); +} diff --git a/components/analysis/price-history-card.tsx b/components/analysis/price-history-card.tsx new file mode 100644 index 0000000..c0cf47d --- /dev/null +++ b/components/analysis/price-history-card.tsx @@ -0,0 +1,119 @@ +'use client'; + +import { format } from 'date-fns'; +import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'; +import { Panel } from '@/components/ui/panel'; +import { formatCurrency } from '@/lib/format'; + +type PriceHistoryCardProps = { + loading: boolean; + priceHistory: Array<{ date: string; close: number }>; + quote: number; +}; + +const CHART_TEXT = '#f3f5f7'; +const CHART_MUTED = '#a1a9b3'; +const CHART_GRID = 'rgba(196, 202, 211, 0.18)'; +const CHART_TOOLTIP_BG = 'rgba(31, 34, 39, 0.96)'; +const CHART_TOOLTIP_BORDER = 'rgba(220, 226, 234, 0.24)'; + +function formatShortDate(value: string) { + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? value : format(parsed, 'MMM yy'); +} + +function formatLongDate(value: string) { + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? value : format(parsed, 'MMM dd, yyyy'); +} + +export function PriceHistoryCard(props: PriceHistoryCardProps) { + const series = props.priceHistory.map((point) => ({ + ...point, + label: formatShortDate(point.date) + })); + const firstPoint = props.priceHistory[0] ?? null; + const lastPoint = props.priceHistory[props.priceHistory.length - 1] ?? null; + const change = firstPoint && lastPoint ? lastPoint.close - firstPoint.close : null; + const changePct = firstPoint && lastPoint && firstPoint.close !== 0 + ? ((lastPoint.close - firstPoint.close) / firstPoint.close) * 100 + : null; + + return ( + +
+
+ {props.loading ? ( +

Loading price history...

+ ) : series.length === 0 ? ( +

No price history available.

+ ) : ( +
+ + + + + `$${value.toFixed(0)}`} + /> + formatCurrency(Array.isArray(value) ? value[0] : value)} + labelFormatter={(value) => String(value)} + contentStyle={{ + backgroundColor: CHART_TOOLTIP_BG, + border: `1px solid ${CHART_TOOLTIP_BORDER}`, + borderRadius: '0.75rem' + }} + labelStyle={{ color: CHART_TEXT }} + itemStyle={{ color: CHART_TEXT }} + cursor={{ stroke: 'rgba(220, 226, 234, 0.28)', strokeWidth: 1 }} + /> + + + +
+ )} +
+ +
+
+

Current price

+

{formatCurrency(props.quote)}

+
+
+

1Y change

+

+ {change === null ? 'n/a' : formatCurrency(change)} +

+
+
+

1Y return

+

+ {changePct === null ? 'n/a' : `${changePct.toFixed(2)}%`} +

+

+ {firstPoint && lastPoint ? `${formatLongDate(firstPoint.date)} to ${formatLongDate(lastPoint.date)}` : 'No comparison range'} +

+
+
+
+
+ ); +} diff --git a/components/analysis/recent-developments-section.tsx b/components/analysis/recent-developments-section.tsx new file mode 100644 index 0000000..29a541e --- /dev/null +++ b/components/analysis/recent-developments-section.tsx @@ -0,0 +1,58 @@ +import { format } from 'date-fns'; +import { ExternalLink } from 'lucide-react'; +import { Panel } from '@/components/ui/panel'; +import { WeeklySnapshotCard } from '@/components/analysis/weekly-snapshot-card'; +import type { RecentDevelopments } from '@/lib/types'; + +type RecentDevelopmentsSectionProps = { + recentDevelopments: RecentDevelopments; +}; + +function formatDate(value: string) { + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? value : format(parsed, 'MMM dd, yyyy'); +} + +export function RecentDevelopmentsSection(props: RecentDevelopmentsSectionProps) { + return ( +
+ + + + {props.recentDevelopments.items.length === 0 ? ( +

No recent development items are available for this ticker yet.

+ ) : ( +
+ {props.recentDevelopments.items.map((item) => ( +
+
+
+

+ {item.kind} · {formatDate(item.publishedAt)} +

+

{item.title}

+
+ + {item.source} + +
+

{item.summary ?? 'No summary is available for this development item yet.'}

+
+

+ {item.accessionNumber ?? 'No accession'} +

+ {item.url ? ( + + Open filing + + + ) : null} +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/components/analysis/valuation-facts-table.tsx b/components/analysis/valuation-facts-table.tsx new file mode 100644 index 0000000..6988768 --- /dev/null +++ b/components/analysis/valuation-facts-table.tsx @@ -0,0 +1,63 @@ +import { Fragment } from 'react'; +import { Panel } from '@/components/ui/panel'; +import { formatCompactCurrency, formatScaledNumber } from '@/lib/format'; +import type { CompanyAnalysis } from '@/lib/types'; + +type ValuationFactsTableProps = { + analysis: CompanyAnalysis; +}; + +function formatRatio(value: number | null) { + return value === null ? 'n/a' : `${value.toFixed(2)}x`; +} + +function formatShares(value: number | null) { + return value === null ? 'n/a' : formatScaledNumber(value, { maximumFractionDigits: 2 }); +} + +export function ValuationFactsTable(props: ValuationFactsTableProps) { + const items = [ + { label: 'Source', value: props.analysis.valuationSnapshot.source }, + { label: 'Market cap', value: props.analysis.valuationSnapshot.marketCap === null ? 'n/a' : formatCompactCurrency(props.analysis.valuationSnapshot.marketCap) }, + { label: 'Enterprise value', value: props.analysis.valuationSnapshot.enterpriseValue === null ? 'n/a' : formatCompactCurrency(props.analysis.valuationSnapshot.enterpriseValue) }, + { label: 'Shares outstanding', value: formatShares(props.analysis.valuationSnapshot.sharesOutstanding) }, + { label: 'Trailing P/E', value: formatRatio(props.analysis.valuationSnapshot.trailingPe) }, + { label: 'EV / Revenue', value: formatRatio(props.analysis.valuationSnapshot.evToRevenue) }, + { label: 'EV / EBITDA', value: formatRatio(props.analysis.valuationSnapshot.evToEbitda) } + ]; + const rows = Array.from({ length: Math.ceil(items.length / 2) }, (_, index) => items.slice(index * 2, index * 2 + 2)); + + return ( + +
+ + + {rows.map((row) => ( + item.label).join('-')} className="border-t border-[color:var(--line-weak)]"> + {row.map((item) => ( + + + + + ))} + {row.length === 1 ? ( + <> + + ))} + +
+ {item.label} + + {item.value} + + + + ) : null} +
+
+
+ ); +} diff --git a/components/analysis/valuation-stat-grid.tsx b/components/analysis/valuation-stat-grid.tsx new file mode 100644 index 0000000..b44e749 --- /dev/null +++ b/components/analysis/valuation-stat-grid.tsx @@ -0,0 +1,36 @@ +import type { CompanyValuationSnapshot } from '@/lib/types'; +import { formatCompactCurrency, formatScaledNumber } from '@/lib/format'; + +type ValuationStatGridProps = { + valuation: CompanyValuationSnapshot; +}; + +function formatRatio(value: number | null) { + return value === null ? 'n/a' : `${value.toFixed(2)}x`; +} + +function formatShares(value: number | null) { + return value === null ? 'n/a' : formatScaledNumber(value, { maximumFractionDigits: 2 }); +} + +export function ValuationStatGrid(props: ValuationStatGridProps) { + const items = [ + { label: 'Market cap', value: props.valuation.marketCap === null ? 'n/a' : formatCompactCurrency(props.valuation.marketCap) }, + { label: 'Enterprise value', value: props.valuation.enterpriseValue === null ? 'n/a' : formatCompactCurrency(props.valuation.enterpriseValue) }, + { label: 'Shares out.', value: formatShares(props.valuation.sharesOutstanding) }, + { label: 'Trailing P/E', value: formatRatio(props.valuation.trailingPe) }, + { label: 'EV / Revenue', value: formatRatio(props.valuation.evToRevenue) }, + { label: 'EV / EBITDA', value: formatRatio(props.valuation.evToEbitda) } + ]; + + return ( +
+ {items.map((item) => ( +
+

{item.label}

+

{item.value}

+
+ ))} +
+ ); +} diff --git a/components/analysis/weekly-snapshot-card.tsx b/components/analysis/weekly-snapshot-card.tsx new file mode 100644 index 0000000..6e5e97e --- /dev/null +++ b/components/analysis/weekly-snapshot-card.tsx @@ -0,0 +1,35 @@ +import { Panel } from '@/components/ui/panel'; +import type { RecentDevelopmentsWeeklySnapshot } from '@/lib/types'; + +type WeeklySnapshotCardProps = { + snapshot: RecentDevelopmentsWeeklySnapshot | null; +}; + +export function WeeklySnapshotCard(props: WeeklySnapshotCardProps) { + return ( + + {props.snapshot ? ( +
+
+

{props.snapshot.summary}

+
+ {props.snapshot.highlights.length > 0 ? ( +
    + {props.snapshot.highlights.map((highlight) => ( +
  • + {highlight} +
  • + ))} +
+ ) : null} +
+ {props.snapshot.itemCount} tracked items + {props.snapshot.startDate} to {props.snapshot.endDate} +
+
+ ) : ( +

No weekly snapshot is available yet.

+ )} +
+ ); +} diff --git a/components/shell/app-shell.tsx b/components/shell/app-shell.tsx index 6139adc..d90475e 100644 --- a/components/shell/app-shell.tsx +++ b/components/shell/app-shell.tsx @@ -67,7 +67,7 @@ const NAV_ITEMS: NavConfigItem[] = [ { id: "analysis", href: "/analysis", - label: "Analysis", + label: "Overview", icon: LineChart, group: "research", matchMode: "prefix", @@ -200,41 +200,41 @@ function buildDefaultBreadcrumbs( if (pathname.startsWith("/analysis/reports/")) { return [ - { label: "Analysis", href: analysisHref }, + { label: "Overview", href: analysisHref }, { label: "Reports", href: analysisHref }, { label: activeTicker ?? "Summary" }, ]; } if (pathname.startsWith("/analysis")) { - return [{ label: "Analysis" }]; + return [{ label: "Overview" }]; } if (pathname.startsWith("/research")) { return [ - { label: "Analysis", href: analysisHref }, + { label: "Overview", href: analysisHref }, { label: "Research", href: researchHref }, ]; } if (pathname.startsWith("/financials")) { - return [{ label: "Analysis", href: analysisHref }, { label: "Financials" }]; + return [{ label: "Overview", href: analysisHref }, { label: "Financials" }]; } if (pathname.startsWith("/graphing")) { return [ - { label: "Analysis", href: analysisHref }, + { label: "Overview", href: analysisHref }, { label: "Graphing", href: graphingHref }, { label: activeTicker ?? "Compare Set" }, ]; } if (pathname.startsWith("/filings")) { - return [{ label: "Analysis", href: analysisHref }, { label: "Filings" }]; + return [{ label: "Overview", href: analysisHref }, { label: "Filings" }]; } if (pathname.startsWith("/search")) { - return [{ label: "Analysis", href: analysisHref }, { label: "Search" }]; + return [{ label: "Overview", href: analysisHref }, { label: "Search" }]; } if (pathname.startsWith("/portfolio")) { diff --git a/e2e/research-mvp.spec.ts b/e2e/research-mvp.spec.ts index 72224e9..61b9383 100644 --- a/e2e/research-mvp.spec.ts +++ b/e2e/research-mvp.spec.ts @@ -182,11 +182,13 @@ test('supports the core coverage-to-research workflow', async ({ page }, testInf await page.getByLabel('NVDA priority').selectOption('high'); await expect(page.getByLabel('NVDA priority')).toHaveValue('high'); - await page.getByRole('link', { name: /^Analyze/ }).first().click(); + await page.getByRole('link', { name: /^Open overview/ }).first().click(); await expect(page).toHaveURL(/\/analysis\?ticker=NVDA/); - await expect(page.getByText('Coverage Workflow')).toBeVisible(); + await expect(page.getByText('Bull vs Bear')).toBeVisible(); + await expect(page.getByText('Past 7 Days')).toBeVisible(); + await expect(page.getByText('Recent Developments')).toBeVisible(); - await page.getByRole('link', { name: 'Open research' }).click(); + await page.getByRole('link', { name: 'Research' }).first().click(); await expect(page).toHaveURL(/\/research\?ticker=NVDA/); await page.getByLabel('Research note title').fill('Own-the-stack moat check'); await page.getByLabel('Research note summary').fill('Initial moat checkpoint'); @@ -202,8 +204,8 @@ test('supports the core coverage-to-research workflow', async ({ page }, testInf await page.locator('button', { hasText: 'Upload file' }).click(); await expect(page.getByText('Uploaded research file.')).toBeVisible(); - await page.goto(`/analysis?ticker=NVDA`); - await page.getByRole('link', { name: 'Open summary' }).first().click(); + await page.goto(`/filings?ticker=NVDA`); + await page.getByRole('link', { name: 'Summary' }).first().click(); await expect(page).toHaveURL(/\/analysis\/reports\/NVDA\//); await page.getByRole('button', { name: 'Save to library' }).click(); await expect(page.getByText('Saved to the company research library.')).toBeVisible(); @@ -229,6 +231,9 @@ test('supports the core coverage-to-research workflow', async ({ page }, testInf await page.goto('/analysis?ticker=NVDA'); await expect(page).toHaveURL(/\/analysis\?ticker=NVDA/); + await expect(page.getByText('Bull vs Bear')).toBeVisible(); + await expect(page.getByText('Past 7 Days')).toBeVisible(); + await expect(page.getByText('Recent Developments')).toBeVisible(); await page.goto('/financials?ticker=NVDA'); await expect(page).toHaveURL(/\/financials\?ticker=NVDA/); diff --git a/lib/server/api/app.ts b/lib/server/api/app.ts index 98a237e..c78ee82 100644 --- a/lib/server/api/app.ts +++ b/lib/server/api/app.ts @@ -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 } }); }, { diff --git a/lib/server/api/task-workflow-hybrid.e2e.test.ts b/lib/server/api/task-workflow-hybrid.e2e.test.ts index d1efc19..b655211 100644 --- a/lib/server/api/task-workflow-hybrid.e2e.test.ts +++ b/lib/server/api/task-workflow-hybrid.e2e.test.ts @@ -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', diff --git a/lib/server/company-overview-synthesis.test.ts b/lib/server/company-overview-synthesis.test.ts new file mode 100644 index 0000000..25ffb18 --- /dev/null +++ b/lib/server/company-overview-synthesis.test.ts @@ -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'); + }); +}); diff --git a/lib/server/company-overview-synthesis.ts b/lib/server/company-overview-synthesis.ts new file mode 100644 index 0000000..1990b76 --- /dev/null +++ b/lib/server/company-overview-synthesis.ts @@ -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 = { + expiresAt: number; + value: T; +}; + +const SYNTHESIS_CACHE_TTL_MS = 1000 * 60 * 30; +const synthesisCache = new Map>(); + +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(); + 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 { + 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 +}; diff --git a/lib/server/recent-developments.test.ts b/lib/server/recent-developments.test.ts new file mode 100644 index 0000000..79a96aa --- /dev/null +++ b/lib/server/recent-developments.test.ts @@ -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 & Pick): 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'); + }); +}); diff --git a/lib/server/recent-developments.ts b/lib/server/recent-developments.ts new file mode 100644 index 0000000..a72cc60 --- /dev/null +++ b/lib/server/recent-developments.ts @@ -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; +}; + +type CacheEntry = { + expiresAt: number; + value: T; +}; + +const RECENT_DEVELOPMENTS_CACHE_TTL_MS = 1000 * 60 * 10; +const recentDevelopmentsCache = new Map>(); + +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 { + 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 +}; diff --git a/lib/server/sec-company-profile.test.ts b/lib/server/sec-company-profile.test.ts new file mode 100644 index 0000000..47b7076 --- /dev/null +++ b/lib/server/sec-company-profile.test.ts @@ -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'); + }); +}); diff --git a/lib/server/sec-company-profile.ts b/lib/server/sec-company-profile.ts new file mode 100644 index 0000000..eaee1ba --- /dev/null +++ b/lib/server/sec-company-profile.ts @@ -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 }>>; +}; + +type FactPoint = { + val?: number; + filed?: string; + end?: string; +}; + +type ExchangeDirectoryPayload = { + fields?: string[]; + data?: Array>; +}; + +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 = { + 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> | null = null; +const submissionsCache = new Map>(); +const companyFactsCache = new Map>(); + +function envUserAgent() { + return process.env.SEC_USER_AGENT || 'Fiscal Clone '; +} + +async function fetchJson(url: string, fetchImpl: FetchImpl = fetch): Promise { + 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(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(); + + 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(`${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(`${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 { + 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 +}; diff --git a/lib/server/sec-description.test.ts b/lib/server/sec-description.test.ts new file mode 100644 index 0000000..39a15db --- /dev/null +++ b/lib/server/sec-description.test.ts @@ -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); + }); +}); diff --git a/lib/server/sec-description.ts b/lib/server/sec-description.ts new file mode 100644 index 0000000..a94bf4b --- /dev/null +++ b/lib/server/sec-description.ts @@ -0,0 +1,134 @@ +import type { Filing } from '@/lib/types'; +import { fetchPrimaryFilingText } from '@/lib/server/sec'; + +type CacheEntry = { + expiresAt: number; + value: T; +}; + +const DESCRIPTION_CACHE_TTL_MS = 1000 * 60 * 60 * 6; +const DESCRIPTION_MAX_CHARS = 1_600; + +const descriptionCache = new Map>(); + +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 | 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 +}; diff --git a/lib/types.ts b/lib/types.ts index ef31629..d7428a2 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -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';