Add research workspace and graphing flows

This commit is contained in:
2026-03-07 16:52:35 -05:00
parent db01f207a5
commit 62bacdf104
37 changed files with 5494 additions and 434 deletions

View 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
View 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
View 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
View 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
};
}