Add research workspace and graphing flows
This commit is contained in:
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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user