Compare commits
3 Commits
codex/expl
...
t3code/exp
| Author | SHA1 | Date | |
|---|---|---|---|
| 62bacdf104 | |||
| db01f207a5 | |||
| a42622ba6e |
@@ -16,6 +16,7 @@ import {
|
||||
import {
|
||||
BrainCircuit,
|
||||
ChartNoAxesCombined,
|
||||
NotebookTabs,
|
||||
NotebookPen,
|
||||
RefreshCcw,
|
||||
Search,
|
||||
@@ -29,6 +30,7 @@ import { Input } from '@/components/ui/input';
|
||||
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,
|
||||
@@ -407,6 +409,14 @@ function AnalysisPageContent() {
|
||||
>
|
||||
Open filing stream
|
||||
</Link>
|
||||
<Link
|
||||
href={buildGraphingHref(analysis.company.ticker)}
|
||||
onMouseEnter={() => prefetchResearchTicker(analysis.company.ticker)}
|
||||
onFocus={() => prefetchResearchTicker(analysis.company.ticker)}
|
||||
className="text-sm text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Open graphing
|
||||
</Link>
|
||||
</>
|
||||
) : null}
|
||||
</form>
|
||||
@@ -756,113 +766,70 @@ function AnalysisPageContent() {
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[1fr_1.3fr]">
|
||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[0.95fr_1.3fr]">
|
||||
<Panel
|
||||
title={editingJournalId === null ? 'Research Journal' : 'Edit Journal Entry'}
|
||||
subtitle="Private markdown notes for this company. Linked filing notes update the coverage review timestamp."
|
||||
title="Research Summary"
|
||||
subtitle="The full thesis workflow now lives in the dedicated Research workspace."
|
||||
actions={(
|
||||
<Link
|
||||
href={`/research?ticker=${encodeURIComponent(activeTicker)}`}
|
||||
onMouseEnter={() => 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)]"
|
||||
>
|
||||
<NotebookTabs className="size-4" />
|
||||
Open research
|
||||
</Link>
|
||||
)}
|
||||
>
|
||||
<form onSubmit={saveJournalEntry} className="space-y-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Title</label>
|
||||
<Input
|
||||
value={journalForm.title}
|
||||
aria-label="Journal title"
|
||||
onChange={(event) => setJournalForm((prev) => ({ ...prev, title: event.target.value }))}
|
||||
placeholder="Investment thesis checkpoint, risk note, follow-up..."
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4">
|
||||
<p className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Workspace focus</p>
|
||||
<p className="mt-2 text-sm leading-6 text-[color:var(--terminal-bright)]">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Linked Filing (optional)</label>
|
||||
<Input
|
||||
value={journalForm.accessionNumber}
|
||||
aria-label="Journal linked filing"
|
||||
onChange={(event) => setJournalForm((prev) => ({ ...prev, accessionNumber: event.target.value }))}
|
||||
placeholder="0000000000-26-000001"
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4">
|
||||
<p className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Stored research entries</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-[color:var(--terminal-bright)]">{journalEntries.length}</p>
|
||||
</div>
|
||||
<div className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4">
|
||||
<p className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Latest update</p>
|
||||
<p className="mt-2 text-sm text-[color:var(--terminal-bright)]">{journalEntries[0] ? formatDateTime(journalEntries[0].updated_at) : 'No research activity yet'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Body</label>
|
||||
<textarea
|
||||
value={journalForm.bodyMarkdown}
|
||||
aria-label="Journal body"
|
||||
onChange={(event) => setJournalForm((prev) => ({ ...prev, bodyMarkdown: event.target.value }))}
|
||||
placeholder="Write your thesis update, questions, risks, and next steps..."
|
||||
className="min-h-[220px] w-full rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)] outline-none transition placeholder:text-[color:var(--terminal-muted)] focus:border-[color:var(--line-strong)] focus:shadow-[0_0_0_3px_rgba(0,255,180,0.14)]"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="submit">
|
||||
<NotebookPen className="size-4" />
|
||||
{editingJournalId === null ? 'Save note' : 'Update note'}
|
||||
</Button>
|
||||
{editingJournalId !== null ? (
|
||||
<Button type="button" variant="ghost" onClick={resetJournalForm}>
|
||||
Cancel edit
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Journal Timeline" subtitle={`${journalEntries.length} stored entries for ${activeTicker}.`}>
|
||||
<Panel title="Recent Research Feed" subtitle={`Previewing the latest ${Math.min(journalEntries.length, 4)} research entries for ${activeTicker}.`}>
|
||||
{journalLoading ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">Loading journal entries...</p>
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">Loading research entries...</p>
|
||||
) : journalEntries.length === 0 ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">No journal notes yet. Start a thesis log or attach a filing note from the filings stream.</p>
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">No research saved yet. Use AI memo saves from reports or open the Research workspace to start building the thesis.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{journalEntries.map((entry) => {
|
||||
const canEdit = entry.entry_type !== 'status_change';
|
||||
|
||||
return (
|
||||
<article key={entry.id} className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">
|
||||
{entry.entry_type.replace('_', ' ')} · {formatDateTime(entry.updated_at)}
|
||||
</p>
|
||||
<h4 className="mt-1 text-sm font-semibold text-[color:var(--terminal-bright)]">
|
||||
{entry.title ?? 'Untitled entry'}
|
||||
</h4>
|
||||
</div>
|
||||
{entry.accession_number ? (
|
||||
<Link
|
||||
href={`/filings?ticker=${activeTicker}`}
|
||||
onMouseEnter={() => prefetchResearchTicker(activeTicker)}
|
||||
onFocus={() => prefetchResearchTicker(activeTicker)}
|
||||
className="text-xs uppercase tracking-[0.12em] text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Open filing stream
|
||||
</Link>
|
||||
) : null}
|
||||
{journalEntries.slice(0, 4).map((entry) => (
|
||||
<article key={entry.id} className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">
|
||||
{entry.entry_type.replace('_', ' ')} · {formatDateTime(entry.updated_at)}
|
||||
</p>
|
||||
<h4 className="mt-1 text-sm font-semibold text-[color:var(--terminal-bright)]">{entry.title ?? 'Untitled entry'}</h4>
|
||||
</div>
|
||||
<p className="mt-3 whitespace-pre-wrap text-sm leading-6 text-[color:var(--terminal-bright)]">{entry.body_markdown}</p>
|
||||
{canEdit ? (
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-1 text-xs"
|
||||
onClick={() => beginEditJournalEntry(entry)}
|
||||
>
|
||||
<SquarePen className="size-3" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
className="px-2 py-1 text-xs"
|
||||
onClick={() => {
|
||||
void removeJournalEntry(entry);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
<Link
|
||||
href={`/research?ticker=${encodeURIComponent(activeTicker)}`}
|
||||
onMouseEnter={() => prefetchResearchTicker(activeTicker)}
|
||||
onFocus={() => prefetchResearchTicker(activeTicker)}
|
||||
className="text-xs uppercase tracking-[0.12em] text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Open workspace
|
||||
</Link>
|
||||
</div>
|
||||
<p className="mt-3 whitespace-pre-wrap text-sm leading-6 text-[color:var(--terminal-bright)]">{entry.body_markdown}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
@@ -871,7 +838,7 @@ function AnalysisPageContent() {
|
||||
<Panel>
|
||||
<div className="flex items-center gap-2 text-xs uppercase tracking-[0.24em] text-[color:var(--terminal-muted)]">
|
||||
<ChartNoAxesCombined className="size-4" />
|
||||
Analysis scope: price + filings + ai synthesis + research journal
|
||||
Analysis scope: price + filings + ai synthesis + research workspace
|
||||
</div>
|
||||
</Panel>
|
||||
</AppShell>
|
||||
|
||||
@@ -14,7 +14,7 @@ import { aiReportQueryOptions } from '@/lib/query/options';
|
||||
import type { CompanyAiReportDetail } from '@/lib/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Panel } from '@/components/ui/panel';
|
||||
import { createResearchJournalEntry } from '@/lib/api';
|
||||
import { createResearchArtifact } from '@/lib/api';
|
||||
|
||||
function formatFilingDate(value: string) {
|
||||
const date = new Date(value);
|
||||
@@ -87,6 +87,7 @@ export default function AnalysisReportPage() {
|
||||
|
||||
const resolvedTicker = report?.ticker ?? tickerFromRoute;
|
||||
const analysisHref = resolvedTicker ? `/analysis?ticker=${encodeURIComponent(resolvedTicker)}` : '/analysis';
|
||||
const researchHref = resolvedTicker ? `/research?ticker=${encodeURIComponent(resolvedTicker)}` : '/research';
|
||||
const filingsHref = resolvedTicker ? `/filings?ticker=${encodeURIComponent(resolvedTicker)}` : '/filings';
|
||||
|
||||
return (
|
||||
@@ -135,6 +136,15 @@ export default function AnalysisReportPage() {
|
||||
<ArrowLeft className="size-3" />
|
||||
Back to filings
|
||||
</Link>
|
||||
<Link
|
||||
href={researchHref}
|
||||
onMouseEnter={() => prefetchResearchTicker(resolvedTicker)}
|
||||
onFocus={() => prefetchResearchTicker(resolvedTicker)}
|
||||
className="inline-flex items-center gap-1 text-xs uppercase tracking-[0.12em] text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
<ArrowLeft className="size-3" />
|
||||
Open research
|
||||
</Link>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
@@ -193,11 +203,14 @@ export default function AnalysisReportPage() {
|
||||
setJournalNotice(null);
|
||||
|
||||
try {
|
||||
await createResearchJournalEntry({
|
||||
await createResearchArtifact({
|
||||
ticker: report.ticker,
|
||||
kind: 'ai_report',
|
||||
source: 'system',
|
||||
subtype: 'filing_analysis',
|
||||
accessionNumber: report.accessionNumber,
|
||||
entryType: 'filing_note',
|
||||
title: `${report.filingType} AI memo`,
|
||||
summary: report.summary,
|
||||
bodyMarkdown: [
|
||||
`Stored AI memo for ${report.companyName} (${report.ticker}).`,
|
||||
`Accession: ${report.accessionNumber}`,
|
||||
@@ -205,19 +218,21 @@ export default function AnalysisReportPage() {
|
||||
report.summary
|
||||
].join('\n')
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.researchWorkspace(report.ticker) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.researchPacket(report.ticker) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.researchJournal(report.ticker) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.companyAnalysis(report.ticker) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.watchlist() });
|
||||
setJournalNotice('Saved to the company research journal.');
|
||||
setJournalNotice('Saved to the company research library.');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to save report to journal');
|
||||
setError(err instanceof Error ? err.message : 'Unable to save report to library');
|
||||
} finally {
|
||||
setSavingToJournal(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<NotebookPen className="size-4" />
|
||||
{savingToJournal ? 'Saving...' : 'Add to journal'}
|
||||
{savingToJournal ? 'Saving...' : 'Save to library'}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap text-sm leading-7 text-[color:var(--terminal-bright)]">
|
||||
|
||||
@@ -1,8 +1,25 @@
|
||||
import { app } from '@/lib/server/api/app';
|
||||
|
||||
export const GET = app.fetch;
|
||||
export const POST = app.fetch;
|
||||
export const PATCH = app.fetch;
|
||||
export const PUT = app.fetch;
|
||||
export const DELETE = app.fetch;
|
||||
export const OPTIONS = app.fetch;
|
||||
export async function GET(request: Request) {
|
||||
return await app.fetch(request);
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
return await app.fetch(request);
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
return await app.fetch(request);
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
return await app.fetch(request);
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request) {
|
||||
return await app.fetch(request);
|
||||
}
|
||||
|
||||
export async function OPTIONS(request: Request) {
|
||||
return await app.fetch(request);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { useAuthGuard } from '@/hooks/use-auth-guard';
|
||||
import { useLinkPrefetch } from '@/hooks/use-link-prefetch';
|
||||
import {
|
||||
createResearchJournalEntry,
|
||||
createResearchArtifact,
|
||||
queueFilingAnalysis,
|
||||
queueFilingSync
|
||||
} from '@/lib/api';
|
||||
@@ -202,27 +202,39 @@ function FilingsPageContent() {
|
||||
}
|
||||
};
|
||||
|
||||
const addToJournal = async (filing: Filing) => {
|
||||
const saveToLibrary = async (filing: Filing) => {
|
||||
try {
|
||||
await createResearchJournalEntry({
|
||||
await createResearchArtifact({
|
||||
ticker: filing.ticker,
|
||||
kind: 'filing',
|
||||
source: 'system',
|
||||
subtype: 'filing_snapshot',
|
||||
accessionNumber: filing.accession_number,
|
||||
entryType: 'filing_note',
|
||||
title: `${filing.filing_type} filing note`,
|
||||
title: `${filing.filing_type} filing snapshot`,
|
||||
summary: filing.analysis?.text ?? filing.analysis?.legacyInsights ?? `Captured filing ${filing.accession_number}.`,
|
||||
bodyMarkdown: [
|
||||
`Captured filing note for ${filing.company_name} (${filing.ticker}).`,
|
||||
`Filed: ${formatFilingDate(filing.filing_date)}`,
|
||||
`Accession: ${filing.accession_number}`,
|
||||
'',
|
||||
filing.analysis?.text ?? filing.analysis?.legacyInsights ?? 'Follow up on this filing from the stream.'
|
||||
].join('\n')
|
||||
].join('\n'),
|
||||
metadata: {
|
||||
filingType: filing.filing_type,
|
||||
filingDate: filing.filing_date,
|
||||
filingUrl: filing.filing_url,
|
||||
submissionUrl: filing.submission_url ?? null,
|
||||
primaryDocument: filing.primary_document ?? null
|
||||
}
|
||||
});
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.researchWorkspace(filing.ticker) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.researchPacket(filing.ticker) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.researchJournal(filing.ticker) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.companyAnalysis(filing.ticker) });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.watchlist() });
|
||||
setActionNotice(`Saved ${filing.accession_number} to the ${filing.ticker} journal.`);
|
||||
setActionNotice(`Saved ${filing.accession_number} to the ${filing.ticker} research library.`);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to add filing to journal');
|
||||
setError(err instanceof Error ? err.message : 'Failed to save filing to library');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -411,11 +423,11 @@ function FilingsPageContent() {
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => void addToJournal(filing)}
|
||||
onClick={() => void saveToLibrary(filing)}
|
||||
className="px-2 py-1 text-xs"
|
||||
>
|
||||
<NotebookPen className="size-3" />
|
||||
Add to journal
|
||||
Save to library
|
||||
</Button>
|
||||
{hasAnalysis ? (
|
||||
<Link
|
||||
@@ -489,7 +501,7 @@ function FilingsPageContent() {
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => void addToJournal(filing)}
|
||||
onClick={() => void saveToLibrary(filing)}
|
||||
className="px-2 py-1 text-xs"
|
||||
>
|
||||
<NotebookPen className="size-3" />
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
664
app/graphing/page.tsx
Normal file
664
app/graphing/page.tsx
Normal file
@@ -0,0 +1,664 @@
|
||||
'use client';
|
||||
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { format } from 'date-fns';
|
||||
import { Bar, BarChart, CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
import Link from 'next/link';
|
||||
import { Suspense, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { BarChart3, RefreshCcw, Search, X } from 'lucide-react';
|
||||
import { AppShell } from '@/components/shell/app-shell';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Panel } from '@/components/ui/panel';
|
||||
import { useAuthGuard } from '@/hooks/use-auth-guard';
|
||||
import { useLinkPrefetch } from '@/hooks/use-link-prefetch';
|
||||
import {
|
||||
GRAPH_CADENCE_OPTIONS,
|
||||
GRAPH_CHART_OPTIONS,
|
||||
GRAPH_SCALE_OPTIONS,
|
||||
GRAPH_SURFACE_LABELS,
|
||||
buildGraphingHref,
|
||||
getGraphMetricDefinition,
|
||||
metricsForSurfaceAndCadence,
|
||||
normalizeGraphTickers,
|
||||
parseGraphingParams,
|
||||
resolveGraphMetric,
|
||||
serializeGraphingParams,
|
||||
type GraphChartKind,
|
||||
type GraphMetricDefinition,
|
||||
type GraphingUrlState
|
||||
} from '@/lib/graphing/catalog';
|
||||
import {
|
||||
buildGraphingComparisonData,
|
||||
type GraphingChartDatum,
|
||||
type GraphingFetchResult,
|
||||
type GraphingLatestValueRow,
|
||||
type GraphingSeriesPoint
|
||||
} from '@/lib/graphing/series';
|
||||
import { ApiError } from '@/lib/api';
|
||||
import {
|
||||
formatCurrencyByScale,
|
||||
formatPercent,
|
||||
type NumberScaleUnit
|
||||
} from '@/lib/format';
|
||||
import type {
|
||||
FinancialCadence,
|
||||
FinancialUnit
|
||||
} from '@/lib/types';
|
||||
import { companyFinancialStatementsQueryOptions } from '@/lib/query/options';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const CHART_COLORS = ['#68ffd5', '#5fd3ff', '#ffd08a', '#ff8a8a', '#c39bff'] as const;
|
||||
const CHART_MUTED = '#b4ced9';
|
||||
const CHART_GRID = 'rgba(126, 217, 255, 0.24)';
|
||||
const CHART_TOOLTIP_BG = 'rgba(6, 17, 24, 0.95)';
|
||||
const CHART_TOOLTIP_BORDER = 'rgba(123, 255, 217, 0.45)';
|
||||
|
||||
type TooltipEntry = {
|
||||
dataKey?: string | number;
|
||||
color?: string;
|
||||
value?: number | string | null;
|
||||
payload?: GraphingChartDatum;
|
||||
};
|
||||
|
||||
function formatLongDate(value: string | null) {
|
||||
if (!value) {
|
||||
return 'n/a';
|
||||
}
|
||||
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return 'n/a';
|
||||
}
|
||||
|
||||
return format(parsed, 'MMM dd, yyyy');
|
||||
}
|
||||
|
||||
function formatShortDate(value: string) {
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return format(parsed, 'MMM yyyy');
|
||||
}
|
||||
|
||||
function formatMetricValue(
|
||||
value: number | null | undefined,
|
||||
unit: FinancialUnit,
|
||||
scale: NumberScaleUnit
|
||||
) {
|
||||
if (value === null || value === undefined) {
|
||||
return 'n/a';
|
||||
}
|
||||
|
||||
switch (unit) {
|
||||
case 'currency':
|
||||
return formatCurrencyByScale(value, scale);
|
||||
case 'percent':
|
||||
return formatPercent(value * 100);
|
||||
case 'ratio':
|
||||
return `${value.toFixed(2)}x`;
|
||||
case 'shares':
|
||||
case 'count':
|
||||
return new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 2 }).format(value);
|
||||
default:
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function formatChangeValue(
|
||||
value: number | null,
|
||||
unit: FinancialUnit,
|
||||
scale: NumberScaleUnit
|
||||
) {
|
||||
if (value === null) {
|
||||
return 'n/a';
|
||||
}
|
||||
|
||||
if (unit === 'percent') {
|
||||
const signed = value >= 0 ? '+' : '';
|
||||
return `${signed}${formatPercent(value * 100)}`;
|
||||
}
|
||||
|
||||
if (unit === 'ratio') {
|
||||
const signed = value >= 0 ? '+' : '';
|
||||
return `${signed}${value.toFixed(2)}x`;
|
||||
}
|
||||
|
||||
if (unit === 'currency') {
|
||||
const formatted = formatCurrencyByScale(Math.abs(value), scale);
|
||||
return value >= 0 ? `+${formatted}` : `-${formatted}`;
|
||||
}
|
||||
|
||||
const formatted = new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 2 }).format(Math.abs(value));
|
||||
return value >= 0 ? `+${formatted}` : `-${formatted}`;
|
||||
}
|
||||
|
||||
function tickerPillClass(disabled?: boolean) {
|
||||
return cn(
|
||||
'inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs',
|
||||
disabled
|
||||
? 'border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] text-[color:var(--terminal-muted)]'
|
||||
: 'border-[color:var(--line-strong)] bg-[color:var(--panel-bright)] text-[color:var(--terminal-bright)]'
|
||||
);
|
||||
}
|
||||
|
||||
function ControlSection<T extends string>(props: {
|
||||
label: string;
|
||||
value: T;
|
||||
options: Array<{ value: T; label: string }>;
|
||||
onChange: (value: T) => void;
|
||||
ariaLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="panel-heading text-[11px] uppercase tracking-[0.18em] text-[color:var(--terminal-muted)]">{props.label}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{props.options.map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
type="button"
|
||||
aria-label={`${props.ariaLabel} ${option.label}`}
|
||||
variant={option.value === props.value ? 'primary' : 'ghost'}
|
||||
className="px-3 py-1.5 text-xs"
|
||||
onClick={() => props.onChange(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ComparisonTooltip(props: {
|
||||
active?: boolean;
|
||||
payload?: TooltipEntry[];
|
||||
metric: GraphMetricDefinition;
|
||||
scale: NumberScaleUnit;
|
||||
}) {
|
||||
if (!props.active || !props.payload || props.payload.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const datum = props.payload[0]?.payload;
|
||||
if (!datum) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entries = props.payload
|
||||
.filter((entry) => typeof entry.dataKey === 'string')
|
||||
.map((entry) => {
|
||||
const ticker = entry.dataKey as string;
|
||||
const meta = datum[`meta__${ticker}`] as GraphingSeriesPoint | undefined;
|
||||
|
||||
return {
|
||||
ticker,
|
||||
color: entry.color ?? CHART_MUTED,
|
||||
value: typeof entry.value === 'number' ? entry.value : null,
|
||||
meta
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-w-[220px] rounded-xl border px-3 py-3 text-sm shadow-[0_16px_40px_rgba(0,0,0,0.34)]"
|
||||
style={{
|
||||
backgroundColor: CHART_TOOLTIP_BG,
|
||||
borderColor: CHART_TOOLTIP_BORDER
|
||||
}}
|
||||
>
|
||||
<p className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">
|
||||
{formatLongDate(entries[0]?.meta?.dateKey ?? null)}
|
||||
</p>
|
||||
<div className="mt-3 space-y-2">
|
||||
{entries.map((entry) => (
|
||||
<div key={entry.ticker} className="rounded-lg border border-[color:var(--line-weak)] bg-[color:rgba(4,16,24,0.72)] px-2 py-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="size-2 rounded-full" style={{ backgroundColor: entry.color }} aria-hidden="true" />
|
||||
<span className="font-medium text-[color:var(--terminal-bright)]">{entry.ticker}</span>
|
||||
</div>
|
||||
<span className="text-[color:var(--terminal-bright)]">
|
||||
{formatMetricValue(entry.value, props.metric.unit, props.scale)}
|
||||
</span>
|
||||
</div>
|
||||
{entry.meta ? (
|
||||
<p className="mt-1 text-xs text-[color:var(--terminal-muted)]">
|
||||
{entry.meta.filingType} · {entry.meta.periodLabel}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GraphingPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Loading graphing desk...</div>}>
|
||||
<GraphingPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function GraphingPageContent() {
|
||||
const { isPending, isAuthenticated } = useAuthGuard();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const { prefetchResearchTicker } = useLinkPrefetch();
|
||||
|
||||
const graphState = useMemo(() => parseGraphingParams(searchParams), [searchParams]);
|
||||
const canonicalQuery = useMemo(() => serializeGraphingParams(graphState), [graphState]);
|
||||
const currentQuery = searchParams.toString();
|
||||
|
||||
const [tickerInput, setTickerInput] = useState(graphState.tickers.join(', '));
|
||||
const [results, setResults] = useState<GraphingFetchResult[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshNonce, setRefreshNonce] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setTickerInput(graphState.tickers.join(', '));
|
||||
}, [graphState.tickers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentQuery !== canonicalQuery) {
|
||||
router.replace(`/graphing?${canonicalQuery}`, { scroll: false });
|
||||
}
|
||||
}, [canonicalQuery, currentQuery, router]);
|
||||
|
||||
const replaceGraphState = useCallback((patch: Partial<GraphingUrlState>) => {
|
||||
const nextSurface = patch.surface ?? graphState.surface;
|
||||
const nextCadence = patch.cadence ?? graphState.cadence;
|
||||
const nextTickers = patch.tickers && patch.tickers.length > 0 ? patch.tickers : graphState.tickers;
|
||||
const nextMetric = resolveGraphMetric(
|
||||
nextSurface,
|
||||
nextCadence,
|
||||
patch.metric ?? graphState.metric
|
||||
);
|
||||
|
||||
const nextState: GraphingUrlState = {
|
||||
tickers: nextTickers,
|
||||
surface: nextSurface,
|
||||
cadence: nextCadence,
|
||||
metric: nextMetric,
|
||||
chart: patch.chart ?? graphState.chart,
|
||||
scale: patch.scale ?? graphState.scale
|
||||
};
|
||||
|
||||
router.replace(`/graphing?${serializeGraphingParams(nextState)}`, { scroll: false });
|
||||
}, [graphState, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPending || !isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
async function loadComparisonSet() {
|
||||
setLoading(true);
|
||||
|
||||
const settled = await Promise.allSettled(graphState.tickers.map(async (ticker) => {
|
||||
const response = await queryClient.ensureQueryData(companyFinancialStatementsQueryOptions({
|
||||
ticker,
|
||||
surfaceKind: graphState.surface,
|
||||
cadence: graphState.cadence,
|
||||
includeDimensions: false,
|
||||
includeFacts: false,
|
||||
limit: 16
|
||||
}));
|
||||
|
||||
return response.financials;
|
||||
}));
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setResults(settled.map((entry, index) => {
|
||||
const ticker = graphState.tickers[index] ?? `ticker-${index + 1}`;
|
||||
|
||||
if (entry.status === 'fulfilled') {
|
||||
return {
|
||||
ticker,
|
||||
financials: entry.value
|
||||
} satisfies GraphingFetchResult;
|
||||
}
|
||||
|
||||
const reason = entry.reason instanceof ApiError
|
||||
? entry.reason.message
|
||||
: entry.reason instanceof Error
|
||||
? entry.reason.message
|
||||
: 'Unable to load financial history';
|
||||
|
||||
return {
|
||||
ticker,
|
||||
error: reason
|
||||
} satisfies GraphingFetchResult;
|
||||
}));
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
void loadComparisonSet();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [graphState, isAuthenticated, isPending, queryClient, refreshNonce]);
|
||||
|
||||
const metricOptions = useMemo(() => metricsForSurfaceAndCadence(graphState.surface, graphState.cadence), [graphState.cadence, graphState.surface]);
|
||||
const selectedMetric = useMemo(
|
||||
() => getGraphMetricDefinition(graphState.surface, graphState.cadence, graphState.metric),
|
||||
[graphState.cadence, graphState.metric, graphState.surface]
|
||||
);
|
||||
const comparison = useMemo(() => buildGraphingComparisonData({
|
||||
results,
|
||||
surface: graphState.surface,
|
||||
metric: graphState.metric
|
||||
}), [graphState.metric, graphState.surface, results]);
|
||||
|
||||
const hasCurrencyScale = selectedMetric?.unit === 'currency';
|
||||
|
||||
if (isPending || !isAuthenticated) {
|
||||
return <div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Loading graphing desk...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
title="Graphing"
|
||||
subtitle="Compare one normalized filing metric across multiple companies with shareable chart state."
|
||||
activeTicker={graphState.tickers[0] ?? null}
|
||||
actions={(
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setRefreshNonce((current) => current + 1)}
|
||||
>
|
||||
<RefreshCcw className="size-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => replaceGraphState(parseGraphingParams(new URLSearchParams()))}
|
||||
>
|
||||
<BarChart3 className="size-4" />
|
||||
Reset View
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Panel title="Compare Set" subtitle="Enter up to five tickers. Duplicates are removed and the first ticker anchors research context.">
|
||||
<form
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
const nextTickers = normalizeGraphTickers(tickerInput);
|
||||
replaceGraphState({ tickers: nextTickers.length > 0 ? nextTickers : [...graphState.tickers] });
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Input
|
||||
aria-label="Compare tickers"
|
||||
value={tickerInput}
|
||||
onChange={(event) => setTickerInput(event.target.value.toUpperCase())}
|
||||
placeholder="MSFT, AAPL, NVDA"
|
||||
className="min-w-[260px] flex-1"
|
||||
/>
|
||||
<Button type="submit">
|
||||
<Search className="size-4" />
|
||||
Update Compare Set
|
||||
</Button>
|
||||
<Link
|
||||
href={buildGraphingHref(graphState.tickers[0] ?? null)}
|
||||
onMouseEnter={() => {
|
||||
if (graphState.tickers[0]) {
|
||||
prefetchResearchTicker(graphState.tickers[0]);
|
||||
}
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (graphState.tickers[0]) {
|
||||
prefetchResearchTicker(graphState.tickers[0]);
|
||||
}
|
||||
}}
|
||||
className="text-sm text-[color:var(--accent)] hover:text-[color:var(--accent-strong)]"
|
||||
>
|
||||
Open canonical graphing URL
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{graphState.tickers.map((ticker, index) => (
|
||||
<span key={ticker} className={tickerPillClass(index === 0)}>
|
||||
{ticker}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove ${ticker}`}
|
||||
disabled={graphState.tickers.length === 1}
|
||||
className="rounded-full p-0.5 text-[color:var(--terminal-muted)] transition hover:text-[color:var(--terminal-bright)] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
onClick={() => {
|
||||
const nextTickers = graphState.tickers.filter((entry) => entry !== ticker);
|
||||
if (nextTickers.length > 0) {
|
||||
replaceGraphState({ tickers: nextTickers });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</form>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Chart Controls" subtitle="Surface, metric, cadence, chart style, and scale stay in the URL for deep-linking.">
|
||||
<div className="grid gap-5 lg:grid-cols-[1.2fr_1fr]">
|
||||
<div className="space-y-5">
|
||||
<ControlSection
|
||||
label="Surface"
|
||||
ariaLabel="Graph surface"
|
||||
value={graphState.surface}
|
||||
options={Object.entries(GRAPH_SURFACE_LABELS).map(([value, label]) => ({ value: value as GraphingUrlState['surface'], label }))}
|
||||
onChange={(value) => replaceGraphState({ surface: value })}
|
||||
/>
|
||||
<ControlSection
|
||||
label="Cadence"
|
||||
ariaLabel="Graph cadence"
|
||||
value={graphState.cadence}
|
||||
options={GRAPH_CADENCE_OPTIONS}
|
||||
onChange={(value) => replaceGraphState({ cadence: value as FinancialCadence })}
|
||||
/>
|
||||
<ControlSection
|
||||
label="Chart Type"
|
||||
ariaLabel="Chart type"
|
||||
value={graphState.chart}
|
||||
options={GRAPH_CHART_OPTIONS}
|
||||
onChange={(value) => replaceGraphState({ chart: value as GraphChartKind })}
|
||||
/>
|
||||
{hasCurrencyScale ? (
|
||||
<ControlSection
|
||||
label="Scale"
|
||||
ariaLabel="Value scale"
|
||||
value={graphState.scale}
|
||||
options={GRAPH_SCALE_OPTIONS}
|
||||
onChange={(value) => replaceGraphState({ scale: value as NumberScaleUnit })}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="panel-heading text-[11px] uppercase tracking-[0.18em] text-[color:var(--terminal-muted)]">Metric</p>
|
||||
<select
|
||||
aria-label="Metric selector"
|
||||
value={graphState.metric}
|
||||
onChange={(event) => replaceGraphState({ metric: event.target.value })}
|
||||
className="w-full rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2.5 text-sm text-[color:var(--terminal-bright)] outline-none transition focus:border-[color:var(--line-strong)] focus:shadow-[0_0_0_3px_rgba(0,255,180,0.14)]"
|
||||
>
|
||||
{metricOptions.map((option) => (
|
||||
<option key={option.key} value={option.key} className="bg-[#07161f]">
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{selectedMetric ? (
|
||||
<div className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-3 text-sm text-[color:var(--terminal-muted)]">
|
||||
<p className="text-[color:var(--terminal-bright)]">{selectedMetric.label}</p>
|
||||
<p className="mt-1">
|
||||
{GRAPH_SURFACE_LABELS[selectedMetric.surface]} · {selectedMetric.category.replace(/_/g, ' ')} · unit {selectedMetric.unit}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Comparison Chart" subtitle="One metric, multiple companies, aligned by actual reported dates.">
|
||||
{loading ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">Loading comparison chart...</p>
|
||||
) : !selectedMetric || !comparison.hasAnyData ? (
|
||||
<div className="rounded-xl border border-dashed border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-4 py-6">
|
||||
<p className="text-sm text-[color:var(--terminal-bright)]">No chart data available for the selected compare set.</p>
|
||||
<p className="mt-2 text-sm text-[color:var(--terminal-muted)]">Try a different metric, cadence, or company basket.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{comparison.hasPartialData ? (
|
||||
<div className="mb-4 rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-3 text-sm text-[color:var(--terminal-muted)]">
|
||||
Partial coverage detected. Some companies are missing values for this metric or failed to load, but the remaining series still render.
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
{comparison.companies.map((company, index) => (
|
||||
<span key={company.ticker} className={tickerPillClass(company.status !== 'ready')}>
|
||||
<span
|
||||
className="size-2 rounded-full"
|
||||
style={{ backgroundColor: CHART_COLORS[index % CHART_COLORS.length] }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{company.ticker}
|
||||
<span className="text-[color:var(--terminal-muted)]">{company.companyName}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="h-[360px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
{graphState.chart === 'line' ? (
|
||||
<LineChart data={comparison.chartData}>
|
||||
<CartesianGrid strokeDasharray="2 2" stroke={CHART_GRID} />
|
||||
<XAxis
|
||||
dataKey="dateKey"
|
||||
stroke={CHART_MUTED}
|
||||
fontSize={12}
|
||||
minTickGap={20}
|
||||
tickFormatter={(value: string) => formatShortDate(value)}
|
||||
/>
|
||||
<YAxis
|
||||
stroke={CHART_MUTED}
|
||||
fontSize={12}
|
||||
tickFormatter={(value: number) => formatMetricValue(value, selectedMetric.unit, graphState.scale)}
|
||||
/>
|
||||
<Tooltip content={<ComparisonTooltip metric={selectedMetric} scale={graphState.scale} />} />
|
||||
{comparison.companies.map((company, index) => (
|
||||
<Line
|
||||
key={company.ticker}
|
||||
type="monotone"
|
||||
dataKey={company.ticker}
|
||||
connectNulls={false}
|
||||
stroke={CHART_COLORS[index % CHART_COLORS.length]}
|
||||
strokeWidth={2.5}
|
||||
dot={false}
|
||||
name={company.ticker}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
) : (
|
||||
<BarChart data={comparison.chartData}>
|
||||
<CartesianGrid strokeDasharray="2 2" stroke={CHART_GRID} />
|
||||
<XAxis
|
||||
dataKey="dateKey"
|
||||
stroke={CHART_MUTED}
|
||||
fontSize={12}
|
||||
minTickGap={20}
|
||||
tickFormatter={(value: string) => formatShortDate(value)}
|
||||
/>
|
||||
<YAxis
|
||||
stroke={CHART_MUTED}
|
||||
fontSize={12}
|
||||
tickFormatter={(value: number) => formatMetricValue(value, selectedMetric.unit, graphState.scale)}
|
||||
/>
|
||||
<Tooltip content={<ComparisonTooltip metric={selectedMetric} scale={graphState.scale} />} />
|
||||
{comparison.companies.map((company, index) => (
|
||||
<Bar
|
||||
key={company.ticker}
|
||||
dataKey={company.ticker}
|
||||
fill={CHART_COLORS[index % CHART_COLORS.length]}
|
||||
radius={[6, 6, 0, 0]}
|
||||
name={company.ticker}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel title="Latest Values" subtitle="Most recent reported point per company, plus the prior point and one-step change.">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="data-table min-w-[920px]">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Company</th>
|
||||
<th>Latest</th>
|
||||
<th>Prior</th>
|
||||
<th>Change</th>
|
||||
<th>Period</th>
|
||||
<th>Filing</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{comparison.latestRows.map((row, index) => (
|
||||
<tr key={row.ticker}>
|
||||
<td>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="size-2 rounded-full"
|
||||
style={{ backgroundColor: CHART_COLORS[index % CHART_COLORS.length] }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>{row.ticker}</span>
|
||||
</div>
|
||||
<span className="text-xs text-[color:var(--terminal-muted)]">{row.companyName}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>{selectedMetric ? formatMetricValue(row.latestValue, selectedMetric.unit, graphState.scale) : 'n/a'}</td>
|
||||
<td>{selectedMetric ? formatMetricValue(row.priorValue, selectedMetric.unit, graphState.scale) : 'n/a'}</td>
|
||||
<td className={cn(row.changeValue !== null && row.changeValue >= 0 ? 'text-[color:var(--accent)]' : row.changeValue !== null ? 'text-[#ffb5b5]' : 'text-[color:var(--terminal-muted)]')}>
|
||||
{selectedMetric ? formatChangeValue(row.changeValue, selectedMetric.unit, graphState.scale) : 'n/a'}
|
||||
</td>
|
||||
<td>{formatLongDate(row.latestDateKey)} {row.latestPeriodLabel ? <span className="text-xs text-[color:var(--terminal-muted)]">· {row.latestPeriodLabel}</span> : null}</td>
|
||||
<td>{row.latestFilingType ?? 'n/a'}</td>
|
||||
<td>
|
||||
{row.status === 'ready' ? (
|
||||
<span className="text-[color:var(--accent)]">Ready</span>
|
||||
) : row.status === 'no_metric_data' ? (
|
||||
<span className="text-[color:var(--terminal-muted)]">No metric data</span>
|
||||
) : (
|
||||
<span className="text-[#ffb5b5]">{row.errorMessage ?? 'Load failed'}</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Panel>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
queuePortfolioInsights,
|
||||
queuePriceRefresh
|
||||
} from '@/lib/api';
|
||||
import { buildGraphingHref } from '@/lib/graphing/catalog';
|
||||
import type { PortfolioInsight, PortfolioSummary, Task } from '@/lib/types';
|
||||
import { formatCompactCurrency, formatCurrency, formatPercent } from '@/lib/format';
|
||||
import { queryKeys } from '@/lib/query/keys';
|
||||
@@ -203,6 +204,10 @@ export default function CommandCenterPage() {
|
||||
<p className="panel-heading text-xs uppercase text-[color:var(--terminal-muted)]">Financials</p>
|
||||
<p className="mt-2 text-sm text-[color:var(--terminal-bright)]">Focus on multi-period filing metrics, margins, leverage, and balance sheet composition.</p>
|
||||
</Link>
|
||||
<Link className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4 transition hover:border-[color:var(--line-strong)] hover:bg-[color:var(--panel-bright)]" href={buildGraphingHref()}>
|
||||
<p className="panel-heading text-xs uppercase text-[color:var(--terminal-muted)]">Graphing</p>
|
||||
<p className="mt-2 text-sm text-[color:var(--terminal-bright)]">Compare one normalized metric across multiple companies with shareable chart state.</p>
|
||||
</Link>
|
||||
<Link
|
||||
className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4 transition hover:border-[color:var(--line-strong)] hover:bg-[color:var(--panel-bright)]"
|
||||
href="/filings"
|
||||
|
||||
831
app/research/page.tsx
Normal file
831
app/research/page.tsx
Normal file
@@ -0,0 +1,831 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense, useDeferredValue, useEffect, useMemo, useState } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { BookOpenText, Download, FilePlus2, Filter, FolderUp, Link2, NotebookPen, Search, ShieldCheck, Sparkles, Trash2 } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { AppShell } from '@/components/shell/app-shell';
|
||||
import { Panel } from '@/components/ui/panel';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useAuthGuard } from '@/hooks/use-auth-guard';
|
||||
import { useLinkPrefetch } from '@/hooks/use-link-prefetch';
|
||||
import {
|
||||
addResearchMemoEvidence,
|
||||
createResearchArtifact,
|
||||
deleteResearchArtifact,
|
||||
deleteResearchMemoEvidence,
|
||||
getResearchArtifactFileUrl,
|
||||
updateResearchArtifact,
|
||||
uploadResearchArtifact,
|
||||
upsertResearchMemo
|
||||
} from '@/lib/api';
|
||||
import { queryKeys } from '@/lib/query/keys';
|
||||
import {
|
||||
researchLibraryQueryOptions,
|
||||
researchWorkspaceQueryOptions
|
||||
} from '@/lib/query/options';
|
||||
import type {
|
||||
ResearchArtifact,
|
||||
ResearchArtifactKind,
|
||||
ResearchArtifactSource,
|
||||
ResearchMemo,
|
||||
ResearchMemoSection,
|
||||
ResearchWorkspace
|
||||
} from '@/lib/types';
|
||||
|
||||
const MEMO_SECTIONS: Array<{ value: ResearchMemoSection; label: string }> = [
|
||||
{ value: 'thesis', label: 'Thesis' },
|
||||
{ value: 'variant_view', label: 'Variant View' },
|
||||
{ value: 'catalysts', label: 'Catalysts' },
|
||||
{ value: 'risks', label: 'Risks' },
|
||||
{ value: 'disconfirming_evidence', label: 'Disconfirming Evidence' },
|
||||
{ value: 'next_actions', label: 'Next Actions' }
|
||||
];
|
||||
|
||||
const KIND_OPTIONS: Array<{ value: '' | ResearchArtifactKind; label: string }> = [
|
||||
{ value: '', label: 'All artifacts' },
|
||||
{ value: 'note', label: 'Notes' },
|
||||
{ value: 'ai_report', label: 'AI memos' },
|
||||
{ value: 'filing', label: 'Filings' },
|
||||
{ value: 'upload', label: 'Uploads' },
|
||||
{ value: 'status_change', label: 'Status events' }
|
||||
];
|
||||
|
||||
const SOURCE_OPTIONS: Array<{ value: '' | ResearchArtifactSource; label: string }> = [
|
||||
{ value: '', label: 'All sources' },
|
||||
{ value: 'user', label: 'User-authored' },
|
||||
{ value: 'system', label: 'System-generated' }
|
||||
];
|
||||
|
||||
type NoteFormState = {
|
||||
id: number | null;
|
||||
title: string;
|
||||
summary: string;
|
||||
bodyMarkdown: string;
|
||||
tags: string;
|
||||
};
|
||||
|
||||
type MemoFormState = {
|
||||
rating: string;
|
||||
conviction: string;
|
||||
timeHorizonMonths: string;
|
||||
packetTitle: string;
|
||||
packetSubtitle: string;
|
||||
thesisMarkdown: string;
|
||||
variantViewMarkdown: string;
|
||||
catalystsMarkdown: string;
|
||||
risksMarkdown: string;
|
||||
disconfirmingEvidenceMarkdown: string;
|
||||
nextActionsMarkdown: string;
|
||||
};
|
||||
|
||||
const EMPTY_NOTE_FORM: NoteFormState = {
|
||||
id: null,
|
||||
title: '',
|
||||
summary: '',
|
||||
bodyMarkdown: '',
|
||||
tags: ''
|
||||
};
|
||||
|
||||
const EMPTY_MEMO_FORM: MemoFormState = {
|
||||
rating: '',
|
||||
conviction: '',
|
||||
timeHorizonMonths: '',
|
||||
packetTitle: '',
|
||||
packetSubtitle: '',
|
||||
thesisMarkdown: '',
|
||||
variantViewMarkdown: '',
|
||||
catalystsMarkdown: '',
|
||||
risksMarkdown: '',
|
||||
disconfirmingEvidenceMarkdown: '',
|
||||
nextActionsMarkdown: ''
|
||||
};
|
||||
|
||||
function parseTags(value: string) {
|
||||
const unique = new Set<string>();
|
||||
|
||||
for (const segment of value.split(',')) {
|
||||
const normalized = segment.trim();
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
|
||||
unique.add(normalized);
|
||||
}
|
||||
|
||||
return [...unique];
|
||||
}
|
||||
|
||||
function tagsToInput(tags: string[]) {
|
||||
return tags.join(', ');
|
||||
}
|
||||
|
||||
function formatTimestamp(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return 'n/a';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return 'n/a';
|
||||
}
|
||||
|
||||
return format(date, 'MMM dd, yyyy · HH:mm');
|
||||
}
|
||||
|
||||
function toMemoForm(memo: ResearchMemo | null): MemoFormState {
|
||||
if (!memo) {
|
||||
return EMPTY_MEMO_FORM;
|
||||
}
|
||||
|
||||
return {
|
||||
rating: memo.rating ?? '',
|
||||
conviction: memo.conviction ?? '',
|
||||
timeHorizonMonths: memo.time_horizon_months ? String(memo.time_horizon_months) : '',
|
||||
packetTitle: memo.packet_title ?? '',
|
||||
packetSubtitle: memo.packet_subtitle ?? '',
|
||||
thesisMarkdown: memo.thesis_markdown,
|
||||
variantViewMarkdown: memo.variant_view_markdown,
|
||||
catalystsMarkdown: memo.catalysts_markdown,
|
||||
risksMarkdown: memo.risks_markdown,
|
||||
disconfirmingEvidenceMarkdown: memo.disconfirming_evidence_markdown,
|
||||
nextActionsMarkdown: memo.next_actions_markdown
|
||||
};
|
||||
}
|
||||
|
||||
function noteFormFromArtifact(artifact: ResearchArtifact): NoteFormState {
|
||||
return {
|
||||
id: artifact.id,
|
||||
title: artifact.title ?? '',
|
||||
summary: artifact.summary ?? '',
|
||||
bodyMarkdown: artifact.body_markdown ?? '',
|
||||
tags: tagsToInput(artifact.tags)
|
||||
};
|
||||
}
|
||||
|
||||
export default function ResearchPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Loading research workspace...</div>}>
|
||||
<ResearchPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function ResearchPageContent() {
|
||||
const { isPending, isAuthenticated } = useAuthGuard();
|
||||
const searchParams = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const { prefetchResearchTicker } = useLinkPrefetch();
|
||||
|
||||
const [workspace, setWorkspace] = useState<ResearchWorkspace | null>(null);
|
||||
const [library, setLibrary] = useState<ResearchArtifact[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [noteForm, setNoteForm] = useState<NoteFormState>(EMPTY_NOTE_FORM);
|
||||
const [memoForm, setMemoForm] = useState<MemoFormState>(EMPTY_MEMO_FORM);
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [kindFilter, setKindFilter] = useState<'' | ResearchArtifactKind>('');
|
||||
const [sourceFilter, setSourceFilter] = useState<'' | ResearchArtifactSource>('');
|
||||
const [tagFilter, setTagFilter] = useState('');
|
||||
const [linkedOnly, setLinkedOnly] = useState(false);
|
||||
const [attachSection, setAttachSection] = useState<ResearchMemoSection>('thesis');
|
||||
const [uploadFile, setUploadFile] = useState<File | null>(null);
|
||||
const [uploadTitle, setUploadTitle] = useState('');
|
||||
const [uploadSummary, setUploadSummary] = useState('');
|
||||
const [uploadTags, setUploadTags] = useState('');
|
||||
|
||||
const ticker = useMemo(() => searchParams.get('ticker')?.trim().toUpperCase() ?? '', [searchParams]);
|
||||
const deferredSearch = useDeferredValue(searchInput);
|
||||
|
||||
const loadWorkspace = async (symbol: string) => {
|
||||
if (!symbol) {
|
||||
setWorkspace(null);
|
||||
setLibrary([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const options = researchWorkspaceQueryOptions(symbol);
|
||||
if (!queryClient.getQueryData(options.queryKey)) {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await queryClient.ensureQueryData(options);
|
||||
setWorkspace(response.workspace);
|
||||
setLibrary(response.workspace.library);
|
||||
setMemoForm(toMemoForm(response.workspace.memo));
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setWorkspace(null);
|
||||
setLibrary([]);
|
||||
setError(err instanceof Error ? err.message : 'Unable to load research workspace');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadLibrary = async (symbol: string) => {
|
||||
if (!symbol) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await queryClient.fetchQuery(researchLibraryQueryOptions({
|
||||
ticker: symbol,
|
||||
q: deferredSearch,
|
||||
kind: kindFilter || undefined,
|
||||
source: sourceFilter || undefined,
|
||||
tag: tagFilter || undefined,
|
||||
linkedToMemo: linkedOnly ? true : undefined,
|
||||
limit: 100
|
||||
}));
|
||||
setLibrary(response.artifacts);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to load filtered library');
|
||||
}
|
||||
};
|
||||
|
||||
const invalidateResearch = async (symbol: string) => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.researchWorkspace(symbol) }),
|
||||
queryClient.invalidateQueries({ queryKey: ['research', 'library', symbol] }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.researchMemo(symbol) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.researchPacket(symbol) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.researchJournal(symbol) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.companyAnalysis(symbol) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.watchlist() })
|
||||
]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPending && isAuthenticated) {
|
||||
void loadWorkspace(ticker);
|
||||
}
|
||||
}, [isPending, isAuthenticated, ticker]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspace) {
|
||||
return;
|
||||
}
|
||||
|
||||
void loadLibrary(workspace.ticker);
|
||||
}, [workspace?.ticker, deferredSearch, kindFilter, sourceFilter, tagFilter, linkedOnly]);
|
||||
|
||||
if (isPending || !isAuthenticated) {
|
||||
return <div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Loading research workspace...</div>;
|
||||
}
|
||||
|
||||
const saveNote = async () => {
|
||||
if (!ticker) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (noteForm.id === null) {
|
||||
await createResearchArtifact({
|
||||
ticker,
|
||||
kind: 'note',
|
||||
source: 'user',
|
||||
title: noteForm.title || undefined,
|
||||
summary: noteForm.summary || undefined,
|
||||
bodyMarkdown: noteForm.bodyMarkdown || undefined,
|
||||
tags: parseTags(noteForm.tags)
|
||||
});
|
||||
setNotice('Saved note to the research library.');
|
||||
} else {
|
||||
await updateResearchArtifact(noteForm.id, {
|
||||
title: noteForm.title || undefined,
|
||||
summary: noteForm.summary || undefined,
|
||||
bodyMarkdown: noteForm.bodyMarkdown || undefined,
|
||||
tags: parseTags(noteForm.tags)
|
||||
});
|
||||
setNotice('Updated research note.');
|
||||
}
|
||||
|
||||
setNoteForm(EMPTY_NOTE_FORM);
|
||||
await invalidateResearch(ticker);
|
||||
await loadWorkspace(ticker);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to save note');
|
||||
}
|
||||
};
|
||||
|
||||
const saveMemo = async () => {
|
||||
if (!ticker) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await upsertResearchMemo({
|
||||
ticker,
|
||||
rating: memoForm.rating ? memoForm.rating as ResearchMemo['rating'] : null,
|
||||
conviction: memoForm.conviction ? memoForm.conviction as ResearchMemo['conviction'] : null,
|
||||
timeHorizonMonths: memoForm.timeHorizonMonths ? Number(memoForm.timeHorizonMonths) : null,
|
||||
packetTitle: memoForm.packetTitle || undefined,
|
||||
packetSubtitle: memoForm.packetSubtitle || undefined,
|
||||
thesisMarkdown: memoForm.thesisMarkdown,
|
||||
variantViewMarkdown: memoForm.variantViewMarkdown,
|
||||
catalystsMarkdown: memoForm.catalystsMarkdown,
|
||||
risksMarkdown: memoForm.risksMarkdown,
|
||||
disconfirmingEvidenceMarkdown: memoForm.disconfirmingEvidenceMarkdown,
|
||||
nextActionsMarkdown: memoForm.nextActionsMarkdown
|
||||
});
|
||||
setNotice('Saved investment memo.');
|
||||
await invalidateResearch(ticker);
|
||||
await loadWorkspace(ticker);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to save investment memo');
|
||||
}
|
||||
};
|
||||
|
||||
const uploadFileToLibrary = async () => {
|
||||
if (!ticker || !uploadFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await uploadResearchArtifact({
|
||||
ticker,
|
||||
file: uploadFile,
|
||||
title: uploadTitle || undefined,
|
||||
summary: uploadSummary || undefined,
|
||||
tags: parseTags(uploadTags)
|
||||
});
|
||||
setUploadFile(null);
|
||||
setUploadTitle('');
|
||||
setUploadSummary('');
|
||||
setUploadTags('');
|
||||
setNotice('Uploaded research file.');
|
||||
await invalidateResearch(ticker);
|
||||
await loadWorkspace(ticker);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to upload research file');
|
||||
}
|
||||
};
|
||||
|
||||
const ensureMemo = async () => {
|
||||
if (!ticker) {
|
||||
throw new Error('Ticker is required');
|
||||
}
|
||||
|
||||
if (workspace?.memo) {
|
||||
return workspace.memo.id;
|
||||
}
|
||||
|
||||
const response = await upsertResearchMemo({ ticker });
|
||||
await invalidateResearch(ticker);
|
||||
await loadWorkspace(ticker);
|
||||
return response.memo.id;
|
||||
};
|
||||
|
||||
const attachArtifact = async (artifact: ResearchArtifact) => {
|
||||
try {
|
||||
const memoId = await ensureMemo();
|
||||
await addResearchMemoEvidence({
|
||||
memoId,
|
||||
artifactId: artifact.id,
|
||||
section: attachSection
|
||||
});
|
||||
setNotice(`Attached evidence to ${MEMO_SECTIONS.find((item) => item.value === attachSection)?.label}.`);
|
||||
await invalidateResearch(ticker);
|
||||
await loadWorkspace(ticker);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to attach evidence');
|
||||
}
|
||||
};
|
||||
|
||||
const availableTags = workspace?.availableTags ?? [];
|
||||
const memoEvidenceCount = workspace?.packet.sections.reduce((sum, section) => sum + section.evidence.length, 0) ?? 0;
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
title="Research"
|
||||
subtitle="Build an investor-grade company dossier with evidence-linked memo sections, uploads, and a packet view."
|
||||
activeTicker={ticker || null}
|
||||
breadcrumbs={[
|
||||
{ label: 'Analysis', href: ticker ? `/analysis?ticker=${encodeURIComponent(ticker)}` : '/analysis' },
|
||||
{ label: 'Research' }
|
||||
]}
|
||||
actions={(
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
void invalidateResearch(ticker);
|
||||
void loadWorkspace(ticker);
|
||||
}}
|
||||
>
|
||||
<Sparkles className="size-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
{ticker ? (
|
||||
<Link
|
||||
href={`/analysis?ticker=${encodeURIComponent(ticker)}`}
|
||||
onMouseEnter={() => prefetchResearchTicker(ticker)}
|
||||
onFocus={() => 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
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{!ticker ? (
|
||||
<Panel title="Select a company" subtitle="Open this page with `?ticker=NVDA` or use the Coverage and Analysis surfaces to pivot into research.">
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">This workspace is company-first by design and activates once a ticker is selected.</p>
|
||||
</Panel>
|
||||
) : null}
|
||||
|
||||
{ticker && workspace ? (
|
||||
<>
|
||||
<Panel className="overflow-hidden border-[color:var(--line-strong)] bg-[linear-gradient(135deg,rgba(6,16,20,0.96),rgba(6,16,20,0.82)_45%,rgba(8,28,30,0.9))]">
|
||||
<div className="grid gap-5 lg:grid-cols-[1.6fr_1fr]">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.22em] text-[color:var(--accent)]">Buy-Side Research Workspace</p>
|
||||
<h2 className="mt-3 text-3xl font-semibold text-[color:var(--terminal-bright)]">{workspace.companyName ?? workspace.ticker}</h2>
|
||||
<p className="mt-2 text-sm text-[color:var(--terminal-muted)]">
|
||||
{workspace.coverage?.status ? `Coverage: ${workspace.coverage.status}` : 'Not yet on the coverage board'} ·
|
||||
{' '}Last filing: {workspace.latestFilingDate ? formatTimestamp(workspace.latestFilingDate).split(' · ')[0] : 'n/a'} ·
|
||||
{' '}Private by default
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{(workspace.coverage?.tags ?? []).map((tag) => (
|
||||
<span key={tag} className="rounded-full border border-[color:var(--line-weak)] bg-[rgba(255,255,255,0.02)] px-3 py-1 text-xs uppercase tracking-[0.14em] text-[color:var(--terminal-muted)]">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-1">
|
||||
<div className="rounded-2xl border border-[color:var(--line-weak)] bg-[rgba(255,255,255,0.03)] p-4">
|
||||
<p className="text-xs uppercase tracking-[0.14em] text-[color:var(--terminal-muted)]">Memo posture</p>
|
||||
<p className="mt-2 text-lg font-semibold text-[color:var(--terminal-bright)]">
|
||||
{workspace.memo?.rating ? workspace.memo.rating.replace('_', ' ') : 'Unrated'}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-[color:var(--terminal-muted)]">
|
||||
Conviction: {workspace.memo?.conviction ?? 'unset'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-[color:var(--line-weak)] bg-[rgba(255,255,255,0.03)] p-4">
|
||||
<p className="text-xs uppercase tracking-[0.14em] text-[color:var(--terminal-muted)]">Research depth</p>
|
||||
<p className="mt-2 text-lg font-semibold text-[color:var(--terminal-bright)]">{workspace.library.length} artifacts</p>
|
||||
<p className="mt-1 text-sm text-[color:var(--terminal-muted)]">{memoEvidenceCount} evidence links in the packet</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{notice ? (
|
||||
<Panel className="border-[color:var(--line-strong)] bg-[color:var(--panel-soft)]">
|
||||
<p className="text-sm text-[color:var(--accent)]">{notice}</p>
|
||||
</Panel>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<Panel>
|
||||
<p className="text-sm text-[#ffb5b5]">{error}</p>
|
||||
</Panel>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<Panel>
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">Loading research workspace...</p>
|
||||
</Panel>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-6 xl:grid-cols-[0.9fr_1.25fr_1.1fr]">
|
||||
<Panel
|
||||
title="Library Filters"
|
||||
subtitle="Narrow the evidence set by structure, ownership, and memo linkage."
|
||||
actions={<Filter className="size-4 text-[color:var(--accent)]" />}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Search</label>
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[color:var(--terminal-muted)]" />
|
||||
<Input aria-label="Research search" className="pl-9" value={searchInput} onChange={(event) => setSearchInput(event.target.value)} placeholder="Keyword search research..." />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Artifact Type</label>
|
||||
<select aria-label="Artifact type filter" className="w-full rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]" value={kindFilter} onChange={(event) => setKindFilter(event.target.value as '' | ResearchArtifactKind)}>
|
||||
{KIND_OPTIONS.map((option) => (
|
||||
<option key={option.label} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Source</label>
|
||||
<select aria-label="Artifact source filter" className="w-full rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]" value={sourceFilter} onChange={(event) => setSourceFilter(event.target.value as '' | ResearchArtifactSource)}>
|
||||
{SOURCE_OPTIONS.map((option) => (
|
||||
<option key={option.label} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Tag</label>
|
||||
<select aria-label="Artifact tag filter" className="w-full rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]" value={tagFilter} onChange={(event) => setTagFilter(event.target.value)}>
|
||||
<option value="">All tags</option>
|
||||
{availableTags.map((tag) => (
|
||||
<option key={tag} value={tag}>{tag}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]">
|
||||
<input type="checkbox" checked={linkedOnly} onChange={(event) => setLinkedOnly(event.target.checked)} />
|
||||
Show memo-linked evidence only
|
||||
</label>
|
||||
<div className="rounded-2xl border border-[color:var(--line-weak)] bg-[rgba(255,255,255,0.02)] p-4">
|
||||
<div className="flex items-center gap-2 text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">
|
||||
<ShieldCheck className="size-4 text-[color:var(--accent)]" />
|
||||
Access Model
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-[color:var(--terminal-muted)]">All research artifacts are private to the authenticated user in this release. The data model is prepared for workspace scopes later.</p>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Panel
|
||||
title={noteForm.id === null ? 'Quick Note' : 'Edit Note'}
|
||||
subtitle="Capture thesis changes, diligence notes, and interpretation gaps directly into the library."
|
||||
actions={<NotebookPen className="size-4 text-[color:var(--accent)]" />}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Input aria-label="Research note title" value={noteForm.title} onChange={(event) => setNoteForm((current) => ({ ...current, title: event.target.value }))} placeholder="Headline or checkpoint title" />
|
||||
<Input aria-label="Research note summary" value={noteForm.summary} onChange={(event) => setNoteForm((current) => ({ ...current, summary: event.target.value }))} placeholder="One-line summary for skimming and search" />
|
||||
<textarea
|
||||
aria-label="Research note body"
|
||||
value={noteForm.bodyMarkdown}
|
||||
onChange={(event) => setNoteForm((current) => ({ ...current, bodyMarkdown: event.target.value }))}
|
||||
placeholder="Write the actual research note, variant view, or diligence conclusion..."
|
||||
className="min-h-[160px] w-full rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)] outline-none transition placeholder:text-[color:var(--terminal-muted)] focus:border-[color:var(--line-strong)] focus:shadow-[0_0_0_3px_rgba(0,255,180,0.14)]"
|
||||
/>
|
||||
<Input aria-label="Research note tags" value={noteForm.tags} onChange={(event) => setNoteForm((current) => ({ ...current, tags: event.target.value }))} placeholder="Tags, comma-separated" />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button onClick={() => void saveNote()}>
|
||||
<FilePlus2 className="size-4" />
|
||||
{noteForm.id === null ? 'Save note' : 'Update note'}
|
||||
</Button>
|
||||
{noteForm.id !== null ? (
|
||||
<Button variant="ghost" onClick={() => setNoteForm(EMPTY_NOTE_FORM)}>
|
||||
Cancel edit
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel
|
||||
title="Research Library"
|
||||
subtitle={`${library.length} artifacts match the current filter set.`}
|
||||
actions={(
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Attach to</span>
|
||||
<select className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-2 py-1 text-xs text-[color:var(--terminal-bright)]" value={attachSection} onChange={(event) => setAttachSection(event.target.value as ResearchMemoSection)}>
|
||||
{MEMO_SECTIONS.map((section) => (
|
||||
<option key={section.value} value={section.value}>{section.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{library.length === 0 ? (
|
||||
<p className="text-sm text-[color:var(--terminal-muted)]">No artifacts match the current search and filter combination.</p>
|
||||
) : (
|
||||
library.map((artifact) => (
|
||||
<article key={artifact.id} className="rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">
|
||||
{artifact.kind.replace('_', ' ')} · {artifact.source} · {formatTimestamp(artifact.updated_at)}
|
||||
</p>
|
||||
<h4 className="mt-1 text-sm font-semibold text-[color:var(--terminal-bright)]">
|
||||
{artifact.title ?? `${artifact.kind.replace('_', ' ')} artifact`}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{artifact.kind === 'upload' && artifact.storage_path ? (
|
||||
<a className="inline-flex items-center gap-1 rounded-lg border border-[color:var(--line-weak)] px-2 py-1 text-xs text-[color:var(--accent)] transition hover:border-[color:var(--line-strong)]" href={getResearchArtifactFileUrl(artifact.id)}>
|
||||
<Download className="size-3" />
|
||||
File
|
||||
</a>
|
||||
) : null}
|
||||
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={() => void attachArtifact(artifact)}>
|
||||
<Link2 className="size-3" />
|
||||
Attach
|
||||
</Button>
|
||||
{artifact.kind === 'note' ? (
|
||||
<Button variant="ghost" className="px-2 py-1 text-xs" onClick={() => setNoteForm(noteFormFromArtifact(artifact))}>
|
||||
Edit
|
||||
</Button>
|
||||
) : null}
|
||||
{artifact.source === 'user' || artifact.kind === 'upload' ? (
|
||||
<Button
|
||||
variant="danger"
|
||||
className="px-2 py-1 text-xs"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await deleteResearchArtifact(artifact.id);
|
||||
setNotice('Removed artifact from the library.');
|
||||
await invalidateResearch(ticker);
|
||||
await loadWorkspace(ticker);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to delete artifact');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
Delete
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{artifact.summary ? (
|
||||
<p className="mt-3 text-sm leading-6 text-[color:var(--terminal-bright)]">{artifact.summary}</p>
|
||||
) : null}
|
||||
{artifact.body_markdown ? (
|
||||
<p className="mt-3 whitespace-pre-wrap text-sm leading-6 text-[color:var(--terminal-muted)]">{artifact.body_markdown}</p>
|
||||
) : null}
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{artifact.linked_to_memo ? (
|
||||
<span className="rounded-full border border-[color:var(--line-strong)] px-2 py-1 text-[10px] uppercase tracking-[0.14em] text-[color:var(--accent)]">In memo</span>
|
||||
) : null}
|
||||
{artifact.accession_number ? (
|
||||
<span className="rounded-full border border-[color:var(--line-weak)] px-2 py-1 text-[10px] uppercase tracking-[0.14em] text-[color:var(--terminal-muted)]">{artifact.accession_number}</span>
|
||||
) : null}
|
||||
{artifact.tags.map((tag) => (
|
||||
<button
|
||||
key={`${artifact.id}-${tag}`}
|
||||
type="button"
|
||||
className="rounded-full border border-[color:var(--line-weak)] px-2 py-1 text-[10px] uppercase tracking-[0.14em] text-[color:var(--terminal-muted)] transition hover:border-[color:var(--line-strong)]"
|
||||
onClick={() => setTagFilter(tag)}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel
|
||||
title="Upload Research"
|
||||
subtitle="Store decks, transcripts, channel-check notes, and internal models with metadata-first handling."
|
||||
actions={<FolderUp className="size-4 text-[color:var(--accent)]" />}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Input aria-label="Upload title" value={uploadTitle} onChange={(event) => setUploadTitle(event.target.value)} placeholder="Optional display title" />
|
||||
<Input aria-label="Upload summary" value={uploadSummary} onChange={(event) => setUploadSummary(event.target.value)} placeholder="Optional file summary" />
|
||||
<Input aria-label="Upload tags" value={uploadTags} onChange={(event) => setUploadTags(event.target.value)} placeholder="Tags, comma-separated" />
|
||||
<input
|
||||
aria-label="Upload file"
|
||||
type="file"
|
||||
onChange={(event) => setUploadFile(event.target.files?.[0] ?? null)}
|
||||
className="block w-full rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]"
|
||||
/>
|
||||
<Button variant="secondary" onClick={() => void uploadFileToLibrary()} disabled={!uploadFile}>
|
||||
<UploadIcon />
|
||||
Upload file
|
||||
</Button>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<Panel
|
||||
title="Investment Memo"
|
||||
subtitle="This is the living buy-side thesis. Use the library to attach evidence into sections before packet review."
|
||||
actions={<BookOpenText className="size-4 text-[color:var(--accent)]" />}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<select aria-label="Memo rating" className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]" value={memoForm.rating} onChange={(event) => setMemoForm((current) => ({ ...current, rating: event.target.value }))}>
|
||||
<option value="">Rating</option>
|
||||
<option value="strong_buy">Strong Buy</option>
|
||||
<option value="buy">Buy</option>
|
||||
<option value="hold">Hold</option>
|
||||
<option value="sell">Sell</option>
|
||||
</select>
|
||||
<select aria-label="Memo conviction" className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)]" value={memoForm.conviction} onChange={(event) => setMemoForm((current) => ({ ...current, conviction: event.target.value }))}>
|
||||
<option value="">Conviction</option>
|
||||
<option value="high">High</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="low">Low</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Input aria-label="Memo time horizon" value={memoForm.timeHorizonMonths} onChange={(event) => setMemoForm((current) => ({ ...current, timeHorizonMonths: event.target.value }))} placeholder="Time horizon in months" />
|
||||
<Input aria-label="Packet title" value={memoForm.packetTitle} onChange={(event) => setMemoForm((current) => ({ ...current, packetTitle: event.target.value }))} placeholder="Packet title override" />
|
||||
</div>
|
||||
<Input aria-label="Packet subtitle" value={memoForm.packetSubtitle} onChange={(event) => setMemoForm((current) => ({ ...current, packetSubtitle: event.target.value }))} placeholder="Packet subtitle" />
|
||||
{MEMO_SECTIONS.map((section) => {
|
||||
const fieldMap: Record<ResearchMemoSection, keyof MemoFormState> = {
|
||||
thesis: 'thesisMarkdown',
|
||||
variant_view: 'variantViewMarkdown',
|
||||
catalysts: 'catalystsMarkdown',
|
||||
risks: 'risksMarkdown',
|
||||
disconfirming_evidence: 'disconfirmingEvidenceMarkdown',
|
||||
next_actions: 'nextActionsMarkdown'
|
||||
};
|
||||
const field = fieldMap[section.value];
|
||||
|
||||
return (
|
||||
<div key={section.value}>
|
||||
<label className="mb-1 block text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">{section.label}</label>
|
||||
<textarea
|
||||
aria-label={`Memo ${section.label}`}
|
||||
value={memoForm[field]}
|
||||
onChange={(event) => setMemoForm((current) => ({ ...current, [field]: event.target.value }))}
|
||||
className="min-h-[108px] w-full rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm text-[color:var(--terminal-bright)] outline-none transition placeholder:text-[color:var(--terminal-muted)] focus:border-[color:var(--line-strong)] focus:shadow-[0_0_0_3px_rgba(0,255,180,0.14)]"
|
||||
placeholder={`Write ${section.label.toLowerCase()}...`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<Button onClick={() => void saveMemo()}>
|
||||
<NotebookPen className="size-4" />
|
||||
Save memo
|
||||
</Button>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<Panel
|
||||
title="Research Packet"
|
||||
subtitle="Presentation-ready memo sections with attached evidence for quick PM or IC review."
|
||||
actions={<Sparkles className="size-4 text-[color:var(--accent)]" />}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{workspace.packet.sections.map((section) => (
|
||||
<section key={section.section} className="rounded-2xl border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">Packet Section</p>
|
||||
<h3 className="mt-1 text-lg font-semibold text-[color:var(--terminal-bright)]">{section.title}</h3>
|
||||
</div>
|
||||
<p className="text-xs uppercase tracking-[0.16em] text-[color:var(--terminal-muted)]">{section.evidence.length} evidence items</p>
|
||||
</div>
|
||||
<p className="mt-4 whitespace-pre-wrap text-sm leading-7 text-[color:var(--terminal-bright)]">
|
||||
{section.body_markdown || 'No memo content yet for this section.'}
|
||||
</p>
|
||||
{section.evidence.length > 0 ? (
|
||||
<div className="mt-4 grid gap-3 lg:grid-cols-2">
|
||||
{section.evidence.map((item) => (
|
||||
<article key={item.id} className="rounded-xl border border-[color:var(--line-weak)] bg-[color:var(--panel)] p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.14em] text-[color:var(--terminal-muted)]">{item.artifact.kind.replace('_', ' ')}</p>
|
||||
<h4 className="mt-1 text-sm font-semibold text-[color:var(--terminal-bright)]">{item.artifact.title ?? 'Untitled evidence'}</h4>
|
||||
</div>
|
||||
{workspace.memo ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-2 py-1 text-xs"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await deleteResearchMemoEvidence(workspace.memo!.id, item.id);
|
||||
setNotice('Removed memo evidence.');
|
||||
await invalidateResearch(ticker);
|
||||
await loadWorkspace(ticker);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unable to remove memo evidence');
|
||||
}
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{item.annotation ? (
|
||||
<p className="mt-2 text-sm text-[color:var(--terminal-muted)]">{item.annotation}</p>
|
||||
) : null}
|
||||
<p className="mt-2 text-sm text-[color:var(--terminal-bright)]">{item.artifact.summary ?? item.artifact.body_markdown ?? 'No summary available.'}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function UploadIcon() {
|
||||
return <FolderUp className="size-4" />;
|
||||
}
|
||||
@@ -391,6 +391,14 @@ export default function WatchlistPage() {
|
||||
Analyze
|
||||
<ArrowRight className="size-3" />
|
||||
</Link>
|
||||
<Link
|
||||
href={`/research?ticker=${item.ticker}`}
|
||||
onMouseEnter={() => prefetchResearchTicker(item.ticker)}
|
||||
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)]"
|
||||
>
|
||||
Research
|
||||
</Link>
|
||||
<Link
|
||||
href={`/financials?ticker=${item.ticker}`}
|
||||
onMouseEnter={() => prefetchResearchTicker(item.ticker)}
|
||||
|
||||
47
bun.lock
47
bun.lock
@@ -12,6 +12,7 @@
|
||||
"@workflow/world-postgres": "^4.1.0-beta.34",
|
||||
"ai": "^6.0.104",
|
||||
"better-auth": "^1.4.19",
|
||||
"cheerio": "^1.1.2",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"drizzle-orm": "^0.41.0",
|
||||
@@ -692,6 +693,8 @@
|
||||
|
||||
"body-parser": ["body-parser@1.20.4", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.14.0", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA=="],
|
||||
|
||||
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
|
||||
|
||||
"bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="],
|
||||
|
||||
"boxen": ["boxen@8.0.1", "", { "dependencies": { "ansi-align": "^3.0.1", "camelcase": "^8.0.0", "chalk": "^5.3.0", "cli-boxes": "^3.0.0", "string-width": "^7.2.0", "type-fest": "^4.21.0", "widest-line": "^5.0.0", "wrap-ansi": "^9.0.0" } }, "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw=="],
|
||||
@@ -738,6 +741,10 @@
|
||||
|
||||
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
|
||||
"cheerio": ["cheerio@1.2.0", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "encoding-sniffer": "^0.2.1", "htmlparser2": "^10.1.0", "parse5": "^7.3.0", "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", "undici": "^7.19.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg=="],
|
||||
|
||||
"cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="],
|
||||
|
||||
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||
|
||||
"citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="],
|
||||
@@ -782,6 +789,10 @@
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="],
|
||||
|
||||
"css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
|
||||
@@ -840,6 +851,14 @@
|
||||
|
||||
"dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="],
|
||||
|
||||
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
|
||||
|
||||
"domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
|
||||
|
||||
"domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
|
||||
|
||||
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
|
||||
|
||||
"dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="],
|
||||
|
||||
"drizzle-kit": ["drizzle-kit@0.31.9", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg=="],
|
||||
@@ -862,8 +881,12 @@
|
||||
|
||||
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
||||
|
||||
"encoding-sniffer": ["encoding-sniffer@0.2.1", "", { "dependencies": { "iconv-lite": "^0.6.3", "whatwg-encoding": "^3.1.1" } }, "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="],
|
||||
|
||||
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
||||
|
||||
"environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="],
|
||||
@@ -1000,6 +1023,8 @@
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="],
|
||||
|
||||
"http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="],
|
||||
|
||||
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
|
||||
@@ -1008,7 +1033,7 @@
|
||||
|
||||
"human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
|
||||
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
|
||||
|
||||
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||
|
||||
@@ -1190,6 +1215,8 @@
|
||||
|
||||
"npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="],
|
||||
|
||||
"nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="],
|
||||
|
||||
"nypm": ["nypm@0.6.5", "", { "dependencies": { "citty": "^0.2.0", "pathe": "^2.0.3", "tinyexec": "^1.0.2" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ=="],
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
@@ -1218,6 +1245,12 @@
|
||||
|
||||
"parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="],
|
||||
|
||||
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
||||
|
||||
"parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@7.1.0", "", { "dependencies": { "domhandler": "^5.0.3", "parse5": "^7.0.0" } }, "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g=="],
|
||||
|
||||
"parse5-parser-stream": ["parse5-parser-stream@7.1.2", "", { "dependencies": { "parse5": "^7.0.0" } }, "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow=="],
|
||||
|
||||
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
||||
|
||||
"path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="],
|
||||
@@ -1498,6 +1531,10 @@
|
||||
|
||||
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
|
||||
|
||||
"whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="],
|
||||
|
||||
"whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
|
||||
|
||||
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
@@ -1792,6 +1829,8 @@
|
||||
|
||||
"body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
|
||||
"body-parser/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
|
||||
|
||||
"boxen/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
|
||||
|
||||
"boxen/widest-line": ["widest-line@5.0.0", "", { "dependencies": { "string-width": "^7.0.0" } }, "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA=="],
|
||||
@@ -1828,6 +1867,8 @@
|
||||
|
||||
"graphile-config/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
|
||||
|
||||
"lightningcss/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
@@ -1846,6 +1887,10 @@
|
||||
|
||||
"ora/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
|
||||
|
||||
"parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
||||
|
||||
"raw-body/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
|
||||
|
||||
"seek-bzip/commander": ["commander@6.2.1", "", {}, "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA=="],
|
||||
|
||||
"send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { Activity, BookOpenText, ChartCandlestick, Eye, Landmark, LineChart, LogOut, Menu } from 'lucide-react';
|
||||
import { Activity, BarChart3, BookOpenText, ChartCandlestick, Eye, Landmark, LineChart, LogOut, Menu, NotebookTabs } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
@@ -11,6 +11,7 @@ import { TaskDetailModal } from '@/components/notifications/task-detail-modal';
|
||||
import { TaskNotificationsTrigger } from '@/components/notifications/task-notifications-trigger';
|
||||
import {
|
||||
companyAnalysisQueryOptions,
|
||||
companyFinancialStatementsQueryOptions,
|
||||
filingsQueryOptions,
|
||||
holdingsQueryOptions,
|
||||
latestPortfolioInsightQueryOptions,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
recentTasksQueryOptions,
|
||||
watchlistQueryOptions
|
||||
} from '@/lib/query/options';
|
||||
import { buildGraphingHref } from '@/lib/graphing/catalog';
|
||||
import type { ActiveContext, NavGroup, NavItem } from '@/lib/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTaskNotificationsCenter } from '@/hooks/use-task-notifications-center';
|
||||
@@ -56,6 +58,26 @@ const NAV_ITEMS: NavConfigItem[] = [
|
||||
preserveTicker: true,
|
||||
mobilePrimary: true
|
||||
},
|
||||
{
|
||||
id: 'research',
|
||||
href: '/research',
|
||||
label: 'Research',
|
||||
icon: NotebookTabs,
|
||||
group: 'research',
|
||||
matchMode: 'exact',
|
||||
preserveTicker: true,
|
||||
mobilePrimary: true
|
||||
},
|
||||
{
|
||||
id: 'graphing',
|
||||
href: '/graphing',
|
||||
label: 'Graphing',
|
||||
icon: BarChart3,
|
||||
group: 'research',
|
||||
matchMode: 'exact',
|
||||
preserveTicker: true,
|
||||
mobilePrimary: false
|
||||
},
|
||||
{
|
||||
id: 'financials',
|
||||
href: '/financials',
|
||||
@@ -117,6 +139,10 @@ function toTickerHref(baseHref: string, activeTicker: string | null) {
|
||||
}
|
||||
|
||||
function resolveNavHref(item: NavItem, context: ActiveContext) {
|
||||
if (item.href === '/graphing') {
|
||||
return buildGraphingHref(context.activeTicker);
|
||||
}
|
||||
|
||||
if (!item.preserveTicker) {
|
||||
return item.href;
|
||||
}
|
||||
@@ -134,6 +160,8 @@ function isItemActive(item: NavItem, pathname: string) {
|
||||
|
||||
function buildDefaultBreadcrumbs(pathname: string, activeTicker: string | null) {
|
||||
const analysisHref = toTickerHref('/analysis', activeTicker);
|
||||
const researchHref = toTickerHref('/research', activeTicker);
|
||||
const graphingHref = buildGraphingHref(activeTicker);
|
||||
const financialsHref = toTickerHref('/financials', activeTicker);
|
||||
const filingsHref = toTickerHref('/filings', activeTicker);
|
||||
|
||||
@@ -153,6 +181,13 @@ function buildDefaultBreadcrumbs(pathname: string, activeTicker: string | null)
|
||||
return [{ label: 'Analysis' }];
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/research')) {
|
||||
return [
|
||||
{ label: 'Analysis', href: analysisHref },
|
||||
{ label: 'Research', href: researchHref }
|
||||
];
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/financials')) {
|
||||
return [
|
||||
{ label: 'Analysis', href: analysisHref },
|
||||
@@ -160,6 +195,14 @@ function buildDefaultBreadcrumbs(pathname: string, activeTicker: string | null)
|
||||
];
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/graphing')) {
|
||||
return [
|
||||
{ label: 'Analysis', href: analysisHref },
|
||||
{ label: 'Graphing', href: graphingHref },
|
||||
{ label: activeTicker ?? 'Compare Set' }
|
||||
];
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/filings')) {
|
||||
return [
|
||||
{ label: 'Analysis', href: analysisHref },
|
||||
@@ -281,6 +324,20 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
|
||||
return;
|
||||
}
|
||||
|
||||
if (href.startsWith('/graphing')) {
|
||||
if (context.activeTicker) {
|
||||
void queryClient.prefetchQuery(companyFinancialStatementsQueryOptions({
|
||||
ticker: context.activeTicker,
|
||||
surfaceKind: 'income_statement',
|
||||
cadence: 'annual',
|
||||
includeDimensions: false,
|
||||
includeFacts: false,
|
||||
limit: 16
|
||||
}));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (href.startsWith('/filings')) {
|
||||
void queryClient.prefetchQuery(filingsQueryOptions({
|
||||
ticker: context.activeTicker ?? undefined,
|
||||
@@ -317,7 +374,7 @@ export function AppShell({ title, subtitle, actions, activeTicker, breadcrumbs,
|
||||
};
|
||||
|
||||
const runPrefetch = () => {
|
||||
const prioritized = navEntries.filter((entry) => entry.id === 'analysis' || entry.id === 'filings' || entry.id === 'portfolio');
|
||||
const prioritized = navEntries.filter((entry) => entry.id === 'analysis' || entry.id === 'graphing' || entry.id === 'filings' || entry.id === 'portfolio');
|
||||
for (const entry of prioritized) {
|
||||
prefetchForHref(entry.href);
|
||||
}
|
||||
|
||||
14
drizzle/0007_company_financial_bundles.sql
Normal file
14
drizzle/0007_company_financial_bundles.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE `company_financial_bundle` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`ticker` text NOT NULL,
|
||||
`surface_kind` text NOT NULL,
|
||||
`cadence` text NOT NULL,
|
||||
`bundle_version` integer NOT NULL,
|
||||
`source_snapshot_ids` text NOT NULL,
|
||||
`source_signature` text NOT NULL,
|
||||
`payload` text NOT NULL,
|
||||
`created_at` text NOT NULL,
|
||||
`updated_at` text NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX `company_financial_bundle_uidx` ON `company_financial_bundle` (`ticker`,`surface_kind`,`cadence`);
|
||||
CREATE INDEX `company_financial_bundle_ticker_idx` ON `company_financial_bundle` (`ticker`,`updated_at`);
|
||||
91
drizzle/0008_research_workspace.sql
Normal file
91
drizzle/0008_research_workspace.sql
Normal file
@@ -0,0 +1,91 @@
|
||||
CREATE TABLE `research_artifact` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`organization_id` text,
|
||||
`ticker` text NOT NULL,
|
||||
`accession_number` text,
|
||||
`kind` text NOT NULL,
|
||||
`source` text NOT NULL DEFAULT 'user',
|
||||
`subtype` text,
|
||||
`title` text,
|
||||
`summary` text,
|
||||
`body_markdown` text,
|
||||
`search_text` text,
|
||||
`visibility_scope` text NOT NULL DEFAULT 'private',
|
||||
`tags` text,
|
||||
`metadata` text,
|
||||
`file_name` text,
|
||||
`mime_type` text,
|
||||
`file_size_bytes` integer,
|
||||
`storage_path` text,
|
||||
`created_at` text NOT NULL,
|
||||
`updated_at` text NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE set null
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `research_artifact_ticker_idx` ON `research_artifact` (`user_id`,`ticker`,`updated_at`);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `research_artifact_kind_idx` ON `research_artifact` (`user_id`,`kind`,`updated_at`);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `research_artifact_accession_idx` ON `research_artifact` (`user_id`,`accession_number`);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `research_artifact_source_idx` ON `research_artifact` (`user_id`,`source`,`updated_at`);
|
||||
--> statement-breakpoint
|
||||
|
||||
CREATE TABLE `research_memo` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`organization_id` text,
|
||||
`ticker` text NOT NULL,
|
||||
`rating` text,
|
||||
`conviction` text,
|
||||
`time_horizon_months` integer,
|
||||
`packet_title` text,
|
||||
`packet_subtitle` text,
|
||||
`thesis_markdown` text NOT NULL DEFAULT '',
|
||||
`variant_view_markdown` text NOT NULL DEFAULT '',
|
||||
`catalysts_markdown` text NOT NULL DEFAULT '',
|
||||
`risks_markdown` text NOT NULL DEFAULT '',
|
||||
`disconfirming_evidence_markdown` text NOT NULL DEFAULT '',
|
||||
`next_actions_markdown` text NOT NULL DEFAULT '',
|
||||
`created_at` text NOT NULL,
|
||||
`updated_at` text NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`organization_id`) REFERENCES `organization`(`id`) ON UPDATE no action ON DELETE set null
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `research_memo_ticker_uidx` ON `research_memo` (`user_id`,`ticker`);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `research_memo_updated_idx` ON `research_memo` (`user_id`,`updated_at`);
|
||||
--> statement-breakpoint
|
||||
|
||||
CREATE TABLE `research_memo_evidence` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`memo_id` integer NOT NULL,
|
||||
`artifact_id` integer NOT NULL,
|
||||
`section` text NOT NULL,
|
||||
`annotation` text,
|
||||
`sort_order` integer NOT NULL DEFAULT 0,
|
||||
`created_at` text NOT NULL,
|
||||
FOREIGN KEY (`memo_id`) REFERENCES `research_memo`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`artifact_id`) REFERENCES `research_artifact`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `research_memo_evidence_memo_idx` ON `research_memo_evidence` (`memo_id`,`section`,`sort_order`);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `research_memo_evidence_artifact_idx` ON `research_memo_evidence` (`artifact_id`);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `research_memo_evidence_unique_uidx` ON `research_memo_evidence` (`memo_id`,`artifact_id`,`section`);
|
||||
--> statement-breakpoint
|
||||
|
||||
CREATE VIRTUAL TABLE `research_artifact_fts` USING fts5(
|
||||
artifact_id UNINDEXED,
|
||||
user_id UNINDEXED,
|
||||
ticker UNINDEXED,
|
||||
title,
|
||||
summary,
|
||||
body_markdown,
|
||||
search_text,
|
||||
tags_text
|
||||
);
|
||||
@@ -50,6 +50,20 @@
|
||||
"when": 1772830800000,
|
||||
"tag": "0006_coverage_journal_tracking",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "6",
|
||||
"when": 1772863200000,
|
||||
"tag": "0007_company_financial_bundles",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "6",
|
||||
"when": 1772906400000,
|
||||
"tag": "0008_research_workspace",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
100
e2e/auth.spec.ts
100
e2e/auth.spec.ts
@@ -1,17 +1,51 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
const PASSWORD = 'Sup3rSecure!123';
|
||||
|
||||
test('redirects protected routes to sign in and preserves the return path', async ({ page }) => {
|
||||
await page.goto('/analysis?ticker=nvda');
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
function createDeferred() {
|
||||
let resolve: (() => void) | null = null;
|
||||
const promise = new Promise<void>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
|
||||
return {
|
||||
promise,
|
||||
resolve: () => resolve?.()
|
||||
};
|
||||
}
|
||||
|
||||
async function gotoAuthPage(page: Page, path: string) {
|
||||
await page.goto(path, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
test('preserves the return path while switching between auth screens and shows the expected controls', async ({ page }) => {
|
||||
await gotoAuthPage(page, '/auth/signin?next=%2Fanalysis%3Fticker%3DNVDA');
|
||||
|
||||
await expect(page).toHaveURL(/\/auth\/signin\?/);
|
||||
await expect(page.getByRole('heading', { name: 'Secure Sign In' })).toBeVisible();
|
||||
expect(new URL(page.url()).searchParams.get('next')).toBe('/analysis?ticker=nvda');
|
||||
await expect(page.getByText('Use email/password or request a magic link.')).toBeVisible();
|
||||
await expect(page.locator('input[autocomplete="email"]')).toBeVisible();
|
||||
await expect(page.locator('input[autocomplete="current-password"]')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Sign in with password' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Send magic link' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Create one' })).toHaveAttribute('href', '/auth/signup?next=%2Fanalysis%3Fticker%3DNVDA');
|
||||
|
||||
await page.getByRole('link', { name: 'Create one' }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/auth\/signup\?next=%2Fanalysis%3Fticker%3DNVDA$/);
|
||||
await expect(page.getByRole('heading', { name: 'Create Account' })).toBeVisible();
|
||||
await expect(page.getByText('Set up your operator profile to access portfolio and filings intelligence.')).toBeVisible();
|
||||
await expect(page.locator('input[autocomplete="name"]')).toBeVisible();
|
||||
await expect(page.locator('input[autocomplete="email"]')).toBeVisible();
|
||||
await expect(page.locator('input[autocomplete="new-password"]').first()).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Create account' })).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Sign in' })).toHaveAttribute('href', '/auth/signin?next=%2Fanalysis%3Fticker%3DNVDA');
|
||||
});
|
||||
|
||||
test('shows client-side validation when signup passwords do not match', async ({ page }) => {
|
||||
await page.goto('/auth/signup');
|
||||
await gotoAuthPage(page, '/auth/signup');
|
||||
|
||||
await page.locator('input[autocomplete="name"]').fill('Playwright User');
|
||||
await page.locator('input[autocomplete="email"]').fill('mismatch@example.com');
|
||||
@@ -22,17 +56,57 @@ test('shows client-side validation when signup passwords do not match', async ({
|
||||
await expect(page.getByText('Passwords do not match.')).toBeVisible();
|
||||
});
|
||||
|
||||
test('creates a new account and lands on the command center', async ({ page }) => {
|
||||
const email = `playwright-${Date.now()}@example.com`;
|
||||
test('shows loading affordances while sign-in is in flight', async ({ page }) => {
|
||||
const gate = createDeferred();
|
||||
|
||||
await page.goto('/auth/signup');
|
||||
await page.route('**/api/auth/sign-in/email', async (route) => {
|
||||
await gate.promise;
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ message: 'Invalid credentials' })
|
||||
});
|
||||
});
|
||||
|
||||
await gotoAuthPage(page, '/auth/signin');
|
||||
await page.locator('input[autocomplete="email"]').fill('playwright@example.com');
|
||||
await page.locator('input[autocomplete="current-password"]').fill(PASSWORD);
|
||||
|
||||
const submitButton = page.getByRole('button', { name: 'Sign in with password' });
|
||||
const magicLinkButton = page.getByRole('button', { name: 'Send magic link' });
|
||||
|
||||
await submitButton.click();
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Signing in...' })).toBeDisabled();
|
||||
await expect(magicLinkButton).toBeDisabled();
|
||||
|
||||
gate.resolve();
|
||||
|
||||
await expect(page.getByText('Invalid credentials')).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows loading affordances while sign-up is in flight', async ({ page }) => {
|
||||
const gate = createDeferred();
|
||||
|
||||
await page.route('**/api/auth/sign-up/email', async (route) => {
|
||||
await gate.promise;
|
||||
await route.fulfill({
|
||||
status: 409,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ message: 'Email already exists' })
|
||||
});
|
||||
});
|
||||
|
||||
await gotoAuthPage(page, '/auth/signup');
|
||||
await page.locator('input[autocomplete="name"]').fill('Playwright User');
|
||||
await page.locator('input[autocomplete="email"]').fill(email);
|
||||
await page.locator('input[autocomplete="email"]').fill(`playwright-${Date.now()}@example.com`);
|
||||
await page.locator('input[autocomplete="new-password"]').first().fill(PASSWORD);
|
||||
await page.locator('input[autocomplete="new-password"]').nth(1).fill(PASSWORD);
|
||||
await page.getByRole('button', { name: 'Create account' }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
await expect(page.getByRole('heading', { name: 'Command Center' })).toBeVisible();
|
||||
await expect(page.getByText('Quick Links')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Creating account...' })).toBeDisabled();
|
||||
|
||||
gate.resolve();
|
||||
|
||||
await expect(page.getByText('Email already exists')).toBeVisible();
|
||||
});
|
||||
|
||||
4
e2e/fixtures/sample-research.txt
Normal file
4
e2e/fixtures/sample-research.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
Supply-chain diligence notes:
|
||||
- Channel checks remain constructive.
|
||||
- Gross margin watchpoint is still mix-driven.
|
||||
- Need follow-up on inventory normalization pace.
|
||||
255
e2e/graphing.spec.ts
Normal file
255
e2e/graphing.spec.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import { expect, test, type Page, type TestInfo } from '@playwright/test';
|
||||
|
||||
const PASSWORD = 'Sup3rSecure!123';
|
||||
|
||||
function toSlug(value: string) {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 48);
|
||||
}
|
||||
|
||||
async function signUp(page: Page, testInfo: TestInfo) {
|
||||
const email = `playwright-graphing-${testInfo.workerIndex}-${toSlug(testInfo.title)}-${Date.now()}@example.com`;
|
||||
|
||||
await page.goto('/auth/signup');
|
||||
await page.locator('input[autocomplete="name"]').fill('Playwright Graphing User');
|
||||
await page.locator('input[autocomplete="email"]').fill(email);
|
||||
await page.locator('input[autocomplete="new-password"]').first().fill(PASSWORD);
|
||||
await page.locator('input[autocomplete="new-password"]').nth(1).fill(PASSWORD);
|
||||
await page.getByRole('button', { name: 'Create account' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Command Center' })).toBeVisible({ timeout: 30_000 });
|
||||
await expect(page).toHaveURL(/\/$/, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
function createFinancialsPayload(input: {
|
||||
ticker: string;
|
||||
companyName: string;
|
||||
cadence: 'annual' | 'quarterly' | 'ltm';
|
||||
surface: string;
|
||||
}) {
|
||||
return {
|
||||
financials: {
|
||||
company: {
|
||||
ticker: input.ticker,
|
||||
companyName: input.companyName,
|
||||
cik: null
|
||||
},
|
||||
surfaceKind: input.surface,
|
||||
cadence: input.cadence,
|
||||
displayModes: ['standardized'],
|
||||
defaultDisplayMode: 'standardized',
|
||||
periods: [
|
||||
{
|
||||
id: `${input.ticker}-p1`,
|
||||
filingId: 1,
|
||||
accessionNumber: `0000-${input.ticker}-1`,
|
||||
filingDate: '2025-02-01',
|
||||
periodStart: '2024-01-01',
|
||||
periodEnd: '2024-12-31',
|
||||
filingType: '10-K',
|
||||
periodLabel: 'FY 2024'
|
||||
},
|
||||
{
|
||||
id: `${input.ticker}-p2`,
|
||||
filingId: 2,
|
||||
accessionNumber: `0000-${input.ticker}-2`,
|
||||
filingDate: '2026-02-01',
|
||||
periodStart: '2025-01-01',
|
||||
periodEnd: '2025-12-31',
|
||||
filingType: '10-K',
|
||||
periodLabel: 'FY 2025'
|
||||
}
|
||||
],
|
||||
statementRows: {
|
||||
faithful: [],
|
||||
standardized: [
|
||||
{
|
||||
key: 'revenue',
|
||||
label: 'Revenue',
|
||||
category: 'revenue',
|
||||
order: 10,
|
||||
unit: 'currency',
|
||||
values: {
|
||||
[`${input.ticker}-p1`]: input.ticker === 'AAPL' ? 320 : 280,
|
||||
[`${input.ticker}-p2`]: input.ticker === 'AAPL' ? 360 : 330
|
||||
},
|
||||
sourceConcepts: ['revenue'],
|
||||
sourceRowKeys: ['revenue'],
|
||||
sourceFactIds: [1],
|
||||
formulaKey: null,
|
||||
hasDimensions: false,
|
||||
resolvedSourceRowKeys: {
|
||||
[`${input.ticker}-p1`]: 'revenue',
|
||||
[`${input.ticker}-p2`]: 'revenue'
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'total_assets',
|
||||
label: 'Total Assets',
|
||||
category: 'asset',
|
||||
order: 20,
|
||||
unit: 'currency',
|
||||
values: {
|
||||
[`${input.ticker}-p1`]: input.ticker === 'AAPL' ? 410 : 380,
|
||||
[`${input.ticker}-p2`]: input.ticker === 'AAPL' ? 450 : 420
|
||||
},
|
||||
sourceConcepts: ['total_assets'],
|
||||
sourceRowKeys: ['total_assets'],
|
||||
sourceFactIds: [2],
|
||||
formulaKey: null,
|
||||
hasDimensions: false,
|
||||
resolvedSourceRowKeys: {
|
||||
[`${input.ticker}-p1`]: 'total_assets',
|
||||
[`${input.ticker}-p2`]: 'total_assets'
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'free_cash_flow',
|
||||
label: 'Free Cash Flow',
|
||||
category: 'cash_flow',
|
||||
order: 30,
|
||||
unit: 'currency',
|
||||
values: {
|
||||
[`${input.ticker}-p1`]: input.ticker === 'AAPL' ? 95 : 80,
|
||||
[`${input.ticker}-p2`]: input.ticker === 'AAPL' ? 105 : 92
|
||||
},
|
||||
sourceConcepts: ['free_cash_flow'],
|
||||
sourceRowKeys: ['free_cash_flow'],
|
||||
sourceFactIds: [3],
|
||||
formulaKey: null,
|
||||
hasDimensions: false,
|
||||
resolvedSourceRowKeys: {
|
||||
[`${input.ticker}-p1`]: 'free_cash_flow',
|
||||
[`${input.ticker}-p2`]: 'free_cash_flow'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
ratioRows: [
|
||||
{
|
||||
key: 'gross_margin',
|
||||
label: 'Gross Margin',
|
||||
category: 'margins',
|
||||
order: 10,
|
||||
unit: 'percent',
|
||||
values: {
|
||||
[`${input.ticker}-p1`]: input.ticker === 'AAPL' ? 0.43 : 0.39,
|
||||
[`${input.ticker}-p2`]: input.ticker === 'AAPL' ? 0.45 : 0.41
|
||||
},
|
||||
sourceConcepts: ['gross_margin'],
|
||||
sourceRowKeys: ['gross_margin'],
|
||||
sourceFactIds: [4],
|
||||
formulaKey: 'gross_margin',
|
||||
hasDimensions: false,
|
||||
resolvedSourceRowKeys: {
|
||||
[`${input.ticker}-p1`]: null,
|
||||
[`${input.ticker}-p2`]: null
|
||||
},
|
||||
denominatorKey: 'revenue'
|
||||
}
|
||||
],
|
||||
kpiRows: null,
|
||||
trendSeries: [],
|
||||
categories: [],
|
||||
availability: {
|
||||
adjusted: false,
|
||||
customMetrics: false
|
||||
},
|
||||
nextCursor: null,
|
||||
facts: null,
|
||||
coverage: {
|
||||
filings: 2,
|
||||
rows: 3,
|
||||
dimensions: 0,
|
||||
facts: 0
|
||||
},
|
||||
dataSourceStatus: {
|
||||
enabled: true,
|
||||
hydratedFilings: 2,
|
||||
partialFilings: 0,
|
||||
failedFilings: 0,
|
||||
pendingFilings: 0,
|
||||
queuedSync: false
|
||||
},
|
||||
metrics: {
|
||||
taxonomy: null,
|
||||
validation: null
|
||||
},
|
||||
dimensionBreakdown: null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function mockGraphingFinancials(page: Page) {
|
||||
await page.route('**/api/financials/company**', async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const ticker = url.searchParams.get('ticker') ?? 'MSFT';
|
||||
const cadence = (url.searchParams.get('cadence') ?? 'annual') as 'annual' | 'quarterly' | 'ltm';
|
||||
const surface = url.searchParams.get('surface') ?? 'income_statement';
|
||||
|
||||
if (ticker === 'BAD') {
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'Ticker not found' })
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const companyName = ticker === 'AAPL'
|
||||
? 'Apple Inc.'
|
||||
: ticker === 'NVDA'
|
||||
? 'NVIDIA Corporation'
|
||||
: ticker === 'AMD'
|
||||
? 'Advanced Micro Devices, Inc.'
|
||||
: 'Microsoft Corporation';
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(createFinancialsPayload({
|
||||
ticker,
|
||||
companyName,
|
||||
cadence,
|
||||
surface
|
||||
}))
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('supports graphing compare controls and partial failures', async ({ page }, testInfo) => {
|
||||
await signUp(page, testInfo);
|
||||
await mockGraphingFinancials(page);
|
||||
|
||||
await page.goto('/graphing');
|
||||
|
||||
await expect(page).toHaveURL(/tickers=MSFT%2CAAPL%2CNVDA/);
|
||||
await expect(page.getByRole('heading', { name: 'Graphing' })).toBeVisible();
|
||||
await expect(page.getByText('Microsoft Corporation')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Graph surface Balance Sheet' }).click();
|
||||
await expect(page).toHaveURL(/surface=balance_sheet/);
|
||||
await expect(page).toHaveURL(/metric=total_assets/);
|
||||
|
||||
await page.getByRole('button', { name: 'Graph cadence Quarterly' }).click();
|
||||
await expect(page).toHaveURL(/cadence=quarterly/);
|
||||
|
||||
await page.getByRole('button', { name: 'Chart type Bar' }).click();
|
||||
await expect(page).toHaveURL(/chart=bar/);
|
||||
|
||||
await page.getByRole('button', { name: 'Remove AAPL' }).click();
|
||||
await expect(page).not.toHaveURL(/AAPL/);
|
||||
|
||||
await page.getByLabel('Compare tickers').fill('MSFT, NVDA, AMD');
|
||||
await page.getByRole('button', { name: 'Update Compare Set' }).click();
|
||||
await expect(page).toHaveURL(/tickers=MSFT%2CNVDA%2CAMD/);
|
||||
await expect(page.getByText('Advanced Micro Devices, Inc.')).toBeVisible();
|
||||
|
||||
await page.goto('/graphing?tickers=MSFT,BAD&surface=income_statement&metric=revenue&cadence=annual&chart=line&scale=millions');
|
||||
await expect(page.getByText('Partial coverage detected.')).toBeVisible();
|
||||
await expect(page.getByRole('cell', { name: /BAD/ })).toBeVisible();
|
||||
await expect(page.getByText('Ticker not found')).toBeVisible();
|
||||
await expect(page.getByText('Microsoft Corporation')).toBeVisible();
|
||||
});
|
||||
@@ -17,14 +17,59 @@ function toSlug(value: string) {
|
||||
|
||||
async function signUp(page: Page, testInfo: TestInfo) {
|
||||
const email = `playwright-research-${testInfo.workerIndex}-${toSlug(testInfo.title)}-${Date.now()}@example.com`;
|
||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://127.0.0.1:3400';
|
||||
const output = execFileSync('bun', [
|
||||
'-e',
|
||||
`
|
||||
const [email, password] = process.argv.slice(1);
|
||||
const { auth } = await import('./lib/auth');
|
||||
const response = await auth.api.signUpEmail({
|
||||
body: {
|
||||
name: 'Playwright Research User',
|
||||
email,
|
||||
password,
|
||||
callbackURL: '/'
|
||||
},
|
||||
asResponse: true
|
||||
});
|
||||
|
||||
await page.goto('/auth/signup');
|
||||
await page.locator('input[autocomplete="name"]').fill('Playwright Research User');
|
||||
await page.locator('input[autocomplete="email"]').fill(email);
|
||||
await page.locator('input[autocomplete="new-password"]').first().fill(PASSWORD);
|
||||
await page.locator('input[autocomplete="new-password"]').nth(1).fill(PASSWORD);
|
||||
await page.getByRole('button', { name: 'Create account' }).click();
|
||||
console.log(JSON.stringify({
|
||||
status: response.status,
|
||||
sessionCookie: response.headers.get('set-cookie')
|
||||
}));
|
||||
`,
|
||||
email,
|
||||
PASSWORD
|
||||
], {
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
DATABASE_URL: `file:${E2E_DATABASE_PATH}`,
|
||||
BETTER_AUTH_BASE_URL: baseURL,
|
||||
BETTER_AUTH_SECRET: 'playwright-e2e-secret-playwright-e2e-secret',
|
||||
BETTER_AUTH_TRUSTED_ORIGINS: baseURL
|
||||
},
|
||||
encoding: 'utf8'
|
||||
});
|
||||
|
||||
const { status, sessionCookie } = JSON.parse(output) as { status: number; sessionCookie: string | null };
|
||||
expect(status).toBe(200);
|
||||
expect(sessionCookie).toBeTruthy();
|
||||
|
||||
const [cookieNameValue] = sessionCookie!.split(';');
|
||||
const separatorIndex = cookieNameValue!.indexOf('=');
|
||||
const cookieName = cookieNameValue!.slice(0, separatorIndex);
|
||||
const cookieValue = cookieNameValue!.slice(separatorIndex + 1);
|
||||
|
||||
await page.context().addCookies([{
|
||||
name: cookieName,
|
||||
value: cookieValue,
|
||||
url: baseURL,
|
||||
httpOnly: true,
|
||||
sameSite: 'Lax'
|
||||
}]);
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
return email;
|
||||
}
|
||||
@@ -109,7 +154,9 @@ finally:
|
||||
}
|
||||
|
||||
test('supports the core coverage-to-research workflow', async ({ page }, testInfo) => {
|
||||
test.slow();
|
||||
const accessionNumber = `0001045810-26-${String(Date.now()).slice(-6)}`;
|
||||
const uploadFixture = join(process.cwd(), 'e2e', 'fixtures', 'sample-research.txt');
|
||||
await signUp(page, testInfo);
|
||||
|
||||
seedFiling({
|
||||
@@ -139,30 +186,61 @@ test('supports the core coverage-to-research workflow', async ({ page }, testInf
|
||||
await expect(page).toHaveURL(/\/analysis\?ticker=NVDA/);
|
||||
await expect(page.getByText('Coverage Workflow')).toBeVisible();
|
||||
|
||||
await page.getByLabel('Journal title').fill('Own-the-stack moat check');
|
||||
await page.getByLabel('Journal body').fill('Monitor hyperscaler concentration, gross margin durability, and Blackwell shipment cadence.');
|
||||
await page.getByRole('link', { name: 'Open research' }).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');
|
||||
await page.getByLabel('Research note body').fill('Monitor hyperscaler concentration, gross margin durability, and Blackwell shipment cadence.');
|
||||
await page.getByLabel('Research note tags').fill('moat, thesis');
|
||||
await page.getByRole('button', { name: 'Save note' }).click();
|
||||
await expect(page.getByText('Own-the-stack moat check')).toBeVisible();
|
||||
await expect(page.getByText('Saved note to the research library.')).toBeVisible();
|
||||
|
||||
await page.getByLabel('Upload title').fill('Supply-chain diligence');
|
||||
await page.getByLabel('Upload summary').fill('Vendor and channel-check notes');
|
||||
await page.getByLabel('Upload tags').fill('diligence, channel-check');
|
||||
await page.getByLabel('Upload file').setInputFiles(uploadFixture);
|
||||
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 expect(page).toHaveURL(/\/analysis\/reports\/NVDA\//);
|
||||
await page.getByRole('button', { name: 'Add to journal' }).click();
|
||||
await expect(page.getByText('Saved to the company research journal.')).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Save to library' }).click();
|
||||
await expect(page.getByText('Saved to the company research library.')).toBeVisible();
|
||||
|
||||
await page.getByRole('link', { name: 'Back to analysis' }).click();
|
||||
await page.goto('/research?ticker=NVDA');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(page).toHaveURL(/\/research\?ticker=NVDA/);
|
||||
await expect(page.getByRole('heading', { name: '10-K AI memo' }).first()).toBeVisible();
|
||||
|
||||
await page.getByLabel('Memo rating').selectOption('buy');
|
||||
await page.getByLabel('Memo conviction').selectOption('high');
|
||||
await page.getByLabel('Memo time horizon').fill('24');
|
||||
await page.getByLabel('Packet title').fill('NVIDIA buy-side packet');
|
||||
await page.getByLabel('Packet subtitle').fill('AI infrastructure compounder');
|
||||
await page.getByLabel('Memo Thesis').fill('Maintain a constructive stance as datacenter demand and platform depth widen the moat.');
|
||||
await page.getByLabel('Memo Catalysts').fill('Blackwell ramp, enterprise inference demand, and sustained operating leverage.');
|
||||
await page.getByLabel('Memo Risks').fill('Customer concentration, competition, and execution on supply.');
|
||||
await page.getByRole('button', { name: 'Save memo' }).click();
|
||||
await expect(page.getByText('Saved investment memo.')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Attach' }).first().click();
|
||||
await expect(page.getByText('Attached evidence to Thesis.')).toBeVisible();
|
||||
|
||||
await page.goto('/analysis?ticker=NVDA');
|
||||
await expect(page).toHaveURL(/\/analysis\?ticker=NVDA/);
|
||||
await expect(page.getByText('10-K AI memo')).toBeVisible();
|
||||
|
||||
await page.getByRole('link', { name: 'Open financials' }).click();
|
||||
await page.goto('/financials?ticker=NVDA');
|
||||
await expect(page).toHaveURL(/\/financials\?ticker=NVDA/);
|
||||
|
||||
await page.getByRole('link', { name: 'Filings' }).first().click();
|
||||
await page.goto('/filings?ticker=NVDA');
|
||||
await expect(page).toHaveURL(/\/filings\?ticker=NVDA/);
|
||||
await expect(page.getByRole('cell', { name: 'NVIDIA Corporation' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /journal/i }).first()).toBeVisible();
|
||||
await expect(page.getByRole('link', { name: 'Summary' }).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('supports add, edit, and delete holding flows with summary refresh', async ({ page }, testInfo) => {
|
||||
test.slow();
|
||||
await signUp(page, testInfo);
|
||||
|
||||
await page.goto('/portfolio');
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useCallback } from 'react';
|
||||
import { buildGraphingHref } from '@/lib/graphing/catalog';
|
||||
import {
|
||||
aiReportQueryOptions,
|
||||
companyAnalysisQueryOptions,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
latestPortfolioInsightQueryOptions,
|
||||
portfolioSummaryQueryOptions,
|
||||
recentTasksQueryOptions,
|
||||
researchWorkspaceQueryOptions,
|
||||
watchlistQueryOptions
|
||||
} from '@/lib/query/options';
|
||||
|
||||
@@ -30,18 +32,23 @@ export function useLinkPrefetch() {
|
||||
}
|
||||
|
||||
const analysisHref = `/analysis?ticker=${encodeURIComponent(normalizedTicker)}`;
|
||||
const researchHref = `/research?ticker=${encodeURIComponent(normalizedTicker)}`;
|
||||
const filingsHref = `/filings?ticker=${encodeURIComponent(normalizedTicker)}`;
|
||||
const financialsHref = `/financials?ticker=${encodeURIComponent(normalizedTicker)}`;
|
||||
const graphingHref = buildGraphingHref(normalizedTicker);
|
||||
|
||||
router.prefetch(analysisHref);
|
||||
router.prefetch(researchHref);
|
||||
router.prefetch(filingsHref);
|
||||
router.prefetch(financialsHref);
|
||||
router.prefetch(graphingHref);
|
||||
|
||||
void queryClient.prefetchQuery(companyAnalysisQueryOptions(normalizedTicker));
|
||||
void queryClient.prefetchQuery(researchWorkspaceQueryOptions(normalizedTicker));
|
||||
void queryClient.prefetchQuery(companyFinancialStatementsQueryOptions({
|
||||
ticker: normalizedTicker,
|
||||
statement: 'income',
|
||||
window: '10y',
|
||||
surfaceKind: 'income_statement',
|
||||
cadence: 'annual',
|
||||
includeDimensions: false
|
||||
}));
|
||||
void queryClient.prefetchQuery(filingsQueryOptions({ ticker: normalizedTicker, limit: 120 }));
|
||||
|
||||
227
lib/api.ts
227
lib/api.ts
@@ -7,13 +7,24 @@ import type {
|
||||
CoveragePriority,
|
||||
CoverageStatus,
|
||||
Filing,
|
||||
FinancialCadence,
|
||||
FinancialSurfaceKind,
|
||||
Holding,
|
||||
FinancialHistoryWindow,
|
||||
FinancialStatementKind,
|
||||
PortfolioInsight,
|
||||
PortfolioSummary,
|
||||
ResearchArtifact,
|
||||
ResearchArtifactKind,
|
||||
ResearchArtifactSource,
|
||||
ResearchJournalEntry,
|
||||
ResearchJournalEntryType,
|
||||
ResearchLibraryResponse,
|
||||
ResearchMemo,
|
||||
ResearchMemoConviction,
|
||||
ResearchMemoEvidenceLink,
|
||||
ResearchMemoRating,
|
||||
ResearchMemoSection,
|
||||
ResearchPacket,
|
||||
ResearchWorkspace,
|
||||
Task,
|
||||
TaskStatus,
|
||||
TaskTimeline,
|
||||
@@ -105,7 +116,7 @@ async function unwrapData<T>(result: TreatyResult, fallback: string) {
|
||||
|
||||
async function requestJson<T>(input: {
|
||||
path: string;
|
||||
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
||||
method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
|
||||
body?: unknown;
|
||||
}, fallback: string) {
|
||||
const response = await fetch(`${API_BASE}${input.path}`, {
|
||||
@@ -206,6 +217,208 @@ export async function createResearchJournalEntry(input: {
|
||||
}, 'Unable to create journal entry');
|
||||
}
|
||||
|
||||
export async function getResearchWorkspace(ticker: string) {
|
||||
return await requestJson<{ workspace: ResearchWorkspace }>({
|
||||
path: `/api/research/workspace?ticker=${encodeURIComponent(ticker.trim().toUpperCase())}`
|
||||
}, 'Unable to fetch research workspace');
|
||||
}
|
||||
|
||||
export async function listResearchLibrary(input: {
|
||||
ticker: string;
|
||||
q?: string;
|
||||
kind?: ResearchArtifactKind;
|
||||
tag?: string;
|
||||
source?: ResearchArtifactSource;
|
||||
linkedToMemo?: boolean;
|
||||
limit?: number;
|
||||
}) {
|
||||
const params = new URLSearchParams({
|
||||
ticker: input.ticker.trim().toUpperCase()
|
||||
});
|
||||
|
||||
if (input.q?.trim()) {
|
||||
params.set('q', input.q.trim());
|
||||
}
|
||||
|
||||
if (input.kind) {
|
||||
params.set('kind', input.kind);
|
||||
}
|
||||
|
||||
if (input.tag?.trim()) {
|
||||
params.set('tag', input.tag.trim());
|
||||
}
|
||||
|
||||
if (input.source) {
|
||||
params.set('source', input.source);
|
||||
}
|
||||
|
||||
if (input.linkedToMemo !== undefined) {
|
||||
params.set('linkedToMemo', input.linkedToMemo ? 'true' : 'false');
|
||||
}
|
||||
|
||||
if (typeof input.limit === 'number') {
|
||||
params.set('limit', String(input.limit));
|
||||
}
|
||||
|
||||
return await requestJson<ResearchLibraryResponse>({
|
||||
path: `/api/research/library?${params.toString()}`
|
||||
}, 'Unable to fetch research library');
|
||||
}
|
||||
|
||||
export async function createResearchArtifact(input: {
|
||||
ticker: string;
|
||||
accessionNumber?: string;
|
||||
kind: ResearchArtifactKind;
|
||||
source?: ResearchArtifactSource;
|
||||
subtype?: string;
|
||||
title?: string;
|
||||
summary?: string;
|
||||
bodyMarkdown?: string;
|
||||
tags?: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
}) {
|
||||
return await requestJson<{ artifact: ResearchArtifact }>({
|
||||
path: '/api/research/library',
|
||||
method: 'POST',
|
||||
body: {
|
||||
...input,
|
||||
ticker: input.ticker.trim().toUpperCase()
|
||||
}
|
||||
}, 'Unable to create research artifact');
|
||||
}
|
||||
|
||||
export async function updateResearchArtifact(id: number, input: {
|
||||
title?: string;
|
||||
summary?: string;
|
||||
bodyMarkdown?: string;
|
||||
tags?: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
}) {
|
||||
return await requestJson<{ artifact: ResearchArtifact }>({
|
||||
path: `/api/research/library/${id}`,
|
||||
method: 'PATCH',
|
||||
body: input
|
||||
}, 'Unable to update research artifact');
|
||||
}
|
||||
|
||||
export async function deleteResearchArtifact(id: number) {
|
||||
return await requestJson<{ success: boolean }>({
|
||||
path: `/api/research/library/${id}`,
|
||||
method: 'DELETE'
|
||||
}, 'Unable to delete research artifact');
|
||||
}
|
||||
|
||||
export async function uploadResearchArtifact(input: {
|
||||
ticker: string;
|
||||
file: File;
|
||||
title?: string;
|
||||
summary?: string;
|
||||
tags?: string[];
|
||||
}) {
|
||||
const form = new FormData();
|
||||
form.set('ticker', input.ticker.trim().toUpperCase());
|
||||
form.set('file', input.file);
|
||||
|
||||
if (input.title?.trim()) {
|
||||
form.set('title', input.title.trim());
|
||||
}
|
||||
|
||||
if (input.summary?.trim()) {
|
||||
form.set('summary', input.summary.trim());
|
||||
}
|
||||
|
||||
if (input.tags && input.tags.length > 0) {
|
||||
form.set('tags', input.tags.join(','));
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}/api/research/library/upload`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
cache: 'no-store',
|
||||
body: form
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new ApiError(
|
||||
extractErrorMessage({ value: payload }, 'Unable to upload research file'),
|
||||
response.status
|
||||
);
|
||||
}
|
||||
|
||||
if (!payload) {
|
||||
throw new ApiError('Unable to upload research file', response.status);
|
||||
}
|
||||
|
||||
return payload as { artifact: ResearchArtifact };
|
||||
}
|
||||
|
||||
export function getResearchArtifactFileUrl(id: number) {
|
||||
return `${API_BASE}/api/research/library/${id}/file`;
|
||||
}
|
||||
|
||||
export async function getResearchMemo(ticker: string) {
|
||||
return await requestJson<{ memo: ResearchMemo | null }>({
|
||||
path: `/api/research/memo?ticker=${encodeURIComponent(ticker.trim().toUpperCase())}`
|
||||
}, 'Unable to fetch research memo');
|
||||
}
|
||||
|
||||
export async function upsertResearchMemo(input: {
|
||||
ticker: string;
|
||||
rating?: ResearchMemoRating | null;
|
||||
conviction?: ResearchMemoConviction | null;
|
||||
timeHorizonMonths?: number | null;
|
||||
packetTitle?: string;
|
||||
packetSubtitle?: string;
|
||||
thesisMarkdown?: string;
|
||||
variantViewMarkdown?: string;
|
||||
catalystsMarkdown?: string;
|
||||
risksMarkdown?: string;
|
||||
disconfirmingEvidenceMarkdown?: string;
|
||||
nextActionsMarkdown?: string;
|
||||
}) {
|
||||
return await requestJson<{ memo: ResearchMemo }>({
|
||||
path: '/api/research/memo',
|
||||
method: 'PUT',
|
||||
body: {
|
||||
...input,
|
||||
ticker: input.ticker.trim().toUpperCase()
|
||||
}
|
||||
}, 'Unable to save research memo');
|
||||
}
|
||||
|
||||
export async function addResearchMemoEvidence(input: {
|
||||
memoId: number;
|
||||
artifactId: number;
|
||||
section: ResearchMemoSection;
|
||||
annotation?: string;
|
||||
sortOrder?: number;
|
||||
}) {
|
||||
return await requestJson<{ evidence: ResearchMemoEvidenceLink[] }>({
|
||||
path: `/api/research/memo/${input.memoId}/evidence`,
|
||||
method: 'POST',
|
||||
body: {
|
||||
artifactId: input.artifactId,
|
||||
section: input.section,
|
||||
annotation: input.annotation,
|
||||
sortOrder: input.sortOrder
|
||||
}
|
||||
}, 'Unable to attach memo evidence');
|
||||
}
|
||||
|
||||
export async function deleteResearchMemoEvidence(memoId: number, linkId: number) {
|
||||
return await requestJson<{ success: boolean }>({
|
||||
path: `/api/research/memo/${memoId}/evidence/${linkId}`,
|
||||
method: 'DELETE'
|
||||
}, 'Unable to delete memo evidence');
|
||||
}
|
||||
|
||||
export async function getResearchPacket(ticker: string) {
|
||||
return await requestJson<{ packet: ResearchPacket }>({
|
||||
path: `/api/research/packet?ticker=${encodeURIComponent(ticker.trim().toUpperCase())}`
|
||||
}, 'Unable to fetch research packet');
|
||||
}
|
||||
|
||||
export async function updateResearchJournalEntry(id: number, input: {
|
||||
title?: string;
|
||||
bodyMarkdown?: string;
|
||||
@@ -307,8 +520,8 @@ export async function getCompanyAnalysis(ticker: string) {
|
||||
|
||||
export async function getCompanyFinancialStatements(input: {
|
||||
ticker: string;
|
||||
statement: FinancialStatementKind;
|
||||
window: FinancialHistoryWindow;
|
||||
surfaceKind: FinancialSurfaceKind;
|
||||
cadence: FinancialCadence;
|
||||
includeDimensions?: boolean;
|
||||
includeFacts?: boolean;
|
||||
factsCursor?: string | null;
|
||||
@@ -318,8 +531,8 @@ export async function getCompanyFinancialStatements(input: {
|
||||
}) {
|
||||
const query = {
|
||||
ticker: input.ticker.trim().toUpperCase(),
|
||||
statement: input.statement,
|
||||
window: input.window,
|
||||
surface: input.surfaceKind,
|
||||
cadence: input.cadence,
|
||||
includeDimensions: input.includeDimensions ? 'true' : 'false',
|
||||
includeFacts: input.includeFacts ? 'true' : 'false',
|
||||
...(typeof input.cursor === 'string' && input.cursor.trim().length > 0
|
||||
|
||||
147
lib/financial-metrics.ts
Normal file
147
lib/financial-metrics.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import type {
|
||||
FinancialCadence,
|
||||
FinancialSurfaceKind,
|
||||
FinancialUnit,
|
||||
RatioRow
|
||||
} from '@/lib/types';
|
||||
|
||||
export type GraphableFinancialSurfaceKind = Extract<
|
||||
FinancialSurfaceKind,
|
||||
'income_statement' | 'balance_sheet' | 'cash_flow_statement' | 'ratios'
|
||||
>;
|
||||
|
||||
export type StatementMetricDefinition = {
|
||||
key: string;
|
||||
label: string;
|
||||
category: string;
|
||||
order: number;
|
||||
unit: FinancialUnit;
|
||||
localNames?: readonly string[];
|
||||
labelIncludes?: readonly string[];
|
||||
};
|
||||
|
||||
export type RatioMetricDefinition = {
|
||||
key: string;
|
||||
label: string;
|
||||
category: string;
|
||||
order: number;
|
||||
unit: RatioRow['unit'];
|
||||
denominatorKey: string | null;
|
||||
supportedCadences?: readonly FinancialCadence[];
|
||||
};
|
||||
|
||||
export const GRAPHABLE_FINANCIAL_SURFACES: readonly GraphableFinancialSurfaceKind[] = [
|
||||
'income_statement',
|
||||
'balance_sheet',
|
||||
'cash_flow_statement',
|
||||
'ratios'
|
||||
] as const;
|
||||
|
||||
export const INCOME_STATEMENT_METRIC_DEFINITIONS: StatementMetricDefinition[] = [
|
||||
{ key: 'revenue', label: 'Revenue', category: 'revenue', order: 10, unit: 'currency', localNames: ['RevenueFromContractWithCustomerExcludingAssessedTax', 'Revenues', 'SalesRevenueNet', 'TotalRevenuesAndOtherIncome'], labelIncludes: ['revenue', 'sales'] },
|
||||
{ key: 'cost_of_revenue', label: 'Cost of Revenue', category: 'expense', order: 20, unit: 'currency', localNames: ['CostOfRevenue', 'CostOfGoodsSold', 'CostOfSales', 'CostOfProductsSold', 'CostOfServices'], labelIncludes: ['cost of revenue', 'cost of sales', 'cost of goods sold'] },
|
||||
{ key: 'gross_profit', label: 'Gross Profit', category: 'profit', order: 30, unit: 'currency', localNames: ['GrossProfit'] },
|
||||
{ key: 'gross_margin', label: 'Gross Margin', category: 'margin', order: 40, unit: 'percent', labelIncludes: ['gross margin'] },
|
||||
{ key: 'research_and_development', label: 'Research & Development', category: 'opex', order: 50, unit: 'currency', localNames: ['ResearchAndDevelopmentExpense'], labelIncludes: ['research and development'] },
|
||||
{ key: 'sales_and_marketing', label: 'Sales & Marketing', category: 'opex', order: 60, unit: 'currency', localNames: ['SellingAndMarketingExpense'], labelIncludes: ['sales and marketing', 'selling and marketing'] },
|
||||
{ key: 'general_and_administrative', label: 'General & Administrative', category: 'opex', order: 70, unit: 'currency', localNames: ['GeneralAndAdministrativeExpense'], labelIncludes: ['general and administrative'] },
|
||||
{ key: 'selling_general_and_administrative', label: 'Selling, General & Administrative', category: 'opex', order: 80, unit: 'currency', localNames: ['SellingGeneralAndAdministrativeExpense'], labelIncludes: ['selling, general', 'selling general'] },
|
||||
{ key: 'operating_income', label: 'Operating Income', category: 'profit', order: 90, unit: 'currency', localNames: ['OperatingIncomeLoss', 'IncomeLossFromOperations'], labelIncludes: ['operating income'] },
|
||||
{ key: 'operating_margin', label: 'Operating Margin', category: 'margin', order: 100, unit: 'percent', labelIncludes: ['operating margin'] },
|
||||
{ key: 'interest_income', label: 'Interest Income', category: 'non_operating', order: 110, unit: 'currency', localNames: ['InterestIncomeExpenseNonoperatingNet', 'InterestIncomeOther', 'InvestmentIncomeInterest'], labelIncludes: ['interest income'] },
|
||||
{ key: 'interest_expense', label: 'Interest Expense', category: 'non_operating', order: 120, unit: 'currency', localNames: ['InterestExpense', 'InterestAndDebtExpense'], labelIncludes: ['interest expense'] },
|
||||
{ key: 'other_non_operating_income', label: 'Other Non-Operating Income', category: 'non_operating', order: 130, unit: 'currency', localNames: ['OtherNonoperatingIncomeExpense', 'NonoperatingIncomeExpense'], labelIncludes: ['other non-operating', 'non-operating income'] },
|
||||
{ key: 'pretax_income', label: 'Pretax Income', category: 'profit', order: 140, unit: 'currency', localNames: ['IncomeBeforeTaxExpenseBenefit', 'PretaxIncome'], labelIncludes: ['income before taxes', 'pretax income'] },
|
||||
{ key: 'income_tax_expense', label: 'Income Tax Expense', category: 'tax', order: 150, unit: 'currency', localNames: ['IncomeTaxExpenseBenefit'], labelIncludes: ['income tax'] },
|
||||
{ key: 'effective_tax_rate', label: 'Effective Tax Rate', category: 'tax', order: 160, unit: 'percent', labelIncludes: ['effective tax rate'] },
|
||||
{ key: 'net_income', label: 'Net Income', category: 'profit', order: 170, unit: 'currency', localNames: ['NetIncomeLoss', 'ProfitLoss'], labelIncludes: ['net income'] },
|
||||
{ key: 'diluted_eps', label: 'Diluted EPS', category: 'per_share', order: 180, unit: 'currency', localNames: ['EarningsPerShareDiluted', 'DilutedEarningsPerShare'], labelIncludes: ['diluted eps', 'diluted earnings per share'] },
|
||||
{ key: 'basic_eps', label: 'Basic EPS', category: 'per_share', order: 190, unit: 'currency', localNames: ['EarningsPerShareBasic', 'BasicEarningsPerShare'], labelIncludes: ['basic eps', 'basic earnings per share'] },
|
||||
{ key: 'diluted_shares', label: 'Diluted Shares', category: 'shares', order: 200, unit: 'shares', localNames: ['WeightedAverageNumberOfDilutedSharesOutstanding', 'WeightedAverageNumberOfShareOutstandingDiluted'], labelIncludes: ['diluted shares', 'weighted average diluted'] },
|
||||
{ key: 'basic_shares', label: 'Basic Shares', category: 'shares', order: 210, unit: 'shares', localNames: ['WeightedAverageNumberOfSharesOutstandingBasic', 'WeightedAverageNumberOfShareOutstandingBasicAndDiluted'], labelIncludes: ['basic shares', 'weighted average basic'] },
|
||||
{ key: 'depreciation_and_amortization', label: 'Depreciation & Amortization', category: 'non_cash', order: 220, unit: 'currency', localNames: ['DepreciationDepletionAndAmortization', 'DepreciationAmortizationAndAccretionNet', 'DepreciationAndAmortization'], labelIncludes: ['depreciation', 'amortization'] },
|
||||
{ key: 'ebitda', label: 'EBITDA', category: 'profit', order: 230, unit: 'currency', localNames: ['OperatingIncomeLoss'], labelIncludes: ['ebitda'] },
|
||||
{ key: 'stock_based_compensation', label: 'Stock-Based Compensation', category: 'non_cash', order: 240, unit: 'currency', localNames: ['ShareBasedCompensation', 'AllocatedShareBasedCompensationExpense'], labelIncludes: ['stock-based compensation', 'share-based compensation'] }
|
||||
] as const satisfies StatementMetricDefinition[];
|
||||
|
||||
export const BALANCE_SHEET_METRIC_DEFINITIONS: StatementMetricDefinition[] = [
|
||||
{ key: 'cash_and_equivalents', label: 'Cash & Equivalents', category: 'asset', order: 10, unit: 'currency', localNames: ['CashAndCashEquivalentsAtCarryingValue', 'CashCashEquivalentsAndShortTermInvestments', 'CashAndShortTermInvestments'], labelIncludes: ['cash and cash equivalents'] },
|
||||
{ key: 'short_term_investments', label: 'Short-Term Investments', category: 'asset', order: 20, unit: 'currency', localNames: ['AvailableForSaleSecuritiesCurrent', 'ShortTermInvestments'], labelIncludes: ['short-term investments', 'marketable securities'] },
|
||||
{ key: 'accounts_receivable', label: 'Accounts Receivable', category: 'asset', order: 30, unit: 'currency', localNames: ['AccountsReceivableNetCurrent', 'ReceivablesNetCurrent'], labelIncludes: ['accounts receivable'] },
|
||||
{ key: 'inventory', label: 'Inventory', category: 'asset', order: 40, unit: 'currency', localNames: ['InventoryNet'], labelIncludes: ['inventory'] },
|
||||
{ key: 'other_current_assets', label: 'Other Current Assets', category: 'asset', order: 50, unit: 'currency', localNames: ['OtherAssetsCurrent'], labelIncludes: ['other current assets'] },
|
||||
{ key: 'current_assets', label: 'Current Assets', category: 'asset', order: 60, unit: 'currency', localNames: ['AssetsCurrent'], labelIncludes: ['current assets'] },
|
||||
{ key: 'property_plant_equipment', label: 'Property, Plant & Equipment', category: 'asset', order: 70, unit: 'currency', localNames: ['PropertyPlantAndEquipmentNet'], labelIncludes: ['property, plant and equipment', 'property and equipment'] },
|
||||
{ key: 'goodwill', label: 'Goodwill', category: 'asset', order: 80, unit: 'currency', localNames: ['Goodwill'], labelIncludes: ['goodwill'] },
|
||||
{ key: 'intangible_assets', label: 'Intangible Assets', category: 'asset', order: 90, unit: 'currency', localNames: ['FiniteLivedIntangibleAssetsNet', 'IndefiniteLivedIntangibleAssetsExcludingGoodwill', 'IntangibleAssetsNetExcludingGoodwill'], labelIncludes: ['intangible assets'] },
|
||||
{ key: 'total_assets', label: 'Total Assets', category: 'asset', order: 100, unit: 'currency', localNames: ['Assets'], labelIncludes: ['total assets'] },
|
||||
{ key: 'accounts_payable', label: 'Accounts Payable', category: 'liability', order: 110, unit: 'currency', localNames: ['AccountsPayableCurrent'], labelIncludes: ['accounts payable'] },
|
||||
{ key: 'accrued_liabilities', label: 'Accrued Liabilities', category: 'liability', order: 120, unit: 'currency', localNames: ['AccruedLiabilitiesCurrent'], labelIncludes: ['accrued liabilities'] },
|
||||
{ key: 'deferred_revenue_current', label: 'Deferred Revenue, Current', category: 'liability', order: 130, unit: 'currency', localNames: ['ContractWithCustomerLiabilityCurrent', 'DeferredRevenueCurrent'], labelIncludes: ['deferred revenue current', 'current deferred revenue'] },
|
||||
{ key: 'current_liabilities', label: 'Current Liabilities', category: 'liability', order: 140, unit: 'currency', localNames: ['LiabilitiesCurrent'], labelIncludes: ['current liabilities'] },
|
||||
{ key: 'long_term_debt', label: 'Long-Term Debt', category: 'liability', order: 150, unit: 'currency', localNames: ['LongTermDebtNoncurrent', 'LongTermDebt', 'DebtNoncurrent', 'LongTermDebtAndCapitalLeaseObligations'], labelIncludes: ['long-term debt'] },
|
||||
{ key: 'current_debt', label: 'Current Debt', category: 'liability', order: 160, unit: 'currency', localNames: ['DebtCurrent', 'ShortTermBorrowings', 'LongTermDebtCurrent'], labelIncludes: ['current debt', 'short-term debt'] },
|
||||
{ key: 'lease_liabilities', label: 'Lease Liabilities', category: 'liability', order: 170, unit: 'currency', localNames: ['OperatingLeaseLiabilityNoncurrent', 'FinanceLeaseLiabilityNoncurrent', 'LesseeOperatingLeaseLiability'], labelIncludes: ['lease liabilities'] },
|
||||
{ key: 'total_debt', label: 'Total Debt', category: 'liability', order: 180, unit: 'currency', localNames: ['DebtAndFinanceLeaseLiabilities', 'Debt'], labelIncludes: ['total debt'] },
|
||||
{ key: 'deferred_revenue_noncurrent', label: 'Deferred Revenue, Noncurrent', category: 'liability', order: 190, unit: 'currency', localNames: ['ContractWithCustomerLiabilityNoncurrent', 'DeferredRevenueNoncurrent'], labelIncludes: ['deferred revenue noncurrent'] },
|
||||
{ key: 'total_liabilities', label: 'Total Liabilities', category: 'liability', order: 200, unit: 'currency', localNames: ['Liabilities'], labelIncludes: ['total liabilities'] },
|
||||
{ key: 'retained_earnings', label: 'Retained Earnings', category: 'equity', order: 210, unit: 'currency', localNames: ['RetainedEarningsAccumulatedDeficit'], labelIncludes: ['retained earnings', 'accumulated deficit'] },
|
||||
{ key: 'total_equity', label: 'Total Equity', category: 'equity', order: 220, unit: 'currency', localNames: ['StockholdersEquity', 'StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest', 'PartnersCapital'], labelIncludes: ['total equity', 'stockholders’ equity', 'stockholders equity'] },
|
||||
{ key: 'net_cash_position', label: 'Net Cash Position', category: 'liquidity', order: 230, unit: 'currency', labelIncludes: ['net cash position'] }
|
||||
] as const satisfies StatementMetricDefinition[];
|
||||
|
||||
export const CASH_FLOW_STATEMENT_METRIC_DEFINITIONS: StatementMetricDefinition[] = [
|
||||
{ key: 'operating_cash_flow', label: 'Operating Cash Flow', category: 'cash_flow', order: 10, unit: 'currency', localNames: ['NetCashProvidedByUsedInOperatingActivities', 'NetCashProvidedByUsedInOperatingActivitiesContinuingOperations'], labelIncludes: ['operating cash flow'] },
|
||||
{ key: 'capital_expenditures', label: 'Capital Expenditures', category: 'cash_flow', order: 20, unit: 'currency', localNames: ['PaymentsToAcquirePropertyPlantAndEquipment', 'CapitalExpendituresIncurredButNotYetPaid'], labelIncludes: ['capital expenditures', 'capital expenditure'] },
|
||||
{ key: 'free_cash_flow', label: 'Free Cash Flow', category: 'cash_flow', order: 30, unit: 'currency', labelIncludes: ['free cash flow'] },
|
||||
{ key: 'stock_based_compensation', label: 'Stock-Based Compensation', category: 'non_cash', order: 40, unit: 'currency', localNames: ['ShareBasedCompensation', 'AllocatedShareBasedCompensationExpense'], labelIncludes: ['stock-based compensation', 'share-based compensation'] },
|
||||
{ key: 'acquisitions', label: 'Acquisitions', category: 'investing', order: 50, unit: 'currency', localNames: ['PaymentsToAcquireBusinessesNetOfCashAcquired'], labelIncludes: ['acquisitions'] },
|
||||
{ key: 'share_repurchases', label: 'Share Repurchases', category: 'financing', order: 60, unit: 'currency', localNames: ['PaymentsForRepurchaseOfCommonStock', 'PaymentsForRepurchaseOfEquity'], labelIncludes: ['share repurchases', 'repurchase of common stock'] },
|
||||
{ key: 'dividends_paid', label: 'Dividends Paid', category: 'financing', order: 70, unit: 'currency', localNames: ['PaymentsOfDividends', 'PaymentsOfDividendsCommonStock'], labelIncludes: ['dividends paid'] },
|
||||
{ key: 'debt_issued', label: 'Debt Issued', category: 'financing', order: 80, unit: 'currency', localNames: ['ProceedsFromIssuanceOfLongTermDebt'], labelIncludes: ['debt issued'] },
|
||||
{ key: 'debt_repaid', label: 'Debt Repaid', category: 'financing', order: 90, unit: 'currency', localNames: ['RepaymentsOfLongTermDebt', 'RepaymentsOfDebt'], labelIncludes: ['debt repaid', 'repayment of debt'] }
|
||||
] as const satisfies StatementMetricDefinition[];
|
||||
|
||||
export const RATIO_DEFINITIONS: RatioMetricDefinition[] = [
|
||||
{ key: 'gross_margin', label: 'Gross Margin', category: 'margins', order: 10, unit: 'percent', denominatorKey: 'revenue' },
|
||||
{ key: 'operating_margin', label: 'Operating Margin', category: 'margins', order: 20, unit: 'percent', denominatorKey: 'revenue' },
|
||||
{ key: 'ebitda_margin', label: 'EBITDA Margin', category: 'margins', order: 30, unit: 'percent', denominatorKey: 'revenue' },
|
||||
{ key: 'net_margin', label: 'Net Margin', category: 'margins', order: 40, unit: 'percent', denominatorKey: 'revenue' },
|
||||
{ key: 'fcf_margin', label: 'FCF Margin', category: 'margins', order: 50, unit: 'percent', denominatorKey: 'revenue' },
|
||||
{ key: 'roa', label: 'ROA', category: 'returns', order: 60, unit: 'percent', denominatorKey: 'total_assets' },
|
||||
{ key: 'roe', label: 'ROE', category: 'returns', order: 70, unit: 'percent', denominatorKey: 'total_equity' },
|
||||
{ key: 'roic', label: 'ROIC', category: 'returns', order: 80, unit: 'percent', denominatorKey: 'average_invested_capital' },
|
||||
{ key: 'roce', label: 'ROCE', category: 'returns', order: 90, unit: 'percent', denominatorKey: 'average_capital_employed' },
|
||||
{ key: 'debt_to_equity', label: 'Debt to Equity', category: 'financial_health', order: 100, unit: 'ratio', denominatorKey: 'total_equity' },
|
||||
{ key: 'net_debt_to_ebitda', label: 'Net Debt to EBITDA', category: 'financial_health', order: 110, unit: 'ratio', denominatorKey: 'ebitda' },
|
||||
{ key: 'cash_to_debt', label: 'Cash to Debt', category: 'financial_health', order: 120, unit: 'ratio', denominatorKey: 'total_debt' },
|
||||
{ key: 'current_ratio', label: 'Current Ratio', category: 'financial_health', order: 130, unit: 'ratio', denominatorKey: 'current_liabilities' },
|
||||
{ key: 'revenue_per_share', label: 'Revenue per Share', category: 'per_share', order: 140, unit: 'currency', denominatorKey: 'diluted_shares' },
|
||||
{ key: 'fcf_per_share', label: 'FCF per Share', category: 'per_share', order: 150, unit: 'currency', denominatorKey: 'diluted_shares' },
|
||||
{ key: 'book_value_per_share', label: 'Book Value per Share', category: 'per_share', order: 160, unit: 'currency', denominatorKey: 'diluted_shares' },
|
||||
{ key: 'revenue_yoy', label: 'Revenue YoY', category: 'growth', order: 170, unit: 'percent', denominatorKey: 'revenue' },
|
||||
{ key: 'net_income_yoy', label: 'Net Income YoY', category: 'growth', order: 180, unit: 'percent', denominatorKey: 'net_income' },
|
||||
{ key: 'eps_yoy', label: 'EPS YoY', category: 'growth', order: 190, unit: 'percent', denominatorKey: 'diluted_eps' },
|
||||
{ key: 'fcf_yoy', label: 'FCF YoY', category: 'growth', order: 200, unit: 'percent', denominatorKey: 'free_cash_flow' },
|
||||
{ key: '3y_revenue_cagr', label: '3Y Revenue CAGR', category: 'growth', order: 210, unit: 'percent', denominatorKey: 'revenue', supportedCadences: ['annual'] },
|
||||
{ key: '5y_revenue_cagr', label: '5Y Revenue CAGR', category: 'growth', order: 220, unit: 'percent', denominatorKey: 'revenue', supportedCadences: ['annual'] },
|
||||
{ key: '3y_eps_cagr', label: '3Y EPS CAGR', category: 'growth', order: 230, unit: 'percent', denominatorKey: 'diluted_eps', supportedCadences: ['annual'] },
|
||||
{ key: '5y_eps_cagr', label: '5Y EPS CAGR', category: 'growth', order: 240, unit: 'percent', denominatorKey: 'diluted_eps', supportedCadences: ['annual'] },
|
||||
{ key: 'market_cap', label: 'Market Cap', category: 'valuation', order: 250, unit: 'currency', denominatorKey: null },
|
||||
{ key: 'enterprise_value', label: 'Enterprise Value', category: 'valuation', order: 260, unit: 'currency', denominatorKey: null },
|
||||
{ key: 'price_to_earnings', label: 'Price to Earnings', category: 'valuation', order: 270, unit: 'ratio', denominatorKey: 'diluted_eps' },
|
||||
{ key: 'price_to_fcf', label: 'Price to FCF', category: 'valuation', order: 280, unit: 'ratio', denominatorKey: 'free_cash_flow' },
|
||||
{ key: 'price_to_book', label: 'Price to Book', category: 'valuation', order: 290, unit: 'ratio', denominatorKey: 'total_equity' },
|
||||
{ key: 'ev_to_sales', label: 'EV to Sales', category: 'valuation', order: 300, unit: 'ratio', denominatorKey: 'revenue' },
|
||||
{ key: 'ev_to_ebitda', label: 'EV to EBITDA', category: 'valuation', order: 310, unit: 'ratio', denominatorKey: 'ebitda' },
|
||||
{ key: 'ev_to_fcf', label: 'EV to FCF', category: 'valuation', order: 320, unit: 'ratio', denominatorKey: 'free_cash_flow' }
|
||||
] as const satisfies RatioMetricDefinition[];
|
||||
|
||||
export const RATIO_CATEGORY_ORDER = [
|
||||
'margins',
|
||||
'returns',
|
||||
'financial_health',
|
||||
'per_share',
|
||||
'growth',
|
||||
'valuation'
|
||||
] as const;
|
||||
51
lib/graphing/catalog.test.ts
Normal file
51
lib/graphing/catalog.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
DEFAULT_GRAPH_TICKERS,
|
||||
buildGraphingHref,
|
||||
metricsForSurfaceAndCadence,
|
||||
normalizeGraphTickers,
|
||||
parseGraphingParams
|
||||
} from '@/lib/graphing/catalog';
|
||||
|
||||
describe('graphing catalog', () => {
|
||||
it('normalizes compare set tickers with dedupe and max five', () => {
|
||||
expect(normalizeGraphTickers(' msft, aapl, msft, nvda, amd, goog, meta ')).toEqual([
|
||||
'MSFT',
|
||||
'AAPL',
|
||||
'NVDA',
|
||||
'AMD',
|
||||
'GOOG'
|
||||
]);
|
||||
});
|
||||
|
||||
it('falls back to defaults when params are missing or invalid', () => {
|
||||
const state = parseGraphingParams(new URLSearchParams('surface=invalid&metric=made_up&chart=nope'));
|
||||
|
||||
expect(state.tickers).toEqual([...DEFAULT_GRAPH_TICKERS]);
|
||||
expect(state.surface).toBe('income_statement');
|
||||
expect(state.metric).toBe('revenue');
|
||||
expect(state.chart).toBe('line');
|
||||
expect(state.scale).toBe('millions');
|
||||
});
|
||||
|
||||
it('filters annual-only ratio metrics for non-annual views', () => {
|
||||
const quarterlyMetricKeys = metricsForSurfaceAndCadence('ratios', 'quarterly').map((metric) => metric.key);
|
||||
|
||||
expect(quarterlyMetricKeys).not.toContain('3y_revenue_cagr');
|
||||
expect(quarterlyMetricKeys).not.toContain('5y_eps_cagr');
|
||||
expect(quarterlyMetricKeys).toContain('gross_margin');
|
||||
});
|
||||
|
||||
it('replaces invalid metrics after surface and cadence normalization', () => {
|
||||
const state = parseGraphingParams(new URLSearchParams('surface=ratios&cadence=quarterly&metric=5y_revenue_cagr&tickers=msft,aapl'));
|
||||
|
||||
expect(state.surface).toBe('ratios');
|
||||
expect(state.cadence).toBe('quarterly');
|
||||
expect(state.metric).toBe('gross_margin');
|
||||
expect(state.tickers).toEqual(['MSFT', 'AAPL']);
|
||||
});
|
||||
|
||||
it('builds graphing hrefs with the primary ticker leading the compare set', () => {
|
||||
expect(buildGraphingHref('amd')).toContain('tickers=AMD%2CMSFT%2CAAPL%2CNVDA');
|
||||
});
|
||||
});
|
||||
238
lib/graphing/catalog.ts
Normal file
238
lib/graphing/catalog.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import type {
|
||||
FinancialCadence,
|
||||
FinancialUnit,
|
||||
NumberScaleUnit
|
||||
} from '@/lib/types';
|
||||
import {
|
||||
BALANCE_SHEET_METRIC_DEFINITIONS,
|
||||
CASH_FLOW_STATEMENT_METRIC_DEFINITIONS,
|
||||
GRAPHABLE_FINANCIAL_SURFACES,
|
||||
type GraphableFinancialSurfaceKind,
|
||||
INCOME_STATEMENT_METRIC_DEFINITIONS,
|
||||
RATIO_DEFINITIONS
|
||||
} from '@/lib/financial-metrics';
|
||||
|
||||
type SearchParamsLike = {
|
||||
get(name: string): string | null;
|
||||
};
|
||||
|
||||
export type GraphChartKind = 'line' | 'bar';
|
||||
|
||||
export type GraphMetricDefinition = {
|
||||
surface: GraphableFinancialSurfaceKind;
|
||||
key: string;
|
||||
label: string;
|
||||
category: string;
|
||||
order: number;
|
||||
unit: FinancialUnit;
|
||||
supportedCadences: readonly FinancialCadence[];
|
||||
};
|
||||
|
||||
export type GraphingUrlState = {
|
||||
tickers: string[];
|
||||
surface: GraphableFinancialSurfaceKind;
|
||||
metric: string;
|
||||
cadence: FinancialCadence;
|
||||
chart: GraphChartKind;
|
||||
scale: NumberScaleUnit;
|
||||
};
|
||||
|
||||
export const DEFAULT_GRAPH_TICKERS = ['MSFT', 'AAPL', 'NVDA'] as const;
|
||||
export const DEFAULT_GRAPH_SURFACE: GraphableFinancialSurfaceKind = 'income_statement';
|
||||
export const DEFAULT_GRAPH_CADENCE: FinancialCadence = 'annual';
|
||||
export const DEFAULT_GRAPH_CHART: GraphChartKind = 'line';
|
||||
export const DEFAULT_GRAPH_SCALE: NumberScaleUnit = 'millions';
|
||||
|
||||
export const GRAPH_SURFACE_LABELS: Record<GraphableFinancialSurfaceKind, string> = {
|
||||
income_statement: 'Income Statement',
|
||||
balance_sheet: 'Balance Sheet',
|
||||
cash_flow_statement: 'Cash Flow Statement',
|
||||
ratios: 'Ratios'
|
||||
};
|
||||
|
||||
export const GRAPH_CADENCE_OPTIONS: Array<{ value: FinancialCadence; label: string }> = [
|
||||
{ value: 'annual', label: 'Annual' },
|
||||
{ value: 'quarterly', label: 'Quarterly' },
|
||||
{ value: 'ltm', label: 'LTM' }
|
||||
];
|
||||
|
||||
export const GRAPH_CHART_OPTIONS: Array<{ value: GraphChartKind; label: string }> = [
|
||||
{ value: 'line', label: 'Line' },
|
||||
{ value: 'bar', label: 'Bar' }
|
||||
];
|
||||
|
||||
export const GRAPH_SCALE_OPTIONS: Array<{ value: NumberScaleUnit; label: string }> = [
|
||||
{ value: 'thousands', label: 'Thousands (K)' },
|
||||
{ value: 'millions', label: 'Millions (M)' },
|
||||
{ value: 'billions', label: 'Billions (B)' }
|
||||
];
|
||||
|
||||
function buildStatementMetrics(
|
||||
surface: Extract<GraphableFinancialSurfaceKind, 'income_statement' | 'balance_sheet' | 'cash_flow_statement'>,
|
||||
metrics: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
category: string;
|
||||
order: number;
|
||||
unit: FinancialUnit;
|
||||
}>
|
||||
) {
|
||||
return metrics.map((metric) => ({
|
||||
...metric,
|
||||
surface,
|
||||
supportedCadences: ['annual', 'quarterly', 'ltm'] as const
|
||||
})) satisfies GraphMetricDefinition[];
|
||||
}
|
||||
|
||||
export const GRAPH_METRIC_CATALOG: Record<GraphableFinancialSurfaceKind, GraphMetricDefinition[]> = {
|
||||
income_statement: buildStatementMetrics('income_statement', INCOME_STATEMENT_METRIC_DEFINITIONS),
|
||||
balance_sheet: buildStatementMetrics('balance_sheet', BALANCE_SHEET_METRIC_DEFINITIONS),
|
||||
cash_flow_statement: buildStatementMetrics('cash_flow_statement', CASH_FLOW_STATEMENT_METRIC_DEFINITIONS),
|
||||
ratios: RATIO_DEFINITIONS.map((metric) => ({
|
||||
surface: 'ratios',
|
||||
key: metric.key,
|
||||
label: metric.label,
|
||||
category: metric.category,
|
||||
order: metric.order,
|
||||
unit: metric.unit,
|
||||
supportedCadences: metric.supportedCadences ?? ['annual', 'quarterly', 'ltm']
|
||||
}))
|
||||
};
|
||||
|
||||
export const DEFAULT_GRAPH_METRIC_BY_SURFACE: Record<GraphableFinancialSurfaceKind, string> = {
|
||||
income_statement: 'revenue',
|
||||
balance_sheet: 'total_assets',
|
||||
cash_flow_statement: 'free_cash_flow',
|
||||
ratios: 'gross_margin'
|
||||
};
|
||||
|
||||
export function normalizeGraphTickers(value: string | null | undefined) {
|
||||
const raw = (value ?? '')
|
||||
.split(',')
|
||||
.map((entry) => entry.trim().toUpperCase())
|
||||
.filter(Boolean);
|
||||
|
||||
const unique = new Set<string>();
|
||||
for (const ticker of raw) {
|
||||
unique.add(ticker);
|
||||
if (unique.size >= 5) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return [...unique];
|
||||
}
|
||||
|
||||
export function isGraphSurfaceKind(value: string | null | undefined): value is GraphableFinancialSurfaceKind {
|
||||
return GRAPHABLE_FINANCIAL_SURFACES.includes(value as GraphableFinancialSurfaceKind);
|
||||
}
|
||||
|
||||
export function isGraphCadence(value: string | null | undefined): value is FinancialCadence {
|
||||
return value === 'annual' || value === 'quarterly' || value === 'ltm';
|
||||
}
|
||||
|
||||
export function isGraphChartKind(value: string | null | undefined): value is GraphChartKind {
|
||||
return value === 'line' || value === 'bar';
|
||||
}
|
||||
|
||||
export function isNumberScaleUnit(value: string | null | undefined): value is NumberScaleUnit {
|
||||
return value === 'thousands' || value === 'millions' || value === 'billions';
|
||||
}
|
||||
|
||||
export function metricsForSurfaceAndCadence(
|
||||
surface: GraphableFinancialSurfaceKind,
|
||||
cadence: FinancialCadence
|
||||
) {
|
||||
return GRAPH_METRIC_CATALOG[surface].filter((metric) => metric.supportedCadences.includes(cadence));
|
||||
}
|
||||
|
||||
export function resolveGraphMetric(
|
||||
surface: GraphableFinancialSurfaceKind,
|
||||
cadence: FinancialCadence,
|
||||
metric: string | null | undefined
|
||||
) {
|
||||
const metrics = metricsForSurfaceAndCadence(surface, cadence);
|
||||
const normalizedMetric = metric?.trim() ?? '';
|
||||
const match = metrics.find((candidate) => candidate.key === normalizedMetric);
|
||||
|
||||
if (match) {
|
||||
return match.key;
|
||||
}
|
||||
|
||||
const surfaceDefault = metrics.find((candidate) => candidate.key === DEFAULT_GRAPH_METRIC_BY_SURFACE[surface]);
|
||||
return surfaceDefault?.key ?? metrics[0]?.key ?? DEFAULT_GRAPH_METRIC_BY_SURFACE[surface];
|
||||
}
|
||||
|
||||
export function getGraphMetricDefinition(
|
||||
surface: GraphableFinancialSurfaceKind,
|
||||
cadence: FinancialCadence,
|
||||
metric: string
|
||||
) {
|
||||
return metricsForSurfaceAndCadence(surface, cadence).find((candidate) => candidate.key === metric) ?? null;
|
||||
}
|
||||
|
||||
export function defaultGraphingState(): GraphingUrlState {
|
||||
return {
|
||||
tickers: [...DEFAULT_GRAPH_TICKERS],
|
||||
surface: DEFAULT_GRAPH_SURFACE,
|
||||
metric: DEFAULT_GRAPH_METRIC_BY_SURFACE[DEFAULT_GRAPH_SURFACE],
|
||||
cadence: DEFAULT_GRAPH_CADENCE,
|
||||
chart: DEFAULT_GRAPH_CHART,
|
||||
scale: DEFAULT_GRAPH_SCALE
|
||||
};
|
||||
}
|
||||
|
||||
export function parseGraphingParams(searchParams: SearchParamsLike): GraphingUrlState {
|
||||
const tickers = normalizeGraphTickers(searchParams.get('tickers'));
|
||||
const surface = isGraphSurfaceKind(searchParams.get('surface'))
|
||||
? searchParams.get('surface') as GraphableFinancialSurfaceKind
|
||||
: DEFAULT_GRAPH_SURFACE;
|
||||
const cadence = isGraphCadence(searchParams.get('cadence'))
|
||||
? searchParams.get('cadence') as FinancialCadence
|
||||
: DEFAULT_GRAPH_CADENCE;
|
||||
const metric = resolveGraphMetric(surface, cadence, searchParams.get('metric'));
|
||||
const chart = isGraphChartKind(searchParams.get('chart'))
|
||||
? searchParams.get('chart') as GraphChartKind
|
||||
: DEFAULT_GRAPH_CHART;
|
||||
const scale = isNumberScaleUnit(searchParams.get('scale'))
|
||||
? searchParams.get('scale') as NumberScaleUnit
|
||||
: DEFAULT_GRAPH_SCALE;
|
||||
|
||||
return {
|
||||
tickers: tickers.length > 0 ? tickers : [...DEFAULT_GRAPH_TICKERS],
|
||||
surface,
|
||||
metric,
|
||||
cadence,
|
||||
chart,
|
||||
scale
|
||||
};
|
||||
}
|
||||
|
||||
export function serializeGraphingParams(state: GraphingUrlState) {
|
||||
const params = new URLSearchParams();
|
||||
params.set('tickers', state.tickers.join(','));
|
||||
params.set('surface', state.surface);
|
||||
params.set('metric', state.metric);
|
||||
params.set('cadence', state.cadence);
|
||||
params.set('chart', state.chart);
|
||||
params.set('scale', state.scale);
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
export function withPrimaryGraphTicker(ticker: string | null | undefined) {
|
||||
const normalized = ticker?.trim().toUpperCase() ?? '';
|
||||
if (!normalized) {
|
||||
return [...DEFAULT_GRAPH_TICKERS];
|
||||
}
|
||||
|
||||
return normalizeGraphTickers([normalized, ...DEFAULT_GRAPH_TICKERS].join(','));
|
||||
}
|
||||
|
||||
export function buildGraphingHref(primaryTicker?: string | null) {
|
||||
const tickers = withPrimaryGraphTicker(primaryTicker);
|
||||
const params = serializeGraphingParams({
|
||||
...defaultGraphingState(),
|
||||
tickers
|
||||
});
|
||||
return `/graphing?${params}`;
|
||||
}
|
||||
225
lib/graphing/series.test.ts
Normal file
225
lib/graphing/series.test.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { buildGraphingComparisonData } from '@/lib/graphing/series';
|
||||
import type {
|
||||
CompanyFinancialStatementsResponse,
|
||||
FinancialStatementPeriod,
|
||||
RatioRow,
|
||||
StandardizedFinancialRow
|
||||
} from '@/lib/types';
|
||||
|
||||
function createPeriod(input: {
|
||||
id: string;
|
||||
filingId: number;
|
||||
filingDate: string;
|
||||
periodEnd: string;
|
||||
filingType?: '10-K' | '10-Q';
|
||||
}) {
|
||||
return {
|
||||
id: input.id,
|
||||
filingId: input.filingId,
|
||||
accessionNumber: `0000-${input.filingId}`,
|
||||
filingDate: input.filingDate,
|
||||
periodStart: '2025-01-01',
|
||||
periodEnd: input.periodEnd,
|
||||
filingType: input.filingType ?? '10-Q',
|
||||
periodLabel: input.id
|
||||
} satisfies FinancialStatementPeriod;
|
||||
}
|
||||
|
||||
function createStatementRow(key: string, values: Record<string, number | null>, unit: StandardizedFinancialRow['unit'] = 'currency') {
|
||||
return {
|
||||
key,
|
||||
label: key,
|
||||
category: 'test',
|
||||
order: 10,
|
||||
unit,
|
||||
values,
|
||||
sourceConcepts: [key],
|
||||
sourceRowKeys: [key],
|
||||
sourceFactIds: [1],
|
||||
formulaKey: null,
|
||||
hasDimensions: false,
|
||||
resolvedSourceRowKeys: Object.fromEntries(Object.keys(values).map((periodId) => [periodId, key]))
|
||||
} satisfies StandardizedFinancialRow;
|
||||
}
|
||||
|
||||
function createRatioRow(key: string, values: Record<string, number | null>, unit: RatioRow['unit'] = 'percent') {
|
||||
return {
|
||||
...createStatementRow(key, values, unit),
|
||||
denominatorKey: null
|
||||
} satisfies RatioRow;
|
||||
}
|
||||
|
||||
function createFinancials(input: {
|
||||
ticker: string;
|
||||
companyName: string;
|
||||
periods: FinancialStatementPeriod[];
|
||||
statementRows?: StandardizedFinancialRow[];
|
||||
ratioRows?: RatioRow[];
|
||||
}) {
|
||||
return {
|
||||
company: {
|
||||
ticker: input.ticker,
|
||||
companyName: input.companyName,
|
||||
cik: null
|
||||
},
|
||||
surfaceKind: 'income_statement',
|
||||
cadence: 'annual',
|
||||
displayModes: ['standardized'],
|
||||
defaultDisplayMode: 'standardized',
|
||||
periods: input.periods,
|
||||
statementRows: {
|
||||
faithful: [],
|
||||
standardized: input.statementRows ?? []
|
||||
},
|
||||
ratioRows: input.ratioRows ?? [],
|
||||
kpiRows: null,
|
||||
trendSeries: [],
|
||||
categories: [],
|
||||
availability: {
|
||||
adjusted: false,
|
||||
customMetrics: false
|
||||
},
|
||||
nextCursor: null,
|
||||
facts: null,
|
||||
coverage: {
|
||||
filings: input.periods.length,
|
||||
rows: input.statementRows?.length ?? 0,
|
||||
dimensions: 0,
|
||||
facts: 0
|
||||
},
|
||||
dataSourceStatus: {
|
||||
enabled: true,
|
||||
hydratedFilings: input.periods.length,
|
||||
partialFilings: 0,
|
||||
failedFilings: 0,
|
||||
pendingFilings: 0,
|
||||
queuedSync: false
|
||||
},
|
||||
metrics: {
|
||||
taxonomy: null,
|
||||
validation: null
|
||||
},
|
||||
dimensionBreakdown: null
|
||||
} satisfies CompanyFinancialStatementsResponse;
|
||||
}
|
||||
|
||||
describe('graphing series', () => {
|
||||
it('aligns multiple companies onto a union date axis', () => {
|
||||
const data = buildGraphingComparisonData({
|
||||
surface: 'income_statement',
|
||||
metric: 'revenue',
|
||||
results: [
|
||||
{
|
||||
ticker: 'MSFT',
|
||||
financials: createFinancials({
|
||||
ticker: 'MSFT',
|
||||
companyName: 'Microsoft',
|
||||
periods: [
|
||||
createPeriod({ id: 'msft-q1', filingId: 1, filingDate: '2025-01-30', periodEnd: '2024-12-31' }),
|
||||
createPeriod({ id: 'msft-q2', filingId: 2, filingDate: '2025-04-28', periodEnd: '2025-03-31' })
|
||||
],
|
||||
statementRows: [createStatementRow('revenue', { 'msft-q1': 100, 'msft-q2': 120 })]
|
||||
})
|
||||
},
|
||||
{
|
||||
ticker: 'AAPL',
|
||||
financials: createFinancials({
|
||||
ticker: 'AAPL',
|
||||
companyName: 'Apple',
|
||||
periods: [
|
||||
createPeriod({ id: 'aapl-q1', filingId: 3, filingDate: '2025-02-02', periodEnd: '2025-01-31' }),
|
||||
createPeriod({ id: 'aapl-q2', filingId: 4, filingDate: '2025-04-30', periodEnd: '2025-03-31' })
|
||||
],
|
||||
statementRows: [createStatementRow('revenue', { 'aapl-q1': 90, 'aapl-q2': 130 })]
|
||||
})
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
expect(data.chartData.map((entry) => entry.dateKey)).toEqual([
|
||||
'2024-12-31',
|
||||
'2025-01-31',
|
||||
'2025-03-31'
|
||||
]);
|
||||
expect(data.chartData[0]?.MSFT).toBe(100);
|
||||
expect(data.chartData[0]?.AAPL).toBeNull();
|
||||
expect(data.chartData[1]?.AAPL).toBe(90);
|
||||
});
|
||||
|
||||
it('preserves partial failures without blanking the whole chart', () => {
|
||||
const data = buildGraphingComparisonData({
|
||||
surface: 'income_statement',
|
||||
metric: 'revenue',
|
||||
results: [
|
||||
{
|
||||
ticker: 'MSFT',
|
||||
financials: createFinancials({
|
||||
ticker: 'MSFT',
|
||||
companyName: 'Microsoft',
|
||||
periods: [createPeriod({ id: 'msft-q1', filingId: 1, filingDate: '2025-01-30', periodEnd: '2024-12-31' })],
|
||||
statementRows: [createStatementRow('revenue', { 'msft-q1': 100 })]
|
||||
})
|
||||
},
|
||||
{
|
||||
ticker: 'FAIL',
|
||||
error: 'Ticker not found'
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
expect(data.hasAnyData).toBe(true);
|
||||
expect(data.hasPartialData).toBe(true);
|
||||
expect(data.latestRows.find((row) => row.ticker === 'FAIL')?.status).toBe('error');
|
||||
});
|
||||
|
||||
it('marks companies with missing metric values as no metric data', () => {
|
||||
const data = buildGraphingComparisonData({
|
||||
surface: 'ratios',
|
||||
metric: 'gross_margin',
|
||||
results: [
|
||||
{
|
||||
ticker: 'NVDA',
|
||||
financials: createFinancials({
|
||||
ticker: 'NVDA',
|
||||
companyName: 'NVIDIA',
|
||||
periods: [createPeriod({ id: 'nvda-q1', filingId: 1, filingDate: '2025-01-30', periodEnd: '2024-12-31' })],
|
||||
ratioRows: [createRatioRow('gross_margin', { 'nvda-q1': null })]
|
||||
})
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
expect(data.latestRows[0]?.status).toBe('no_metric_data');
|
||||
expect(data.hasAnyData).toBe(false);
|
||||
});
|
||||
|
||||
it('derives latest and prior values for the summary table', () => {
|
||||
const data = buildGraphingComparisonData({
|
||||
surface: 'income_statement',
|
||||
metric: 'revenue',
|
||||
results: [
|
||||
{
|
||||
ticker: 'AMD',
|
||||
financials: createFinancials({
|
||||
ticker: 'AMD',
|
||||
companyName: 'AMD',
|
||||
periods: [
|
||||
createPeriod({ id: 'amd-q1', filingId: 1, filingDate: '2025-01-30', periodEnd: '2024-12-31' }),
|
||||
createPeriod({ id: 'amd-q2', filingId: 2, filingDate: '2025-04-30', periodEnd: '2025-03-31' })
|
||||
],
|
||||
statementRows: [createStatementRow('revenue', { 'amd-q1': 50, 'amd-q2': 70 })]
|
||||
})
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
expect(data.latestRows[0]).toMatchObject({
|
||||
ticker: 'AMD',
|
||||
latestValue: 70,
|
||||
priorValue: 50,
|
||||
changeValue: 20,
|
||||
latestDateKey: '2025-03-31'
|
||||
});
|
||||
});
|
||||
});
|
||||
189
lib/graphing/series.ts
Normal file
189
lib/graphing/series.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import type {
|
||||
CompanyFinancialStatementsResponse,
|
||||
FinancialUnit,
|
||||
RatioRow,
|
||||
StandardizedFinancialRow
|
||||
} from '@/lib/types';
|
||||
import type { GraphableFinancialSurfaceKind } from '@/lib/financial-metrics';
|
||||
|
||||
type GraphingMetricRow = StandardizedFinancialRow | RatioRow;
|
||||
|
||||
export type GraphingFetchResult = {
|
||||
ticker: string;
|
||||
financials?: CompanyFinancialStatementsResponse;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type GraphingSeriesPoint = {
|
||||
periodId: string;
|
||||
dateKey: string;
|
||||
filingType: '10-K' | '10-Q';
|
||||
filingDate: string;
|
||||
periodEnd: string | null;
|
||||
periodLabel: string;
|
||||
value: number | null;
|
||||
};
|
||||
|
||||
export type GraphingCompanySeries = {
|
||||
ticker: string;
|
||||
companyName: string;
|
||||
status: 'ready' | 'error' | 'no_metric_data';
|
||||
errorMessage: string | null;
|
||||
unit: FinancialUnit | null;
|
||||
points: GraphingSeriesPoint[];
|
||||
latestPoint: GraphingSeriesPoint | null;
|
||||
priorPoint: GraphingSeriesPoint | null;
|
||||
};
|
||||
|
||||
export type GraphingLatestValueRow = {
|
||||
ticker: string;
|
||||
companyName: string;
|
||||
status: GraphingCompanySeries['status'];
|
||||
errorMessage: string | null;
|
||||
latestValue: number | null;
|
||||
priorValue: number | null;
|
||||
changeValue: number | null;
|
||||
latestDateKey: string | null;
|
||||
latestPeriodLabel: string | null;
|
||||
latestFilingType: '10-K' | '10-Q' | null;
|
||||
};
|
||||
|
||||
export type GraphingChartDatum = Record<string, unknown> & {
|
||||
dateKey: string;
|
||||
dateMs: number;
|
||||
};
|
||||
|
||||
export type GraphingComparisonData = {
|
||||
companies: GraphingCompanySeries[];
|
||||
chartData: GraphingChartDatum[];
|
||||
latestRows: GraphingLatestValueRow[];
|
||||
hasAnyData: boolean;
|
||||
hasPartialData: boolean;
|
||||
};
|
||||
|
||||
function sortPeriods(left: { periodEnd: string | null; filingDate: string }, right: { periodEnd: string | null; filingDate: string }) {
|
||||
return Date.parse(left.periodEnd ?? left.filingDate) - Date.parse(right.periodEnd ?? right.filingDate);
|
||||
}
|
||||
|
||||
function extractMetricRow(
|
||||
financials: CompanyFinancialStatementsResponse,
|
||||
surface: GraphableFinancialSurfaceKind,
|
||||
metric: string
|
||||
): GraphingMetricRow | null {
|
||||
if (surface === 'ratios') {
|
||||
return financials.ratioRows?.find((row) => row.key === metric) ?? null;
|
||||
}
|
||||
|
||||
return financials.statementRows?.standardized.find((row) => row.key === metric) ?? null;
|
||||
}
|
||||
|
||||
function extractCompanySeries(
|
||||
result: GraphingFetchResult,
|
||||
surface: GraphableFinancialSurfaceKind,
|
||||
metric: string
|
||||
): GraphingCompanySeries {
|
||||
if (result.error || !result.financials) {
|
||||
return {
|
||||
ticker: result.ticker,
|
||||
companyName: result.ticker,
|
||||
status: 'error',
|
||||
errorMessage: result.error ?? 'Unable to load financial history',
|
||||
unit: null,
|
||||
points: [],
|
||||
latestPoint: null,
|
||||
priorPoint: null
|
||||
};
|
||||
}
|
||||
|
||||
const metricRow = extractMetricRow(result.financials, surface, metric);
|
||||
const periods = [...result.financials.periods].sort(sortPeriods);
|
||||
const points = periods.map((period) => ({
|
||||
periodId: period.id,
|
||||
dateKey: period.periodEnd ?? period.filingDate,
|
||||
filingType: period.filingType,
|
||||
filingDate: period.filingDate,
|
||||
periodEnd: period.periodEnd,
|
||||
periodLabel: period.periodLabel,
|
||||
value: metricRow?.values[period.id] ?? null
|
||||
}));
|
||||
const populatedPoints = points.filter((point) => point.value !== null);
|
||||
const latestPoint = populatedPoints[populatedPoints.length - 1] ?? null;
|
||||
const priorPoint = populatedPoints.length > 1 ? populatedPoints[populatedPoints.length - 2] ?? null : null;
|
||||
|
||||
return {
|
||||
ticker: result.financials.company.ticker,
|
||||
companyName: result.financials.company.companyName,
|
||||
status: latestPoint ? 'ready' : 'no_metric_data',
|
||||
errorMessage: latestPoint ? null : 'No data available for the selected metric.',
|
||||
unit: metricRow?.unit ?? null,
|
||||
points,
|
||||
latestPoint,
|
||||
priorPoint
|
||||
};
|
||||
}
|
||||
|
||||
export function buildGraphingComparisonData(input: {
|
||||
results: GraphingFetchResult[];
|
||||
surface: GraphableFinancialSurfaceKind;
|
||||
metric: string;
|
||||
}): GraphingComparisonData {
|
||||
const companies = input.results.map((result) => extractCompanySeries(result, input.surface, input.metric));
|
||||
const chartDatumByDate = new Map<string, GraphingChartDatum>();
|
||||
|
||||
for (const company of companies) {
|
||||
for (const point of company.points) {
|
||||
const dateMs = Date.parse(point.dateKey);
|
||||
const existing = chartDatumByDate.get(point.dateKey) ?? {
|
||||
dateKey: point.dateKey,
|
||||
dateMs
|
||||
};
|
||||
|
||||
existing[company.ticker] = point.value;
|
||||
existing[`meta__${company.ticker}`] = point;
|
||||
chartDatumByDate.set(point.dateKey, existing);
|
||||
}
|
||||
}
|
||||
|
||||
const chartData = [...chartDatumByDate.values()]
|
||||
.sort((left, right) => left.dateMs - right.dateMs)
|
||||
.map((datum) => {
|
||||
for (const company of companies) {
|
||||
if (!(company.ticker in datum)) {
|
||||
datum[company.ticker] = null;
|
||||
}
|
||||
}
|
||||
|
||||
return datum;
|
||||
});
|
||||
|
||||
const latestRows = companies.map((company) => ({
|
||||
ticker: company.ticker,
|
||||
companyName: company.companyName,
|
||||
status: company.status,
|
||||
errorMessage: company.errorMessage,
|
||||
latestValue: company.latestPoint?.value ?? null,
|
||||
priorValue: company.priorPoint?.value ?? null,
|
||||
changeValue:
|
||||
company.latestPoint?.value !== null
|
||||
&& company.latestPoint?.value !== undefined
|
||||
&& company.priorPoint?.value !== null
|
||||
&& company.priorPoint?.value !== undefined
|
||||
? company.latestPoint.value - company.priorPoint.value
|
||||
: null,
|
||||
latestDateKey: company.latestPoint?.dateKey ?? null,
|
||||
latestPeriodLabel: company.latestPoint?.periodLabel ?? null,
|
||||
latestFilingType: company.latestPoint?.filingType ?? null
|
||||
}));
|
||||
|
||||
const hasAnyData = companies.some((company) => company.latestPoint !== null);
|
||||
const hasPartialData = companies.some((company) => company.status !== 'ready')
|
||||
|| companies.some((company) => company.points.some((point) => point.value === null));
|
||||
|
||||
return {
|
||||
companies,
|
||||
chartData,
|
||||
latestRows,
|
||||
hasAnyData,
|
||||
hasPartialData
|
||||
};
|
||||
}
|
||||
@@ -2,17 +2,29 @@ export const queryKeys = {
|
||||
companyAnalysis: (ticker: string) => ['analysis', ticker] as const,
|
||||
companyFinancialStatements: (
|
||||
ticker: string,
|
||||
statement: string,
|
||||
window: string,
|
||||
surfaceKind: string,
|
||||
cadence: string,
|
||||
includeDimensions: boolean,
|
||||
includeFacts: boolean,
|
||||
factsCursor: string | null,
|
||||
factsLimit: number,
|
||||
cursor: string | null,
|
||||
limit: number
|
||||
) => ['financials-v3', ticker, statement, window, includeDimensions ? 'dims' : 'no-dims', includeFacts ? 'facts' : 'rows', factsCursor ?? '', factsLimit, cursor ?? '', limit] as const,
|
||||
) => ['financials-v3', ticker, surfaceKind, cadence, includeDimensions ? 'dims' : 'no-dims', includeFacts ? 'facts' : 'rows', factsCursor ?? '', factsLimit, cursor ?? '', limit] as const,
|
||||
filings: (ticker: string | null, limit: number) => ['filings', ticker ?? '', limit] as const,
|
||||
report: (accessionNumber: string) => ['report', accessionNumber] as const,
|
||||
researchWorkspace: (ticker: string) => ['research', 'workspace', ticker] as const,
|
||||
researchLibrary: (
|
||||
ticker: string,
|
||||
q: string,
|
||||
kind: string,
|
||||
tag: string,
|
||||
source: string,
|
||||
linkedToMemo: string,
|
||||
limit: number
|
||||
) => ['research', 'library', ticker, q, kind, tag, source, linkedToMemo, limit] as const,
|
||||
researchMemo: (ticker: string) => ['research', 'memo', ticker] as const,
|
||||
researchPacket: (ticker: string) => ['research', 'packet', ticker] as const,
|
||||
watchlist: () => ['watchlist'] as const,
|
||||
researchJournal: (ticker: string) => ['research', 'journal', ticker] as const,
|
||||
holdings: () => ['portfolio', 'holdings'] as const,
|
||||
|
||||
@@ -5,9 +5,13 @@ import {
|
||||
getCompanyFinancialStatements,
|
||||
getLatestPortfolioInsight,
|
||||
getPortfolioSummary,
|
||||
getResearchMemo,
|
||||
getResearchPacket,
|
||||
getResearchWorkspace,
|
||||
getTask,
|
||||
getTaskTimeline,
|
||||
listFilings,
|
||||
listResearchLibrary,
|
||||
listHoldings,
|
||||
listRecentTasks,
|
||||
listResearchJournal,
|
||||
@@ -15,8 +19,10 @@ import {
|
||||
} from '@/lib/api';
|
||||
import { queryKeys } from '@/lib/query/keys';
|
||||
import type {
|
||||
FinancialHistoryWindow,
|
||||
FinancialStatementKind
|
||||
FinancialCadence,
|
||||
FinancialSurfaceKind,
|
||||
ResearchArtifactKind,
|
||||
ResearchArtifactSource
|
||||
} from '@/lib/types';
|
||||
|
||||
export function companyAnalysisQueryOptions(ticker: string) {
|
||||
@@ -31,8 +37,8 @@ export function companyAnalysisQueryOptions(ticker: string) {
|
||||
|
||||
export function companyFinancialStatementsQueryOptions(input: {
|
||||
ticker: string;
|
||||
statement: FinancialStatementKind;
|
||||
window: FinancialHistoryWindow;
|
||||
surfaceKind: FinancialSurfaceKind;
|
||||
cadence: FinancialCadence;
|
||||
includeDimensions?: boolean;
|
||||
includeFacts?: boolean;
|
||||
factsCursor?: string | null;
|
||||
@@ -51,8 +57,8 @@ export function companyFinancialStatementsQueryOptions(input: {
|
||||
return queryOptions({
|
||||
queryKey: queryKeys.companyFinancialStatements(
|
||||
normalizedTicker,
|
||||
input.statement,
|
||||
input.window,
|
||||
input.surfaceKind,
|
||||
input.cadence,
|
||||
includeDimensions,
|
||||
includeFacts,
|
||||
factsCursor,
|
||||
@@ -62,8 +68,8 @@ export function companyFinancialStatementsQueryOptions(input: {
|
||||
),
|
||||
queryFn: () => getCompanyFinancialStatements({
|
||||
ticker: normalizedTicker,
|
||||
statement: input.statement,
|
||||
window: input.window,
|
||||
surfaceKind: input.surfaceKind,
|
||||
cadence: input.cadence,
|
||||
includeDimensions,
|
||||
includeFacts,
|
||||
factsCursor,
|
||||
@@ -96,6 +102,68 @@ export function aiReportQueryOptions(accessionNumber: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export function researchWorkspaceQueryOptions(ticker: string) {
|
||||
const normalizedTicker = ticker.trim().toUpperCase();
|
||||
|
||||
return queryOptions({
|
||||
queryKey: queryKeys.researchWorkspace(normalizedTicker),
|
||||
queryFn: () => getResearchWorkspace(normalizedTicker),
|
||||
staleTime: 15_000
|
||||
});
|
||||
}
|
||||
|
||||
export function researchLibraryQueryOptions(input: {
|
||||
ticker: string;
|
||||
q?: string;
|
||||
kind?: ResearchArtifactKind;
|
||||
tag?: string;
|
||||
source?: ResearchArtifactSource;
|
||||
linkedToMemo?: boolean;
|
||||
limit?: number;
|
||||
}) {
|
||||
const normalizedTicker = input.ticker.trim().toUpperCase();
|
||||
const q = input.q?.trim() ?? '';
|
||||
const kind = input.kind ?? '';
|
||||
const tag = input.tag?.trim() ?? '';
|
||||
const source = input.source ?? '';
|
||||
const linkedToMemo = input.linkedToMemo === undefined ? '' : input.linkedToMemo ? 'true' : 'false';
|
||||
const limit = input.limit ?? 100;
|
||||
|
||||
return queryOptions({
|
||||
queryKey: queryKeys.researchLibrary(normalizedTicker, q, kind, tag, source, linkedToMemo, limit),
|
||||
queryFn: () => listResearchLibrary({
|
||||
ticker: normalizedTicker,
|
||||
q,
|
||||
kind: input.kind,
|
||||
tag,
|
||||
source: input.source,
|
||||
linkedToMemo: input.linkedToMemo,
|
||||
limit
|
||||
}),
|
||||
staleTime: 10_000
|
||||
});
|
||||
}
|
||||
|
||||
export function researchMemoQueryOptions(ticker: string) {
|
||||
const normalizedTicker = ticker.trim().toUpperCase();
|
||||
|
||||
return queryOptions({
|
||||
queryKey: queryKeys.researchMemo(normalizedTicker),
|
||||
queryFn: () => getResearchMemo(normalizedTicker),
|
||||
staleTime: 10_000
|
||||
});
|
||||
}
|
||||
|
||||
export function researchPacketQueryOptions(ticker: string) {
|
||||
const normalizedTicker = ticker.trim().toUpperCase();
|
||||
|
||||
return queryOptions({
|
||||
queryKey: queryKeys.researchPacket(normalizedTicker),
|
||||
queryFn: () => getResearchPacket(normalizedTicker),
|
||||
staleTime: 10_000
|
||||
});
|
||||
}
|
||||
|
||||
export function watchlistQueryOptions() {
|
||||
return queryOptions({
|
||||
queryKey: queryKeys.watchlist(),
|
||||
|
||||
@@ -4,9 +4,15 @@ import type {
|
||||
CoveragePriority,
|
||||
CoverageStatus,
|
||||
Filing,
|
||||
FinancialHistoryWindow,
|
||||
FinancialCadence,
|
||||
FinancialStatementKind,
|
||||
FinancialSurfaceKind,
|
||||
ResearchArtifactKind,
|
||||
ResearchArtifactSource,
|
||||
ResearchJournalEntryType,
|
||||
ResearchMemoConviction,
|
||||
ResearchMemoRating,
|
||||
ResearchMemoSection,
|
||||
TaskStatus
|
||||
} from '@/lib/types';
|
||||
import { auth } from '@/lib/auth';
|
||||
@@ -15,7 +21,7 @@ import { asErrorMessage, jsonError } from '@/lib/server/http';
|
||||
import { buildPortfolioSummary } from '@/lib/server/portfolio';
|
||||
import {
|
||||
defaultFinancialSyncLimit,
|
||||
getCompanyFinancialTaxonomy
|
||||
getCompanyFinancials
|
||||
} from '@/lib/server/financial-taxonomy';
|
||||
import { redactInternalFilingAnalysisFields } from '@/lib/server/api/filing-redaction';
|
||||
import {
|
||||
@@ -31,6 +37,22 @@ import {
|
||||
upsertHoldingRecord
|
||||
} from '@/lib/server/repos/holdings';
|
||||
import { getLatestPortfolioInsight } from '@/lib/server/repos/insights';
|
||||
import {
|
||||
addResearchMemoEvidenceLink,
|
||||
createAiReportArtifactFromAccession,
|
||||
createFilingArtifactFromAccession,
|
||||
createResearchArtifactRecord,
|
||||
deleteResearchArtifactRecord,
|
||||
deleteResearchMemoEvidenceLink,
|
||||
getResearchArtifactFileResponse,
|
||||
getResearchMemoByTicker,
|
||||
getResearchPacket,
|
||||
getResearchWorkspace,
|
||||
listResearchArtifacts,
|
||||
storeResearchUpload,
|
||||
updateResearchArtifactRecord,
|
||||
upsertResearchMemoRecord
|
||||
} from '@/lib/server/repos/research-library';
|
||||
import {
|
||||
createResearchJournalEntryRecord,
|
||||
deleteResearchJournalEntryRecord,
|
||||
@@ -68,10 +90,31 @@ const FINANCIAL_STATEMENT_KINDS: FinancialStatementKind[] = [
|
||||
'equity',
|
||||
'comprehensive_income'
|
||||
];
|
||||
const FINANCIAL_HISTORY_WINDOWS: FinancialHistoryWindow[] = ['10y', 'all'];
|
||||
const FINANCIAL_CADENCES: FinancialCadence[] = ['annual', 'quarterly', 'ltm'];
|
||||
const FINANCIAL_SURFACES: FinancialSurfaceKind[] = [
|
||||
'income_statement',
|
||||
'balance_sheet',
|
||||
'cash_flow_statement',
|
||||
'ratios',
|
||||
'segments_kpis',
|
||||
'adjusted',
|
||||
'custom_metrics'
|
||||
];
|
||||
const COVERAGE_STATUSES: CoverageStatus[] = ['backlog', 'active', 'watch', 'archive'];
|
||||
const COVERAGE_PRIORITIES: CoveragePriority[] = ['low', 'medium', 'high'];
|
||||
const JOURNAL_ENTRY_TYPES: ResearchJournalEntryType[] = ['note', 'filing_note', 'status_change'];
|
||||
const RESEARCH_ARTIFACT_KINDS: ResearchArtifactKind[] = ['filing', 'ai_report', 'note', 'upload', 'memo_snapshot', 'status_change'];
|
||||
const RESEARCH_ARTIFACT_SOURCES: ResearchArtifactSource[] = ['system', 'user'];
|
||||
const RESEARCH_MEMO_RATINGS: ResearchMemoRating[] = ['strong_buy', 'buy', 'hold', 'sell'];
|
||||
const RESEARCH_MEMO_CONVICTIONS: ResearchMemoConviction[] = ['low', 'medium', 'high'];
|
||||
const RESEARCH_MEMO_SECTIONS: ResearchMemoSection[] = [
|
||||
'thesis',
|
||||
'variant_view',
|
||||
'catalysts',
|
||||
'risks',
|
||||
'disconfirming_evidence',
|
||||
'next_actions'
|
||||
];
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
@@ -152,10 +195,29 @@ function asStatementKind(value: unknown): FinancialStatementKind {
|
||||
: 'income';
|
||||
}
|
||||
|
||||
function asHistoryWindow(value: unknown): FinancialHistoryWindow {
|
||||
return FINANCIAL_HISTORY_WINDOWS.includes(value as FinancialHistoryWindow)
|
||||
? value as FinancialHistoryWindow
|
||||
: '10y';
|
||||
function asCadence(value: unknown): FinancialCadence {
|
||||
return FINANCIAL_CADENCES.includes(value as FinancialCadence)
|
||||
? value as FinancialCadence
|
||||
: 'annual';
|
||||
}
|
||||
|
||||
function surfaceFromLegacyStatement(statement: FinancialStatementKind): FinancialSurfaceKind {
|
||||
switch (statement) {
|
||||
case 'balance':
|
||||
return 'balance_sheet';
|
||||
case 'cash_flow':
|
||||
return 'cash_flow_statement';
|
||||
default:
|
||||
return 'income_statement';
|
||||
}
|
||||
}
|
||||
|
||||
function asSurfaceKind(surface: unknown, statement: unknown): FinancialSurfaceKind {
|
||||
if (FINANCIAL_SURFACES.includes(surface as FinancialSurfaceKind)) {
|
||||
return surface as FinancialSurfaceKind;
|
||||
}
|
||||
|
||||
return surfaceFromLegacyStatement(asStatementKind(statement));
|
||||
}
|
||||
|
||||
function asCoverageStatus(value: unknown) {
|
||||
@@ -176,6 +238,44 @@ function asJournalEntryType(value: unknown) {
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function asResearchArtifactKind(value: unknown) {
|
||||
return RESEARCH_ARTIFACT_KINDS.includes(value as ResearchArtifactKind)
|
||||
? value as ResearchArtifactKind
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function asResearchArtifactSource(value: unknown) {
|
||||
return RESEARCH_ARTIFACT_SOURCES.includes(value as ResearchArtifactSource)
|
||||
? value as ResearchArtifactSource
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function asResearchMemoRating(value: unknown) {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return RESEARCH_MEMO_RATINGS.includes(value as ResearchMemoRating)
|
||||
? value as ResearchMemoRating
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function asResearchMemoConviction(value: unknown) {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return RESEARCH_MEMO_CONVICTIONS.includes(value as ResearchMemoConviction)
|
||||
? value as ResearchMemoConviction
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function asResearchMemoSection(value: unknown) {
|
||||
return RESEARCH_MEMO_SECTIONS.includes(value as ResearchMemoSection)
|
||||
? value as ResearchMemoSection
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function formatLabel(value: string) {
|
||||
return value
|
||||
.split('_')
|
||||
@@ -183,6 +283,10 @@ function formatLabel(value: string) {
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function normalizeTicker(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim().toUpperCase() : '';
|
||||
}
|
||||
|
||||
function withFinancialMetricsPolicy(filing: Filing): Filing {
|
||||
if (FINANCIAL_FORMS.has(filing.filing_type)) {
|
||||
return filing;
|
||||
@@ -678,6 +782,383 @@ export const app = new Elysia({ prefix: '/api' })
|
||||
|
||||
return Response.json({ insight });
|
||||
})
|
||||
.get('/research/workspace', async ({ query }) => {
|
||||
const { session, response } = await requireAuthenticatedSession();
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const ticker = typeof query.ticker === 'string' ? query.ticker.trim().toUpperCase() : '';
|
||||
if (!ticker) {
|
||||
return jsonError('ticker is required');
|
||||
}
|
||||
|
||||
const workspace = await getResearchWorkspace(session.user.id, ticker);
|
||||
return Response.json({ workspace });
|
||||
}, {
|
||||
query: t.Object({
|
||||
ticker: t.String({ minLength: 1 })
|
||||
})
|
||||
})
|
||||
.get('/research/library', async ({ query }) => {
|
||||
const { session, response } = await requireAuthenticatedSession();
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const ticker = typeof query.ticker === 'string' ? query.ticker.trim().toUpperCase() : '';
|
||||
if (!ticker) {
|
||||
return jsonError('ticker is required');
|
||||
}
|
||||
|
||||
const linkedToMemo = query.linkedToMemo === undefined
|
||||
? null
|
||||
: asBoolean(query.linkedToMemo, false);
|
||||
|
||||
const library = await listResearchArtifacts(session.user.id, {
|
||||
ticker,
|
||||
q: asOptionalString(query.q),
|
||||
kind: asResearchArtifactKind(query.kind) ?? null,
|
||||
tag: asOptionalString(query.tag),
|
||||
source: asResearchArtifactSource(query.source) ?? null,
|
||||
linkedToMemo,
|
||||
limit: typeof query.limit === 'number' ? query.limit : Number(query.limit ?? 100)
|
||||
});
|
||||
|
||||
return Response.json(library);
|
||||
}, {
|
||||
query: t.Object({
|
||||
ticker: t.String({ minLength: 1 }),
|
||||
q: t.Optional(t.String()),
|
||||
kind: t.Optional(t.String()),
|
||||
tag: t.Optional(t.String()),
|
||||
source: t.Optional(t.String()),
|
||||
linkedToMemo: t.Optional(t.String()),
|
||||
limit: t.Optional(t.Numeric())
|
||||
})
|
||||
})
|
||||
.post('/research/library', async ({ body }) => {
|
||||
const { session, response } = await requireAuthenticatedSession();
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const payload = asRecord(body);
|
||||
const ticker = typeof payload.ticker === 'string' ? payload.ticker.trim().toUpperCase() : '';
|
||||
const kind = asResearchArtifactKind(payload.kind);
|
||||
const source = asResearchArtifactSource(payload.source);
|
||||
const title = asOptionalString(payload.title);
|
||||
const summary = asOptionalString(payload.summary);
|
||||
const bodyMarkdown = asOptionalString(payload.bodyMarkdown);
|
||||
|
||||
if (!ticker) {
|
||||
return jsonError('ticker is required');
|
||||
}
|
||||
|
||||
if (!kind) {
|
||||
return jsonError('kind is required');
|
||||
}
|
||||
|
||||
if (kind === 'upload') {
|
||||
return jsonError('Use /api/research/library/upload for file uploads');
|
||||
}
|
||||
|
||||
if (!title && !summary && !bodyMarkdown) {
|
||||
return jsonError('title, summary, or bodyMarkdown is required');
|
||||
}
|
||||
|
||||
try {
|
||||
const artifact = await createResearchArtifactRecord({
|
||||
userId: session.user.id,
|
||||
ticker,
|
||||
accessionNumber: asOptionalString(payload.accessionNumber),
|
||||
kind,
|
||||
source: source ?? 'user',
|
||||
subtype: asOptionalString(payload.subtype),
|
||||
title,
|
||||
summary,
|
||||
bodyMarkdown,
|
||||
tags: asTags(payload.tags),
|
||||
metadata: asOptionalRecord(payload.metadata)
|
||||
});
|
||||
|
||||
await updateWatchlistReviewByTicker(session.user.id, ticker, artifact.updated_at);
|
||||
|
||||
return Response.json({ artifact });
|
||||
} catch (error) {
|
||||
return jsonError(asErrorMessage(error, 'Failed to create research artifact'));
|
||||
}
|
||||
})
|
||||
.post('/research/library/upload', async ({ request }) => {
|
||||
const { session, response } = await requireAuthenticatedSession();
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
|
||||
try {
|
||||
const form = await request.formData();
|
||||
const ticker = normalizeTicker(String(form.get('ticker') ?? ''));
|
||||
const title = asOptionalString(String(form.get('title') ?? ''));
|
||||
const summary = asOptionalString(String(form.get('summary') ?? ''));
|
||||
const tags = asTags(String(form.get('tags') ?? ''));
|
||||
const file = form.get('file');
|
||||
|
||||
if (!ticker) {
|
||||
return jsonError('ticker is required');
|
||||
}
|
||||
|
||||
if (!(file instanceof File)) {
|
||||
return jsonError('file is required');
|
||||
}
|
||||
|
||||
const artifact = await storeResearchUpload({
|
||||
userId: session.user.id,
|
||||
ticker,
|
||||
file,
|
||||
title,
|
||||
summary,
|
||||
tags
|
||||
});
|
||||
|
||||
return Response.json({ artifact });
|
||||
} catch (error) {
|
||||
return jsonError(asErrorMessage(error, 'Failed to upload research file'));
|
||||
}
|
||||
})
|
||||
.patch('/research/library/:id', async ({ params, body }) => {
|
||||
const { session, response } = await requireAuthenticatedSession();
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const numericId = Number(params.id);
|
||||
if (!Number.isInteger(numericId) || numericId <= 0) {
|
||||
return jsonError('Invalid artifact id', 400);
|
||||
}
|
||||
|
||||
const payload = asRecord(body);
|
||||
|
||||
try {
|
||||
const artifact = await updateResearchArtifactRecord({
|
||||
userId: session.user.id,
|
||||
id: numericId,
|
||||
title: payload.title === undefined ? undefined : asOptionalString(payload.title),
|
||||
summary: payload.summary === undefined ? undefined : asOptionalString(payload.summary),
|
||||
bodyMarkdown: payload.bodyMarkdown === undefined
|
||||
? undefined
|
||||
: (typeof payload.bodyMarkdown === 'string' ? payload.bodyMarkdown : ''),
|
||||
tags: payload.tags === undefined ? undefined : asTags(payload.tags),
|
||||
metadata: payload.metadata === undefined ? undefined : asOptionalRecord(payload.metadata)
|
||||
});
|
||||
|
||||
if (!artifact) {
|
||||
return jsonError('Research artifact not found', 404);
|
||||
}
|
||||
|
||||
return Response.json({ artifact });
|
||||
} catch (error) {
|
||||
return jsonError(asErrorMessage(error, 'Failed to update research artifact'));
|
||||
}
|
||||
}, {
|
||||
params: t.Object({
|
||||
id: t.String({ minLength: 1 })
|
||||
})
|
||||
})
|
||||
.delete('/research/library/:id', async ({ params }) => {
|
||||
const { session, response } = await requireAuthenticatedSession();
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const numericId = Number(params.id);
|
||||
if (!Number.isInteger(numericId) || numericId <= 0) {
|
||||
return jsonError('Invalid artifact id', 400);
|
||||
}
|
||||
|
||||
const removed = await deleteResearchArtifactRecord(session.user.id, numericId);
|
||||
if (!removed) {
|
||||
return jsonError('Research artifact not found', 404);
|
||||
}
|
||||
|
||||
return Response.json({ success: true });
|
||||
}, {
|
||||
params: t.Object({
|
||||
id: t.String({ minLength: 1 })
|
||||
})
|
||||
})
|
||||
.get('/research/library/:id/file', async ({ params }) => {
|
||||
const { session, response } = await requireAuthenticatedSession();
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const numericId = Number(params.id);
|
||||
if (!Number.isInteger(numericId) || numericId <= 0) {
|
||||
return jsonError('Invalid artifact id', 400);
|
||||
}
|
||||
|
||||
const fileResponse = await getResearchArtifactFileResponse(session.user.id, numericId);
|
||||
if (!fileResponse) {
|
||||
return jsonError('Research upload not found', 404);
|
||||
}
|
||||
|
||||
return fileResponse;
|
||||
}, {
|
||||
params: t.Object({
|
||||
id: t.String({ minLength: 1 })
|
||||
})
|
||||
})
|
||||
.get('/research/memo', async ({ query }) => {
|
||||
const { session, response } = await requireAuthenticatedSession();
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const ticker = typeof query.ticker === 'string' ? query.ticker.trim().toUpperCase() : '';
|
||||
if (!ticker) {
|
||||
return jsonError('ticker is required');
|
||||
}
|
||||
|
||||
const memo = await getResearchMemoByTicker(session.user.id, ticker);
|
||||
return Response.json({ memo });
|
||||
}, {
|
||||
query: t.Object({
|
||||
ticker: t.String({ minLength: 1 })
|
||||
})
|
||||
})
|
||||
.put('/research/memo', async ({ body }) => {
|
||||
const { session, response } = await requireAuthenticatedSession();
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const payload = asRecord(body);
|
||||
const ticker = typeof payload.ticker === 'string' ? payload.ticker.trim().toUpperCase() : '';
|
||||
if (!ticker) {
|
||||
return jsonError('ticker is required');
|
||||
}
|
||||
|
||||
const rating = asResearchMemoRating(payload.rating);
|
||||
const conviction = asResearchMemoConviction(payload.conviction);
|
||||
|
||||
if (payload.rating !== undefined && rating === undefined) {
|
||||
return jsonError('Invalid memo rating', 400);
|
||||
}
|
||||
|
||||
if (payload.conviction !== undefined && conviction === undefined) {
|
||||
return jsonError('Invalid memo conviction', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const memo = await upsertResearchMemoRecord({
|
||||
userId: session.user.id,
|
||||
ticker,
|
||||
rating,
|
||||
conviction,
|
||||
timeHorizonMonths: payload.timeHorizonMonths === undefined
|
||||
? undefined
|
||||
: (typeof payload.timeHorizonMonths === 'number' ? payload.timeHorizonMonths : Number(payload.timeHorizonMonths)),
|
||||
packetTitle: payload.packetTitle === undefined ? undefined : asOptionalString(payload.packetTitle),
|
||||
packetSubtitle: payload.packetSubtitle === undefined ? undefined : asOptionalString(payload.packetSubtitle),
|
||||
thesisMarkdown: payload.thesisMarkdown === undefined ? undefined : String(payload.thesisMarkdown),
|
||||
variantViewMarkdown: payload.variantViewMarkdown === undefined ? undefined : String(payload.variantViewMarkdown),
|
||||
catalystsMarkdown: payload.catalystsMarkdown === undefined ? undefined : String(payload.catalystsMarkdown),
|
||||
risksMarkdown: payload.risksMarkdown === undefined ? undefined : String(payload.risksMarkdown),
|
||||
disconfirmingEvidenceMarkdown: payload.disconfirmingEvidenceMarkdown === undefined ? undefined : String(payload.disconfirmingEvidenceMarkdown),
|
||||
nextActionsMarkdown: payload.nextActionsMarkdown === undefined ? undefined : String(payload.nextActionsMarkdown)
|
||||
});
|
||||
|
||||
return Response.json({ memo });
|
||||
} catch (error) {
|
||||
return jsonError(asErrorMessage(error, 'Failed to save research memo'));
|
||||
}
|
||||
})
|
||||
.post('/research/memo/:id/evidence', async ({ params, body }) => {
|
||||
const { session, response } = await requireAuthenticatedSession();
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const numericId = Number(params.id);
|
||||
if (!Number.isInteger(numericId) || numericId <= 0) {
|
||||
return jsonError('Invalid memo id', 400);
|
||||
}
|
||||
|
||||
const payload = asRecord(body);
|
||||
const section = asResearchMemoSection(payload.section);
|
||||
const artifactId = typeof payload.artifactId === 'number' ? payload.artifactId : Number(payload.artifactId);
|
||||
|
||||
if (!section) {
|
||||
return jsonError('section is required', 400);
|
||||
}
|
||||
|
||||
if (!Number.isInteger(artifactId) || artifactId <= 0) {
|
||||
return jsonError('artifactId is required', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
const evidence = await addResearchMemoEvidenceLink({
|
||||
userId: session.user.id,
|
||||
memoId: numericId,
|
||||
artifactId,
|
||||
section,
|
||||
annotation: asOptionalString(payload.annotation),
|
||||
sortOrder: payload.sortOrder === undefined
|
||||
? undefined
|
||||
: (typeof payload.sortOrder === 'number' ? payload.sortOrder : Number(payload.sortOrder))
|
||||
});
|
||||
|
||||
return Response.json({ evidence });
|
||||
} catch (error) {
|
||||
return jsonError(asErrorMessage(error, 'Failed to attach memo evidence'));
|
||||
}
|
||||
}, {
|
||||
params: t.Object({
|
||||
id: t.String({ minLength: 1 })
|
||||
})
|
||||
})
|
||||
.delete('/research/memo/:id/evidence/:linkId', async ({ params }) => {
|
||||
const { session, response } = await requireAuthenticatedSession();
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const memoId = Number(params.id);
|
||||
const linkId = Number(params.linkId);
|
||||
if (!Number.isInteger(memoId) || memoId <= 0 || !Number.isInteger(linkId) || linkId <= 0) {
|
||||
return jsonError('Invalid memo evidence id', 400);
|
||||
}
|
||||
|
||||
const removed = await deleteResearchMemoEvidenceLink(session.user.id, memoId, linkId);
|
||||
if (!removed) {
|
||||
return jsonError('Memo evidence not found', 404);
|
||||
}
|
||||
|
||||
return Response.json({ success: true });
|
||||
}, {
|
||||
params: t.Object({
|
||||
id: t.String({ minLength: 1 }),
|
||||
linkId: t.String({ minLength: 1 })
|
||||
})
|
||||
})
|
||||
.get('/research/packet', async ({ query }) => {
|
||||
const { session, response } = await requireAuthenticatedSession();
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const ticker = typeof query.ticker === 'string' ? query.ticker.trim().toUpperCase() : '';
|
||||
if (!ticker) {
|
||||
return jsonError('ticker is required');
|
||||
}
|
||||
|
||||
const packet = await getResearchPacket(session.user.id, ticker);
|
||||
return Response.json({ packet });
|
||||
}, {
|
||||
query: t.Object({
|
||||
ticker: t.String({ minLength: 1 })
|
||||
})
|
||||
})
|
||||
.get('/research/journal', async ({ query }) => {
|
||||
const { session, response } = await requireAuthenticatedSession();
|
||||
if (response) {
|
||||
@@ -733,6 +1214,10 @@ export const app = new Elysia({ prefix: '/api' })
|
||||
metadata
|
||||
});
|
||||
|
||||
if (!entry) {
|
||||
return jsonError('Failed to create journal entry', 500);
|
||||
}
|
||||
|
||||
await updateWatchlistReviewByTicker(session.user.id, ticker, entry.updated_at);
|
||||
|
||||
return Response.json({ entry });
|
||||
@@ -926,8 +1411,8 @@ export const app = new Elysia({ prefix: '/api' })
|
||||
return jsonError('ticker is required');
|
||||
}
|
||||
|
||||
const statement = asStatementKind(query.statement);
|
||||
const window = asHistoryWindow(query.window);
|
||||
const surfaceKind = asSurfaceKind(query.surface, query.statement);
|
||||
const cadence = asCadence(query.cadence);
|
||||
const includeDimensions = asBoolean(query.includeDimensions, false);
|
||||
const includeFacts = asBoolean(query.includeFacts, false);
|
||||
const cursor = typeof query.cursor === 'string' && query.cursor.trim().length > 0
|
||||
@@ -943,10 +1428,10 @@ export const app = new Elysia({ prefix: '/api' })
|
||||
? Number(query.factsLimit)
|
||||
: undefined;
|
||||
|
||||
let payload = await getCompanyFinancialTaxonomy({
|
||||
let payload = await getCompanyFinancials({
|
||||
ticker,
|
||||
statement,
|
||||
window,
|
||||
surfaceKind,
|
||||
cadence,
|
||||
includeDimensions,
|
||||
includeFacts,
|
||||
factsCursor,
|
||||
@@ -961,7 +1446,7 @@ export const app = new Elysia({ prefix: '/api' })
|
||||
const shouldQueueSync = cursor === null && (
|
||||
payload.dataSourceStatus.pendingFilings > 0
|
||||
|| payload.coverage.filings === 0
|
||||
|| (window === 'all' && payload.nextCursor !== null)
|
||||
|| payload.nextCursor !== null
|
||||
);
|
||||
|
||||
if (shouldQueueSync) {
|
||||
@@ -972,7 +1457,7 @@ export const app = new Elysia({ prefix: '/api' })
|
||||
taskType: 'sync_filings',
|
||||
payload: buildSyncFilingsPayload({
|
||||
ticker,
|
||||
limit: defaultFinancialSyncLimit(window),
|
||||
limit: defaultFinancialSyncLimit(),
|
||||
category: watchlistItem?.category,
|
||||
tags: watchlistItem?.tags
|
||||
}),
|
||||
@@ -999,6 +1484,20 @@ export const app = new Elysia({ prefix: '/api' })
|
||||
}, {
|
||||
query: t.Object({
|
||||
ticker: t.String({ minLength: 1 }),
|
||||
surface: t.Optional(t.Union([
|
||||
t.Literal('income_statement'),
|
||||
t.Literal('balance_sheet'),
|
||||
t.Literal('cash_flow_statement'),
|
||||
t.Literal('ratios'),
|
||||
t.Literal('segments_kpis'),
|
||||
t.Literal('adjusted'),
|
||||
t.Literal('custom_metrics')
|
||||
])),
|
||||
cadence: t.Optional(t.Union([
|
||||
t.Literal('annual'),
|
||||
t.Literal('quarterly'),
|
||||
t.Literal('ltm')
|
||||
])),
|
||||
statement: t.Optional(t.Union([
|
||||
t.Literal('income'),
|
||||
t.Literal('balance'),
|
||||
@@ -1006,7 +1505,6 @@ export const app = new Elysia({ prefix: '/api' })
|
||||
t.Literal('equity'),
|
||||
t.Literal('comprehensive_income')
|
||||
])),
|
||||
window: t.Optional(t.Union([t.Literal('10y'), t.Literal('all')])),
|
||||
includeDimensions: t.Optional(t.Union([t.String(), t.Boolean()])),
|
||||
includeFacts: t.Optional(t.Union([t.String(), t.Boolean()])),
|
||||
cursor: t.Optional(t.String()),
|
||||
|
||||
@@ -24,6 +24,8 @@ describe('sqlite schema compatibility bootstrap', () => {
|
||||
expect(__dbInternals.hasColumn(client, 'holding', 'company_name')).toBe(false);
|
||||
expect(__dbInternals.hasTable(client, 'filing_taxonomy_snapshot')).toBe(false);
|
||||
expect(__dbInternals.hasTable(client, 'research_journal_entry')).toBe(false);
|
||||
expect(__dbInternals.hasTable(client, 'research_artifact')).toBe(false);
|
||||
expect(__dbInternals.hasTable(client, 'research_memo')).toBe(false);
|
||||
|
||||
__dbInternals.ensureLocalSqliteSchema(client);
|
||||
|
||||
@@ -37,6 +39,9 @@ describe('sqlite schema compatibility bootstrap', () => {
|
||||
expect(__dbInternals.hasTable(client, 'filing_taxonomy_snapshot')).toBe(true);
|
||||
expect(__dbInternals.hasTable(client, 'filing_taxonomy_fact')).toBe(true);
|
||||
expect(__dbInternals.hasTable(client, 'research_journal_entry')).toBe(true);
|
||||
expect(__dbInternals.hasTable(client, 'research_artifact')).toBe(true);
|
||||
expect(__dbInternals.hasTable(client, 'research_memo')).toBe(true);
|
||||
expect(__dbInternals.hasTable(client, 'research_memo_evidence')).toBe(true);
|
||||
|
||||
client.close();
|
||||
});
|
||||
|
||||
@@ -50,7 +50,304 @@ function applySqlFile(client: Database, fileName: string) {
|
||||
client.exec(sql);
|
||||
}
|
||||
|
||||
function applyBaseSchemaCompat(client: Database) {
|
||||
const sql = readFileSync(join(process.cwd(), 'drizzle', '0000_cold_silver_centurion.sql'), 'utf8')
|
||||
.replaceAll('CREATE TABLE `', 'CREATE TABLE IF NOT EXISTS `')
|
||||
.replaceAll('CREATE UNIQUE INDEX `', 'CREATE UNIQUE INDEX IF NOT EXISTS `')
|
||||
.replaceAll('CREATE INDEX `', 'CREATE INDEX IF NOT EXISTS `');
|
||||
|
||||
client.exec(sql);
|
||||
}
|
||||
|
||||
function ensureResearchWorkspaceSchema(client: Database) {
|
||||
if (!hasTable(client, 'research_artifact')) {
|
||||
client.exec(`
|
||||
CREATE TABLE IF NOT EXISTS \`research_artifact\` (
|
||||
\`id\` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
\`user_id\` text NOT NULL,
|
||||
\`organization_id\` text,
|
||||
\`ticker\` text NOT NULL,
|
||||
\`accession_number\` text,
|
||||
\`kind\` text NOT NULL,
|
||||
\`source\` text NOT NULL DEFAULT 'user',
|
||||
\`subtype\` text,
|
||||
\`title\` text,
|
||||
\`summary\` text,
|
||||
\`body_markdown\` text,
|
||||
\`search_text\` text,
|
||||
\`visibility_scope\` text NOT NULL DEFAULT 'private',
|
||||
\`tags\` text,
|
||||
\`metadata\` text,
|
||||
\`file_name\` text,
|
||||
\`mime_type\` text,
|
||||
\`file_size_bytes\` integer,
|
||||
\`storage_path\` text,
|
||||
\`created_at\` text NOT NULL,
|
||||
\`updated_at\` text NOT NULL,
|
||||
FOREIGN KEY (\`user_id\`) REFERENCES \`user\`(\`id\`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (\`organization_id\`) REFERENCES \`organization\`(\`id\`) ON UPDATE no action ON DELETE set null
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
if (!hasTable(client, 'research_memo')) {
|
||||
client.exec(`
|
||||
CREATE TABLE IF NOT EXISTS \`research_memo\` (
|
||||
\`id\` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
\`user_id\` text NOT NULL,
|
||||
\`organization_id\` text,
|
||||
\`ticker\` text NOT NULL,
|
||||
\`rating\` text,
|
||||
\`conviction\` text,
|
||||
\`time_horizon_months\` integer,
|
||||
\`packet_title\` text,
|
||||
\`packet_subtitle\` text,
|
||||
\`thesis_markdown\` text NOT NULL DEFAULT '',
|
||||
\`variant_view_markdown\` text NOT NULL DEFAULT '',
|
||||
\`catalysts_markdown\` text NOT NULL DEFAULT '',
|
||||
\`risks_markdown\` text NOT NULL DEFAULT '',
|
||||
\`disconfirming_evidence_markdown\` text NOT NULL DEFAULT '',
|
||||
\`next_actions_markdown\` text NOT NULL DEFAULT '',
|
||||
\`created_at\` text NOT NULL,
|
||||
\`updated_at\` text NOT NULL,
|
||||
FOREIGN KEY (\`user_id\`) REFERENCES \`user\`(\`id\`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (\`organization_id\`) REFERENCES \`organization\`(\`id\`) ON UPDATE no action ON DELETE set null
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
if (!hasTable(client, 'research_memo_evidence')) {
|
||||
client.exec(`
|
||||
CREATE TABLE IF NOT EXISTS \`research_memo_evidence\` (
|
||||
\`id\` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
\`memo_id\` integer NOT NULL,
|
||||
\`artifact_id\` integer NOT NULL,
|
||||
\`section\` text NOT NULL,
|
||||
\`annotation\` text,
|
||||
\`sort_order\` integer NOT NULL DEFAULT 0,
|
||||
\`created_at\` text NOT NULL,
|
||||
FOREIGN KEY (\`memo_id\`) REFERENCES \`research_memo\`(\`id\`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (\`artifact_id\`) REFERENCES \`research_artifact\`(\`id\`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
client.exec('CREATE INDEX IF NOT EXISTS `research_artifact_ticker_idx` ON `research_artifact` (`user_id`, `ticker`, `updated_at`);');
|
||||
client.exec('CREATE INDEX IF NOT EXISTS `research_artifact_kind_idx` ON `research_artifact` (`user_id`, `kind`, `updated_at`);');
|
||||
client.exec('CREATE INDEX IF NOT EXISTS `research_artifact_accession_idx` ON `research_artifact` (`user_id`, `accession_number`);');
|
||||
client.exec('CREATE INDEX IF NOT EXISTS `research_artifact_source_idx` ON `research_artifact` (`user_id`, `source`, `updated_at`);');
|
||||
client.exec('CREATE UNIQUE INDEX IF NOT EXISTS `research_memo_ticker_uidx` ON `research_memo` (`user_id`, `ticker`);');
|
||||
client.exec('CREATE INDEX IF NOT EXISTS `research_memo_updated_idx` ON `research_memo` (`user_id`, `updated_at`);');
|
||||
client.exec('CREATE INDEX IF NOT EXISTS `research_memo_evidence_memo_idx` ON `research_memo_evidence` (`memo_id`, `section`, `sort_order`);');
|
||||
client.exec('CREATE INDEX IF NOT EXISTS `research_memo_evidence_artifact_idx` ON `research_memo_evidence` (`artifact_id`);');
|
||||
client.exec('CREATE UNIQUE INDEX IF NOT EXISTS `research_memo_evidence_unique_uidx` ON `research_memo_evidence` (`memo_id`, `artifact_id`, `section`);');
|
||||
client.exec(`
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS \`research_artifact_fts\` USING fts5(
|
||||
artifact_id UNINDEXED,
|
||||
user_id UNINDEXED,
|
||||
ticker UNINDEXED,
|
||||
title,
|
||||
summary,
|
||||
body_markdown,
|
||||
search_text,
|
||||
tags_text
|
||||
);
|
||||
`);
|
||||
|
||||
client.exec(`
|
||||
INSERT INTO \`research_artifact\` (
|
||||
\`user_id\`,
|
||||
\`organization_id\`,
|
||||
\`ticker\`,
|
||||
\`accession_number\`,
|
||||
\`kind\`,
|
||||
\`source\`,
|
||||
\`subtype\`,
|
||||
\`title\`,
|
||||
\`summary\`,
|
||||
\`body_markdown\`,
|
||||
\`search_text\`,
|
||||
\`visibility_scope\`,
|
||||
\`tags\`,
|
||||
\`metadata\`,
|
||||
\`created_at\`,
|
||||
\`updated_at\`
|
||||
)
|
||||
SELECT
|
||||
r.\`user_id\`,
|
||||
NULL,
|
||||
r.\`ticker\`,
|
||||
r.\`accession_number\`,
|
||||
CASE
|
||||
WHEN r.\`entry_type\` = 'status_change' THEN 'status_change'
|
||||
ELSE 'note'
|
||||
END,
|
||||
CASE
|
||||
WHEN r.\`entry_type\` = 'status_change' THEN 'system'
|
||||
ELSE 'user'
|
||||
END,
|
||||
r.\`entry_type\`,
|
||||
r.\`title\`,
|
||||
CASE
|
||||
WHEN r.\`body_markdown\` IS NULL OR TRIM(r.\`body_markdown\`) = '' THEN NULL
|
||||
ELSE SUBSTR(r.\`body_markdown\`, 1, 280)
|
||||
END,
|
||||
r.\`body_markdown\`,
|
||||
r.\`body_markdown\`,
|
||||
'private',
|
||||
NULL,
|
||||
r.\`metadata\`,
|
||||
r.\`created_at\`,
|
||||
r.\`updated_at\`
|
||||
FROM \`research_journal_entry\` r
|
||||
WHERE EXISTS (SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'research_journal_entry')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM \`research_artifact\` a
|
||||
WHERE a.\`user_id\` = r.\`user_id\`
|
||||
AND a.\`ticker\` = r.\`ticker\`
|
||||
AND IFNULL(a.\`accession_number\`, '') = IFNULL(r.\`accession_number\`, '')
|
||||
AND a.\`kind\` = CASE
|
||||
WHEN r.\`entry_type\` = 'status_change' THEN 'status_change'
|
||||
ELSE 'note'
|
||||
END
|
||||
AND IFNULL(a.\`title\`, '') = IFNULL(r.\`title\`, '')
|
||||
AND a.\`created_at\` = r.\`created_at\`
|
||||
);
|
||||
`);
|
||||
|
||||
client.exec(`
|
||||
INSERT INTO \`research_artifact\` (
|
||||
\`user_id\`,
|
||||
\`organization_id\`,
|
||||
\`ticker\`,
|
||||
\`accession_number\`,
|
||||
\`kind\`,
|
||||
\`source\`,
|
||||
\`subtype\`,
|
||||
\`title\`,
|
||||
\`summary\`,
|
||||
\`body_markdown\`,
|
||||
\`search_text\`,
|
||||
\`visibility_scope\`,
|
||||
\`tags\`,
|
||||
\`metadata\`,
|
||||
\`created_at\`,
|
||||
\`updated_at\`
|
||||
)
|
||||
SELECT
|
||||
w.\`user_id\`,
|
||||
NULL,
|
||||
f.\`ticker\`,
|
||||
f.\`accession_number\`,
|
||||
'ai_report',
|
||||
'system',
|
||||
'filing_analysis',
|
||||
f.\`filing_type\` || ' AI memo',
|
||||
COALESCE(json_extract(f.\`analysis\`, '$.text'), json_extract(f.\`analysis\`, '$.legacyInsights')),
|
||||
'Stored AI memo for ' || f.\`company_name\` || ' (' || f.\`ticker\` || ').' || CHAR(10) ||
|
||||
'Accession: ' || f.\`accession_number\` || CHAR(10) || CHAR(10) ||
|
||||
COALESCE(json_extract(f.\`analysis\`, '$.text'), json_extract(f.\`analysis\`, '$.legacyInsights')),
|
||||
COALESCE(json_extract(f.\`analysis\`, '$.text'), json_extract(f.\`analysis\`, '$.legacyInsights')),
|
||||
'private',
|
||||
NULL,
|
||||
json_object(
|
||||
'provider', json_extract(f.\`analysis\`, '$.provider'),
|
||||
'model', json_extract(f.\`analysis\`, '$.model'),
|
||||
'filingType', f.\`filing_type\`,
|
||||
'filingDate', f.\`filing_date\`
|
||||
),
|
||||
f.\`created_at\`,
|
||||
f.\`updated_at\`
|
||||
FROM \`filing\` f
|
||||
JOIN \`watchlist_item\` w
|
||||
ON w.\`ticker\` = f.\`ticker\`
|
||||
WHERE f.\`analysis\` IS NOT NULL
|
||||
AND TRIM(COALESCE(json_extract(f.\`analysis\`, '$.text'), json_extract(f.\`analysis\`, '$.legacyInsights'), '')) <> ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM \`research_artifact\` a
|
||||
WHERE a.\`user_id\` = w.\`user_id\`
|
||||
AND a.\`ticker\` = f.\`ticker\`
|
||||
AND a.\`accession_number\` = f.\`accession_number\`
|
||||
AND a.\`kind\` = 'ai_report'
|
||||
);
|
||||
`);
|
||||
|
||||
client.exec('DELETE FROM `research_artifact_fts`;');
|
||||
client.exec(`
|
||||
INSERT INTO \`research_artifact_fts\` (
|
||||
\`artifact_id\`,
|
||||
\`user_id\`,
|
||||
\`ticker\`,
|
||||
\`title\`,
|
||||
\`summary\`,
|
||||
\`body_markdown\`,
|
||||
\`search_text\`,
|
||||
\`tags_text\`
|
||||
)
|
||||
SELECT
|
||||
\`id\`,
|
||||
\`user_id\`,
|
||||
\`ticker\`,
|
||||
COALESCE(\`title\`, ''),
|
||||
COALESCE(\`summary\`, ''),
|
||||
COALESCE(\`body_markdown\`, ''),
|
||||
COALESCE(\`search_text\`, ''),
|
||||
CASE
|
||||
WHEN \`tags\` IS NULL OR TRIM(\`tags\`) = '' THEN ''
|
||||
ELSE REPLACE(REPLACE(REPLACE(\`tags\`, '[', ''), ']', ''), '\"', '')
|
||||
END
|
||||
FROM \`research_artifact\`;
|
||||
`);
|
||||
}
|
||||
|
||||
function ensureLocalSqliteSchema(client: Database) {
|
||||
const missingBaseSchema = [
|
||||
'filing',
|
||||
'watchlist_item',
|
||||
'holding',
|
||||
'task_run',
|
||||
'portfolio_insight'
|
||||
].some((tableName) => !hasTable(client, tableName));
|
||||
|
||||
if (missingBaseSchema) {
|
||||
applyBaseSchemaCompat(client);
|
||||
}
|
||||
|
||||
if (!hasTable(client, 'user')) {
|
||||
client.exec(`
|
||||
CREATE TABLE IF NOT EXISTS \`user\` (
|
||||
\`id\` text PRIMARY KEY NOT NULL,
|
||||
\`name\` text NOT NULL,
|
||||
\`email\` text NOT NULL,
|
||||
\`emailVerified\` integer NOT NULL DEFAULT 0,
|
||||
\`image\` text,
|
||||
\`createdAt\` integer NOT NULL,
|
||||
\`updatedAt\` integer NOT NULL,
|
||||
\`role\` text,
|
||||
\`banned\` integer DEFAULT 0,
|
||||
\`banReason\` text,
|
||||
\`banExpires\` integer
|
||||
);
|
||||
`);
|
||||
client.exec('CREATE UNIQUE INDEX IF NOT EXISTS `user_email_uidx` ON `user` (`email`);');
|
||||
}
|
||||
|
||||
if (!hasTable(client, 'organization')) {
|
||||
client.exec(`
|
||||
CREATE TABLE IF NOT EXISTS \`organization\` (
|
||||
\`id\` text PRIMARY KEY NOT NULL,
|
||||
\`name\` text NOT NULL,
|
||||
\`slug\` text NOT NULL,
|
||||
\`logo\` text,
|
||||
\`createdAt\` integer NOT NULL,
|
||||
\`metadata\` text
|
||||
);
|
||||
`);
|
||||
client.exec('CREATE UNIQUE INDEX IF NOT EXISTS `organization_slug_uidx` ON `organization` (`slug`);');
|
||||
}
|
||||
|
||||
if (!hasTable(client, 'filing_statement_snapshot')) {
|
||||
applySqlFile(client, '0001_glossy_statement_snapshots.sql');
|
||||
}
|
||||
@@ -119,6 +416,10 @@ function ensureLocalSqliteSchema(client: Database) {
|
||||
applySqlFile(client, '0005_financial_taxonomy_v3.sql');
|
||||
}
|
||||
|
||||
if (!hasTable(client, 'company_financial_bundle')) {
|
||||
applySqlFile(client, '0007_company_financial_bundles.sql');
|
||||
}
|
||||
|
||||
if (!hasTable(client, 'research_journal_entry')) {
|
||||
client.exec(`
|
||||
CREATE TABLE IF NOT EXISTS \`research_journal_entry\` (
|
||||
@@ -138,6 +439,8 @@ function ensureLocalSqliteSchema(client: Database) {
|
||||
client.exec('CREATE INDEX IF NOT EXISTS `research_journal_ticker_idx` ON `research_journal_entry` (`user_id`, `ticker`, `created_at`);');
|
||||
client.exec('CREATE INDEX IF NOT EXISTS `research_journal_accession_idx` ON `research_journal_entry` (`user_id`, `accession_number`);');
|
||||
}
|
||||
|
||||
ensureResearchWorkspaceSchema(client);
|
||||
}
|
||||
|
||||
export function getSqliteClient() {
|
||||
|
||||
@@ -30,6 +30,27 @@ type TaxonomyMetricValidationStatus = 'not_run' | 'matched' | 'mismatch' | 'erro
|
||||
type CoverageStatus = 'backlog' | 'active' | 'watch' | 'archive';
|
||||
type CoveragePriority = 'low' | 'medium' | 'high';
|
||||
type ResearchJournalEntryType = 'note' | 'filing_note' | 'status_change';
|
||||
type ResearchArtifactKind = 'filing' | 'ai_report' | 'note' | 'upload' | 'memo_snapshot' | 'status_change';
|
||||
type ResearchArtifactSource = 'system' | 'user';
|
||||
type ResearchVisibilityScope = 'private' | 'organization';
|
||||
type ResearchMemoRating = 'strong_buy' | 'buy' | 'hold' | 'sell';
|
||||
type ResearchMemoConviction = 'low' | 'medium' | 'high';
|
||||
type ResearchMemoSection =
|
||||
| 'thesis'
|
||||
| 'variant_view'
|
||||
| 'catalysts'
|
||||
| 'risks'
|
||||
| 'disconfirming_evidence'
|
||||
| 'next_actions';
|
||||
type FinancialCadence = 'annual' | 'quarterly' | 'ltm';
|
||||
type FinancialSurfaceKind =
|
||||
| 'income_statement'
|
||||
| 'balance_sheet'
|
||||
| 'cash_flow_statement'
|
||||
| 'ratios'
|
||||
| 'segments_kpis'
|
||||
| 'adjusted'
|
||||
| 'custom_metrics';
|
||||
|
||||
type FilingAnalysis = {
|
||||
provider?: string;
|
||||
@@ -460,6 +481,22 @@ export const filingTaxonomyMetricValidation = sqliteTable('filing_taxonomy_metri
|
||||
filingTaxonomyMetricValidationUnique: uniqueIndex('filing_taxonomy_metric_validation_uidx').on(table.snapshot_id, table.metric_key)
|
||||
}));
|
||||
|
||||
export const companyFinancialBundle = sqliteTable('company_financial_bundle', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
ticker: text('ticker').notNull(),
|
||||
surface_kind: text('surface_kind').$type<FinancialSurfaceKind>().notNull(),
|
||||
cadence: text('cadence').$type<FinancialCadence>().notNull(),
|
||||
bundle_version: integer('bundle_version').notNull(),
|
||||
source_snapshot_ids: text('source_snapshot_ids', { mode: 'json' }).$type<number[]>().notNull(),
|
||||
source_signature: text('source_signature').notNull(),
|
||||
payload: text('payload', { mode: 'json' }).$type<Record<string, unknown>>().notNull(),
|
||||
created_at: text('created_at').notNull(),
|
||||
updated_at: text('updated_at').notNull()
|
||||
}, (table) => ({
|
||||
companyFinancialBundleUnique: uniqueIndex('company_financial_bundle_uidx').on(table.ticker, table.surface_kind, table.cadence),
|
||||
companyFinancialBundleTickerIndex: index('company_financial_bundle_ticker_idx').on(table.ticker, table.updated_at)
|
||||
}));
|
||||
|
||||
export const filingLink = sqliteTable('filing_link', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
filing_id: integer('filing_id').notNull().references(() => filing.id, { onDelete: 'cascade' }),
|
||||
@@ -545,6 +582,72 @@ export const researchJournalEntry = sqliteTable('research_journal_entry', {
|
||||
researchJournalAccessionIndex: index('research_journal_accession_idx').on(table.user_id, table.accession_number)
|
||||
}));
|
||||
|
||||
export const researchArtifact = sqliteTable('research_artifact', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
user_id: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
||||
organization_id: text('organization_id').references(() => organization.id, { onDelete: 'set null' }),
|
||||
ticker: text('ticker').notNull(),
|
||||
accession_number: text('accession_number'),
|
||||
kind: text('kind').$type<ResearchArtifactKind>().notNull(),
|
||||
source: text('source').$type<ResearchArtifactSource>().notNull().default('user'),
|
||||
subtype: text('subtype'),
|
||||
title: text('title'),
|
||||
summary: text('summary'),
|
||||
body_markdown: text('body_markdown'),
|
||||
search_text: text('search_text'),
|
||||
visibility_scope: text('visibility_scope').$type<ResearchVisibilityScope>().notNull().default('private'),
|
||||
tags: text('tags', { mode: 'json' }).$type<string[]>(),
|
||||
metadata: text('metadata', { mode: 'json' }).$type<Record<string, unknown> | null>(),
|
||||
file_name: text('file_name'),
|
||||
mime_type: text('mime_type'),
|
||||
file_size_bytes: integer('file_size_bytes'),
|
||||
storage_path: text('storage_path'),
|
||||
created_at: text('created_at').notNull(),
|
||||
updated_at: text('updated_at').notNull()
|
||||
}, (table) => ({
|
||||
researchArtifactTickerIndex: index('research_artifact_ticker_idx').on(table.user_id, table.ticker, table.updated_at),
|
||||
researchArtifactKindIndex: index('research_artifact_kind_idx').on(table.user_id, table.kind, table.updated_at),
|
||||
researchArtifactAccessionIndex: index('research_artifact_accession_idx').on(table.user_id, table.accession_number),
|
||||
researchArtifactSourceIndex: index('research_artifact_source_idx').on(table.user_id, table.source, table.updated_at)
|
||||
}));
|
||||
|
||||
export const researchMemo = sqliteTable('research_memo', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
user_id: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),
|
||||
organization_id: text('organization_id').references(() => organization.id, { onDelete: 'set null' }),
|
||||
ticker: text('ticker').notNull(),
|
||||
rating: text('rating').$type<ResearchMemoRating>(),
|
||||
conviction: text('conviction').$type<ResearchMemoConviction>(),
|
||||
time_horizon_months: integer('time_horizon_months'),
|
||||
packet_title: text('packet_title'),
|
||||
packet_subtitle: text('packet_subtitle'),
|
||||
thesis_markdown: text('thesis_markdown').notNull().default(''),
|
||||
variant_view_markdown: text('variant_view_markdown').notNull().default(''),
|
||||
catalysts_markdown: text('catalysts_markdown').notNull().default(''),
|
||||
risks_markdown: text('risks_markdown').notNull().default(''),
|
||||
disconfirming_evidence_markdown: text('disconfirming_evidence_markdown').notNull().default(''),
|
||||
next_actions_markdown: text('next_actions_markdown').notNull().default(''),
|
||||
created_at: text('created_at').notNull(),
|
||||
updated_at: text('updated_at').notNull()
|
||||
}, (table) => ({
|
||||
researchMemoTickerUnique: uniqueIndex('research_memo_ticker_uidx').on(table.user_id, table.ticker),
|
||||
researchMemoUpdatedIndex: index('research_memo_updated_idx').on(table.user_id, table.updated_at)
|
||||
}));
|
||||
|
||||
export const researchMemoEvidence = sqliteTable('research_memo_evidence', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
memo_id: integer('memo_id').notNull().references(() => researchMemo.id, { onDelete: 'cascade' }),
|
||||
artifact_id: integer('artifact_id').notNull().references(() => researchArtifact.id, { onDelete: 'cascade' }),
|
||||
section: text('section').$type<ResearchMemoSection>().notNull(),
|
||||
annotation: text('annotation'),
|
||||
sort_order: integer('sort_order').notNull().default(0),
|
||||
created_at: text('created_at').notNull()
|
||||
}, (table) => ({
|
||||
researchMemoEvidenceMemoIndex: index('research_memo_evidence_memo_idx').on(table.memo_id, table.section, table.sort_order),
|
||||
researchMemoEvidenceArtifactIndex: index('research_memo_evidence_artifact_idx').on(table.artifact_id),
|
||||
researchMemoEvidenceUnique: uniqueIndex('research_memo_evidence_unique_uidx').on(table.memo_id, table.artifact_id, table.section)
|
||||
}));
|
||||
|
||||
export const authSchema = {
|
||||
user,
|
||||
session,
|
||||
@@ -565,11 +668,15 @@ export const appSchema = {
|
||||
filingTaxonomyConcept,
|
||||
filingTaxonomyFact,
|
||||
filingTaxonomyMetricValidation,
|
||||
companyFinancialBundle,
|
||||
filingLink,
|
||||
taskRun,
|
||||
taskStageEvent,
|
||||
portfolioInsight,
|
||||
researchJournalEntry
|
||||
researchJournalEntry,
|
||||
researchArtifact,
|
||||
researchMemo,
|
||||
researchMemoEvidence
|
||||
};
|
||||
|
||||
export const schema = {
|
||||
|
||||
@@ -2,8 +2,7 @@ import { describe, expect, it } from 'bun:test';
|
||||
import { __financialStatementsInternals } from './financial-statements';
|
||||
|
||||
describe('financial statements service internals', () => {
|
||||
it('returns default sync limits by window', () => {
|
||||
expect(__financialStatementsInternals.defaultFinancialSyncLimit('10y')).toBe(60);
|
||||
expect(__financialStatementsInternals.defaultFinancialSyncLimit('all')).toBe(120);
|
||||
it('returns the default sync limit', () => {
|
||||
expect(__financialStatementsInternals.defaultFinancialSyncLimit()).toBe(60);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import type {
|
||||
CompanyFinancialStatementsResponse,
|
||||
FinancialHistoryWindow,
|
||||
FinancialStatementKind
|
||||
FinancialCadence,
|
||||
FinancialSurfaceKind
|
||||
} from '@/lib/types';
|
||||
import {
|
||||
defaultFinancialSyncLimit,
|
||||
getCompanyFinancialTaxonomy
|
||||
getCompanyFinancials
|
||||
} from '@/lib/server/financial-taxonomy';
|
||||
|
||||
type GetCompanyFinancialStatementsInput = {
|
||||
ticker: string;
|
||||
statement: FinancialStatementKind;
|
||||
window: FinancialHistoryWindow;
|
||||
surfaceKind: FinancialSurfaceKind;
|
||||
cadence: FinancialCadence;
|
||||
includeDimensions: boolean;
|
||||
includeFacts?: boolean;
|
||||
factsCursor?: string | null;
|
||||
@@ -26,10 +26,10 @@ type GetCompanyFinancialStatementsInput = {
|
||||
export async function getCompanyFinancialStatements(
|
||||
input: GetCompanyFinancialStatementsInput
|
||||
): Promise<CompanyFinancialStatementsResponse> {
|
||||
return await getCompanyFinancialTaxonomy({
|
||||
return await getCompanyFinancials({
|
||||
ticker: input.ticker,
|
||||
statement: input.statement,
|
||||
window: input.window,
|
||||
surfaceKind: input.surfaceKind,
|
||||
cadence: input.cadence,
|
||||
includeDimensions: input.includeDimensions,
|
||||
includeFacts: input.includeFacts ?? false,
|
||||
factsCursor: input.factsCursor,
|
||||
|
||||
@@ -171,7 +171,7 @@ describe('financial taxonomy internals', () => {
|
||||
]
|
||||
});
|
||||
|
||||
const selection = __financialTaxonomyInternals.selectPrimaryPeriods([snapshot], 'income');
|
||||
const selection = __financialTaxonomyInternals.selectPrimaryPeriodsByCadence([snapshot], 'income', 'quarterly');
|
||||
|
||||
expect(selection.periods).toHaveLength(1);
|
||||
expect(selection.periods[0]?.id).toBe('quarter');
|
||||
@@ -189,7 +189,7 @@ describe('financial taxonomy internals', () => {
|
||||
]
|
||||
});
|
||||
|
||||
const selection = __financialTaxonomyInternals.selectPrimaryPeriods([snapshot], 'balance');
|
||||
const selection = __financialTaxonomyInternals.selectPrimaryPeriodsByCadence([snapshot], 'balance', 'annual');
|
||||
|
||||
expect(selection.periods).toHaveLength(1);
|
||||
expect(selection.periods[0]?.id).toBe('current');
|
||||
@@ -218,9 +218,11 @@ describe('financial taxonomy internals', () => {
|
||||
]
|
||||
});
|
||||
|
||||
const periods = __financialTaxonomyInternals.buildPeriods([annual, quarterly], 'income');
|
||||
const annualPeriods = __financialTaxonomyInternals.selectPrimaryPeriodsByCadence([annual, quarterly], 'income', 'annual').periods;
|
||||
const quarterlyPeriods = __financialTaxonomyInternals.selectPrimaryPeriodsByCadence([annual, quarterly], 'income', 'quarterly').periods;
|
||||
|
||||
expect(periods.map((period) => period.id)).toEqual(['annual', 'quarter']);
|
||||
expect(annualPeriods.map((period) => period.id)).toEqual(['annual']);
|
||||
expect(quarterlyPeriods.map((period) => period.id)).toEqual(['quarter']);
|
||||
});
|
||||
|
||||
it('maps overlapping GAAP aliases into one standardized COGS row while preserving faithful rows', () => {
|
||||
@@ -281,14 +283,17 @@ describe('financial taxonomy internals', () => {
|
||||
], 'income', new Set(['2024-q4', '2025-q4']));
|
||||
|
||||
const standardizedRows = __financialTaxonomyInternals.buildStandardizedRows(
|
||||
faithfulRows,
|
||||
'income',
|
||||
[period2024, period2025]
|
||||
{
|
||||
rows: faithfulRows,
|
||||
statement: 'income',
|
||||
periods: [period2024, period2025],
|
||||
facts: []
|
||||
}
|
||||
);
|
||||
|
||||
expect(faithfulRows).toHaveLength(2);
|
||||
|
||||
const cogs = standardizedRows.find((row) => row.key === 'cost-of-revenue');
|
||||
const cogs = standardizedRows.find((row) => row.key === 'cost_of_revenue');
|
||||
expect(cogs).toBeDefined();
|
||||
expect(cogs?.values['2024-q4']).toBe(45_000);
|
||||
expect(cogs?.values['2025-q4']).toBe(48_000);
|
||||
@@ -327,9 +332,12 @@ describe('financial taxonomy internals', () => {
|
||||
})
|
||||
];
|
||||
const standardizedRows = __financialTaxonomyInternals.buildStandardizedRows(
|
||||
faithfulRows,
|
||||
'income',
|
||||
[period2024, period2025]
|
||||
{
|
||||
rows: faithfulRows,
|
||||
statement: 'income',
|
||||
periods: [period2024, period2025],
|
||||
facts: []
|
||||
}
|
||||
);
|
||||
|
||||
const breakdown = __financialTaxonomyInternals.buildDimensionBreakdown([
|
||||
@@ -355,7 +363,7 @@ describe('financial taxonomy internals', () => {
|
||||
})
|
||||
], [period2024, period2025], faithfulRows, standardizedRows);
|
||||
|
||||
const cogs = breakdown?.['cost-of-revenue'] ?? [];
|
||||
const cogs = breakdown?.['cost_of_revenue'] ?? [];
|
||||
expect(cogs).toHaveLength(2);
|
||||
expect(cogs.map((row) => row.sourceLabel)).toEqual([
|
||||
'Cost of Revenue',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
53
lib/server/financials/bundles.ts
Normal file
53
lib/server/financials/bundles.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type {
|
||||
FinancialCadence,
|
||||
FinancialSurfaceKind
|
||||
} from '@/lib/types';
|
||||
import {
|
||||
getCompanyFinancialBundle,
|
||||
upsertCompanyFinancialBundle
|
||||
} from '@/lib/server/repos/company-financial-bundles';
|
||||
import type { FilingTaxonomySnapshotRecord } from '@/lib/server/repos/filing-taxonomy';
|
||||
|
||||
export function computeSourceSignature(snapshots: FilingTaxonomySnapshotRecord[]) {
|
||||
return snapshots
|
||||
.map((snapshot) => `${snapshot.id}:${snapshot.updated_at}`)
|
||||
.sort((left, right) => left.localeCompare(right))
|
||||
.join('|');
|
||||
}
|
||||
|
||||
export async function readCachedFinancialBundle(input: {
|
||||
ticker: string;
|
||||
surfaceKind: FinancialSurfaceKind;
|
||||
cadence: FinancialCadence;
|
||||
snapshots: FilingTaxonomySnapshotRecord[];
|
||||
}) {
|
||||
const sourceSignature = computeSourceSignature(input.snapshots);
|
||||
const cached = await getCompanyFinancialBundle({
|
||||
ticker: input.ticker,
|
||||
surfaceKind: input.surfaceKind,
|
||||
cadence: input.cadence
|
||||
});
|
||||
|
||||
if (!cached || cached.source_signature !== sourceSignature) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return cached.payload;
|
||||
}
|
||||
|
||||
export async function writeFinancialBundle(input: {
|
||||
ticker: string;
|
||||
surfaceKind: FinancialSurfaceKind;
|
||||
cadence: FinancialCadence;
|
||||
snapshots: FilingTaxonomySnapshotRecord[];
|
||||
payload: Record<string, unknown>;
|
||||
}) {
|
||||
return await upsertCompanyFinancialBundle({
|
||||
ticker: input.ticker,
|
||||
surfaceKind: input.surfaceKind,
|
||||
cadence: input.cadence,
|
||||
sourceSnapshotIds: input.snapshots.map((snapshot) => snapshot.id),
|
||||
sourceSignature: computeSourceSignature(input.snapshots),
|
||||
payload: input.payload
|
||||
});
|
||||
}
|
||||
303
lib/server/financials/cadence.ts
Normal file
303
lib/server/financials/cadence.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
import type {
|
||||
FinancialCadence,
|
||||
FinancialStatementKind,
|
||||
FinancialStatementPeriod,
|
||||
FinancialSurfaceKind,
|
||||
TaxonomyStatementRow
|
||||
} from '@/lib/types';
|
||||
import type { FilingTaxonomySnapshotRecord } from '@/lib/server/repos/filing-taxonomy';
|
||||
|
||||
type PrimaryPeriodSelection = {
|
||||
periods: FinancialStatementPeriod[];
|
||||
selectedPeriodIds: Set<string>;
|
||||
snapshots: FilingTaxonomySnapshotRecord[];
|
||||
};
|
||||
|
||||
function parseEpoch(value: string | null) {
|
||||
if (!value) {
|
||||
return Number.NaN;
|
||||
}
|
||||
|
||||
return Date.parse(value);
|
||||
}
|
||||
|
||||
export function periodSorter(left: FinancialStatementPeriod, right: FinancialStatementPeriod) {
|
||||
const leftDate = parseEpoch(left.periodEnd ?? left.filingDate);
|
||||
const rightDate = parseEpoch(right.periodEnd ?? right.filingDate);
|
||||
|
||||
if (Number.isFinite(leftDate) && Number.isFinite(rightDate) && leftDate !== rightDate) {
|
||||
return leftDate - rightDate;
|
||||
}
|
||||
|
||||
return left.id.localeCompare(right.id);
|
||||
}
|
||||
|
||||
export function isInstantPeriod(period: FinancialStatementPeriod) {
|
||||
return period.periodStart === null;
|
||||
}
|
||||
|
||||
function periodDurationDays(period: FinancialStatementPeriod) {
|
||||
if (!period.periodStart || !period.periodEnd) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const start = Date.parse(period.periodStart);
|
||||
const end = Date.parse(period.periodEnd);
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.round((end - start) / 86_400_000) + 1;
|
||||
}
|
||||
|
||||
function preferredDurationDays(filingType: FinancialStatementPeriod['filingType']) {
|
||||
return filingType === '10-K' ? 365 : 90;
|
||||
}
|
||||
|
||||
function selectPrimaryPeriodFromSnapshot(
|
||||
snapshot: FilingTaxonomySnapshotRecord,
|
||||
statement: FinancialStatementKind
|
||||
) {
|
||||
const rows = snapshot.statement_rows?.[statement] ?? [];
|
||||
if (rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const usedPeriodIds = new Set<string>();
|
||||
for (const row of rows) {
|
||||
for (const periodId of Object.keys(row.values)) {
|
||||
usedPeriodIds.add(periodId);
|
||||
}
|
||||
}
|
||||
|
||||
const candidates = (snapshot.periods ?? []).filter((period) => usedPeriodIds.has(period.id));
|
||||
if (candidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (statement === 'balance') {
|
||||
const instantCandidates = candidates.filter(isInstantPeriod);
|
||||
return (instantCandidates.length > 0 ? instantCandidates : candidates)
|
||||
.sort((left, right) => periodSorter(right, left))[0] ?? null;
|
||||
}
|
||||
|
||||
const durationCandidates = candidates.filter((period) => !isInstantPeriod(period));
|
||||
if (durationCandidates.length === 0) {
|
||||
return candidates.sort((left, right) => periodSorter(right, left))[0] ?? null;
|
||||
}
|
||||
|
||||
const targetDays = preferredDurationDays(snapshot.filing_type);
|
||||
return durationCandidates.sort((left, right) => {
|
||||
const leftDate = parseEpoch(left.periodEnd ?? left.filingDate);
|
||||
const rightDate = parseEpoch(right.periodEnd ?? right.filingDate);
|
||||
if (Number.isFinite(leftDate) && Number.isFinite(rightDate) && leftDate !== rightDate) {
|
||||
return rightDate - leftDate;
|
||||
}
|
||||
|
||||
const leftDistance = Math.abs((periodDurationDays(left) ?? targetDays) - targetDays);
|
||||
const rightDistance = Math.abs((periodDurationDays(right) ?? targetDays) - targetDays);
|
||||
if (leftDistance !== rightDistance) {
|
||||
return leftDistance - rightDistance;
|
||||
}
|
||||
|
||||
return left.id.localeCompare(right.id);
|
||||
})[0] ?? null;
|
||||
}
|
||||
|
||||
function filingTypeForCadence(cadence: FinancialCadence) {
|
||||
return cadence === 'annual' ? '10-K' : '10-Q';
|
||||
}
|
||||
|
||||
export function surfaceToStatementKind(surfaceKind: FinancialSurfaceKind): FinancialStatementKind | null {
|
||||
switch (surfaceKind) {
|
||||
case 'income_statement':
|
||||
return 'income';
|
||||
case 'balance_sheet':
|
||||
return 'balance';
|
||||
case 'cash_flow_statement':
|
||||
return 'cash_flow';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isStatementSurface(surfaceKind: FinancialSurfaceKind) {
|
||||
return surfaceToStatementKind(surfaceKind) !== null;
|
||||
}
|
||||
|
||||
export function selectPrimaryPeriodsByCadence(
|
||||
snapshots: FilingTaxonomySnapshotRecord[],
|
||||
statement: FinancialStatementKind,
|
||||
cadence: FinancialCadence
|
||||
): PrimaryPeriodSelection {
|
||||
const filingType = filingTypeForCadence(cadence);
|
||||
const filteredSnapshots = snapshots.filter((snapshot) => snapshot.filing_type === filingType);
|
||||
|
||||
const selected = filteredSnapshots
|
||||
.map((snapshot) => ({
|
||||
snapshot,
|
||||
period: selectPrimaryPeriodFromSnapshot(snapshot, statement)
|
||||
}))
|
||||
.filter((entry): entry is { snapshot: FilingTaxonomySnapshotRecord; period: FinancialStatementPeriod } => entry.period !== null)
|
||||
.sort((left, right) => periodSorter(left.period, right.period));
|
||||
|
||||
const periods = selected.map((entry) => entry.period);
|
||||
return {
|
||||
periods,
|
||||
selectedPeriodIds: new Set(periods.map((period) => period.id)),
|
||||
snapshots: selected.map((entry) => entry.snapshot)
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRows(
|
||||
snapshots: FilingTaxonomySnapshotRecord[],
|
||||
statement: FinancialStatementKind,
|
||||
selectedPeriodIds: Set<string>
|
||||
) {
|
||||
const rowMap = new Map<string, TaxonomyStatementRow>();
|
||||
|
||||
for (const snapshot of snapshots) {
|
||||
const rows = snapshot.statement_rows?.[statement] ?? [];
|
||||
|
||||
for (const row of rows) {
|
||||
const existing = rowMap.get(row.key);
|
||||
if (!existing) {
|
||||
rowMap.set(row.key, {
|
||||
...row,
|
||||
values: Object.fromEntries(
|
||||
Object.entries(row.values).filter(([periodId]) => selectedPeriodIds.has(periodId))
|
||||
),
|
||||
units: Object.fromEntries(
|
||||
Object.entries(row.units).filter(([periodId]) => selectedPeriodIds.has(periodId))
|
||||
),
|
||||
sourceFactIds: [...row.sourceFactIds]
|
||||
});
|
||||
|
||||
if (Object.keys(rowMap.get(row.key)?.values ?? {}).length === 0) {
|
||||
rowMap.delete(row.key);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
existing.hasDimensions = existing.hasDimensions || row.hasDimensions;
|
||||
existing.order = Math.min(existing.order, row.order);
|
||||
existing.depth = Math.min(existing.depth, row.depth);
|
||||
if (!existing.parentKey && row.parentKey) {
|
||||
existing.parentKey = row.parentKey;
|
||||
}
|
||||
|
||||
for (const [periodId, value] of Object.entries(row.values)) {
|
||||
if (selectedPeriodIds.has(periodId) && !(periodId in existing.values)) {
|
||||
existing.values[periodId] = value;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [periodId, unit] of Object.entries(row.units)) {
|
||||
if (selectedPeriodIds.has(periodId) && !(periodId in existing.units)) {
|
||||
existing.units[periodId] = unit;
|
||||
}
|
||||
}
|
||||
|
||||
for (const factId of row.sourceFactIds) {
|
||||
if (!existing.sourceFactIds.includes(factId)) {
|
||||
existing.sourceFactIds.push(factId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...rowMap.values()].sort((left, right) => {
|
||||
if (left.order !== right.order) {
|
||||
return left.order - right.order;
|
||||
}
|
||||
|
||||
return left.label.localeCompare(right.label);
|
||||
});
|
||||
}
|
||||
|
||||
function canBuildRollingFour(periods: FinancialStatementPeriod[]) {
|
||||
if (periods.length < 4) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sorted = [...periods].sort(periodSorter);
|
||||
const first = sorted[0];
|
||||
const last = sorted[sorted.length - 1];
|
||||
const spanDays = Math.round((Date.parse(last.periodEnd ?? last.filingDate) - Date.parse(first.periodEnd ?? first.filingDate)) / 86_400_000);
|
||||
return spanDays >= 250 && spanDays <= 460;
|
||||
}
|
||||
|
||||
function aggregateValues(values: Array<number | null>) {
|
||||
if (values.some((value) => value === null)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return values.reduce<number>((sum, value) => sum + (value ?? 0), 0);
|
||||
}
|
||||
|
||||
export function buildLtmPeriods(periods: FinancialStatementPeriod[]) {
|
||||
const sorted = [...periods].sort(periodSorter);
|
||||
const windows: FinancialStatementPeriod[] = [];
|
||||
|
||||
for (let index = 3; index < sorted.length; index += 1) {
|
||||
const slice = sorted.slice(index - 3, index + 1);
|
||||
if (!canBuildRollingFour(slice)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const last = slice[slice.length - 1];
|
||||
windows.push({
|
||||
...last,
|
||||
id: `ltm:${last.id}`,
|
||||
periodStart: slice[0]?.periodStart ?? null,
|
||||
periodEnd: last.periodEnd,
|
||||
periodLabel: `LTM ending ${last.periodEnd ?? last.filingDate}`
|
||||
});
|
||||
}
|
||||
|
||||
return windows;
|
||||
}
|
||||
|
||||
export function buildLtmFaithfulRows(
|
||||
quarterlyRows: TaxonomyStatementRow[],
|
||||
quarterlyPeriods: FinancialStatementPeriod[],
|
||||
ltmPeriods: FinancialStatementPeriod[],
|
||||
statement: FinancialStatementKind
|
||||
) {
|
||||
const sourceRows = new Map(quarterlyRows.map((row) => [row.key, row]));
|
||||
const rowMap = new Map<string, TaxonomyStatementRow>();
|
||||
const sortedQuarterlyPeriods = [...quarterlyPeriods].sort(periodSorter);
|
||||
|
||||
for (const row of quarterlyRows) {
|
||||
rowMap.set(row.key, {
|
||||
...row,
|
||||
values: {},
|
||||
units: {}
|
||||
});
|
||||
}
|
||||
|
||||
for (const ltmPeriod of ltmPeriods) {
|
||||
const anchorIndex = sortedQuarterlyPeriods.findIndex((period) => `ltm:${period.id}` === ltmPeriod.id);
|
||||
if (anchorIndex < 3) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const slice = sortedQuarterlyPeriods.slice(anchorIndex - 3, anchorIndex + 1);
|
||||
for (const row of rowMap.values()) {
|
||||
const sourceRow = sourceRows.get(row.key);
|
||||
if (!sourceRow) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourceValues = slice.map((period) => sourceRow.values[period.id] ?? null);
|
||||
const sourceUnits = slice.map((period) => sourceRow.units[period.id] ?? null).filter((unit): unit is string => unit !== null);
|
||||
|
||||
row.values[ltmPeriod.id] = statement === 'balance'
|
||||
? sourceValues[sourceValues.length - 1] ?? null
|
||||
: aggregateValues(sourceValues);
|
||||
row.units[ltmPeriod.id] = sourceUnits[sourceUnits.length - 1] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
return [...rowMap.values()].filter((row) => Object.keys(row.values).length > 0);
|
||||
}
|
||||
25
lib/server/financials/canonical-definitions.ts
Normal file
25
lib/server/financials/canonical-definitions.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type {
|
||||
FinancialStatementKind,
|
||||
FinancialUnit
|
||||
} from '@/lib/types';
|
||||
import {
|
||||
BALANCE_SHEET_METRIC_DEFINITIONS,
|
||||
CASH_FLOW_STATEMENT_METRIC_DEFINITIONS,
|
||||
INCOME_STATEMENT_METRIC_DEFINITIONS
|
||||
} from '@/lib/financial-metrics';
|
||||
|
||||
export type CanonicalRowDefinition = {
|
||||
key: string;
|
||||
label: string;
|
||||
category: string;
|
||||
order: number;
|
||||
unit: FinancialUnit;
|
||||
localNames?: readonly string[];
|
||||
labelIncludes?: readonly string[];
|
||||
};
|
||||
|
||||
export const CANONICAL_ROW_DEFINITIONS: Record<Extract<FinancialStatementKind, 'income' | 'balance' | 'cash_flow'>, CanonicalRowDefinition[]> = {
|
||||
income: INCOME_STATEMENT_METRIC_DEFINITIONS,
|
||||
balance: BALANCE_SHEET_METRIC_DEFINITIONS,
|
||||
cash_flow: CASH_FLOW_STATEMENT_METRIC_DEFINITIONS
|
||||
};
|
||||
65
lib/server/financials/kpi-dimensions.test.ts
Normal file
65
lib/server/financials/kpi-dimensions.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import type {
|
||||
FinancialStatementPeriod,
|
||||
TaxonomyFactRow
|
||||
} from '@/lib/types';
|
||||
import { extractStructuredKpisFromDimensions } from './kpi-dimensions';
|
||||
|
||||
const PERIOD: FinancialStatementPeriod = {
|
||||
id: '2025-q4',
|
||||
filingId: 1,
|
||||
accessionNumber: '0000-1',
|
||||
filingDate: '2026-01-31',
|
||||
periodStart: '2025-10-01',
|
||||
periodEnd: '2025-12-31',
|
||||
filingType: '10-Q',
|
||||
periodLabel: 'Q4 2025'
|
||||
};
|
||||
|
||||
const FACT: TaxonomyFactRow = {
|
||||
id: 10,
|
||||
snapshotId: 5,
|
||||
filingId: 1,
|
||||
filingDate: '2026-01-31',
|
||||
statement: 'income',
|
||||
roleUri: 'income',
|
||||
conceptKey: 'us-gaap:Revenues',
|
||||
qname: 'us-gaap:Revenues',
|
||||
namespaceUri: 'http://fasb.org/us-gaap/2024',
|
||||
localName: 'Revenues',
|
||||
value: 50000,
|
||||
contextId: 'ctx-1',
|
||||
unit: 'iso4217:USD',
|
||||
decimals: null,
|
||||
periodStart: '2025-10-01',
|
||||
periodEnd: '2025-12-31',
|
||||
periodInstant: null,
|
||||
dimensions: [{
|
||||
axis: 'srt:ProductOrServiceAxis',
|
||||
member: 'msft:CloudMember'
|
||||
}],
|
||||
isDimensionless: false,
|
||||
sourceFile: null
|
||||
};
|
||||
|
||||
describe('dimension KPI extraction', () => {
|
||||
it('builds stable taxonomy KPI keys and provenance', () => {
|
||||
const rows = extractStructuredKpisFromDimensions({
|
||||
facts: [FACT],
|
||||
periods: [PERIOD],
|
||||
definitions: [{
|
||||
key: 'segment_revenue',
|
||||
label: 'Segment Revenue',
|
||||
category: 'segment_revenue',
|
||||
unit: 'currency',
|
||||
preferredConceptNames: ['Revenues']
|
||||
}]
|
||||
});
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]?.key).toBe('segment_revenue__srt_productorserviceaxis__msft_cloudmember');
|
||||
expect(rows[0]?.provenanceType).toBe('taxonomy');
|
||||
expect(rows[0]?.values['2025-q4']).toBe(50000);
|
||||
expect(rows[0]?.sourceFactIds).toEqual([10]);
|
||||
});
|
||||
});
|
||||
159
lib/server/financials/kpi-dimensions.ts
Normal file
159
lib/server/financials/kpi-dimensions.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import type {
|
||||
FinancialStatementPeriod,
|
||||
StructuredKpiRow,
|
||||
TaxonomyFactRow
|
||||
} from '@/lib/types';
|
||||
import type { KpiDefinition } from '@/lib/server/financials/kpi-registry';
|
||||
import { factMatchesPeriod } from '@/lib/server/financials/standardize';
|
||||
|
||||
function normalizeSegmentToken(value: string) {
|
||||
return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
|
||||
}
|
||||
|
||||
function humanizeMember(value: string) {
|
||||
const source = value.split(':').pop() ?? value;
|
||||
return source
|
||||
.replace(/Member$/i, '')
|
||||
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||
.replace(/_/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function factMatchesDefinition(fact: TaxonomyFactRow, definition: KpiDefinition) {
|
||||
if (definition.preferredConceptNames && !definition.preferredConceptNames.includes(fact.localName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!definition.preferredAxisIncludes || definition.preferredAxisIncludes.length === 0) {
|
||||
return fact.dimensions.length > 0;
|
||||
}
|
||||
|
||||
return fact.dimensions.some((dimension) => {
|
||||
const axisMatch = definition.preferredAxisIncludes?.some((token) => dimension.axis.toLowerCase().includes(token.toLowerCase())) ?? false;
|
||||
const memberMatch = definition.preferredMemberIncludes && definition.preferredMemberIncludes.length > 0
|
||||
? definition.preferredMemberIncludes.some((token) => dimension.member.toLowerCase().includes(token.toLowerCase()))
|
||||
: true;
|
||||
return axisMatch && memberMatch;
|
||||
});
|
||||
}
|
||||
|
||||
function categoryForDefinition(definition: KpiDefinition, axis: string) {
|
||||
if (definition.key === 'segment_revenue' && /geo|country|region|area/i.test(axis)) {
|
||||
return 'geographic_mix';
|
||||
}
|
||||
|
||||
return definition.category;
|
||||
}
|
||||
|
||||
export function extractStructuredKpisFromDimensions(input: {
|
||||
facts: TaxonomyFactRow[];
|
||||
periods: FinancialStatementPeriod[];
|
||||
definitions: KpiDefinition[];
|
||||
}) {
|
||||
const rowMap = new Map<string, StructuredKpiRow>();
|
||||
const orderByKey = new Map<string, number>();
|
||||
|
||||
input.definitions.forEach((definition, index) => {
|
||||
orderByKey.set(definition.key, (index + 1) * 10);
|
||||
});
|
||||
|
||||
for (const definition of input.definitions) {
|
||||
for (const fact of input.facts) {
|
||||
if (fact.dimensions.length === 0 || !factMatchesDefinition(fact, definition)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const matchedPeriod = input.periods.find((period) => period.filingId === fact.filingId && factMatchesPeriod(fact, period));
|
||||
if (!matchedPeriod) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const dimension of fact.dimensions) {
|
||||
const axis = dimension.axis;
|
||||
const member = dimension.member;
|
||||
const normalizedAxis = normalizeSegmentToken(axis);
|
||||
const normalizedMember = normalizeSegmentToken(member);
|
||||
const key = `${definition.key}__${normalizedAxis}__${normalizedMember}`;
|
||||
const labelSuffix = humanizeMember(member);
|
||||
const existing = rowMap.get(key);
|
||||
|
||||
if (existing) {
|
||||
existing.values[matchedPeriod.id] = fact.value;
|
||||
if (!existing.sourceConcepts.includes(fact.qname)) {
|
||||
existing.sourceConcepts.push(fact.qname);
|
||||
}
|
||||
if (!existing.sourceFactIds.includes(fact.id)) {
|
||||
existing.sourceFactIds.push(fact.id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
rowMap.set(key, {
|
||||
key,
|
||||
label: `${definition.label} - ${labelSuffix}`,
|
||||
category: categoryForDefinition(definition, axis),
|
||||
unit: definition.unit,
|
||||
order: orderByKey.get(definition.key) ?? 999,
|
||||
segment: labelSuffix || null,
|
||||
axis,
|
||||
member,
|
||||
values: { [matchedPeriod.id]: fact.value },
|
||||
sourceConcepts: [fact.qname],
|
||||
sourceFactIds: [fact.id],
|
||||
provenanceType: 'taxonomy',
|
||||
hasDimensions: true
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rows = [...rowMap.values()].sort((left, right) => {
|
||||
if (left.order !== right.order) {
|
||||
return left.order - right.order;
|
||||
}
|
||||
|
||||
return left.label.localeCompare(right.label);
|
||||
});
|
||||
|
||||
const marginRows = new Map<string, StructuredKpiRow>();
|
||||
for (const row of rows.filter((entry) => entry.category === 'segment_profit')) {
|
||||
const revenueKey = row.key.replace(/^segment_profit__/, 'segment_revenue__');
|
||||
const revenueRow = rowMap.get(revenueKey);
|
||||
if (!revenueRow) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const values: Record<string, number | null> = {};
|
||||
for (const period of input.periods) {
|
||||
const revenue = revenueRow.values[period.id] ?? null;
|
||||
const profit = row.values[period.id] ?? null;
|
||||
values[period.id] = revenue === null || profit === null || revenue === 0
|
||||
? null
|
||||
: profit / revenue;
|
||||
}
|
||||
|
||||
marginRows.set(row.key.replace(/^segment_profit__/, 'segment_margin__'), {
|
||||
key: row.key.replace(/^segment_profit__/, 'segment_margin__'),
|
||||
label: row.label.replace(/^Segment Profit/, 'Segment Margin'),
|
||||
category: 'segment_margin',
|
||||
unit: 'percent',
|
||||
order: 25,
|
||||
segment: row.segment,
|
||||
axis: row.axis,
|
||||
member: row.member,
|
||||
values,
|
||||
sourceConcepts: [...new Set([...row.sourceConcepts, ...revenueRow.sourceConcepts])],
|
||||
sourceFactIds: [...new Set([...row.sourceFactIds, ...revenueRow.sourceFactIds])],
|
||||
provenanceType: 'taxonomy',
|
||||
hasDimensions: true
|
||||
});
|
||||
}
|
||||
|
||||
return [...rows, ...marginRows.values()].sort((left, right) => {
|
||||
if (left.order !== right.order) {
|
||||
return left.order - right.order;
|
||||
}
|
||||
|
||||
return left.label.localeCompare(right.label);
|
||||
});
|
||||
}
|
||||
131
lib/server/financials/kpi-notes.ts
Normal file
131
lib/server/financials/kpi-notes.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { load } from 'cheerio';
|
||||
import type {
|
||||
FinancialStatementPeriod,
|
||||
StructuredKpiRow
|
||||
} from '@/lib/types';
|
||||
import { resolvePrimaryFilingUrl } from '@/lib/server/sec';
|
||||
import type { KpiDefinition } from '@/lib/server/financials/kpi-registry';
|
||||
|
||||
type FilingDocumentRef = {
|
||||
filingId: number;
|
||||
cik: string;
|
||||
accessionNumber: string;
|
||||
filingUrl: string | null;
|
||||
primaryDocument: string | null;
|
||||
};
|
||||
|
||||
function parseNumericCell(value: string) {
|
||||
const normalized = value.replace(/[$,%]/g, '').replace(/[(),]/g, '').trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const numeric = Number(normalized);
|
||||
return Number.isFinite(numeric) ? numeric : null;
|
||||
}
|
||||
|
||||
function buildRowKey(definition: KpiDefinition, label: string) {
|
||||
const normalized = label.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
|
||||
return normalized ? `${definition.key}__note__${normalized}` : definition.key;
|
||||
}
|
||||
|
||||
async function fetchHtml(ref: FilingDocumentRef) {
|
||||
const url = resolvePrimaryFilingUrl({
|
||||
filingUrl: ref.filingUrl,
|
||||
cik: ref.cik,
|
||||
accessionNumber: ref.accessionNumber,
|
||||
primaryDocument: ref.primaryDocument
|
||||
});
|
||||
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': process.env.SEC_USER_AGENT || 'Fiscal Clone <support@fiscal.local>'
|
||||
},
|
||||
cache: 'no-store'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await response.text();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function extractStructuredKpisFromNotes(input: {
|
||||
ticker: string;
|
||||
periods: FinancialStatementPeriod[];
|
||||
filings: FilingDocumentRef[];
|
||||
definitions: KpiDefinition[];
|
||||
}) {
|
||||
const rows = new Map<string, StructuredKpiRow>();
|
||||
|
||||
for (const definition of input.definitions) {
|
||||
if (!definition.noteLabelIncludes || definition.noteLabelIncludes.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const period of input.periods) {
|
||||
const filing = input.filings.find((entry) => entry.filingId === period.filingId);
|
||||
if (!filing) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const html = await fetchHtml(filing);
|
||||
if (!html) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const $ = load(html);
|
||||
$('table tr').each((_index, element) => {
|
||||
const cells = $(element).find('th,td').toArray().map((node) => $(node).text().replace(/\s+/g, ' ').trim()).filter(Boolean);
|
||||
if (cells.length < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
const label = cells[0] ?? '';
|
||||
const normalizedLabel = label.toLowerCase();
|
||||
if (!definition.noteLabelIncludes?.some((token) => normalizedLabel.includes(token.toLowerCase()))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const numericCell = cells.slice(1).map(parseNumericCell).find((value) => value !== null) ?? null;
|
||||
if (numericCell === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = buildRowKey(definition, label === definition.label ? '' : label);
|
||||
const existing = rows.get(key);
|
||||
if (existing) {
|
||||
existing.values[period.id] = numericCell;
|
||||
return;
|
||||
}
|
||||
|
||||
rows.set(key, {
|
||||
key,
|
||||
label: label || definition.label,
|
||||
category: definition.category,
|
||||
unit: definition.unit,
|
||||
order: 500,
|
||||
segment: null,
|
||||
axis: null,
|
||||
member: null,
|
||||
values: { [period.id]: numericCell },
|
||||
sourceConcepts: [],
|
||||
sourceFactIds: [],
|
||||
provenanceType: 'structured_note',
|
||||
hasDimensions: false
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [...rows.values()].sort((left, right) => left.label.localeCompare(right.label));
|
||||
}
|
||||
120
lib/server/financials/kpi-registry.ts
Normal file
120
lib/server/financials/kpi-registry.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import type {
|
||||
FinancialCategory,
|
||||
FinancialUnit
|
||||
} from '@/lib/types';
|
||||
|
||||
export type IndustryTemplate =
|
||||
| 'internet_platforms'
|
||||
| 'software_saas'
|
||||
| 'semiconductors_industrial_auto';
|
||||
|
||||
export type KpiDefinition = {
|
||||
key: string;
|
||||
label: string;
|
||||
category: FinancialCategory;
|
||||
unit: FinancialUnit;
|
||||
preferredConceptNames?: string[];
|
||||
preferredAxisIncludes?: string[];
|
||||
preferredMemberIncludes?: string[];
|
||||
noteLabelIncludes?: string[];
|
||||
};
|
||||
|
||||
type RegistryBundle = {
|
||||
tickerTemplates: Record<string, IndustryTemplate>;
|
||||
globalDefinitions: KpiDefinition[];
|
||||
industryDefinitions: Record<IndustryTemplate, KpiDefinition[]>;
|
||||
tickerDefinitions: Record<string, KpiDefinition[]>;
|
||||
};
|
||||
|
||||
const KPI_REGISTRY: RegistryBundle = {
|
||||
tickerTemplates: {
|
||||
GOOG: 'internet_platforms',
|
||||
META: 'internet_platforms',
|
||||
NFLX: 'internet_platforms',
|
||||
MSFT: 'software_saas',
|
||||
CRM: 'software_saas',
|
||||
NOW: 'software_saas',
|
||||
NVDA: 'semiconductors_industrial_auto',
|
||||
TSLA: 'semiconductors_industrial_auto',
|
||||
CAT: 'semiconductors_industrial_auto'
|
||||
},
|
||||
globalDefinitions: [
|
||||
{
|
||||
key: 'segment_revenue',
|
||||
label: 'Segment Revenue',
|
||||
category: 'segment_revenue',
|
||||
unit: 'currency',
|
||||
preferredConceptNames: ['Revenues', 'RevenueFromContractWithCustomerExcludingAssessedTax', 'SalesRevenueNet']
|
||||
},
|
||||
{
|
||||
key: 'segment_profit',
|
||||
label: 'Segment Profit',
|
||||
category: 'segment_profit',
|
||||
unit: 'currency',
|
||||
preferredConceptNames: ['OperatingIncomeLoss', 'SegmentProfitLoss']
|
||||
}
|
||||
],
|
||||
industryDefinitions: {
|
||||
internet_platforms: [
|
||||
{ key: 'tac', label: 'TAC', category: 'operating_kpi', unit: 'currency', preferredConceptNames: ['TrafficAcquisitionCosts'], noteLabelIncludes: ['traffic acquisition costs', 'tac'] },
|
||||
{ key: 'paid_clicks', label: 'Paid Clicks', category: 'operating_kpi', unit: 'count', noteLabelIncludes: ['paid clicks'] },
|
||||
{ key: 'cpc', label: 'CPC', category: 'operating_kpi', unit: 'currency', noteLabelIncludes: ['cost per click', 'cpc'] },
|
||||
{ key: 'dau', label: 'DAU', category: 'user_metric', unit: 'count', preferredConceptNames: ['DailyActiveUsers'], noteLabelIncludes: ['daily active users', 'dau'] },
|
||||
{ key: 'mau', label: 'MAU', category: 'user_metric', unit: 'count', preferredConceptNames: ['MonthlyActiveUsers'], noteLabelIncludes: ['monthly active users', 'mau'] },
|
||||
{ key: 'arpu', label: 'ARPU', category: 'operating_kpi', unit: 'currency', noteLabelIncludes: ['average revenue per user', 'arpu'] },
|
||||
{ key: 'arpp', label: 'ARPP', category: 'operating_kpi', unit: 'currency', noteLabelIncludes: ['average revenue per paying user', 'arpp'] },
|
||||
{ key: 'backlog', label: 'Backlog', category: 'backlog', unit: 'currency', noteLabelIncludes: ['backlog'] }
|
||||
],
|
||||
software_saas: [
|
||||
{ key: 'arr', label: 'ARR', category: 'operating_kpi', unit: 'currency', noteLabelIncludes: ['annual recurring revenue', 'arr'] },
|
||||
{ key: 'rpo', label: 'RPO', category: 'backlog', unit: 'currency', preferredConceptNames: ['RemainingPerformanceObligation'], noteLabelIncludes: ['remaining performance obligations', 'rpo'] },
|
||||
{ key: 'remaining_performance_obligations', label: 'Remaining Performance Obligations', category: 'backlog', unit: 'currency', preferredConceptNames: ['RemainingPerformanceObligation'], noteLabelIncludes: ['remaining performance obligations'] },
|
||||
{ key: 'large_customer_count', label: 'Large Customer Count', category: 'operating_kpi', unit: 'count', noteLabelIncludes: ['customers contributing', 'large customers'] }
|
||||
],
|
||||
semiconductors_industrial_auto: [
|
||||
{ key: 'deliveries', label: 'Deliveries', category: 'operating_kpi', unit: 'count', noteLabelIncludes: ['deliveries'] },
|
||||
{ key: 'utilization', label: 'Utilization', category: 'operating_kpi', unit: 'percent', noteLabelIncludes: ['utilization'] },
|
||||
{ key: 'backlog', label: 'Backlog', category: 'backlog', unit: 'currency', noteLabelIncludes: ['backlog'] }
|
||||
]
|
||||
},
|
||||
tickerDefinitions: {}
|
||||
};
|
||||
|
||||
export function getTickerIndustryTemplate(ticker: string): IndustryTemplate | null {
|
||||
return KPI_REGISTRY.tickerTemplates[ticker.trim().toUpperCase()] ?? null;
|
||||
}
|
||||
|
||||
export function resolveKpiDefinitions(ticker: string) {
|
||||
const normalizedTicker = ticker.trim().toUpperCase();
|
||||
const template = getTickerIndustryTemplate(normalizedTicker);
|
||||
|
||||
const definitionsByKey = new Map<string, KpiDefinition>();
|
||||
for (const definition of KPI_REGISTRY.globalDefinitions) {
|
||||
definitionsByKey.set(definition.key, definition);
|
||||
}
|
||||
if (template) {
|
||||
for (const definition of KPI_REGISTRY.industryDefinitions[template]) {
|
||||
definitionsByKey.set(definition.key, definition);
|
||||
}
|
||||
}
|
||||
for (const definition of KPI_REGISTRY.tickerDefinitions[normalizedTicker] ?? []) {
|
||||
definitionsByKey.set(definition.key, definition);
|
||||
}
|
||||
|
||||
return {
|
||||
template,
|
||||
definitions: [...definitionsByKey.values()]
|
||||
};
|
||||
}
|
||||
|
||||
export const KPI_CATEGORY_ORDER = [
|
||||
'segment_revenue',
|
||||
'segment_profit',
|
||||
'segment_margin',
|
||||
'operating_kpi',
|
||||
'geographic_mix',
|
||||
'capital_returns',
|
||||
'backlog',
|
||||
'user_metric',
|
||||
'other'
|
||||
] as const;
|
||||
81
lib/server/financials/ratios.test.ts
Normal file
81
lib/server/financials/ratios.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import type {
|
||||
FinancialCadence,
|
||||
FinancialStatementPeriod,
|
||||
StandardizedFinancialRow
|
||||
} from '@/lib/types';
|
||||
import { buildRatioRows } from './ratios';
|
||||
|
||||
function createPeriod(id: string, filingId: number, filingDate: string, periodEnd: string): FinancialStatementPeriod {
|
||||
return {
|
||||
id,
|
||||
filingId,
|
||||
accessionNumber: `0000-${filingId}`,
|
||||
filingDate,
|
||||
periodStart: '2025-01-01',
|
||||
periodEnd,
|
||||
filingType: '10-Q',
|
||||
periodLabel: id
|
||||
};
|
||||
}
|
||||
|
||||
function createRow(key: string, values: Record<string, number | null>): StandardizedFinancialRow {
|
||||
return {
|
||||
key,
|
||||
label: key,
|
||||
category: 'test',
|
||||
order: 10,
|
||||
unit: 'currency',
|
||||
values,
|
||||
sourceConcepts: [`us-gaap:${key}`],
|
||||
sourceRowKeys: [key],
|
||||
sourceFactIds: [1],
|
||||
formulaKey: null,
|
||||
hasDimensions: false,
|
||||
resolvedSourceRowKeys: Object.fromEntries(Object.keys(values).map((periodId) => [periodId, key]))
|
||||
};
|
||||
}
|
||||
|
||||
describe('ratio engine', () => {
|
||||
it('nulls valuation ratios when price data is unavailable', () => {
|
||||
const periods = [createPeriod('2025-q4', 1, '2026-01-31', '2025-12-31')];
|
||||
const rows = {
|
||||
income: [createRow('revenue', { '2025-q4': 100 }), createRow('diluted_eps', { '2025-q4': 2 }), createRow('ebitda', { '2025-q4': 20 }), createRow('net_income', { '2025-q4': 10 })],
|
||||
balance: [createRow('total_equity', { '2025-q4': 50 }), createRow('total_debt', { '2025-q4': 30 }), createRow('cash_and_equivalents', { '2025-q4': 5 }), createRow('short_term_investments', { '2025-q4': 5 }), createRow('diluted_shares', { '2025-q4': 10 })],
|
||||
cashFlow: [createRow('free_cash_flow', { '2025-q4': 12 })]
|
||||
};
|
||||
|
||||
const ratioRows = buildRatioRows({
|
||||
periods,
|
||||
cadence: 'quarterly' satisfies FinancialCadence,
|
||||
rows,
|
||||
pricesByPeriodId: { '2025-q4': null }
|
||||
});
|
||||
|
||||
expect(ratioRows.find((row) => row.key === 'market_cap')?.values['2025-q4']).toBeNull();
|
||||
expect(ratioRows.find((row) => row.key === 'price_to_earnings')?.values['2025-q4']).toBeNull();
|
||||
expect(ratioRows.find((row) => row.key === 'ev_to_sales')?.values['2025-q4']).toBeNull();
|
||||
});
|
||||
|
||||
it('nulls ratios on zero denominators', () => {
|
||||
const periods = [
|
||||
createPeriod('2024-q4', 1, '2025-01-31', '2024-12-31'),
|
||||
createPeriod('2025-q4', 2, '2026-01-31', '2025-12-31')
|
||||
];
|
||||
const rows = {
|
||||
income: [createRow('net_income', { '2024-q4': 5, '2025-q4': 10 }), createRow('revenue', { '2024-q4': 100, '2025-q4': 120 }), createRow('diluted_eps', { '2024-q4': 1, '2025-q4': 2 }), createRow('ebitda', { '2024-q4': 10, '2025-q4': 12 })],
|
||||
balance: [createRow('total_equity', { '2024-q4': 0, '2025-q4': 0 }), createRow('total_assets', { '2024-q4': 50, '2025-q4': 60 }), createRow('total_debt', { '2024-q4': 20, '2025-q4': 25 }), createRow('cash_and_equivalents', { '2024-q4': 2, '2025-q4': 3 }), createRow('short_term_investments', { '2024-q4': 1, '2025-q4': 1 }), createRow('current_assets', { '2024-q4': 10, '2025-q4': 12 }), createRow('current_liabilities', { '2024-q4': 0, '2025-q4': 0 }), createRow('diluted_shares', { '2024-q4': 10, '2025-q4': 10 })],
|
||||
cashFlow: [createRow('free_cash_flow', { '2024-q4': 6, '2025-q4': 7 })]
|
||||
};
|
||||
|
||||
const ratioRows = buildRatioRows({
|
||||
periods,
|
||||
cadence: 'quarterly',
|
||||
rows,
|
||||
pricesByPeriodId: { '2024-q4': 10, '2025-q4': 12 }
|
||||
});
|
||||
|
||||
expect(ratioRows.find((row) => row.key === 'debt_to_equity')?.values['2025-q4']).toBeNull();
|
||||
expect(ratioRows.find((row) => row.key === 'current_ratio')?.values['2025-q4']).toBeNull();
|
||||
});
|
||||
});
|
||||
320
lib/server/financials/ratios.ts
Normal file
320
lib/server/financials/ratios.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
import type {
|
||||
FinancialCadence,
|
||||
FinancialStatementPeriod,
|
||||
RatioRow,
|
||||
StandardizedFinancialRow
|
||||
} from '@/lib/types';
|
||||
import {
|
||||
RATIO_CATEGORY_ORDER,
|
||||
RATIO_DEFINITIONS
|
||||
} from '@/lib/financial-metrics';
|
||||
|
||||
type StatementRowMap = {
|
||||
income: StandardizedFinancialRow[];
|
||||
balance: StandardizedFinancialRow[];
|
||||
cashFlow: StandardizedFinancialRow[];
|
||||
};
|
||||
|
||||
function valueFor(row: StandardizedFinancialRow | undefined, periodId: string) {
|
||||
return row?.values[periodId] ?? null;
|
||||
}
|
||||
|
||||
function divideValues(left: number | null, right: number | null) {
|
||||
if (left === null || right === null || right === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return left / right;
|
||||
}
|
||||
|
||||
function averageValues(left: number | null, right: number | null) {
|
||||
if (left === null || right === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (left + right) / 2;
|
||||
}
|
||||
|
||||
function sumValues(values: Array<number | null>) {
|
||||
if (values.some((value) => value === null)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return values.reduce<number>((sum, value) => sum + (value ?? 0), 0);
|
||||
}
|
||||
|
||||
function subtractValues(left: number | null, right: number | null) {
|
||||
if (left === null || right === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return left - right;
|
||||
}
|
||||
|
||||
function buildRowMap(rows: StatementRowMap) {
|
||||
return new Map<string, StandardizedFinancialRow>([
|
||||
...rows.income.map((row) => [row.key, row] as const),
|
||||
...rows.balance.map((row) => [row.key, row] as const),
|
||||
...rows.cashFlow.map((row) => [row.key, row] as const)
|
||||
]);
|
||||
}
|
||||
|
||||
function collectSourceRowData(rowsByKey: Map<string, StandardizedFinancialRow>, keys: string[]) {
|
||||
const sourceConcepts = new Set<string>();
|
||||
const sourceRowKeys = new Set<string>();
|
||||
const sourceFactIds = new Set<number>();
|
||||
let hasDimensions = false;
|
||||
|
||||
for (const key of keys) {
|
||||
const row = rowsByKey.get(key);
|
||||
if (!row) {
|
||||
continue;
|
||||
}
|
||||
|
||||
hasDimensions = hasDimensions || row.hasDimensions;
|
||||
for (const concept of row.sourceConcepts) {
|
||||
sourceConcepts.add(concept);
|
||||
}
|
||||
for (const sourceRowKey of row.sourceRowKeys) {
|
||||
sourceRowKeys.add(sourceRowKey);
|
||||
}
|
||||
for (const sourceFactId of row.sourceFactIds) {
|
||||
sourceFactIds.add(sourceFactId);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sourceConcepts: [...sourceConcepts].sort((left, right) => left.localeCompare(right)),
|
||||
sourceRowKeys: [...sourceRowKeys].sort((left, right) => left.localeCompare(right)),
|
||||
sourceFactIds: [...sourceFactIds].sort((left, right) => left - right),
|
||||
hasDimensions
|
||||
};
|
||||
}
|
||||
|
||||
function previousPeriodId(periods: FinancialStatementPeriod[], periodId: string) {
|
||||
const index = periods.findIndex((period) => period.id === periodId);
|
||||
return index > 0 ? periods[index - 1]?.id ?? null : null;
|
||||
}
|
||||
|
||||
function cagr(current: number | null, previous: number | null, years: number) {
|
||||
if (current === null || previous === null || previous <= 0 || years <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Math.pow(current / previous, 1 / years) - 1;
|
||||
}
|
||||
|
||||
export function buildRatioRows(input: {
|
||||
periods: FinancialStatementPeriod[];
|
||||
cadence: FinancialCadence;
|
||||
rows: StatementRowMap;
|
||||
pricesByPeriodId: Record<string, number | null>;
|
||||
}) {
|
||||
const periods = [...input.periods].sort((left, right) => {
|
||||
return Date.parse(left.periodEnd ?? left.filingDate) - Date.parse(right.periodEnd ?? right.filingDate);
|
||||
});
|
||||
const rowsByKey = buildRowMap(input.rows);
|
||||
|
||||
const valuesByKey = new Map<string, Record<string, number | null>>();
|
||||
const setValue = (key: string, periodId: string, value: number | null) => {
|
||||
const existing = valuesByKey.get(key);
|
||||
if (existing) {
|
||||
existing[periodId] = value;
|
||||
} else {
|
||||
valuesByKey.set(key, { [periodId]: value });
|
||||
}
|
||||
};
|
||||
|
||||
for (const period of periods) {
|
||||
const periodId = period.id;
|
||||
const priorId = previousPeriodId(periods, periodId);
|
||||
|
||||
const revenue = valueFor(rowsByKey.get('revenue'), periodId);
|
||||
const grossProfit = valueFor(rowsByKey.get('gross_profit'), periodId);
|
||||
const operatingIncome = valueFor(rowsByKey.get('operating_income'), periodId);
|
||||
const ebitda = valueFor(rowsByKey.get('ebitda'), periodId);
|
||||
const netIncome = valueFor(rowsByKey.get('net_income'), periodId);
|
||||
const freeCashFlow = valueFor(rowsByKey.get('free_cash_flow'), periodId);
|
||||
const totalAssets = valueFor(rowsByKey.get('total_assets'), periodId);
|
||||
const priorAssets = priorId ? valueFor(rowsByKey.get('total_assets'), priorId) : null;
|
||||
const totalEquity = valueFor(rowsByKey.get('total_equity'), periodId);
|
||||
const priorEquity = priorId ? valueFor(rowsByKey.get('total_equity'), priorId) : null;
|
||||
const totalDebt = valueFor(rowsByKey.get('total_debt'), periodId);
|
||||
const cash = valueFor(rowsByKey.get('cash_and_equivalents'), periodId);
|
||||
const shortTermInvestments = valueFor(rowsByKey.get('short_term_investments'), periodId);
|
||||
const currentAssets = valueFor(rowsByKey.get('current_assets'), periodId);
|
||||
const currentLiabilities = valueFor(rowsByKey.get('current_liabilities'), periodId);
|
||||
const dilutedShares = valueFor(rowsByKey.get('diluted_shares'), periodId);
|
||||
const dilutedEps = valueFor(rowsByKey.get('diluted_eps'), periodId);
|
||||
const effectiveTaxRate = valueFor(rowsByKey.get('effective_tax_rate'), periodId);
|
||||
const pretaxIncome = valueFor(rowsByKey.get('pretax_income'), periodId);
|
||||
const incomeTaxExpense = valueFor(rowsByKey.get('income_tax_expense'), periodId);
|
||||
const priorRevenue = priorId ? valueFor(rowsByKey.get('revenue'), priorId) : null;
|
||||
const priorNetIncome = priorId ? valueFor(rowsByKey.get('net_income'), priorId) : null;
|
||||
const priorDilutedEps = priorId ? valueFor(rowsByKey.get('diluted_eps'), priorId) : null;
|
||||
const priorFcf = priorId ? valueFor(rowsByKey.get('free_cash_flow'), priorId) : null;
|
||||
const price = input.pricesByPeriodId[periodId] ?? null;
|
||||
|
||||
const fallbackTaxRate = divideValues(incomeTaxExpense, pretaxIncome);
|
||||
const nopat = operatingIncome === null
|
||||
? null
|
||||
: (effectiveTaxRate ?? fallbackTaxRate) === null
|
||||
? null
|
||||
: operatingIncome * (1 - ((effectiveTaxRate ?? fallbackTaxRate) ?? 0));
|
||||
const investedCapital = subtractValues(sumValues([totalDebt, totalEquity]), sumValues([cash, shortTermInvestments]));
|
||||
const priorInvestedCapital = priorId
|
||||
? subtractValues(
|
||||
sumValues([
|
||||
valueFor(rowsByKey.get('total_debt'), priorId),
|
||||
valueFor(rowsByKey.get('total_equity'), priorId)
|
||||
]),
|
||||
sumValues([
|
||||
valueFor(rowsByKey.get('cash_and_equivalents'), priorId),
|
||||
valueFor(rowsByKey.get('short_term_investments'), priorId)
|
||||
])
|
||||
)
|
||||
: null;
|
||||
const averageInvestedCapital = averageValues(investedCapital, priorInvestedCapital);
|
||||
const capitalEmployed = subtractValues(totalAssets, currentLiabilities);
|
||||
const priorCapitalEmployed = priorId
|
||||
? subtractValues(valueFor(rowsByKey.get('total_assets'), priorId), valueFor(rowsByKey.get('current_liabilities'), priorId))
|
||||
: null;
|
||||
const averageCapitalEmployed = averageValues(capitalEmployed, priorCapitalEmployed);
|
||||
const marketCap = price === null || dilutedShares === null ? null : price * dilutedShares;
|
||||
const enterpriseValue = marketCap === null ? null : subtractValues(sumValues([marketCap, totalDebt]), sumValues([cash, shortTermInvestments]));
|
||||
|
||||
setValue('gross_margin', periodId, divideValues(grossProfit, revenue));
|
||||
setValue('operating_margin', periodId, divideValues(operatingIncome, revenue));
|
||||
setValue('ebitda_margin', periodId, divideValues(ebitda, revenue));
|
||||
setValue('net_margin', periodId, divideValues(netIncome, revenue));
|
||||
setValue('fcf_margin', periodId, divideValues(freeCashFlow, revenue));
|
||||
setValue('roa', periodId, divideValues(netIncome, averageValues(totalAssets, priorAssets)));
|
||||
setValue('roe', periodId, divideValues(netIncome, averageValues(totalEquity, priorEquity)));
|
||||
setValue('roic', periodId, divideValues(nopat, averageInvestedCapital));
|
||||
setValue('roce', periodId, divideValues(operatingIncome, averageCapitalEmployed));
|
||||
setValue('debt_to_equity', periodId, divideValues(totalDebt, totalEquity));
|
||||
setValue('net_debt_to_ebitda', periodId, divideValues(subtractValues(totalDebt, sumValues([cash, shortTermInvestments])), ebitda));
|
||||
setValue('cash_to_debt', periodId, divideValues(sumValues([cash, shortTermInvestments]), totalDebt));
|
||||
setValue('current_ratio', periodId, divideValues(currentAssets, currentLiabilities));
|
||||
setValue('revenue_per_share', periodId, divideValues(revenue, dilutedShares));
|
||||
setValue('fcf_per_share', periodId, divideValues(freeCashFlow, dilutedShares));
|
||||
setValue('book_value_per_share', periodId, divideValues(totalEquity, dilutedShares));
|
||||
setValue('revenue_yoy', periodId, priorId ? divideValues(subtractValues(revenue, priorRevenue), priorRevenue) : null);
|
||||
setValue('net_income_yoy', periodId, priorId ? divideValues(subtractValues(netIncome, priorNetIncome), priorNetIncome) : null);
|
||||
setValue('eps_yoy', periodId, priorId ? divideValues(subtractValues(dilutedEps, priorDilutedEps), priorDilutedEps) : null);
|
||||
setValue('fcf_yoy', periodId, priorId ? divideValues(subtractValues(freeCashFlow, priorFcf), priorFcf) : null);
|
||||
setValue('market_cap', periodId, marketCap);
|
||||
setValue('enterprise_value', periodId, enterpriseValue);
|
||||
setValue('price_to_earnings', periodId, divideValues(price, dilutedEps));
|
||||
setValue('price_to_fcf', periodId, divideValues(marketCap, freeCashFlow));
|
||||
setValue('price_to_book', periodId, divideValues(marketCap, totalEquity));
|
||||
setValue('ev_to_sales', periodId, divideValues(enterpriseValue, revenue));
|
||||
setValue('ev_to_ebitda', periodId, divideValues(enterpriseValue, ebitda));
|
||||
setValue('ev_to_fcf', periodId, divideValues(enterpriseValue, freeCashFlow));
|
||||
}
|
||||
|
||||
if (input.cadence === 'annual') {
|
||||
for (let index = 0; index < periods.length; index += 1) {
|
||||
const period = periods[index];
|
||||
const periodId = period.id;
|
||||
const revenue = valueFor(rowsByKey.get('revenue'), periodId);
|
||||
const eps = valueFor(rowsByKey.get('diluted_eps'), periodId);
|
||||
setValue('3y_revenue_cagr', periodId, index >= 3 ? cagr(revenue, valueFor(rowsByKey.get('revenue'), periods[index - 3]?.id ?? ''), 3) : null);
|
||||
setValue('5y_revenue_cagr', periodId, index >= 5 ? cagr(revenue, valueFor(rowsByKey.get('revenue'), periods[index - 5]?.id ?? ''), 5) : null);
|
||||
setValue('3y_eps_cagr', periodId, index >= 3 ? cagr(eps, valueFor(rowsByKey.get('diluted_eps'), periods[index - 3]?.id ?? ''), 3) : null);
|
||||
setValue('5y_eps_cagr', periodId, index >= 5 ? cagr(eps, valueFor(rowsByKey.get('diluted_eps'), periods[index - 5]?.id ?? ''), 5) : null);
|
||||
}
|
||||
}
|
||||
|
||||
return RATIO_DEFINITIONS.map((definition) => {
|
||||
const dependencyKeys = (() => {
|
||||
switch (definition.key) {
|
||||
case 'gross_margin':
|
||||
return ['gross_profit', 'revenue'];
|
||||
case 'operating_margin':
|
||||
return ['operating_income', 'revenue'];
|
||||
case 'ebitda_margin':
|
||||
return ['ebitda', 'revenue'];
|
||||
case 'net_margin':
|
||||
return ['net_income', 'revenue'];
|
||||
case 'fcf_margin':
|
||||
return ['free_cash_flow', 'revenue'];
|
||||
case 'roa':
|
||||
return ['net_income', 'total_assets'];
|
||||
case 'roe':
|
||||
return ['net_income', 'total_equity'];
|
||||
case 'roic':
|
||||
return ['operating_income', 'effective_tax_rate', 'income_tax_expense', 'pretax_income', 'total_debt', 'total_equity', 'cash_and_equivalents', 'short_term_investments'];
|
||||
case 'roce':
|
||||
return ['operating_income', 'total_assets', 'current_liabilities'];
|
||||
case 'debt_to_equity':
|
||||
return ['total_debt', 'total_equity'];
|
||||
case 'net_debt_to_ebitda':
|
||||
return ['total_debt', 'cash_and_equivalents', 'short_term_investments', 'ebitda'];
|
||||
case 'cash_to_debt':
|
||||
return ['cash_and_equivalents', 'short_term_investments', 'total_debt'];
|
||||
case 'current_ratio':
|
||||
return ['current_assets', 'current_liabilities'];
|
||||
case 'revenue_per_share':
|
||||
return ['revenue', 'diluted_shares'];
|
||||
case 'fcf_per_share':
|
||||
return ['free_cash_flow', 'diluted_shares'];
|
||||
case 'book_value_per_share':
|
||||
return ['total_equity', 'diluted_shares'];
|
||||
case 'revenue_yoy':
|
||||
case '3y_revenue_cagr':
|
||||
case '5y_revenue_cagr':
|
||||
return ['revenue'];
|
||||
case 'net_income_yoy':
|
||||
return ['net_income'];
|
||||
case 'eps_yoy':
|
||||
case '3y_eps_cagr':
|
||||
case '5y_eps_cagr':
|
||||
return ['diluted_eps'];
|
||||
case 'fcf_yoy':
|
||||
return ['free_cash_flow'];
|
||||
case 'market_cap':
|
||||
return ['diluted_shares'];
|
||||
case 'enterprise_value':
|
||||
return ['diluted_shares', 'total_debt', 'cash_and_equivalents', 'short_term_investments'];
|
||||
case 'price_to_earnings':
|
||||
return ['diluted_eps'];
|
||||
case 'price_to_fcf':
|
||||
return ['diluted_shares', 'free_cash_flow'];
|
||||
case 'price_to_book':
|
||||
return ['diluted_shares', 'total_equity'];
|
||||
case 'ev_to_sales':
|
||||
return ['diluted_shares', 'total_debt', 'cash_and_equivalents', 'short_term_investments', 'revenue'];
|
||||
case 'ev_to_ebitda':
|
||||
return ['diluted_shares', 'total_debt', 'cash_and_equivalents', 'short_term_investments', 'ebitda'];
|
||||
case 'ev_to_fcf':
|
||||
return ['diluted_shares', 'total_debt', 'cash_and_equivalents', 'short_term_investments', 'free_cash_flow'];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
})();
|
||||
const sources = collectSourceRowData(rowsByKey, dependencyKeys);
|
||||
|
||||
return {
|
||||
key: definition.key,
|
||||
label: definition.label,
|
||||
category: definition.category,
|
||||
order: definition.order,
|
||||
unit: definition.unit,
|
||||
values: valuesByKey.get(definition.key) ?? {},
|
||||
sourceConcepts: sources.sourceConcepts,
|
||||
sourceRowKeys: sources.sourceRowKeys,
|
||||
sourceFactIds: sources.sourceFactIds,
|
||||
formulaKey: definition.key,
|
||||
hasDimensions: false,
|
||||
resolvedSourceRowKeys: Object.fromEntries(periods.map((period) => [period.id, null])),
|
||||
denominatorKey: definition.denominatorKey
|
||||
} satisfies RatioRow;
|
||||
}).filter((row) => {
|
||||
if (row.key.includes('_cagr')) {
|
||||
return Object.values(row.values).some((value) => value !== null);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
450
lib/server/financials/standardize.ts
Normal file
450
lib/server/financials/standardize.ts
Normal file
@@ -0,0 +1,450 @@
|
||||
import type {
|
||||
DerivedFinancialRow,
|
||||
DimensionBreakdownRow,
|
||||
FinancialStatementKind,
|
||||
FinancialStatementPeriod,
|
||||
FinancialUnit,
|
||||
StandardizedFinancialRow,
|
||||
TaxonomyFactRow,
|
||||
TaxonomyStatementRow
|
||||
} from '@/lib/types';
|
||||
import {
|
||||
CANONICAL_ROW_DEFINITIONS,
|
||||
type CanonicalRowDefinition
|
||||
} from '@/lib/server/financials/canonical-definitions';
|
||||
|
||||
function normalizeToken(value: string) {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function valueOrNull(values: Record<string, number | null>, periodId: string) {
|
||||
return periodId in values ? values[periodId] : null;
|
||||
}
|
||||
|
||||
function sumValues(values: Array<number | null>) {
|
||||
if (values.some((value) => value === null)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return values.reduce<number>((sum, value) => sum + (value ?? 0), 0);
|
||||
}
|
||||
|
||||
function subtractValues(left: number | null, right: number | null) {
|
||||
if (left === null || right === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return left - right;
|
||||
}
|
||||
|
||||
function divideValues(left: number | null, right: number | null) {
|
||||
if (left === null || right === null || right === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return left / right;
|
||||
}
|
||||
|
||||
function matchesDefinition(row: TaxonomyStatementRow, definition: CanonicalRowDefinition) {
|
||||
const rowLocalName = normalizeToken(row.localName);
|
||||
if (definition.localNames?.some((localName) => normalizeToken(localName) === rowLocalName)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const label = normalizeToken(row.label);
|
||||
return definition.labelIncludes?.some((token) => label.includes(normalizeToken(token))) ?? false;
|
||||
}
|
||||
|
||||
function matchesDefinitionFact(fact: TaxonomyFactRow, definition: CanonicalRowDefinition) {
|
||||
const localName = normalizeToken(fact.localName);
|
||||
return definition.localNames?.some((entry) => normalizeToken(entry) === localName) ?? false;
|
||||
}
|
||||
|
||||
function inferUnit(rawUnit: string | null, fallback: FinancialUnit) {
|
||||
const normalized = (rawUnit ?? '').toLowerCase();
|
||||
if (!normalized) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (normalized.includes('usd') || normalized.includes('iso4217')) {
|
||||
return 'currency';
|
||||
}
|
||||
|
||||
if (normalized.includes('shares')) {
|
||||
return 'shares';
|
||||
}
|
||||
|
||||
if (normalized.includes('pure') || normalized.includes('percent')) {
|
||||
return fallback === 'percent' ? 'percent' : 'ratio';
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function factMatchesPeriod(fact: TaxonomyFactRow, period: FinancialStatementPeriod) {
|
||||
if (period.periodStart) {
|
||||
return fact.periodStart === period.periodStart && fact.periodEnd === period.periodEnd;
|
||||
}
|
||||
|
||||
return (fact.periodInstant ?? fact.periodEnd) === period.periodEnd;
|
||||
}
|
||||
|
||||
function buildCanonicalRow(
|
||||
definition: CanonicalRowDefinition,
|
||||
matches: TaxonomyStatementRow[],
|
||||
facts: TaxonomyFactRow[],
|
||||
periods: FinancialStatementPeriod[]
|
||||
) {
|
||||
const sortedMatches = [...matches].sort((left, right) => {
|
||||
if (left.order !== right.order) {
|
||||
return left.order - right.order;
|
||||
}
|
||||
|
||||
return left.label.localeCompare(right.label);
|
||||
});
|
||||
const matchedFacts = facts.filter((fact) => matchesDefinitionFact(fact, definition) && fact.isDimensionless);
|
||||
|
||||
const sourceConcepts = new Set<string>();
|
||||
const sourceRowKeys = new Set<string>();
|
||||
const sourceFactIds = new Set<number>();
|
||||
for (const row of sortedMatches) {
|
||||
sourceConcepts.add(row.qname);
|
||||
sourceRowKeys.add(row.key);
|
||||
for (const factId of row.sourceFactIds) {
|
||||
sourceFactIds.add(factId);
|
||||
}
|
||||
}
|
||||
|
||||
const values: Record<string, number | null> = {};
|
||||
const resolvedSourceRowKeys: Record<string, string | null> = {};
|
||||
let unit = definition.unit;
|
||||
|
||||
for (const period of periods) {
|
||||
const directMatch = sortedMatches.find((row) => period.id in row.values);
|
||||
if (directMatch) {
|
||||
values[period.id] = directMatch.values[period.id] ?? null;
|
||||
unit = inferUnit(directMatch.units[period.id] ?? null, definition.unit);
|
||||
resolvedSourceRowKeys[period.id] = directMatch.key;
|
||||
continue;
|
||||
}
|
||||
|
||||
const factMatch = matchedFacts.find((fact) => factMatchesPeriod(fact, period));
|
||||
values[period.id] = factMatch?.value ?? null;
|
||||
unit = inferUnit(factMatch?.unit ?? null, definition.unit);
|
||||
resolvedSourceRowKeys[period.id] = factMatch?.conceptKey ?? null;
|
||||
|
||||
if (factMatch) {
|
||||
sourceConcepts.add(factMatch.qname);
|
||||
sourceRowKeys.add(factMatch.conceptKey);
|
||||
sourceFactIds.add(factMatch.id);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
key: definition.key,
|
||||
label: definition.label,
|
||||
category: definition.category,
|
||||
order: definition.order,
|
||||
unit,
|
||||
values,
|
||||
sourceConcepts: [...sourceConcepts].sort((left, right) => left.localeCompare(right)),
|
||||
sourceRowKeys: [...sourceRowKeys].sort((left, right) => left.localeCompare(right)),
|
||||
sourceFactIds: [...sourceFactIds].sort((left, right) => left - right),
|
||||
formulaKey: null,
|
||||
hasDimensions: sortedMatches.some((row) => row.hasDimensions),
|
||||
resolvedSourceRowKeys
|
||||
} satisfies StandardizedFinancialRow;
|
||||
}
|
||||
|
||||
type FormulaDefinition = {
|
||||
key: string;
|
||||
formulaKey: string;
|
||||
compute: (rowsByKey: Map<string, StandardizedFinancialRow>, periodId: string) => number | null;
|
||||
};
|
||||
|
||||
const FORMULAS: Record<Extract<FinancialStatementKind, 'income' | 'balance' | 'cash_flow'>, FormulaDefinition[]> = {
|
||||
income: [
|
||||
{
|
||||
key: 'gross_profit',
|
||||
formulaKey: 'gross_profit',
|
||||
compute: (rowsByKey, periodId) => subtractValues(
|
||||
valueOrNull(rowsByKey.get('revenue')?.values ?? {}, periodId),
|
||||
valueOrNull(rowsByKey.get('cost_of_revenue')?.values ?? {}, periodId)
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'gross_margin',
|
||||
formulaKey: 'gross_margin',
|
||||
compute: (rowsByKey, periodId) => divideValues(
|
||||
valueOrNull(rowsByKey.get('gross_profit')?.values ?? {}, periodId),
|
||||
valueOrNull(rowsByKey.get('revenue')?.values ?? {}, periodId)
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'operating_margin',
|
||||
formulaKey: 'operating_margin',
|
||||
compute: (rowsByKey, periodId) => divideValues(
|
||||
valueOrNull(rowsByKey.get('operating_income')?.values ?? {}, periodId),
|
||||
valueOrNull(rowsByKey.get('revenue')?.values ?? {}, periodId)
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'effective_tax_rate',
|
||||
formulaKey: 'effective_tax_rate',
|
||||
compute: (rowsByKey, periodId) => divideValues(
|
||||
valueOrNull(rowsByKey.get('income_tax_expense')?.values ?? {}, periodId),
|
||||
valueOrNull(rowsByKey.get('pretax_income')?.values ?? {}, periodId)
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'ebitda',
|
||||
formulaKey: 'ebitda',
|
||||
compute: (rowsByKey, periodId) => sumValues([
|
||||
valueOrNull(rowsByKey.get('operating_income')?.values ?? {}, periodId),
|
||||
valueOrNull(rowsByKey.get('depreciation_and_amortization')?.values ?? {}, periodId)
|
||||
])
|
||||
}
|
||||
],
|
||||
balance: [
|
||||
{
|
||||
key: 'total_debt',
|
||||
formulaKey: 'total_debt',
|
||||
compute: (rowsByKey, periodId) => sumValues([
|
||||
valueOrNull(rowsByKey.get('long_term_debt')?.values ?? {}, periodId),
|
||||
valueOrNull(rowsByKey.get('current_debt')?.values ?? {}, periodId),
|
||||
valueOrNull(rowsByKey.get('lease_liabilities')?.values ?? {}, periodId)
|
||||
])
|
||||
},
|
||||
{
|
||||
key: 'net_cash_position',
|
||||
formulaKey: 'net_cash_position',
|
||||
compute: (rowsByKey, periodId) => subtractValues(
|
||||
sumValues([
|
||||
valueOrNull(rowsByKey.get('cash_and_equivalents')?.values ?? {}, periodId),
|
||||
valueOrNull(rowsByKey.get('short_term_investments')?.values ?? {}, periodId)
|
||||
]),
|
||||
valueOrNull(rowsByKey.get('total_debt')?.values ?? {}, periodId)
|
||||
)
|
||||
}
|
||||
],
|
||||
cash_flow: [
|
||||
{
|
||||
key: 'free_cash_flow',
|
||||
formulaKey: 'free_cash_flow',
|
||||
compute: (rowsByKey, periodId) => subtractValues(
|
||||
valueOrNull(rowsByKey.get('operating_cash_flow')?.values ?? {}, periodId),
|
||||
valueOrNull(rowsByKey.get('capital_expenditures')?.values ?? {}, periodId)
|
||||
)
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
function applyFormulas(
|
||||
rowsByKey: Map<string, StandardizedFinancialRow>,
|
||||
statement: Extract<FinancialStatementKind, 'income' | 'balance' | 'cash_flow'>,
|
||||
periods: FinancialStatementPeriod[]
|
||||
) {
|
||||
for (const formula of FORMULAS[statement]) {
|
||||
const target = rowsByKey.get(formula.key);
|
||||
if (!target) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let usedFormula = target.formulaKey !== null;
|
||||
for (const period of periods) {
|
||||
if (target.values[period.id] !== null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const computed = formula.compute(rowsByKey, period.id);
|
||||
if (computed === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
target.values[period.id] = computed;
|
||||
target.resolvedSourceRowKeys[period.id] = null;
|
||||
usedFormula = true;
|
||||
}
|
||||
|
||||
if (usedFormula) {
|
||||
target.formulaKey = formula.formulaKey;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function buildStandardizedRows(input: {
|
||||
rows: TaxonomyStatementRow[];
|
||||
statement: Extract<FinancialStatementKind, 'income' | 'balance' | 'cash_flow'>;
|
||||
periods: FinancialStatementPeriod[];
|
||||
facts: TaxonomyFactRow[];
|
||||
}) {
|
||||
const definitions = CANONICAL_ROW_DEFINITIONS[input.statement];
|
||||
const rowsByKey = new Map<string, StandardizedFinancialRow>();
|
||||
const matchedRowKeys = new Set<string>();
|
||||
|
||||
for (const definition of definitions) {
|
||||
const matches = input.rows.filter((row) => matchesDefinition(row, definition));
|
||||
for (const row of matches) {
|
||||
matchedRowKeys.add(row.key);
|
||||
}
|
||||
|
||||
const canonical = buildCanonicalRow(definition, matches, input.facts, input.periods);
|
||||
const hasAnyValue = Object.values(canonical.values).some((value) => value !== null);
|
||||
if (hasAnyValue || definition.key.startsWith('gross_') || definition.key === 'operating_margin' || definition.key === 'effective_tax_rate' || definition.key === 'ebitda' || definition.key === 'total_debt' || definition.key === 'net_cash_position' || definition.key === 'free_cash_flow') {
|
||||
rowsByKey.set(definition.key, canonical);
|
||||
}
|
||||
}
|
||||
|
||||
applyFormulas(rowsByKey, input.statement, input.periods);
|
||||
|
||||
const unmatchedRows = input.rows
|
||||
.filter((row) => !matchedRowKeys.has(row.key))
|
||||
.map((row) => ({
|
||||
key: `other:${row.key}`,
|
||||
label: row.label,
|
||||
category: 'other',
|
||||
order: 10_000 + row.order,
|
||||
unit: inferUnit(Object.values(row.units)[0] ?? null, 'currency'),
|
||||
values: { ...row.values },
|
||||
sourceConcepts: [row.qname],
|
||||
sourceRowKeys: [row.key],
|
||||
sourceFactIds: [...row.sourceFactIds],
|
||||
formulaKey: null,
|
||||
hasDimensions: row.hasDimensions,
|
||||
resolvedSourceRowKeys: Object.fromEntries(
|
||||
input.periods.map((period) => [period.id, period.id in row.values ? row.key : null])
|
||||
)
|
||||
} satisfies StandardizedFinancialRow));
|
||||
|
||||
return [...rowsByKey.values(), ...unmatchedRows].sort((left, right) => {
|
||||
if (left.order !== right.order) {
|
||||
return left.order - right.order;
|
||||
}
|
||||
|
||||
return left.label.localeCompare(right.label);
|
||||
});
|
||||
}
|
||||
|
||||
export function buildDimensionBreakdown(
|
||||
facts: TaxonomyFactRow[],
|
||||
periods: FinancialStatementPeriod[],
|
||||
faithfulRows: TaxonomyStatementRow[],
|
||||
standardizedRows: StandardizedFinancialRow[]
|
||||
) {
|
||||
const periodByFilingId = new Map<number, FinancialStatementPeriod>();
|
||||
for (const period of periods) {
|
||||
periodByFilingId.set(period.filingId, period);
|
||||
}
|
||||
|
||||
const faithfulRowByKey = new Map(faithfulRows.map((row) => [row.key, row]));
|
||||
const standardizedRowsBySource = new Map<string, StandardizedFinancialRow[]>();
|
||||
for (const row of standardizedRows) {
|
||||
for (const sourceRowKey of row.sourceRowKeys) {
|
||||
const existing = standardizedRowsBySource.get(sourceRowKey);
|
||||
if (existing) {
|
||||
existing.push(row);
|
||||
} else {
|
||||
standardizedRowsBySource.set(sourceRowKey, [row]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const map = new Map<string, DimensionBreakdownRow[]>();
|
||||
const pushRow = (key: string, row: DimensionBreakdownRow) => {
|
||||
const existing = map.get(key);
|
||||
if (existing) {
|
||||
existing.push(row);
|
||||
} else {
|
||||
map.set(key, [row]);
|
||||
}
|
||||
};
|
||||
|
||||
for (const fact of facts) {
|
||||
if (fact.dimensions.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const period = periodByFilingId.get(fact.filingId) ?? null;
|
||||
if (!period || !factMatchesPeriod(fact, period)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const faithfulRow = faithfulRowByKey.get(fact.conceptKey) ?? null;
|
||||
const standardizedMatches = standardizedRowsBySource.get(fact.conceptKey) ?? [];
|
||||
|
||||
for (const dimension of fact.dimensions) {
|
||||
const faithfulDimensionRow: DimensionBreakdownRow = {
|
||||
rowKey: fact.conceptKey,
|
||||
concept: fact.qname,
|
||||
sourceRowKey: fact.conceptKey,
|
||||
sourceLabel: faithfulRow?.label ?? null,
|
||||
periodId: period.id,
|
||||
axis: dimension.axis,
|
||||
member: dimension.member,
|
||||
value: fact.value,
|
||||
unit: fact.unit,
|
||||
provenanceType: 'taxonomy'
|
||||
};
|
||||
|
||||
pushRow(fact.conceptKey, faithfulDimensionRow);
|
||||
for (const standardizedRow of standardizedMatches) {
|
||||
pushRow(standardizedRow.key, {
|
||||
...faithfulDimensionRow,
|
||||
rowKey: standardizedRow.key
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return map.size > 0 ? Object.fromEntries(map.entries()) : null;
|
||||
}
|
||||
|
||||
export function cloneStandardizedRows(rows: StandardizedFinancialRow[]) {
|
||||
return rows.map((row) => ({
|
||||
...row,
|
||||
values: { ...row.values },
|
||||
sourceConcepts: [...row.sourceConcepts],
|
||||
sourceRowKeys: [...row.sourceRowKeys],
|
||||
sourceFactIds: [...row.sourceFactIds],
|
||||
resolvedSourceRowKeys: { ...row.resolvedSourceRowKeys }
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildLtmStandardizedRows(
|
||||
quarterlyRows: StandardizedFinancialRow[],
|
||||
quarterlyPeriods: FinancialStatementPeriod[],
|
||||
ltmPeriods: FinancialStatementPeriod[],
|
||||
statement: Extract<FinancialStatementKind, 'income' | 'balance' | 'cash_flow'>
|
||||
) {
|
||||
const sortedQuarterlyPeriods = [...quarterlyPeriods].sort((left, right) => {
|
||||
return Date.parse(left.periodEnd ?? left.filingDate) - Date.parse(right.periodEnd ?? right.filingDate);
|
||||
});
|
||||
const result = cloneStandardizedRows(quarterlyRows).map((row) => ({
|
||||
...row,
|
||||
values: {} as Record<string, number | null>,
|
||||
resolvedSourceRowKeys: {} as Record<string, string | null>
|
||||
}));
|
||||
|
||||
for (const row of result) {
|
||||
const source = quarterlyRows.find((entry) => entry.key === row.key);
|
||||
if (!source) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const ltmPeriod of ltmPeriods) {
|
||||
const anchorIndex = sortedQuarterlyPeriods.findIndex((period) => `ltm:${period.id}` === ltmPeriod.id);
|
||||
if (anchorIndex < 3) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const slice = sortedQuarterlyPeriods.slice(anchorIndex - 3, anchorIndex + 1);
|
||||
const sourceValues = slice.map((period) => source.values[period.id] ?? null);
|
||||
row.values[ltmPeriod.id] = statement === 'balance'
|
||||
? sourceValues[sourceValues.length - 1] ?? null
|
||||
: sumValues(sourceValues);
|
||||
row.resolvedSourceRowKeys[ltmPeriod.id] = source.formulaKey ? null : source.resolvedSourceRowKeys[slice[slice.length - 1]?.id ?? ''] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
82
lib/server/financials/trend-series.ts
Normal file
82
lib/server/financials/trend-series.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type {
|
||||
FinancialSurfaceKind,
|
||||
RatioRow,
|
||||
StandardizedFinancialRow,
|
||||
StructuredKpiRow,
|
||||
TrendSeries
|
||||
} from '@/lib/types';
|
||||
import { RATIO_CATEGORY_ORDER } from '@/lib/financial-metrics';
|
||||
import { KPI_CATEGORY_ORDER } from '@/lib/server/financials/kpi-registry';
|
||||
|
||||
function toTrendSeriesRow(row: {
|
||||
key: string;
|
||||
label: string;
|
||||
category: string;
|
||||
unit: TrendSeries['unit'];
|
||||
values: Record<string, number | null>;
|
||||
}) {
|
||||
return {
|
||||
key: row.key,
|
||||
label: row.label,
|
||||
category: row.category,
|
||||
unit: row.unit,
|
||||
values: row.values
|
||||
} satisfies TrendSeries;
|
||||
}
|
||||
|
||||
export function buildFinancialCategories(rows: Array<{ category: string }>, surfaceKind: FinancialSurfaceKind) {
|
||||
const counts = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
counts.set(row.category, (counts.get(row.category) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const order = surfaceKind === 'ratios'
|
||||
? [...RATIO_CATEGORY_ORDER]
|
||||
: surfaceKind === 'segments_kpis'
|
||||
? [...KPI_CATEGORY_ORDER]
|
||||
: [...counts.keys()];
|
||||
|
||||
return order
|
||||
.filter((key) => (counts.get(key) ?? 0) > 0)
|
||||
.map((key) => ({
|
||||
key,
|
||||
label: key.replace(/_/g, ' ').replace(/\b\w/g, (char) => char.toUpperCase()),
|
||||
count: counts.get(key) ?? 0
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildTrendSeries(input: {
|
||||
surfaceKind: FinancialSurfaceKind;
|
||||
statementRows?: StandardizedFinancialRow[];
|
||||
ratioRows?: RatioRow[];
|
||||
kpiRows?: StructuredKpiRow[];
|
||||
}) {
|
||||
switch (input.surfaceKind) {
|
||||
case 'income_statement':
|
||||
return (input.statementRows ?? [])
|
||||
.filter((row) => row.key === 'revenue' || row.key === 'net_income')
|
||||
.map(toTrendSeriesRow);
|
||||
case 'balance_sheet':
|
||||
return (input.statementRows ?? [])
|
||||
.filter((row) => row.key === 'total_assets' || row.key === 'cash_and_equivalents' || row.key === 'total_debt')
|
||||
.map(toTrendSeriesRow);
|
||||
case 'cash_flow_statement':
|
||||
return (input.statementRows ?? [])
|
||||
.filter((row) => row.key === 'operating_cash_flow' || row.key === 'free_cash_flow' || row.key === 'capital_expenditures')
|
||||
.map(toTrendSeriesRow);
|
||||
case 'ratios':
|
||||
return (input.ratioRows ?? [])
|
||||
.filter((row) => row.category === 'margins')
|
||||
.map(toTrendSeriesRow);
|
||||
case 'segments_kpis': {
|
||||
const rows = input.kpiRows ?? [];
|
||||
const firstCategory = buildFinancialCategories(rows, 'segments_kpis')[0]?.key ?? null;
|
||||
return rows
|
||||
.filter((row) => row.category === firstCategory)
|
||||
.slice(0, 4)
|
||||
.map(toTrendSeriesRow);
|
||||
}
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,102 @@ export async function getQuote(ticker: string): Promise<number> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getQuoteOrNull(ticker: string): Promise<number | null> {
|
||||
const normalizedTicker = ticker.trim().toUpperCase();
|
||||
|
||||
try {
|
||||
const response = await fetch(`${YAHOO_BASE}/${normalizedTicker}?interval=1d&range=1d`, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; FiscalClone/3.0)'
|
||||
},
|
||||
cache: 'no-store'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = await response.json() as {
|
||||
chart?: {
|
||||
result?: Array<{ meta?: { regularMarketPrice?: number } }>;
|
||||
};
|
||||
};
|
||||
|
||||
const price = payload.chart?.result?.[0]?.meta?.regularMarketPrice;
|
||||
return typeof price === 'number' && Number.isFinite(price) ? price : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getHistoricalClosingPrices(ticker: string, dates: string[]) {
|
||||
const normalizedTicker = ticker.trim().toUpperCase();
|
||||
const normalizedDates = dates
|
||||
.map((value) => {
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isFinite(parsed)
|
||||
? { raw: value, iso: new Date(parsed).toISOString().slice(0, 10), epoch: parsed }
|
||||
: null;
|
||||
})
|
||||
.filter((entry): entry is { raw: string; iso: string; epoch: number } => entry !== null);
|
||||
|
||||
if (normalizedDates.length === 0) {
|
||||
return {} as Record<string, number | null>;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${YAHOO_BASE}/${normalizedTicker}?interval=1d&range=10y`, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; FiscalClone/3.0)'
|
||||
},
|
||||
cache: 'no-store'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return Object.fromEntries(normalizedDates.map((entry) => [entry.raw, null]));
|
||||
}
|
||||
|
||||
const payload = await response.json() as {
|
||||
chart?: {
|
||||
result?: Array<{
|
||||
timestamp?: number[];
|
||||
indicators?: {
|
||||
quote?: Array<{
|
||||
close?: Array<number | null>;
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
const result = payload.chart?.result?.[0];
|
||||
const timestamps = result?.timestamp ?? [];
|
||||
const closes = result?.indicators?.quote?.[0]?.close ?? [];
|
||||
const points = timestamps
|
||||
.map((timestamp, index) => {
|
||||
const close = closes[index];
|
||||
if (typeof close !== 'number' || !Number.isFinite(close)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
epoch: timestamp * 1000,
|
||||
close
|
||||
};
|
||||
})
|
||||
.filter((entry): entry is { epoch: number; close: number } => entry !== null);
|
||||
|
||||
return Object.fromEntries(normalizedDates.map((entry) => {
|
||||
const point = [...points]
|
||||
.reverse()
|
||||
.find((candidate) => candidate.epoch <= entry.epoch) ?? null;
|
||||
return [entry.raw, point?.close ?? null];
|
||||
}));
|
||||
} catch {
|
||||
return Object.fromEntries(normalizedDates.map((entry) => [entry.raw, null]));
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPriceHistory(ticker: string): Promise<Array<{ date: string; close: number }>> {
|
||||
const normalizedTicker = ticker.trim().toUpperCase();
|
||||
|
||||
|
||||
107
lib/server/repos/company-financial-bundles.ts
Normal file
107
lib/server/repos/company-financial-bundles.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import type {
|
||||
FinancialCadence,
|
||||
FinancialSurfaceKind
|
||||
} from '@/lib/types';
|
||||
import { db } from '@/lib/server/db';
|
||||
import { companyFinancialBundle } from '@/lib/server/db/schema';
|
||||
|
||||
const BUNDLE_VERSION = 1;
|
||||
|
||||
export type CompanyFinancialBundleRecord = {
|
||||
id: number;
|
||||
ticker: string;
|
||||
surface_kind: FinancialSurfaceKind;
|
||||
cadence: FinancialCadence;
|
||||
bundle_version: number;
|
||||
source_snapshot_ids: number[];
|
||||
source_signature: string;
|
||||
payload: Record<string, unknown>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
function toBundleRecord(row: typeof companyFinancialBundle.$inferSelect): CompanyFinancialBundleRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
ticker: row.ticker,
|
||||
surface_kind: row.surface_kind,
|
||||
cadence: row.cadence,
|
||||
bundle_version: row.bundle_version,
|
||||
source_snapshot_ids: row.source_snapshot_ids ?? [],
|
||||
source_signature: row.source_signature,
|
||||
payload: row.payload,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCompanyFinancialBundle(input: {
|
||||
ticker: string;
|
||||
surfaceKind: FinancialSurfaceKind;
|
||||
cadence: FinancialCadence;
|
||||
}) {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(companyFinancialBundle)
|
||||
.where(and(
|
||||
eq(companyFinancialBundle.ticker, input.ticker.trim().toUpperCase()),
|
||||
eq(companyFinancialBundle.surface_kind, input.surfaceKind),
|
||||
eq(companyFinancialBundle.cadence, input.cadence)
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
return row ? toBundleRecord(row) : null;
|
||||
}
|
||||
|
||||
export async function upsertCompanyFinancialBundle(input: {
|
||||
ticker: string;
|
||||
surfaceKind: FinancialSurfaceKind;
|
||||
cadence: FinancialCadence;
|
||||
sourceSnapshotIds: number[];
|
||||
sourceSignature: string;
|
||||
payload: Record<string, unknown>;
|
||||
}) {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const [saved] = await db
|
||||
.insert(companyFinancialBundle)
|
||||
.values({
|
||||
ticker: input.ticker.trim().toUpperCase(),
|
||||
surface_kind: input.surfaceKind,
|
||||
cadence: input.cadence,
|
||||
bundle_version: BUNDLE_VERSION,
|
||||
source_snapshot_ids: input.sourceSnapshotIds,
|
||||
source_signature: input.sourceSignature,
|
||||
payload: input.payload,
|
||||
created_at: now,
|
||||
updated_at: now
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
companyFinancialBundle.ticker,
|
||||
companyFinancialBundle.surface_kind,
|
||||
companyFinancialBundle.cadence
|
||||
],
|
||||
set: {
|
||||
bundle_version: BUNDLE_VERSION,
|
||||
source_snapshot_ids: input.sourceSnapshotIds,
|
||||
source_signature: input.sourceSignature,
|
||||
payload: input.payload,
|
||||
updated_at: now
|
||||
}
|
||||
})
|
||||
.returning();
|
||||
|
||||
return toBundleRecord(saved);
|
||||
}
|
||||
|
||||
export async function deleteCompanyFinancialBundlesForTicker(ticker: string) {
|
||||
return await db
|
||||
.delete(companyFinancialBundle)
|
||||
.where(eq(companyFinancialBundle.ticker, ticker.trim().toUpperCase()));
|
||||
}
|
||||
|
||||
export const __companyFinancialBundlesInternals = {
|
||||
BUNDLE_VERSION
|
||||
};
|
||||
@@ -515,6 +515,7 @@ export async function upsertFilingTaxonomySnapshot(input: UpsertFilingTaxonomySn
|
||||
export async function listFilingTaxonomySnapshotsByTicker(input: {
|
||||
ticker: string;
|
||||
window: '10y' | 'all';
|
||||
filingTypes?: Array<'10-K' | '10-Q'>;
|
||||
limit?: number;
|
||||
cursor?: string | null;
|
||||
}) {
|
||||
@@ -530,6 +531,10 @@ export async function listFilingTaxonomySnapshotsByTicker(input: {
|
||||
constraints.push(lt(filingTaxonomySnapshot.id, cursorId));
|
||||
}
|
||||
|
||||
if (input.filingTypes && input.filingTypes.length > 0) {
|
||||
constraints.push(inArray(filingTaxonomySnapshot.filing_type, input.filingTypes));
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(filingTaxonomySnapshot)
|
||||
@@ -573,6 +578,7 @@ export async function listTaxonomyFactsByTicker(input: {
|
||||
ticker: string;
|
||||
window: '10y' | 'all';
|
||||
statement?: FinancialStatementKind;
|
||||
filingTypes?: Array<'10-K' | '10-Q'>;
|
||||
cursor?: string | null;
|
||||
limit?: number;
|
||||
}) {
|
||||
@@ -588,6 +594,10 @@ export async function listTaxonomyFactsByTicker(input: {
|
||||
conditions.push(eq(filingTaxonomyFact.statement_kind, input.statement));
|
||||
}
|
||||
|
||||
if (input.filingTypes && input.filingTypes.length > 0) {
|
||||
conditions.push(inArray(filingTaxonomySnapshot.filing_type, input.filingTypes));
|
||||
}
|
||||
|
||||
if (cursorId && Number.isFinite(cursorId) && cursorId > 0) {
|
||||
conditions.push(lt(filingTaxonomyFact.id, cursorId));
|
||||
}
|
||||
|
||||
@@ -1,148 +1,6 @@
|
||||
import { and, desc, eq } from 'drizzle-orm';
|
||||
import type {
|
||||
ResearchJournalEntry,
|
||||
ResearchJournalEntryType
|
||||
} from '@/lib/types';
|
||||
import { db } from '@/lib/server/db';
|
||||
import { researchJournalEntry } from '@/lib/server/db/schema';
|
||||
|
||||
type ResearchJournalRow = typeof researchJournalEntry.$inferSelect;
|
||||
|
||||
function normalizeTicker(ticker: string) {
|
||||
return ticker.trim().toUpperCase();
|
||||
}
|
||||
|
||||
function normalizeTitle(title?: string | null) {
|
||||
const normalized = title?.trim();
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
function normalizeAccessionNumber(accessionNumber?: string | null) {
|
||||
const normalized = accessionNumber?.trim();
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
function normalizeMetadata(metadata?: Record<string, unknown> | null) {
|
||||
if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
function toResearchJournalEntry(row: ResearchJournalRow): ResearchJournalEntry {
|
||||
return {
|
||||
id: row.id,
|
||||
user_id: row.user_id,
|
||||
ticker: row.ticker,
|
||||
accession_number: row.accession_number ?? null,
|
||||
entry_type: row.entry_type,
|
||||
title: row.title ?? null,
|
||||
body_markdown: row.body_markdown,
|
||||
metadata: row.metadata ?? null,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
export async function listResearchJournalEntries(userId: string, ticker: string, limit = 100) {
|
||||
const normalizedTicker = normalizeTicker(ticker);
|
||||
if (!normalizedTicker) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const safeLimit = Math.min(Math.max(Math.trunc(limit), 1), 250);
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(researchJournalEntry)
|
||||
.where(and(eq(researchJournalEntry.user_id, userId), eq(researchJournalEntry.ticker, normalizedTicker)))
|
||||
.orderBy(desc(researchJournalEntry.created_at), desc(researchJournalEntry.id))
|
||||
.limit(safeLimit);
|
||||
|
||||
return rows.map(toResearchJournalEntry);
|
||||
}
|
||||
|
||||
export async function createResearchJournalEntryRecord(input: {
|
||||
userId: string;
|
||||
ticker: string;
|
||||
accessionNumber?: string | null;
|
||||
entryType: ResearchJournalEntryType;
|
||||
title?: string | null;
|
||||
bodyMarkdown: string;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}) {
|
||||
const ticker = normalizeTicker(input.ticker);
|
||||
const bodyMarkdown = input.bodyMarkdown.trim();
|
||||
if (!ticker) {
|
||||
throw new Error('ticker is required');
|
||||
}
|
||||
|
||||
if (!bodyMarkdown) {
|
||||
throw new Error('bodyMarkdown is required');
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const [created] = await db
|
||||
.insert(researchJournalEntry)
|
||||
.values({
|
||||
user_id: input.userId,
|
||||
ticker,
|
||||
accession_number: normalizeAccessionNumber(input.accessionNumber),
|
||||
entry_type: input.entryType,
|
||||
title: normalizeTitle(input.title),
|
||||
body_markdown: bodyMarkdown,
|
||||
metadata: normalizeMetadata(input.metadata),
|
||||
created_at: now,
|
||||
updated_at: now
|
||||
})
|
||||
.returning();
|
||||
|
||||
return toResearchJournalEntry(created);
|
||||
}
|
||||
|
||||
export async function updateResearchJournalEntryRecord(input: {
|
||||
userId: string;
|
||||
id: number;
|
||||
title?: string | null;
|
||||
bodyMarkdown?: string;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
}) {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(researchJournalEntry)
|
||||
.where(and(eq(researchJournalEntry.user_id, input.userId), eq(researchJournalEntry.id, input.id)))
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextBodyMarkdown = input.bodyMarkdown === undefined
|
||||
? existing.body_markdown
|
||||
: input.bodyMarkdown.trim();
|
||||
if (!nextBodyMarkdown) {
|
||||
throw new Error('bodyMarkdown is required');
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(researchJournalEntry)
|
||||
.set({
|
||||
title: input.title === undefined ? existing.title : normalizeTitle(input.title),
|
||||
body_markdown: nextBodyMarkdown,
|
||||
metadata: input.metadata === undefined ? existing.metadata ?? null : normalizeMetadata(input.metadata),
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.where(and(eq(researchJournalEntry.user_id, input.userId), eq(researchJournalEntry.id, input.id)))
|
||||
.returning();
|
||||
|
||||
return updated ? toResearchJournalEntry(updated) : null;
|
||||
}
|
||||
|
||||
export async function deleteResearchJournalEntryRecord(userId: string, id: number) {
|
||||
const rows = await db
|
||||
.delete(researchJournalEntry)
|
||||
.where(and(eq(researchJournalEntry.user_id, userId), eq(researchJournalEntry.id, id)))
|
||||
.returning({ id: researchJournalEntry.id });
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
export {
|
||||
createResearchJournalEntryCompat as createResearchJournalEntryRecord,
|
||||
deleteResearchJournalEntryCompat as deleteResearchJournalEntryRecord,
|
||||
listResearchJournalEntriesCompat as listResearchJournalEntries,
|
||||
updateResearchJournalEntryCompat as updateResearchJournalEntryRecord
|
||||
} from '@/lib/server/repos/research-library';
|
||||
|
||||
1122
lib/server/repos/research-library.ts
Normal file
1122
lib/server/repos/research-library.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,9 @@ import {
|
||||
updateFilingMetricsById,
|
||||
upsertFilingsRecords
|
||||
} from '@/lib/server/repos/filings';
|
||||
import {
|
||||
deleteCompanyFinancialBundlesForTicker
|
||||
} from '@/lib/server/repos/company-financial-bundles';
|
||||
import {
|
||||
getFilingTaxonomySnapshotByFilingId,
|
||||
upsertFilingTaxonomySnapshot
|
||||
@@ -623,6 +626,7 @@ async function processSyncFilings(task: Task) {
|
||||
|
||||
await upsertFilingTaxonomySnapshot(snapshot);
|
||||
await updateFilingMetricsById(filing.id, snapshot.derived_metrics);
|
||||
await deleteCompanyFinancialBundlesForTicker(filing.ticker);
|
||||
taxonomySnapshotsHydrated += 1;
|
||||
} catch (error) {
|
||||
const now = new Date().toISOString();
|
||||
@@ -656,6 +660,7 @@ async function processSyncFilings(task: Task) {
|
||||
facts: [],
|
||||
metric_validations: []
|
||||
});
|
||||
await deleteCompanyFinancialBundlesForTicker(filing.ticker);
|
||||
taxonomySnapshotsFailed += 1;
|
||||
}
|
||||
|
||||
|
||||
202
lib/types.ts
202
lib/types.ts
@@ -8,6 +8,19 @@ export type User = {
|
||||
export type CoverageStatus = 'backlog' | 'active' | 'watch' | 'archive';
|
||||
export type CoveragePriority = 'low' | 'medium' | 'high';
|
||||
export type ResearchJournalEntryType = 'note' | 'filing_note' | 'status_change';
|
||||
export type NumberScaleUnit = 'thousands' | 'millions' | 'billions';
|
||||
export type ResearchArtifactKind = 'filing' | 'ai_report' | 'note' | 'upload' | 'memo_snapshot' | 'status_change';
|
||||
export type ResearchArtifactSource = 'system' | 'user';
|
||||
export type ResearchVisibilityScope = 'private' | 'organization';
|
||||
export type ResearchMemoRating = 'strong_buy' | 'buy' | 'hold' | 'sell';
|
||||
export type ResearchMemoConviction = 'low' | 'medium' | 'high';
|
||||
export type ResearchMemoSection =
|
||||
| 'thesis'
|
||||
| 'variant_view'
|
||||
| 'catalysts'
|
||||
| 'risks'
|
||||
| 'disconfirming_evidence'
|
||||
| 'next_actions';
|
||||
|
||||
export type WatchlistItem = {
|
||||
id: number;
|
||||
@@ -188,6 +201,93 @@ export type ResearchJournalEntry = {
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type ResearchArtifact = {
|
||||
id: number;
|
||||
user_id: string;
|
||||
organization_id: string | null;
|
||||
ticker: string;
|
||||
accession_number: string | null;
|
||||
kind: ResearchArtifactKind;
|
||||
source: ResearchArtifactSource;
|
||||
subtype: string | null;
|
||||
title: string | null;
|
||||
summary: string | null;
|
||||
body_markdown: string | null;
|
||||
search_text: string | null;
|
||||
visibility_scope: ResearchVisibilityScope;
|
||||
tags: string[];
|
||||
metadata: Record<string, unknown> | null;
|
||||
file_name: string | null;
|
||||
mime_type: string | null;
|
||||
file_size_bytes: number | null;
|
||||
storage_path: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
linked_to_memo: boolean;
|
||||
};
|
||||
|
||||
export type ResearchMemo = {
|
||||
id: number;
|
||||
user_id: string;
|
||||
organization_id: string | null;
|
||||
ticker: string;
|
||||
rating: ResearchMemoRating | null;
|
||||
conviction: ResearchMemoConviction | null;
|
||||
time_horizon_months: number | null;
|
||||
packet_title: string | null;
|
||||
packet_subtitle: string | null;
|
||||
thesis_markdown: string;
|
||||
variant_view_markdown: string;
|
||||
catalysts_markdown: string;
|
||||
risks_markdown: string;
|
||||
disconfirming_evidence_markdown: string;
|
||||
next_actions_markdown: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type ResearchMemoEvidenceLink = {
|
||||
id: number;
|
||||
memo_id: number;
|
||||
artifact_id: number;
|
||||
section: ResearchMemoSection;
|
||||
annotation: string | null;
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
artifact: ResearchArtifact;
|
||||
};
|
||||
|
||||
export type ResearchPacketSection = {
|
||||
section: ResearchMemoSection;
|
||||
title: string;
|
||||
body_markdown: string;
|
||||
evidence: ResearchMemoEvidenceLink[];
|
||||
};
|
||||
|
||||
export type ResearchPacket = {
|
||||
ticker: string;
|
||||
companyName: string | null;
|
||||
generated_at: string;
|
||||
memo: ResearchMemo | null;
|
||||
sections: ResearchPacketSection[];
|
||||
};
|
||||
|
||||
export type ResearchLibraryResponse = {
|
||||
artifacts: ResearchArtifact[];
|
||||
availableTags: string[];
|
||||
};
|
||||
|
||||
export type ResearchWorkspace = {
|
||||
ticker: string;
|
||||
companyName: string | null;
|
||||
coverage: WatchlistItem | null;
|
||||
latestFilingDate: string | null;
|
||||
memo: ResearchMemo | null;
|
||||
library: ResearchArtifact[];
|
||||
packet: ResearchPacket;
|
||||
availableTags: string[];
|
||||
};
|
||||
|
||||
export type CompanyFinancialPoint = {
|
||||
filingDate: string;
|
||||
filingType: Filing['filing_type'];
|
||||
@@ -200,6 +300,18 @@ export type CompanyFinancialPoint = {
|
||||
|
||||
export type FinancialStatementKind = 'income' | 'balance' | 'cash_flow' | 'equity' | 'comprehensive_income';
|
||||
export type FinancialHistoryWindow = '10y' | 'all';
|
||||
export type FinancialCadence = 'annual' | 'quarterly' | 'ltm';
|
||||
export type FinancialDisplayMode = 'faithful' | 'standardized';
|
||||
export type FinancialSurfaceKind =
|
||||
| 'income_statement'
|
||||
| 'balance_sheet'
|
||||
| 'cash_flow_statement'
|
||||
| 'ratios'
|
||||
| 'segments_kpis'
|
||||
| 'adjusted'
|
||||
| 'custom_metrics';
|
||||
export type FinancialUnit = 'currency' | 'count' | 'shares' | 'percent' | 'ratio';
|
||||
export type FinancialCategory = string;
|
||||
|
||||
export type FinancialStatementPeriod = {
|
||||
id: string;
|
||||
@@ -236,21 +348,46 @@ export type TaxonomyStatementRow = {
|
||||
sourceFactIds: number[];
|
||||
};
|
||||
|
||||
export type FinancialStatementSurfaceKind = 'faithful' | 'standardized';
|
||||
export type FinancialStatementSurfaceKind = FinancialDisplayMode;
|
||||
|
||||
export type StandardizedStatementRow = {
|
||||
export type DerivedFinancialRow = {
|
||||
key: string;
|
||||
label: string;
|
||||
category: string;
|
||||
category: FinancialCategory;
|
||||
order: number;
|
||||
unit: FinancialUnit;
|
||||
values: Record<string, number | null>;
|
||||
hasDimensions: boolean;
|
||||
sourceConcepts: string[];
|
||||
sourceRowKeys: string[];
|
||||
sourceFactIds: number[];
|
||||
formulaKey: string | null;
|
||||
hasDimensions: boolean;
|
||||
resolvedSourceRowKeys: Record<string, string | null>;
|
||||
};
|
||||
|
||||
export type StandardizedFinancialRow = DerivedFinancialRow;
|
||||
export type StandardizedStatementRow = StandardizedFinancialRow;
|
||||
|
||||
export type RatioRow = DerivedFinancialRow & {
|
||||
denominatorKey: string | null;
|
||||
};
|
||||
|
||||
export type StructuredKpiRow = {
|
||||
key: string;
|
||||
label: string;
|
||||
category: FinancialCategory;
|
||||
unit: FinancialUnit;
|
||||
order: number;
|
||||
segment: string | null;
|
||||
axis: string | null;
|
||||
member: string | null;
|
||||
values: Record<string, number | null>;
|
||||
sourceConcepts: string[];
|
||||
sourceFactIds: number[];
|
||||
provenanceType: 'taxonomy' | 'structured_note';
|
||||
hasDimensions: boolean;
|
||||
};
|
||||
|
||||
export type TaxonomyFactRow = {
|
||||
id: number;
|
||||
snapshotId: number;
|
||||
@@ -315,11 +452,15 @@ export type DimensionBreakdownRow = {
|
||||
member: string;
|
||||
value: number | null;
|
||||
unit: string | null;
|
||||
provenanceType?: 'taxonomy' | 'structured_note';
|
||||
};
|
||||
|
||||
export type FinancialStatementSurface<Row> = {
|
||||
kind: FinancialStatementSurfaceKind;
|
||||
rows: Row[];
|
||||
export type TrendSeries = {
|
||||
key: string;
|
||||
label: string;
|
||||
category: FinancialCategory;
|
||||
unit: FinancialUnit;
|
||||
values: Record<string, number | null>;
|
||||
};
|
||||
|
||||
export type CompanyFinancialStatementsResponse = {
|
||||
@@ -328,13 +469,26 @@ export type CompanyFinancialStatementsResponse = {
|
||||
companyName: string;
|
||||
cik: string | null;
|
||||
};
|
||||
statement: FinancialStatementKind;
|
||||
window: FinancialHistoryWindow;
|
||||
defaultSurface: FinancialStatementSurfaceKind;
|
||||
surfaceKind: FinancialSurfaceKind;
|
||||
cadence: FinancialCadence;
|
||||
displayModes: FinancialDisplayMode[];
|
||||
defaultDisplayMode: FinancialDisplayMode;
|
||||
periods: FinancialStatementPeriod[];
|
||||
surfaces: {
|
||||
faithful: FinancialStatementSurface<TaxonomyStatementRow>;
|
||||
standardized: FinancialStatementSurface<StandardizedStatementRow>;
|
||||
statementRows: {
|
||||
faithful: TaxonomyStatementRow[];
|
||||
standardized: StandardizedFinancialRow[];
|
||||
} | null;
|
||||
ratioRows: RatioRow[] | null;
|
||||
kpiRows: StructuredKpiRow[] | null;
|
||||
trendSeries: TrendSeries[];
|
||||
categories: Array<{
|
||||
key: FinancialCategory;
|
||||
label: string;
|
||||
count: number;
|
||||
}>;
|
||||
availability: {
|
||||
adjusted: boolean;
|
||||
customMetrics: boolean;
|
||||
};
|
||||
nextCursor: string | null;
|
||||
facts: {
|
||||
@@ -355,28 +509,6 @@ export type CompanyFinancialStatementsResponse = {
|
||||
pendingFilings: number;
|
||||
queuedSync: boolean;
|
||||
};
|
||||
overviewMetrics: {
|
||||
referencePeriodId: string | null;
|
||||
referenceDate: string | null;
|
||||
latest: {
|
||||
revenue: number | null;
|
||||
netIncome: number | null;
|
||||
totalAssets: number | null;
|
||||
cash: number | null;
|
||||
debt: number | null;
|
||||
};
|
||||
series: Array<{
|
||||
periodId: string;
|
||||
filingDate: string;
|
||||
periodEnd: string | null;
|
||||
label: string;
|
||||
revenue: number | null;
|
||||
netIncome: number | null;
|
||||
totalAssets: number | null;
|
||||
cash: number | null;
|
||||
debt: number | null;
|
||||
}>;
|
||||
};
|
||||
metrics: {
|
||||
taxonomy: Filing['metrics'];
|
||||
validation: MetricValidationResult | null;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"dev:next": "bun --bun next dev --turbopack",
|
||||
"build": "bun --bun next build --turbopack",
|
||||
"start": "bun --bun next start",
|
||||
"lint": "bun --bun tsc --noEmit",
|
||||
"lint": "bun x tsc --noEmit",
|
||||
"e2e:prepare": "bun run scripts/e2e-prepare.ts",
|
||||
"e2e:webserver": "bun run scripts/e2e-webserver.ts",
|
||||
"workflow:setup": "workflow-postgres-setup",
|
||||
@@ -31,6 +31,7 @@
|
||||
"@workflow/world-postgres": "^4.1.0-beta.34",
|
||||
"ai": "^6.0.104",
|
||||
"better-auth": "^1.4.19",
|
||||
"cheerio": "^1.1.2",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"drizzle-orm": "^0.41.0",
|
||||
|
||||
@@ -71,7 +71,16 @@ function bootstrapFreshDatabase(databaseUrl: string) {
|
||||
try {
|
||||
database.exec('PRAGMA foreign_keys = ON;');
|
||||
|
||||
if (hasTable(database, 'user')) {
|
||||
const existingCoreTables = [
|
||||
'user',
|
||||
'filing',
|
||||
'watchlist_item',
|
||||
'filing_statement_snapshot',
|
||||
'filing_taxonomy_snapshot',
|
||||
'task_run'
|
||||
];
|
||||
|
||||
if (existingCoreTables.some((tableName) => hasTable(database, tableName))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,9 @@ const MIGRATION_FILES = [
|
||||
'0003_task_stage_event_timeline.sql',
|
||||
'0004_watchlist_company_taxonomy.sql',
|
||||
'0005_financial_taxonomy_v3.sql',
|
||||
'0006_coverage_journal_tracking.sql'
|
||||
'0006_coverage_journal_tracking.sql',
|
||||
'0007_company_financial_bundles.sql',
|
||||
'0008_research_workspace.sql'
|
||||
] as const;
|
||||
|
||||
export const E2E_DATABASE_PATH = join(process.cwd(), 'data', 'e2e.sqlite');
|
||||
|
||||
Reference in New Issue
Block a user