Implement dual-surface financials and db bootstrap
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Suspense, useCallback, useEffect, useMemo, useState } from 'react';
|
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { useSearchParams } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import {
|
import {
|
||||||
@@ -17,7 +17,15 @@ import {
|
|||||||
XAxis,
|
XAxis,
|
||||||
YAxis
|
YAxis
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import { AlertTriangle, ChartNoAxesCombined, ChevronDown, Download, RefreshCcw, Search } from 'lucide-react';
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
ChartNoAxesCombined,
|
||||||
|
ChevronDown,
|
||||||
|
Download,
|
||||||
|
GitCompareArrows,
|
||||||
|
RefreshCcw,
|
||||||
|
Search
|
||||||
|
} from 'lucide-react';
|
||||||
import { AppShell } from '@/components/shell/app-shell';
|
import { AppShell } from '@/components/shell/app-shell';
|
||||||
import { MetricCard } from '@/components/dashboard/metric-card';
|
import { MetricCard } from '@/components/dashboard/metric-card';
|
||||||
import {
|
import {
|
||||||
@@ -42,7 +50,9 @@ import type {
|
|||||||
CompanyFinancialStatementsResponse,
|
CompanyFinancialStatementsResponse,
|
||||||
DimensionBreakdownRow,
|
DimensionBreakdownRow,
|
||||||
FinancialHistoryWindow,
|
FinancialHistoryWindow,
|
||||||
|
FinancialStatementSurfaceKind,
|
||||||
FinancialStatementKind,
|
FinancialStatementKind,
|
||||||
|
StandardizedStatementRow,
|
||||||
TaxonomyStatementRow
|
TaxonomyStatementRow
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
|
|
||||||
@@ -51,6 +61,20 @@ type LoadOptions = {
|
|||||||
append?: boolean;
|
append?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type OverviewPoint = {
|
||||||
|
periodId: string;
|
||||||
|
filingDate: string;
|
||||||
|
periodEnd: string | null;
|
||||||
|
label: string;
|
||||||
|
revenue: number | null;
|
||||||
|
netIncome: number | null;
|
||||||
|
totalAssets: number | null;
|
||||||
|
cash: number | null;
|
||||||
|
debt: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DisplayRow = TaxonomyStatementRow | StandardizedStatementRow;
|
||||||
|
|
||||||
const FINANCIAL_VALUE_SCALE_OPTIONS: Array<{ value: NumberScaleUnit; label: string }> = [
|
const FINANCIAL_VALUE_SCALE_OPTIONS: Array<{ value: NumberScaleUnit; label: string }> = [
|
||||||
{ value: 'thousands', label: 'Thousands (K)' },
|
{ value: 'thousands', label: 'Thousands (K)' },
|
||||||
{ value: 'millions', label: 'Millions (M)' },
|
{ value: 'millions', label: 'Millions (M)' },
|
||||||
@@ -70,23 +94,16 @@ const WINDOW_OPTIONS: Array<{ value: FinancialHistoryWindow; label: string }> =
|
|||||||
{ value: 'all', label: 'Full Available' }
|
{ value: 'all', label: 'Full Available' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const SURFACE_OPTIONS: Array<{ value: FinancialStatementSurfaceKind; label: string }> = [
|
||||||
|
{ value: 'standardized', label: 'Standardized' },
|
||||||
|
{ value: 'faithful', label: 'Filing-faithful' }
|
||||||
|
];
|
||||||
|
|
||||||
const CHART_MUTED = '#b4ced9';
|
const CHART_MUTED = '#b4ced9';
|
||||||
const CHART_GRID = 'rgba(126, 217, 255, 0.24)';
|
const CHART_GRID = 'rgba(126, 217, 255, 0.24)';
|
||||||
const CHART_TOOLTIP_BG = 'rgba(6, 17, 24, 0.95)';
|
const CHART_TOOLTIP_BG = 'rgba(6, 17, 24, 0.95)';
|
||||||
const CHART_TOOLTIP_BORDER = 'rgba(123, 255, 217, 0.45)';
|
const CHART_TOOLTIP_BORDER = 'rgba(123, 255, 217, 0.45)';
|
||||||
|
|
||||||
type OverviewPoint = {
|
|
||||||
periodId: string;
|
|
||||||
filingDate: string;
|
|
||||||
periodEnd: string | null;
|
|
||||||
label: string;
|
|
||||||
revenue: number | null;
|
|
||||||
netIncome: number | null;
|
|
||||||
totalAssets: number | null;
|
|
||||||
cash: number | null;
|
|
||||||
debt: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatLongDate(value: string) {
|
function formatLongDate(value: string) {
|
||||||
const parsed = new Date(value);
|
const parsed = new Date(value);
|
||||||
if (Number.isNaN(parsed.getTime())) {
|
if (Number.isNaN(parsed.getTime())) {
|
||||||
@@ -134,6 +151,33 @@ function rowValue(row: { values: Record<string, number | null> }, periodId: stri
|
|||||||
return periodId in row.values ? row.values[periodId] : null;
|
return periodId in row.values ? row.values[periodId] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isFaithfulRow(row: DisplayRow): row is TaxonomyStatementRow {
|
||||||
|
return 'localName' in row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStandardizedRow(row: DisplayRow): row is StandardizedStatementRow {
|
||||||
|
return 'sourceRowKeys' in row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeSurfaceRows<T extends { key: string; values: Record<string, number | null>; hasDimensions: boolean }>(
|
||||||
|
rows: T[],
|
||||||
|
mergeRow: (existing: T, row: T) => void
|
||||||
|
) {
|
||||||
|
const rowMap = new Map<string, T>();
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const existing = rowMap.get(row.key);
|
||||||
|
if (!existing) {
|
||||||
|
rowMap.set(row.key, structuredClone(row));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
mergeRow(existing, row);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...rowMap.values()];
|
||||||
|
}
|
||||||
|
|
||||||
function mergeFinancialPages(
|
function mergeFinancialPages(
|
||||||
base: CompanyFinancialStatementsResponse | null,
|
base: CompanyFinancialStatementsResponse | null,
|
||||||
next: CompanyFinancialStatementsResponse
|
next: CompanyFinancialStatementsResponse
|
||||||
@@ -146,23 +190,15 @@ function mergeFinancialPages(
|
|||||||
.filter((period, index, list) => list.findIndex((item) => item.id === period.id) === index)
|
.filter((period, index, list) => list.findIndex((item) => item.id === period.id) === index)
|
||||||
.sort((a, b) => Date.parse(a.filingDate) - Date.parse(b.filingDate));
|
.sort((a, b) => Date.parse(a.filingDate) - Date.parse(b.filingDate));
|
||||||
|
|
||||||
const rowMap = new Map<string, TaxonomyStatementRow>();
|
const faithfulRows = mergeSurfaceRows(
|
||||||
|
[...base.surfaces.faithful.rows, ...next.surfaces.faithful.rows],
|
||||||
for (const row of [...base.rows, ...next.rows]) {
|
(existing, row) => {
|
||||||
const existing = rowMap.get(row.key);
|
|
||||||
if (!existing) {
|
|
||||||
rowMap.set(row.key, {
|
|
||||||
...row,
|
|
||||||
values: { ...row.values },
|
|
||||||
units: { ...row.units },
|
|
||||||
sourceFactIds: [...row.sourceFactIds]
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
existing.hasDimensions = existing.hasDimensions || row.hasDimensions;
|
existing.hasDimensions = existing.hasDimensions || row.hasDimensions;
|
||||||
existing.order = Math.min(existing.order, row.order);
|
existing.order = Math.min(existing.order, row.order);
|
||||||
existing.depth = Math.min(existing.depth, row.depth);
|
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)) {
|
for (const [periodId, value] of Object.entries(row.values)) {
|
||||||
if (!(periodId in existing.values)) {
|
if (!(periodId in existing.values)) {
|
||||||
@@ -182,6 +218,57 @@ function mergeFinancialPages(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
).sort((left, right) => {
|
||||||
|
if (left.order !== right.order) {
|
||||||
|
return left.order - right.order;
|
||||||
|
}
|
||||||
|
|
||||||
|
return left.label.localeCompare(right.label);
|
||||||
|
});
|
||||||
|
|
||||||
|
const standardizedRows = mergeSurfaceRows(
|
||||||
|
[...base.surfaces.standardized.rows, ...next.surfaces.standardized.rows],
|
||||||
|
(existing, row) => {
|
||||||
|
existing.hasDimensions = existing.hasDimensions || row.hasDimensions;
|
||||||
|
existing.order = Math.min(existing.order, row.order);
|
||||||
|
|
||||||
|
for (const [periodId, value] of Object.entries(row.values)) {
|
||||||
|
if (!(periodId in existing.values)) {
|
||||||
|
existing.values[periodId] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [periodId, sourceRowKey] of Object.entries(row.resolvedSourceRowKeys)) {
|
||||||
|
if (!(periodId in existing.resolvedSourceRowKeys)) {
|
||||||
|
existing.resolvedSourceRowKeys[periodId] = sourceRowKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const concept of row.sourceConcepts) {
|
||||||
|
if (!existing.sourceConcepts.includes(concept)) {
|
||||||
|
existing.sourceConcepts.push(concept);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const rowKey of row.sourceRowKeys) {
|
||||||
|
if (!existing.sourceRowKeys.includes(rowKey)) {
|
||||||
|
existing.sourceRowKeys.push(rowKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const factId of row.sourceFactIds) {
|
||||||
|
if (!existing.sourceFactIds.includes(factId)) {
|
||||||
|
existing.sourceFactIds.push(factId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
).sort((left, right) => {
|
||||||
|
if (left.order !== right.order) {
|
||||||
|
return left.order - right.order;
|
||||||
|
}
|
||||||
|
|
||||||
|
return left.label.localeCompare(right.label);
|
||||||
|
});
|
||||||
|
|
||||||
const dimensionBreakdown = (() => {
|
const dimensionBreakdown = (() => {
|
||||||
if (!base.dimensionBreakdown && !next.dimensionBreakdown) {
|
if (!base.dimensionBreakdown && !next.dimensionBreakdown) {
|
||||||
@@ -210,16 +297,21 @@ function mergeFinancialPages(
|
|||||||
return {
|
return {
|
||||||
...next,
|
...next,
|
||||||
periods,
|
periods,
|
||||||
rows: [...rowMap.values()],
|
surfaces: {
|
||||||
|
faithful: {
|
||||||
|
kind: 'faithful' as const,
|
||||||
|
rows: faithfulRows
|
||||||
|
},
|
||||||
|
standardized: {
|
||||||
|
kind: 'standardized' as const,
|
||||||
|
rows: standardizedRows
|
||||||
|
}
|
||||||
|
},
|
||||||
nextCursor: next.nextCursor,
|
nextCursor: next.nextCursor,
|
||||||
coverage: {
|
coverage: {
|
||||||
...next.coverage,
|
...next.coverage,
|
||||||
filings: periods.length,
|
filings: periods.length,
|
||||||
rows: rowMap.size,
|
rows: faithfulRows.length
|
||||||
dimensions: dimensionBreakdown
|
|
||||||
? Object.values(dimensionBreakdown).reduce((total, rows) => total + rows.length, 0)
|
|
||||||
: 0,
|
|
||||||
facts: next.coverage.facts
|
|
||||||
},
|
},
|
||||||
dataSourceStatus: {
|
dataSourceStatus: {
|
||||||
...next.dataSourceStatus,
|
...next.dataSourceStatus,
|
||||||
@@ -229,23 +321,23 @@ function mergeFinancialPages(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function findRowByLocalNames(rows: TaxonomyStatementRow[], localNames: string[]) {
|
function findFaithfulRowByLocalNames(rows: TaxonomyStatementRow[], localNames: string[]) {
|
||||||
const exact = rows.find((row) => localNames.some((name) => row.localName.toLowerCase() === name.toLowerCase()));
|
const normalizedNames = localNames.map((name) => name.toLowerCase());
|
||||||
|
const exact = rows.find((row) => normalizedNames.includes(row.localName.toLowerCase()));
|
||||||
if (exact) {
|
if (exact) {
|
||||||
return exact;
|
return exact;
|
||||||
}
|
}
|
||||||
|
|
||||||
const exactLabel = rows.find((row) => localNames.some((name) => row.label.toLowerCase() === name.toLowerCase()));
|
|
||||||
if (exactLabel) {
|
|
||||||
return exactLabel;
|
|
||||||
}
|
|
||||||
|
|
||||||
return rows.find((row) => {
|
return rows.find((row) => {
|
||||||
const haystack = `${row.key} ${row.label} ${row.localName} ${row.qname}`.toLowerCase();
|
const haystack = `${row.key} ${row.label} ${row.localName} ${row.qname}`.toLowerCase();
|
||||||
return localNames.some((name) => haystack.includes(name.toLowerCase()));
|
return normalizedNames.some((name) => haystack.includes(name));
|
||||||
}) ?? null;
|
}) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findStandardizedRow(rows: StandardizedStatementRow[], key: string) {
|
||||||
|
return rows.find((row) => row.key === key) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
function buildOverviewSeries(
|
function buildOverviewSeries(
|
||||||
incomeData: CompanyFinancialStatementsResponse | null,
|
incomeData: CompanyFinancialStatementsResponse | null,
|
||||||
balanceData: CompanyFinancialStatementsResponse | null
|
balanceData: CompanyFinancialStatementsResponse | null
|
||||||
@@ -269,24 +361,21 @@ function buildOverviewSeries(
|
|||||||
}))
|
}))
|
||||||
.sort((a, b) => Date.parse(a.periodEnd ?? a.filingDate) - Date.parse(b.periodEnd ?? b.filingDate));
|
.sort((a, b) => Date.parse(a.periodEnd ?? a.filingDate) - Date.parse(b.periodEnd ?? b.filingDate));
|
||||||
|
|
||||||
const incomeRows = incomeData?.rows ?? [];
|
const incomeStandardized = incomeData?.surfaces.standardized.rows ?? [];
|
||||||
const balanceRows = balanceData?.rows ?? [];
|
const balanceStandardized = balanceData?.surfaces.standardized.rows ?? [];
|
||||||
|
const incomeFaithful = incomeData?.surfaces.faithful.rows ?? [];
|
||||||
|
const balanceFaithful = balanceData?.surfaces.faithful.rows ?? [];
|
||||||
|
|
||||||
const revenueRow = findRowByLocalNames(incomeRows, [
|
const revenueRow = findStandardizedRow(incomeStandardized, 'revenue')
|
||||||
'RevenueFromContractWithCustomerExcludingAssessedTax',
|
?? findFaithfulRowByLocalNames(incomeFaithful, ['RevenueFromContractWithCustomerExcludingAssessedTax', 'Revenues', 'SalesRevenueNet', 'Revenue']);
|
||||||
'Revenues',
|
const netIncomeRow = findStandardizedRow(incomeStandardized, 'net-income')
|
||||||
'SalesRevenueNet',
|
?? findFaithfulRowByLocalNames(incomeFaithful, ['NetIncomeLoss', 'ProfitLoss']);
|
||||||
'Revenue'
|
const assetsRow = findStandardizedRow(balanceStandardized, 'total-assets')
|
||||||
]);
|
?? findFaithfulRowByLocalNames(balanceFaithful, ['Assets']);
|
||||||
const netIncomeRow = findRowByLocalNames(incomeRows, ['NetIncomeLoss', 'ProfitLoss']);
|
const cashRow = findStandardizedRow(balanceStandardized, 'cash-and-equivalents')
|
||||||
const assetsRow = findRowByLocalNames(balanceRows, ['Assets']);
|
?? findFaithfulRowByLocalNames(balanceFaithful, ['CashAndCashEquivalentsAtCarryingValue', 'CashAndShortTermInvestments', 'Cash']);
|
||||||
const cashRow = findRowByLocalNames(balanceRows, [
|
const debtRow = findStandardizedRow(balanceStandardized, 'total-debt')
|
||||||
'CashAndCashEquivalentsAtCarryingValue',
|
?? findFaithfulRowByLocalNames(balanceFaithful, ['LongTermDebt', 'Debt', 'LongTermDebtNoncurrent']);
|
||||||
'CashCashEquivalentsAndShortTermInvestments',
|
|
||||||
'CashAndShortTermInvestments',
|
|
||||||
'Cash'
|
|
||||||
]);
|
|
||||||
const debtRow = findRowByLocalNames(balanceRows, ['LongTermDebt', 'Debt', 'LongTermDebtNoncurrent']);
|
|
||||||
|
|
||||||
return periods.map((period) => ({
|
return periods.map((period) => ({
|
||||||
periodId: period.periodId,
|
periodId: period.periodId,
|
||||||
@@ -301,6 +390,54 @@ function buildOverviewSeries(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function groupDimensionRows(
|
||||||
|
rows: DimensionBreakdownRow[],
|
||||||
|
surface: FinancialStatementSurfaceKind
|
||||||
|
) {
|
||||||
|
if (surface === 'faithful') {
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...rows].sort((left, right) => {
|
||||||
|
if (left.periodId !== right.periodId) {
|
||||||
|
return left.periodId.localeCompare(right.periodId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${left.sourceLabel ?? ''}${left.concept ?? ''}`.localeCompare(`${right.sourceLabel ?? ''}${right.concept ?? ''}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChartFrame({ children }: { children: React.ReactNode }) {
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = containerRef.current;
|
||||||
|
if (!element) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new ResizeObserver((entries) => {
|
||||||
|
const next = entries[0];
|
||||||
|
if (!next) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { width, height } = next.contentRect;
|
||||||
|
setReady(width > 0 && height > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(element);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="h-[320px]">
|
||||||
|
{ready ? children : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function FinancialsPage() {
|
export default function FinancialsPage() {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Loading financial terminal...</div>}>
|
<Suspense fallback={<div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Loading financial terminal...</div>}>
|
||||||
@@ -319,6 +456,7 @@ function FinancialsPageContent() {
|
|||||||
const [ticker, setTicker] = useState('MSFT');
|
const [ticker, setTicker] = useState('MSFT');
|
||||||
const [statement, setStatement] = useState<FinancialStatementKind>('income');
|
const [statement, setStatement] = useState<FinancialStatementKind>('income');
|
||||||
const [window, setWindow] = useState<FinancialHistoryWindow>('10y');
|
const [window, setWindow] = useState<FinancialHistoryWindow>('10y');
|
||||||
|
const [surface, setSurface] = useState<FinancialStatementSurfaceKind>('standardized');
|
||||||
const [valueScale, setValueScale] = useState<NumberScaleUnit>('millions');
|
const [valueScale, setValueScale] = useState<NumberScaleUnit>('millions');
|
||||||
const [financials, setFinancials] = useState<CompanyFinancialStatementsResponse | null>(null);
|
const [financials, setFinancials] = useState<CompanyFinancialStatementsResponse | null>(null);
|
||||||
const [overviewIncome, setOverviewIncome] = useState<CompanyFinancialStatementsResponse | null>(null);
|
const [overviewIncome, setOverviewIncome] = useState<CompanyFinancialStatementsResponse | null>(null);
|
||||||
@@ -412,12 +550,12 @@ function FinancialsPageContent() {
|
|||||||
setLoadingMore(false);
|
setLoadingMore(false);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
queryClient,
|
|
||||||
statement,
|
|
||||||
window,
|
|
||||||
dimensionsEnabled,
|
dimensionsEnabled,
|
||||||
|
loadOverview,
|
||||||
|
queryClient,
|
||||||
selectedRowKey,
|
selectedRowKey,
|
||||||
loadOverview
|
statement,
|
||||||
|
window
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const syncFinancials = useCallback(async () => {
|
const syncFinancials = useCallback(async () => {
|
||||||
@@ -440,20 +578,24 @@ function FinancialsPageContent() {
|
|||||||
} finally {
|
} finally {
|
||||||
setSyncingFinancials(false);
|
setSyncingFinancials(false);
|
||||||
}
|
}
|
||||||
}, [financials?.company.ticker, ticker, queryClient, loadFinancials]);
|
}, [financials?.company.ticker, loadFinancials, queryClient, ticker]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isPending && isAuthenticated) {
|
if (!isPending && isAuthenticated) {
|
||||||
void loadFinancials(ticker);
|
void loadFinancials(ticker);
|
||||||
}
|
}
|
||||||
}, [isPending, isAuthenticated, ticker, statement, window, dimensionsEnabled, loadFinancials]);
|
}, [isPending, isAuthenticated, loadFinancials, ticker]);
|
||||||
|
|
||||||
const periods = useMemo(() => {
|
const periods = useMemo(() => {
|
||||||
return [...(financials?.periods ?? [])]
|
return [...(financials?.periods ?? [])]
|
||||||
.sort((a, b) => Date.parse(a.filingDate) - Date.parse(b.filingDate));
|
.sort((a, b) => Date.parse(a.filingDate) - Date.parse(b.filingDate));
|
||||||
}, [financials?.periods]);
|
}, [financials?.periods]);
|
||||||
|
|
||||||
const statementRows = useMemo(() => financials?.rows ?? [], [financials?.rows]);
|
const faithfulRows = useMemo(() => financials?.surfaces.faithful.rows ?? [], [financials?.surfaces.faithful.rows]);
|
||||||
|
const standardizedRows = useMemo(() => financials?.surfaces.standardized.rows ?? [], [financials?.surfaces.standardized.rows]);
|
||||||
|
const statementRows = useMemo<DisplayRow[]>(() => {
|
||||||
|
return surface === 'standardized' ? standardizedRows : faithfulRows;
|
||||||
|
}, [faithfulRows, standardizedRows, surface]);
|
||||||
|
|
||||||
const overviewSeries = useMemo(() => {
|
const overviewSeries = useMemo(() => {
|
||||||
return buildOverviewSeries(overviewIncome, overviewBalance);
|
return buildOverviewSeries(overviewIncome, overviewBalance);
|
||||||
@@ -464,11 +606,29 @@ function FinancialsPageContent() {
|
|||||||
?? overviewIncome?.metrics.taxonomy
|
?? overviewIncome?.metrics.taxonomy
|
||||||
?? overviewBalance?.metrics.taxonomy
|
?? overviewBalance?.metrics.taxonomy
|
||||||
?? null;
|
?? null;
|
||||||
const latestRevenue = latestOverview?.revenue ?? latestTaxonomyMetrics?.revenue ?? null;
|
|
||||||
const latestNetIncome = latestOverview?.netIncome ?? latestTaxonomyMetrics?.netIncome ?? null;
|
const latestStandardizedIncome = overviewIncome?.surfaces.standardized.rows ?? [];
|
||||||
const latestTotalAssets = latestOverview?.totalAssets ?? latestTaxonomyMetrics?.totalAssets ?? null;
|
const latestStandardizedBalance = overviewBalance?.surfaces.standardized.rows ?? [];
|
||||||
const latestCash = latestOverview?.cash ?? latestTaxonomyMetrics?.cash ?? null;
|
const latestRevenue = latestOverview?.revenue
|
||||||
const latestDebt = latestOverview?.debt ?? latestTaxonomyMetrics?.debt ?? null;
|
?? findStandardizedRow(latestStandardizedIncome, 'revenue')?.values[periods[periods.length - 1]?.id ?? '']
|
||||||
|
?? latestTaxonomyMetrics?.revenue
|
||||||
|
?? null;
|
||||||
|
const latestNetIncome = latestOverview?.netIncome
|
||||||
|
?? findStandardizedRow(latestStandardizedIncome, 'net-income')?.values[periods[periods.length - 1]?.id ?? '']
|
||||||
|
?? latestTaxonomyMetrics?.netIncome
|
||||||
|
?? null;
|
||||||
|
const latestTotalAssets = latestOverview?.totalAssets
|
||||||
|
?? findStandardizedRow(latestStandardizedBalance, 'total-assets')?.values[periods[periods.length - 1]?.id ?? '']
|
||||||
|
?? latestTaxonomyMetrics?.totalAssets
|
||||||
|
?? null;
|
||||||
|
const latestCash = latestOverview?.cash
|
||||||
|
?? findStandardizedRow(latestStandardizedBalance, 'cash-and-equivalents')?.values[periods[periods.length - 1]?.id ?? '']
|
||||||
|
?? latestTaxonomyMetrics?.cash
|
||||||
|
?? null;
|
||||||
|
const latestDebt = latestOverview?.debt
|
||||||
|
?? findStandardizedRow(latestStandardizedBalance, 'total-debt')?.values[periods[periods.length - 1]?.id ?? '']
|
||||||
|
?? latestTaxonomyMetrics?.debt
|
||||||
|
?? null;
|
||||||
const latestReferenceDate = latestOverview?.filingDate ?? periods[periods.length - 1]?.filingDate ?? null;
|
const latestReferenceDate = latestOverview?.filingDate ?? periods[periods.length - 1]?.filingDate ?? null;
|
||||||
|
|
||||||
const selectedRow = useMemo(() => {
|
const selectedRow = useMemo(() => {
|
||||||
@@ -485,20 +645,8 @@ function FinancialsPageContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const direct = financials.dimensionBreakdown[selectedRow.key] ?? [];
|
const direct = financials.dimensionBreakdown[selectedRow.key] ?? [];
|
||||||
if (direct.length > 0) {
|
return groupDimensionRows(direct, surface);
|
||||||
return direct;
|
}, [financials?.dimensionBreakdown, selectedRow, surface]);
|
||||||
}
|
|
||||||
|
|
||||||
const conceptKey = selectedRow.qname.toLowerCase();
|
|
||||||
for (const rows of Object.values(financials.dimensionBreakdown)) {
|
|
||||||
const matched = rows.filter((row) => (row.concept ?? '').toLowerCase() === conceptKey);
|
|
||||||
if (matched.length > 0) {
|
|
||||||
return matched;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}, [selectedRow, financials?.dimensionBreakdown]);
|
|
||||||
|
|
||||||
const selectedScaleLabel = useMemo(() => {
|
const selectedScaleLabel = useMemo(() => {
|
||||||
return FINANCIAL_VALUE_SCALE_OPTIONS.find((option) => option.value === valueScale)?.label ?? 'Millions (M)';
|
return FINANCIAL_VALUE_SCALE_OPTIONS.find((option) => option.value === valueScale)?.label ?? 'Millions (M)';
|
||||||
@@ -532,7 +680,7 @@ function FinancialsPageContent() {
|
|||||||
options: FINANCIAL_VALUE_SCALE_OPTIONS,
|
options: FINANCIAL_VALUE_SCALE_OPTIONS,
|
||||||
onChange: (nextValue) => setValueScale(nextValue as NumberScaleUnit)
|
onChange: (nextValue) => setValueScale(nextValue as NumberScaleUnit)
|
||||||
}
|
}
|
||||||
], [statement, window, valueScale]);
|
], [statement, valueScale, window]);
|
||||||
|
|
||||||
const controlActions = useMemo<FinancialControlAction[]>(() => {
|
const controlActions = useMemo<FinancialControlAction[]>(() => {
|
||||||
const actions: FinancialControlAction[] = [];
|
const actions: FinancialControlAction[] = [];
|
||||||
@@ -569,7 +717,7 @@ function FinancialsPageContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return actions;
|
return actions;
|
||||||
}, [window, financials?.nextCursor, loadingMore, loadFinancials, ticker]);
|
}, [financials?.nextCursor, loadFinancials, loadingMore, ticker, window]);
|
||||||
|
|
||||||
if (isPending || !isAuthenticated) {
|
if (isPending || !isAuthenticated) {
|
||||||
return <div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Loading financial terminal...</div>;
|
return <div className="flex min-h-screen items-center justify-center text-sm text-[color:var(--terminal-muted)]">Loading financial terminal...</div>;
|
||||||
@@ -578,7 +726,7 @@ function FinancialsPageContent() {
|
|||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
title="Financials"
|
title="Financials"
|
||||||
subtitle="Taxonomy-native financial statements with PDF LLM metric validation."
|
subtitle="Filing-faithful financial statements with a standardized comparison surface and PDF metric validation."
|
||||||
activeTicker={financials?.company.ticker ?? ticker}
|
activeTicker={financials?.company.ticker ?? ticker}
|
||||||
actions={(
|
actions={(
|
||||||
<div className="flex w-full flex-wrap items-center justify-end gap-2 sm:w-auto">
|
<div className="flex w-full flex-wrap items-center justify-end gap-2 sm:w-auto">
|
||||||
@@ -604,7 +752,7 @@ function FinancialsPageContent() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Panel title="Company Selector" subtitle="Load statement history by ticker. Default window is 10 years; full history is on demand.">
|
<Panel title="Company Selector" subtitle="Load statement history by ticker. The standardized comparison view is the default surface.">
|
||||||
<form
|
<form
|
||||||
className="flex flex-wrap items-center gap-3"
|
className="flex flex-wrap items-center gap-3"
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
@@ -673,28 +821,42 @@ function FinancialsPageContent() {
|
|||||||
|
|
||||||
<FinancialControlBar
|
<FinancialControlBar
|
||||||
title="Financial Controls"
|
title="Financial Controls"
|
||||||
subtitle={`Taxonomy statement controls. Current display scale: ${selectedScaleLabel}.`}
|
subtitle={`Current display scale: ${selectedScaleLabel}. Choose between standardized comparison and filing-faithful rendering below.`}
|
||||||
sections={controlSections}
|
sections={controlSections}
|
||||||
actions={controlActions}
|
actions={controlActions}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Panel title="Statement Surface" subtitle="Standardized rows flatten curated GAAP aliases for comparison while the faithful view preserves the filing's presentation.">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{SURFACE_OPTIONS.map((option) => (
|
||||||
|
<Button
|
||||||
|
key={option.value}
|
||||||
|
variant={surface === option.value ? 'primary' : 'ghost'}
|
||||||
|
onClick={() => {
|
||||||
|
setSurface(option.value);
|
||||||
|
setSelectedRowKey(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option.value === 'standardized' ? <GitCompareArrows className="size-4" /> : <ChartNoAxesCombined className="size-4" />}
|
||||||
|
{option.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
|
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
|
||||||
<Panel title="Income Trend" subtitle="Overview chart from taxonomy-derived income and balance concepts.">
|
<Panel title="Income Trend" subtitle="Overview chart sourced from standardized statement rows first, then taxonomy metrics as fallback.">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-sm text-[color:var(--terminal-muted)]">Loading overview chart...</p>
|
<p className="text-sm text-[color:var(--terminal-muted)]">Loading overview chart...</p>
|
||||||
) : overviewSeries.length === 0 ? (
|
) : overviewSeries.length === 0 ? (
|
||||||
<p className="text-sm text-[color:var(--terminal-muted)]">No income history available yet.</p>
|
<p className="text-sm text-[color:var(--terminal-muted)]">No income history available yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-[320px]">
|
<ChartFrame>
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart data={overviewSeries}>
|
<BarChart data={overviewSeries}>
|
||||||
<CartesianGrid strokeDasharray="2 2" stroke={CHART_GRID} />
|
<CartesianGrid strokeDasharray="2 2" stroke={CHART_GRID} />
|
||||||
<XAxis dataKey="label" stroke={CHART_MUTED} fontSize={12} minTickGap={20} />
|
<XAxis dataKey="label" stroke={CHART_MUTED} fontSize={12} minTickGap={20} />
|
||||||
<YAxis
|
<YAxis stroke={CHART_MUTED} fontSize={12} tickFormatter={(value: number) => asAxisCurrencyTick(value, valueScale)} />
|
||||||
stroke={CHART_MUTED}
|
|
||||||
fontSize={12}
|
|
||||||
tickFormatter={(value: number) => asAxisCurrencyTick(value, valueScale)}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
<Tooltip
|
||||||
formatter={(value) => asTooltipCurrency(value, valueScale)}
|
formatter={(value) => asTooltipCurrency(value, valueScale)}
|
||||||
contentStyle={{
|
contentStyle={{
|
||||||
@@ -707,26 +869,22 @@ function FinancialsPageContent() {
|
|||||||
<Bar dataKey="netIncome" name="Net Income" fill="#5fd3ff" radius={[4, 4, 0, 0]} />
|
<Bar dataKey="netIncome" name="Net Income" fill="#5fd3ff" radius={[4, 4, 0, 0]} />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</ChartFrame>
|
||||||
)}
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<Panel title="Balance Trend" subtitle="Assets, cash, and debt from taxonomy-derived balance concepts.">
|
<Panel title="Balance Trend" subtitle="Assets, cash, and debt sourced from standardized balance rows for cleaner period-over-period comparison.">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-sm text-[color:var(--terminal-muted)]">Loading balance chart...</p>
|
<p className="text-sm text-[color:var(--terminal-muted)]">Loading balance chart...</p>
|
||||||
) : overviewSeries.length === 0 ? (
|
) : overviewSeries.length === 0 ? (
|
||||||
<p className="text-sm text-[color:var(--terminal-muted)]">No balance history available yet.</p>
|
<p className="text-sm text-[color:var(--terminal-muted)]">No balance history available yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-[320px]">
|
<ChartFrame>
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<AreaChart data={overviewSeries}>
|
<AreaChart data={overviewSeries}>
|
||||||
<CartesianGrid strokeDasharray="2 2" stroke={CHART_GRID} />
|
<CartesianGrid strokeDasharray="2 2" stroke={CHART_GRID} />
|
||||||
<XAxis dataKey="label" stroke={CHART_MUTED} fontSize={12} minTickGap={20} />
|
<XAxis dataKey="label" stroke={CHART_MUTED} fontSize={12} minTickGap={20} />
|
||||||
<YAxis
|
<YAxis stroke={CHART_MUTED} fontSize={12} tickFormatter={(value: number) => asAxisCurrencyTick(value, valueScale)} />
|
||||||
stroke={CHART_MUTED}
|
|
||||||
fontSize={12}
|
|
||||||
tickFormatter={(value: number) => asAxisCurrencyTick(value, valueScale)}
|
|
||||||
/>
|
|
||||||
<Tooltip
|
<Tooltip
|
||||||
formatter={(value) => asTooltipCurrency(value, valueScale)}
|
formatter={(value) => asTooltipCurrency(value, valueScale)}
|
||||||
contentStyle={{
|
contentStyle={{
|
||||||
@@ -740,14 +898,16 @@ function FinancialsPageContent() {
|
|||||||
<Line type="monotone" dataKey="debt" name="Debt" stroke="#ffd08a" strokeWidth={2} dot={false} />
|
<Line type="monotone" dataKey="debt" name="Debt" stroke="#ffd08a" strokeWidth={2} dot={false} />
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</ChartFrame>
|
||||||
)}
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Panel
|
<Panel
|
||||||
title="Statement Matrix"
|
title="Statement Matrix"
|
||||||
subtitle={`Taxonomy-native ${statement.replace('_', ' ')} rows by period. Extension concepts are tagged; rows with dimensions can be drilled down.`}
|
subtitle={surface === 'standardized'
|
||||||
|
? `Standardized ${statement.replace('_', ' ')} rows merge curated GAAP aliases for comparison while preserving source provenance.`
|
||||||
|
: `Filing-faithful ${statement.replace('_', ' ')} rows preserve issuer taxonomy, ordering, extensions, and dimensional detail.`}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-sm text-[color:var(--terminal-muted)]">Loading statement matrix...</p>
|
<p className="text-sm text-[color:var(--terminal-muted)]">Loading statement matrix...</p>
|
||||||
@@ -782,11 +942,29 @@ function FinancialsPageContent() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<td className="sticky left-0 z-10 bg-[color:var(--panel)]">
|
<td className="sticky left-0 z-10 bg-[color:var(--panel)]">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span style={{ paddingLeft: `${Math.min(row.depth, 10) * 12}px` }}>{row.label}</span>
|
<span style={{ paddingLeft: `${isFaithfulRow(row) ? Math.min(row.depth, 10) * 12 : 0}px` }}>{row.label}</span>
|
||||||
{row.isExtension ? <span className="rounded border border-[color:var(--line-weak)] px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-[color:var(--terminal-muted)]">Ext</span> : null}
|
{isFaithfulRow(row) && row.isExtension ? (
|
||||||
|
<span className="rounded border border-[color:var(--line-weak)] px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-[color:var(--terminal-muted)]">Ext</span>
|
||||||
|
) : null}
|
||||||
{row.hasDimensions ? <ChevronDown className="size-3 text-[color:var(--accent)]" /> : null}
|
{row.hasDimensions ? <ChevronDown className="size-3 text-[color:var(--accent)]" /> : null}
|
||||||
</div>
|
</div>
|
||||||
|
{isStandardizedRow(row) ? (
|
||||||
|
<details className="text-xs text-[color:var(--terminal-muted)]">
|
||||||
|
<summary className="cursor-pointer list-none">
|
||||||
|
Mapped from {row.sourceConcepts.length} concept{row.sourceConcepts.length === 1 ? '' : 's'}
|
||||||
|
</summary>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
|
{row.sourceConcepts.map((concept) => (
|
||||||
|
<span key={`${row.key}-${concept}`} className="rounded border border-[color:var(--line-weak)] px-1.5 py-0.5">
|
||||||
|
{concept}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
{periods.map((period) => (
|
{periods.map((period) => (
|
||||||
<td key={`${row.key}-${period.id}`}>
|
<td key={`${row.key}-${period.id}`}>
|
||||||
@@ -799,10 +977,14 @@ function FinancialsPageContent() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<Panel title="Dimension Drill-down" subtitle="Segment/geography/product axes are shown only for the selected row when available.">
|
<Panel
|
||||||
|
title="Dimension Drill-down"
|
||||||
|
subtitle={surface === 'standardized'
|
||||||
|
? 'Standardized rows aggregate dimensional facts across every mapped source concept.'
|
||||||
|
: 'Filing-faithful rows show the exact dimensional facts attached to the selected concept.'}
|
||||||
|
>
|
||||||
{!selectedRow ? (
|
{!selectedRow ? (
|
||||||
<p className="text-sm text-[color:var(--terminal-muted)]">Select a statement row to inspect dimensional facts.</p>
|
<p className="text-sm text-[color:var(--terminal-muted)]">Select a statement row to inspect dimensional facts.</p>
|
||||||
) : !selectedRow.hasDimensions ? (
|
) : !selectedRow.hasDimensions ? (
|
||||||
@@ -813,10 +995,11 @@ function FinancialsPageContent() {
|
|||||||
<p className="text-sm text-[color:var(--terminal-muted)]">Dimensions are still loading or unavailable for this row.</p>
|
<p className="text-sm text-[color:var(--terminal-muted)]">Dimensions are still loading or unavailable for this row.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="data-table min-w-[760px]">
|
<table className="data-table min-w-[860px]">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Period</th>
|
<th>Period</th>
|
||||||
|
{surface === 'standardized' ? <th>Source</th> : null}
|
||||||
<th>Axis</th>
|
<th>Axis</th>
|
||||||
<th>Member</th>
|
<th>Member</th>
|
||||||
<th>Value</th>
|
<th>Value</th>
|
||||||
@@ -828,6 +1011,7 @@ function FinancialsPageContent() {
|
|||||||
return (
|
return (
|
||||||
<tr key={`${row.rowKey}-${row.periodId}-${row.axis}-${row.member}-${index}`}>
|
<tr key={`${row.rowKey}-${row.periodId}-${row.axis}-${row.member}-${index}`}>
|
||||||
<td>{period ? formatLongDate(period.filingDate) : row.periodId}</td>
|
<td>{period ? formatLongDate(period.filingDate) : row.periodId}</td>
|
||||||
|
{surface === 'standardized' ? <td>{row.sourceLabel ?? row.concept ?? 'Unknown source'}</td> : null}
|
||||||
<td>{row.axis}</td>
|
<td>{row.axis}</td>
|
||||||
<td>{row.member}</td>
|
<td>{row.member}</td>
|
||||||
<td>{asDisplayCurrency(row.value, valueScale)}</td>
|
<td>{asDisplayCurrency(row.value, valueScale)}</td>
|
||||||
@@ -841,7 +1025,7 @@ function FinancialsPageContent() {
|
|||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
{financials ? (
|
{financials ? (
|
||||||
<Panel title="Metric Validation" subtitle="Taxonomy-derived metrics are canonical; PDF LLM extraction is used for validation only.">
|
<Panel title="Metric Validation" subtitle="The standardized taxonomy surface is the canonical financial layer; PDF LLM extraction is used only to validate selected metrics.">
|
||||||
<div className="mb-3 flex items-center gap-2 text-sm text-[color:var(--terminal-muted)]">
|
<div className="mb-3 flex items-center gap-2 text-sm text-[color:var(--terminal-muted)]">
|
||||||
<AlertTriangle className="size-4" />
|
<AlertTriangle className="size-4" />
|
||||||
<span>Overall status: {financials.metrics.validation?.status ?? 'not_run'}</span>
|
<span>Overall status: {financials.metrics.validation?.status ?? 'not_run'}</span>
|
||||||
@@ -878,7 +1062,7 @@ function FinancialsPageContent() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{financials ? (
|
{financials ? (
|
||||||
<Panel title="Data Source Status" subtitle="Hydration and taxonomy parsing status for filing snapshots.">
|
<Panel title="Data Source Status" subtitle="Hydration and taxonomy parsing status for filing snapshots powering both financial statement surfaces.">
|
||||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-5">
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-5">
|
||||||
<div className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm">
|
<div className="rounded-lg border border-[color:var(--line-weak)] bg-[color:var(--panel-soft)] px-3 py-2 text-sm">
|
||||||
<p className="text-[color:var(--terminal-muted)]">Hydrated</p>
|
<p className="text-[color:var(--terminal-muted)]">Hydrated</p>
|
||||||
@@ -907,7 +1091,7 @@ function FinancialsPageContent() {
|
|||||||
<Panel>
|
<Panel>
|
||||||
<div className="flex items-center gap-2 text-xs uppercase tracking-[0.24em] text-[color:var(--terminal-muted)]">
|
<div className="flex items-center gap-2 text-xs uppercase tracking-[0.24em] text-[color:var(--terminal-muted)]">
|
||||||
<ChartNoAxesCombined className="size-4" />
|
<ChartNoAxesCombined className="size-4" />
|
||||||
Financial Statements V3: taxonomy + PDF LLM validation
|
Financial Statements V3: faithful filing reconstruction + standardized taxonomy comparison
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
|
|||||||
@@ -104,6 +104,13 @@ export function useTaskNotificationsCenter(): UseTaskNotificationsCenterResult {
|
|||||||
const invalidatedTerminalRef = useRef(new Set<string>());
|
const invalidatedTerminalRef = useRef(new Set<string>());
|
||||||
const activeSnapshotRef = useRef<Task[]>([]);
|
const activeSnapshotRef = useRef<Task[]>([]);
|
||||||
const finishedSnapshotRef = useRef<Task[]>([]);
|
const finishedSnapshotRef = useRef<Task[]>([]);
|
||||||
|
const [isDocumentVisible, setIsDocumentVisible] = useState(() => {
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return document.visibilityState === 'visible';
|
||||||
|
});
|
||||||
|
|
||||||
const applyTaskLocally = useCallback((task: Task) => {
|
const applyTaskLocally = useCallback((task: Task) => {
|
||||||
setActiveTasks((prev) => prev.map((entry) => (entry.id === task.id ? task : entry)));
|
setActiveTasks((prev) => prev.map((entry) => (entry.id === task.id ? task : entry)));
|
||||||
@@ -285,10 +292,54 @@ export function useTaskNotificationsCenter(): UseTaskNotificationsCenterResult {
|
|||||||
}
|
}
|
||||||
}, [processSnapshots]);
|
}, [processSnapshots]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onVisibilityChange = () => {
|
||||||
|
setIsDocumentVisible(document.visibilityState === 'visible');
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||||
|
return () => document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let activeTimer: ReturnType<typeof setTimeout> | null = null;
|
let activeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let terminalTimer: ReturnType<typeof setTimeout> | null = null;
|
let terminalTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let stableTerminalPolls = 0;
|
||||||
|
let previousTerminalSignature = '';
|
||||||
|
|
||||||
|
const nextActiveDelay = () => {
|
||||||
|
if (!isDocumentVisible) {
|
||||||
|
return 30_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasActiveTasks = activeSnapshotRef.current.length > 0;
|
||||||
|
if (isPopoverOpen || isDetailOpen || hasActiveTasks) {
|
||||||
|
return 2_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 12_000;
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextTerminalDelay = () => {
|
||||||
|
if (!isDocumentVisible) {
|
||||||
|
return 60_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPopoverOpen || isDetailOpen) {
|
||||||
|
return 4_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finishedSnapshotRef.current.some((task) => isUnread(task))) {
|
||||||
|
return 15_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
return stableTerminalPolls >= 2 ? 45_000 : 20_000;
|
||||||
|
};
|
||||||
|
|
||||||
const runActiveLoop = async () => {
|
const runActiveLoop = async () => {
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
@@ -314,7 +365,7 @@ export function useTaskNotificationsCenter(): UseTaskNotificationsCenterResult {
|
|||||||
// ignore transient polling failures
|
// ignore transient polling failures
|
||||||
}
|
}
|
||||||
|
|
||||||
activeTimer = setTimeout(runActiveLoop, 2_000);
|
activeTimer = setTimeout(runActiveLoop, nextActiveDelay());
|
||||||
};
|
};
|
||||||
|
|
||||||
const runTerminalLoop = async () => {
|
const runTerminalLoop = async () => {
|
||||||
@@ -337,11 +388,19 @@ export function useTaskNotificationsCenter(): UseTaskNotificationsCenterResult {
|
|||||||
setHasLoadedFinished(true);
|
setHasLoadedFinished(true);
|
||||||
setFinishedTasks(response.tasks);
|
setFinishedTasks(response.tasks);
|
||||||
processSnapshots();
|
processSnapshots();
|
||||||
|
|
||||||
|
const signature = response.tasks.map((task) => taskSignature(task)).join('||');
|
||||||
|
if (signature === previousTerminalSignature) {
|
||||||
|
stableTerminalPolls += 1;
|
||||||
|
} else {
|
||||||
|
stableTerminalPolls = 0;
|
||||||
|
previousTerminalSignature = signature;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore transient polling failures
|
// ignore transient polling failures
|
||||||
}
|
}
|
||||||
|
|
||||||
terminalTimer = setTimeout(runTerminalLoop, 4_000);
|
terminalTimer = setTimeout(runTerminalLoop, nextTerminalDelay());
|
||||||
};
|
};
|
||||||
|
|
||||||
void runActiveLoop();
|
void runActiveLoop();
|
||||||
@@ -356,7 +415,7 @@ export function useTaskNotificationsCenter(): UseTaskNotificationsCenterResult {
|
|||||||
clearTimeout(terminalTimer);
|
clearTimeout(terminalTimer);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [processSnapshots]);
|
}, [isDetailOpen, isDocumentVisible, isPopoverOpen, processSnapshots]);
|
||||||
|
|
||||||
const normalizedActiveTasks = useMemo(() => {
|
const normalizedActiveTasks = useMemo(() => {
|
||||||
return activeTasks.filter((task) => ACTIVE_STATUSES.includes(task.status));
|
return activeTasks.filter((task) => ACTIVE_STATUSES.includes(task.status));
|
||||||
|
|||||||
34
lib/server/db/index.test.ts
Normal file
34
lib/server/db/index.test.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { Database } from 'bun:sqlite';
|
||||||
|
import { __dbInternals } from './index';
|
||||||
|
|
||||||
|
function applyMigration(client: Database, fileName: string) {
|
||||||
|
const sql = readFileSync(join(process.cwd(), 'drizzle', fileName), 'utf8');
|
||||||
|
client.exec(sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('sqlite schema compatibility bootstrap', () => {
|
||||||
|
it('adds missing watchlist columns and taxonomy tables for older local databases', () => {
|
||||||
|
const client = new Database(':memory:');
|
||||||
|
client.exec('PRAGMA foreign_keys = ON;');
|
||||||
|
|
||||||
|
applyMigration(client, '0000_cold_silver_centurion.sql');
|
||||||
|
applyMigration(client, '0001_glossy_statement_snapshots.sql');
|
||||||
|
applyMigration(client, '0002_workflow_task_projection_metadata.sql');
|
||||||
|
applyMigration(client, '0003_task_stage_event_timeline.sql');
|
||||||
|
|
||||||
|
expect(__dbInternals.hasColumn(client, 'watchlist_item', 'category')).toBe(false);
|
||||||
|
expect(__dbInternals.hasTable(client, 'filing_taxonomy_snapshot')).toBe(false);
|
||||||
|
|
||||||
|
__dbInternals.ensureLocalSqliteSchema(client);
|
||||||
|
|
||||||
|
expect(__dbInternals.hasColumn(client, 'watchlist_item', 'category')).toBe(true);
|
||||||
|
expect(__dbInternals.hasColumn(client, 'watchlist_item', 'tags')).toBe(true);
|
||||||
|
expect(__dbInternals.hasTable(client, 'filing_taxonomy_snapshot')).toBe(true);
|
||||||
|
expect(__dbInternals.hasTable(client, 'filing_taxonomy_fact')).toBe(true);
|
||||||
|
|
||||||
|
client.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { mkdirSync } from 'node:fs';
|
import { mkdirSync, readFileSync } from 'node:fs';
|
||||||
import { dirname } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
import { Database } from 'bun:sqlite';
|
import { Database } from 'bun:sqlite';
|
||||||
import { drizzle } from 'drizzle-orm/bun-sqlite';
|
import { drizzle } from 'drizzle-orm/bun-sqlite';
|
||||||
import { schema } from './schema';
|
import { schema } from './schema';
|
||||||
@@ -28,6 +28,71 @@ function getDatabasePath() {
|
|||||||
return databasePath;
|
return databasePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasTable(client: Database, tableName: string) {
|
||||||
|
const row = client
|
||||||
|
.query('SELECT name FROM sqlite_master WHERE type = ? AND name = ? LIMIT 1')
|
||||||
|
.get('table', tableName) as { name: string } | null;
|
||||||
|
|
||||||
|
return row !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasColumn(client: Database, tableName: string, columnName: string) {
|
||||||
|
if (!hasTable(client, tableName)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = client.query(`PRAGMA table_info(${tableName})`).all() as Array<{ name: string }>;
|
||||||
|
return rows.some((row) => row.name === columnName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySqlFile(client: Database, fileName: string) {
|
||||||
|
const sql = readFileSync(join(process.cwd(), 'drizzle', fileName), 'utf8');
|
||||||
|
client.exec(sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureLocalSqliteSchema(client: Database) {
|
||||||
|
if (!hasTable(client, 'filing_statement_snapshot')) {
|
||||||
|
applySqlFile(client, '0001_glossy_statement_snapshots.sql');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasTable(client, 'task_run')) {
|
||||||
|
const missingTaskColumns: Array<{ name: string; sql: string }> = [
|
||||||
|
{ name: 'stage', sql: "ALTER TABLE `task_run` ADD `stage` text NOT NULL DEFAULT 'queued';" },
|
||||||
|
{ name: 'stage_detail', sql: 'ALTER TABLE `task_run` ADD `stage_detail` text;' },
|
||||||
|
{ name: 'resource_key', sql: 'ALTER TABLE `task_run` ADD `resource_key` text;' },
|
||||||
|
{ name: 'notification_read_at', sql: 'ALTER TABLE `task_run` ADD `notification_read_at` text;' },
|
||||||
|
{ name: 'notification_silenced_at', sql: 'ALTER TABLE `task_run` ADD `notification_silenced_at` text;' }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const column of missingTaskColumns) {
|
||||||
|
if (!hasColumn(client, 'task_run', column.name)) {
|
||||||
|
client.exec(column.sql);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasTable(client, 'task_stage_event')) {
|
||||||
|
applySqlFile(client, '0003_task_stage_event_timeline.sql');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasTable(client, 'watchlist_item')) {
|
||||||
|
const missingWatchlistColumns: Array<{ name: string; sql: string }> = [
|
||||||
|
{ name: 'category', sql: 'ALTER TABLE `watchlist_item` ADD `category` text;' },
|
||||||
|
{ name: 'tags', sql: 'ALTER TABLE `watchlist_item` ADD `tags` text;' }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const column of missingWatchlistColumns) {
|
||||||
|
if (!hasColumn(client, 'watchlist_item', column.name)) {
|
||||||
|
client.exec(column.sql);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasTable(client, 'filing_taxonomy_snapshot')) {
|
||||||
|
applySqlFile(client, '0005_financial_taxonomy_v3.sql');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function getSqliteClient() {
|
export function getSqliteClient() {
|
||||||
if (!globalThis.__fiscalSqliteClient) {
|
if (!globalThis.__fiscalSqliteClient) {
|
||||||
const databasePath = getDatabasePath();
|
const databasePath = getDatabasePath();
|
||||||
@@ -40,6 +105,7 @@ export function getSqliteClient() {
|
|||||||
client.exec('PRAGMA foreign_keys = ON;');
|
client.exec('PRAGMA foreign_keys = ON;');
|
||||||
client.exec('PRAGMA journal_mode = WAL;');
|
client.exec('PRAGMA journal_mode = WAL;');
|
||||||
client.exec('PRAGMA busy_timeout = 5000;');
|
client.exec('PRAGMA busy_timeout = 5000;');
|
||||||
|
ensureLocalSqliteSchema(client);
|
||||||
|
|
||||||
globalThis.__fiscalSqliteClient = client;
|
globalThis.__fiscalSqliteClient = client;
|
||||||
}
|
}
|
||||||
@@ -56,3 +122,10 @@ export const db = globalThis.__fiscalDrizzleDb ?? createDb();
|
|||||||
if (!globalThis.__fiscalDrizzleDb) {
|
if (!globalThis.__fiscalDrizzleDb) {
|
||||||
globalThis.__fiscalDrizzleDb = db;
|
globalThis.__fiscalDrizzleDb = db;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const __dbInternals = {
|
||||||
|
ensureLocalSqliteSchema,
|
||||||
|
getDatabasePath,
|
||||||
|
hasColumn,
|
||||||
|
hasTable
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,26 +1,47 @@
|
|||||||
import { describe, expect, it } from 'bun:test';
|
import { describe, expect, it } from 'bun:test';
|
||||||
import { __financialTaxonomyInternals } from './financial-taxonomy';
|
import { __financialTaxonomyInternals } from './financial-taxonomy';
|
||||||
import type { FilingTaxonomySnapshotRecord } from './repos/filing-taxonomy';
|
import type { FilingTaxonomySnapshotRecord } from './repos/filing-taxonomy';
|
||||||
import type { FinancialStatementKind, TaxonomyStatementRow } from '@/lib/types';
|
import type {
|
||||||
|
FinancialStatementKind,
|
||||||
|
FinancialStatementPeriod,
|
||||||
|
TaxonomyFactRow,
|
||||||
|
TaxonomyStatementRow
|
||||||
|
} from '@/lib/types';
|
||||||
|
|
||||||
|
function createRow(input: {
|
||||||
|
key?: string;
|
||||||
|
label?: string;
|
||||||
|
conceptKey?: string;
|
||||||
|
qname?: string;
|
||||||
|
localName?: string;
|
||||||
|
statement?: FinancialStatementKind;
|
||||||
|
order?: number;
|
||||||
|
depth?: number;
|
||||||
|
hasDimensions?: boolean;
|
||||||
|
values: Record<string, number | null>;
|
||||||
|
sourceFactIds?: number[];
|
||||||
|
}): TaxonomyStatementRow {
|
||||||
|
const localName = input.localName ?? 'RevenueFromContractWithCustomerExcludingAssessedTax';
|
||||||
|
const conceptKey = input.conceptKey ?? `http://fasb.org/us-gaap/2024#${localName}`;
|
||||||
|
const qname = input.qname ?? `us-gaap:${localName}`;
|
||||||
|
|
||||||
function createRow(periodIds: string[]): TaxonomyStatementRow {
|
|
||||||
return {
|
return {
|
||||||
key: 'us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax',
|
key: input.key ?? conceptKey,
|
||||||
label: 'Revenue From Contract With Customer Excluding Assessed Tax',
|
label: input.label ?? localName,
|
||||||
conceptKey: 'us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax',
|
conceptKey,
|
||||||
qname: 'us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax',
|
qname,
|
||||||
namespaceUri: 'http://fasb.org/us-gaap/2021-01-31',
|
namespaceUri: 'http://fasb.org/us-gaap/2024',
|
||||||
localName: 'RevenueFromContractWithCustomerExcludingAssessedTax',
|
localName,
|
||||||
isExtension: false,
|
isExtension: false,
|
||||||
statement: 'income',
|
statement: input.statement ?? 'income',
|
||||||
roleUri: 'income',
|
roleUri: input.statement ?? 'income',
|
||||||
order: 1,
|
order: input.order ?? 1,
|
||||||
depth: 0,
|
depth: input.depth ?? 0,
|
||||||
parentKey: null,
|
parentKey: null,
|
||||||
values: Object.fromEntries(periodIds.map((periodId, index) => [periodId, 100 + index])),
|
values: input.values,
|
||||||
units: Object.fromEntries(periodIds.map((periodId) => [periodId, 'iso4217:USD'])),
|
units: Object.fromEntries(Object.keys(input.values).map((periodId) => [periodId, 'iso4217:USD'])),
|
||||||
hasDimensions: false,
|
hasDimensions: input.hasDimensions ?? false,
|
||||||
sourceFactIds: periodIds.map((_, index) => index + 1)
|
sourceFactIds: input.sourceFactIds ?? [1]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,8 +56,12 @@ function createSnapshot(input: {
|
|||||||
periodLabel: string;
|
periodLabel: string;
|
||||||
}>;
|
}>;
|
||||||
statement: FinancialStatementKind;
|
statement: FinancialStatementKind;
|
||||||
|
rows?: TaxonomyStatementRow[];
|
||||||
}) {
|
}) {
|
||||||
const row = createRow(input.periods.map((period) => period.id));
|
const defaultRow = createRow({
|
||||||
|
statement: input.statement,
|
||||||
|
values: Object.fromEntries(input.periods.map((period, index) => [period.id, 100 + index]))
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: input.filingId,
|
id: input.filingId,
|
||||||
@@ -58,9 +83,9 @@ function createSnapshot(input: {
|
|||||||
periodLabel: period.periodLabel
|
periodLabel: period.periodLabel
|
||||||
})),
|
})),
|
||||||
statement_rows: {
|
statement_rows: {
|
||||||
income: input.statement === 'income' ? [row] : [],
|
income: input.statement === 'income' ? (input.rows ?? [defaultRow]) : [],
|
||||||
balance: input.statement === 'balance' ? [{ ...row, statement: 'balance' }] : [],
|
balance: input.statement === 'balance' ? (input.rows ?? [{ ...defaultRow, statement: 'balance' }]) : [],
|
||||||
cash_flow: [],
|
cash_flow: input.statement === 'cash_flow' ? (input.rows ?? [{ ...defaultRow, statement: 'cash_flow' }]) : [],
|
||||||
equity: [],
|
equity: [],
|
||||||
comprehensive_income: []
|
comprehensive_income: []
|
||||||
},
|
},
|
||||||
@@ -74,6 +99,64 @@ function createSnapshot(input: {
|
|||||||
} satisfies FilingTaxonomySnapshotRecord;
|
} satisfies FilingTaxonomySnapshotRecord;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createPeriod(input: {
|
||||||
|
id: string;
|
||||||
|
filingId: number;
|
||||||
|
filingDate: string;
|
||||||
|
periodEnd: string;
|
||||||
|
periodStart?: string | null;
|
||||||
|
filingType?: '10-K' | '10-Q';
|
||||||
|
}): FinancialStatementPeriod {
|
||||||
|
return {
|
||||||
|
id: input.id,
|
||||||
|
filingId: input.filingId,
|
||||||
|
accessionNumber: `0000-${input.filingId}`,
|
||||||
|
filingDate: input.filingDate,
|
||||||
|
periodStart: input.periodStart ?? null,
|
||||||
|
periodEnd: input.periodEnd,
|
||||||
|
filingType: input.filingType ?? '10-Q',
|
||||||
|
periodLabel: 'Test period'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDimensionFact(input: {
|
||||||
|
filingId: number;
|
||||||
|
filingDate: string;
|
||||||
|
conceptKey: string;
|
||||||
|
qname: string;
|
||||||
|
localName: string;
|
||||||
|
periodEnd: string;
|
||||||
|
value: number;
|
||||||
|
axis?: string;
|
||||||
|
member?: string;
|
||||||
|
}): TaxonomyFactRow {
|
||||||
|
return {
|
||||||
|
id: input.filingId,
|
||||||
|
snapshotId: input.filingId,
|
||||||
|
filingId: input.filingId,
|
||||||
|
filingDate: input.filingDate,
|
||||||
|
statement: 'income',
|
||||||
|
roleUri: 'income',
|
||||||
|
conceptKey: input.conceptKey,
|
||||||
|
qname: input.qname,
|
||||||
|
namespaceUri: 'http://fasb.org/us-gaap/2024',
|
||||||
|
localName: input.localName,
|
||||||
|
value: input.value,
|
||||||
|
contextId: `ctx-${input.filingId}`,
|
||||||
|
unit: 'iso4217:USD',
|
||||||
|
decimals: null,
|
||||||
|
periodStart: '2025-01-01',
|
||||||
|
periodEnd: input.periodEnd,
|
||||||
|
periodInstant: null,
|
||||||
|
dimensions: [{
|
||||||
|
axis: input.axis ?? 'srt:ProductOrServiceAxis',
|
||||||
|
member: input.member ?? 'msft:CloudMember'
|
||||||
|
}],
|
||||||
|
isDimensionless: false,
|
||||||
|
sourceFile: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe('financial taxonomy internals', () => {
|
describe('financial taxonomy internals', () => {
|
||||||
it('selects the primary quarter duration for 10-Q income statements', () => {
|
it('selects the primary quarter duration for 10-Q income statements', () => {
|
||||||
const snapshot = createSnapshot({
|
const snapshot = createSnapshot({
|
||||||
@@ -139,4 +222,144 @@ describe('financial taxonomy internals', () => {
|
|||||||
|
|
||||||
expect(periods.map((period) => period.id)).toEqual(['annual', 'quarter']);
|
expect(periods.map((period) => period.id)).toEqual(['annual', 'quarter']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('maps overlapping GAAP aliases into one standardized COGS row while preserving faithful rows', () => {
|
||||||
|
const period2024 = createPeriod({
|
||||||
|
id: '2024-q4',
|
||||||
|
filingId: 30,
|
||||||
|
filingDate: '2025-01-29',
|
||||||
|
periodEnd: '2024-12-31'
|
||||||
|
});
|
||||||
|
const period2025 = createPeriod({
|
||||||
|
id: '2025-q4',
|
||||||
|
filingId: 31,
|
||||||
|
filingDate: '2026-01-28',
|
||||||
|
periodEnd: '2025-12-31'
|
||||||
|
});
|
||||||
|
|
||||||
|
const faithfulRows = __financialTaxonomyInternals.buildRows([
|
||||||
|
createSnapshot({
|
||||||
|
filingId: 30,
|
||||||
|
filingType: '10-Q',
|
||||||
|
filingDate: '2025-01-29',
|
||||||
|
statement: 'income',
|
||||||
|
periods: [{
|
||||||
|
id: '2024-q4',
|
||||||
|
periodStart: '2024-10-01',
|
||||||
|
periodEnd: '2024-12-31',
|
||||||
|
periodLabel: '2024-10-01 to 2024-12-31'
|
||||||
|
}],
|
||||||
|
rows: [
|
||||||
|
createRow({
|
||||||
|
localName: 'CostOfRevenue',
|
||||||
|
label: 'Cost of Revenue',
|
||||||
|
values: { '2024-q4': 45_000 },
|
||||||
|
sourceFactIds: [101]
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
createSnapshot({
|
||||||
|
filingId: 31,
|
||||||
|
filingType: '10-Q',
|
||||||
|
filingDate: '2026-01-28',
|
||||||
|
statement: 'income',
|
||||||
|
periods: [{
|
||||||
|
id: '2025-q4',
|
||||||
|
periodStart: '2025-10-01',
|
||||||
|
periodEnd: '2025-12-31',
|
||||||
|
periodLabel: '2025-10-01 to 2025-12-31'
|
||||||
|
}],
|
||||||
|
rows: [
|
||||||
|
createRow({
|
||||||
|
localName: 'CostOfGoodsSold',
|
||||||
|
label: 'Cost of Goods Sold',
|
||||||
|
values: { '2025-q4': 48_000 },
|
||||||
|
sourceFactIds: [202]
|
||||||
|
})
|
||||||
|
]
|
||||||
|
})
|
||||||
|
], 'income', new Set(['2024-q4', '2025-q4']));
|
||||||
|
|
||||||
|
const standardizedRows = __financialTaxonomyInternals.buildStandardizedRows(
|
||||||
|
faithfulRows,
|
||||||
|
'income',
|
||||||
|
[period2024, period2025]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(faithfulRows).toHaveLength(2);
|
||||||
|
|
||||||
|
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);
|
||||||
|
expect(cogs?.sourceConcepts).toEqual([
|
||||||
|
'us-gaap:CostOfGoodsSold',
|
||||||
|
'us-gaap:CostOfRevenue'
|
||||||
|
]);
|
||||||
|
expect(cogs?.sourceRowKeys).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('aggregates standardized dimension drill-down across mapped source concepts', () => {
|
||||||
|
const period2024 = createPeriod({
|
||||||
|
id: '2024-q4',
|
||||||
|
filingId: 40,
|
||||||
|
filingDate: '2025-01-29',
|
||||||
|
periodEnd: '2024-12-31'
|
||||||
|
});
|
||||||
|
const period2025 = createPeriod({
|
||||||
|
id: '2025-q4',
|
||||||
|
filingId: 41,
|
||||||
|
filingDate: '2026-01-28',
|
||||||
|
periodEnd: '2025-12-31'
|
||||||
|
});
|
||||||
|
const faithfulRows = [
|
||||||
|
createRow({
|
||||||
|
localName: 'CostOfRevenue',
|
||||||
|
label: 'Cost of Revenue',
|
||||||
|
values: { '2024-q4': 45_000 },
|
||||||
|
hasDimensions: true
|
||||||
|
}),
|
||||||
|
createRow({
|
||||||
|
localName: 'CostOfGoodsSold',
|
||||||
|
label: 'Cost of Goods Sold',
|
||||||
|
values: { '2025-q4': 48_000 },
|
||||||
|
hasDimensions: true
|
||||||
|
})
|
||||||
|
];
|
||||||
|
const standardizedRows = __financialTaxonomyInternals.buildStandardizedRows(
|
||||||
|
faithfulRows,
|
||||||
|
'income',
|
||||||
|
[period2024, period2025]
|
||||||
|
);
|
||||||
|
|
||||||
|
const breakdown = __financialTaxonomyInternals.buildDimensionBreakdown([
|
||||||
|
createDimensionFact({
|
||||||
|
filingId: 40,
|
||||||
|
filingDate: '2025-01-29',
|
||||||
|
conceptKey: faithfulRows[0].key,
|
||||||
|
qname: faithfulRows[0].qname,
|
||||||
|
localName: faithfulRows[0].localName,
|
||||||
|
periodEnd: '2024-12-31',
|
||||||
|
value: 20_000,
|
||||||
|
member: 'msft:ProductivityMember'
|
||||||
|
}),
|
||||||
|
createDimensionFact({
|
||||||
|
filingId: 41,
|
||||||
|
filingDate: '2026-01-28',
|
||||||
|
conceptKey: faithfulRows[1].key,
|
||||||
|
qname: faithfulRows[1].qname,
|
||||||
|
localName: faithfulRows[1].localName,
|
||||||
|
periodEnd: '2025-12-31',
|
||||||
|
value: 28_000,
|
||||||
|
member: 'msft:IntelligentCloudMember'
|
||||||
|
})
|
||||||
|
], [period2024, period2025], faithfulRows, standardizedRows);
|
||||||
|
|
||||||
|
const cogs = breakdown?.['cost-of-revenue'] ?? [];
|
||||||
|
expect(cogs).toHaveLength(2);
|
||||||
|
expect(cogs.map((row) => row.sourceLabel)).toEqual([
|
||||||
|
'Cost of Revenue',
|
||||||
|
'Cost of Goods Sold'
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
FinancialHistoryWindow,
|
FinancialHistoryWindow,
|
||||||
FinancialStatementKind,
|
FinancialStatementKind,
|
||||||
FinancialStatementPeriod,
|
FinancialStatementPeriod,
|
||||||
|
StandardizedStatementRow,
|
||||||
TaxonomyStatementRow
|
TaxonomyStatementRow
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
import { listFilingsRecords } from '@/lib/server/repos/filings';
|
import { listFilingsRecords } from '@/lib/server/repos/filings';
|
||||||
@@ -28,6 +29,19 @@ type GetCompanyFinancialTaxonomyInput = {
|
|||||||
queuedSync: boolean;
|
queuedSync: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CanonicalRowDefinition = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
category: string;
|
||||||
|
order: number;
|
||||||
|
localNames?: readonly string[];
|
||||||
|
labelIncludes?: readonly string[];
|
||||||
|
formula?: (
|
||||||
|
rowsByKey: Map<string, StandardizedStatementRow>,
|
||||||
|
periodIds: string[]
|
||||||
|
) => Pick<StandardizedStatementRow, 'values' | 'resolvedSourceRowKeys'> | null;
|
||||||
|
};
|
||||||
|
|
||||||
function safeTicker(input: string) {
|
function safeTicker(input: string) {
|
||||||
return input.trim().toUpperCase();
|
return input.trim().toUpperCase();
|
||||||
}
|
}
|
||||||
@@ -215,16 +229,419 @@ function buildRows(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeToken(value: string) {
|
||||||
|
return value.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumValues(left: number | null, right: number | null) {
|
||||||
|
if (left === null || right === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return left + right;
|
||||||
|
}
|
||||||
|
|
||||||
|
function subtractValues(left: number | null, right: number | null) {
|
||||||
|
if (left === null || right === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return left - right;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STANDARDIZED_ROW_DEFINITIONS: Record<FinancialStatementKind, CanonicalRowDefinition[]> = {
|
||||||
|
income: [
|
||||||
|
{
|
||||||
|
key: 'revenue',
|
||||||
|
label: 'Revenue',
|
||||||
|
category: 'revenue',
|
||||||
|
order: 10,
|
||||||
|
localNames: [
|
||||||
|
'RevenueFromContractWithCustomerExcludingAssessedTax',
|
||||||
|
'Revenues',
|
||||||
|
'SalesRevenueNet',
|
||||||
|
'TotalRevenuesAndOtherIncome'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cost-of-revenue',
|
||||||
|
label: 'Cost of Revenue',
|
||||||
|
category: 'expense',
|
||||||
|
order: 20,
|
||||||
|
localNames: [
|
||||||
|
'CostOfRevenue',
|
||||||
|
'CostOfGoodsSold',
|
||||||
|
'CostOfSales',
|
||||||
|
'CostOfProductsSold',
|
||||||
|
'CostOfServices'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'gross-profit',
|
||||||
|
label: 'Gross Profit',
|
||||||
|
category: 'profit',
|
||||||
|
order: 30,
|
||||||
|
localNames: ['GrossProfit'],
|
||||||
|
formula: (rowsByKey, periodIds) => {
|
||||||
|
const revenue = rowsByKey.get('revenue');
|
||||||
|
const cogs = rowsByKey.get('cost-of-revenue');
|
||||||
|
if (!revenue || !cogs) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
values: Object.fromEntries(periodIds.map((periodId) => [
|
||||||
|
periodId,
|
||||||
|
subtractValues(revenue.values[periodId] ?? null, cogs.values[periodId] ?? null)
|
||||||
|
])),
|
||||||
|
resolvedSourceRowKeys: Object.fromEntries(periodIds.map((periodId) => [periodId, null]))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'research-and-development',
|
||||||
|
label: 'Research & Development',
|
||||||
|
category: 'opex',
|
||||||
|
order: 40,
|
||||||
|
localNames: ['ResearchAndDevelopmentExpense']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'selling-general-and-administrative',
|
||||||
|
label: 'Selling, General & Administrative',
|
||||||
|
category: 'opex',
|
||||||
|
order: 50,
|
||||||
|
localNames: [
|
||||||
|
'SellingGeneralAndAdministrativeExpense',
|
||||||
|
'SellingAndMarketingExpense',
|
||||||
|
'GeneralAndAdministrativeExpense'
|
||||||
|
],
|
||||||
|
labelIncludes: ['selling, general', 'selling general', 'general and administrative']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'operating-income',
|
||||||
|
label: 'Operating Income',
|
||||||
|
category: 'profit',
|
||||||
|
order: 60,
|
||||||
|
localNames: ['OperatingIncomeLoss', 'IncomeLossFromOperations']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'net-income',
|
||||||
|
label: 'Net Income',
|
||||||
|
category: 'profit',
|
||||||
|
order: 70,
|
||||||
|
localNames: ['NetIncomeLoss', 'ProfitLoss']
|
||||||
|
}
|
||||||
|
],
|
||||||
|
balance: [
|
||||||
|
{
|
||||||
|
key: 'cash-and-equivalents',
|
||||||
|
label: 'Cash & Equivalents',
|
||||||
|
category: 'asset',
|
||||||
|
order: 10,
|
||||||
|
localNames: [
|
||||||
|
'CashAndCashEquivalentsAtCarryingValue',
|
||||||
|
'CashCashEquivalentsAndShortTermInvestments',
|
||||||
|
'CashAndShortTermInvestments'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'accounts-receivable',
|
||||||
|
label: 'Accounts Receivable',
|
||||||
|
category: 'asset',
|
||||||
|
order: 20,
|
||||||
|
localNames: [
|
||||||
|
'AccountsReceivableNetCurrent',
|
||||||
|
'ReceivablesNetCurrent'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'inventory',
|
||||||
|
label: 'Inventory',
|
||||||
|
category: 'asset',
|
||||||
|
order: 30,
|
||||||
|
localNames: ['InventoryNet']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'total-assets',
|
||||||
|
label: 'Total Assets',
|
||||||
|
category: 'asset',
|
||||||
|
order: 40,
|
||||||
|
localNames: ['Assets']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'current-liabilities',
|
||||||
|
label: 'Current Liabilities',
|
||||||
|
category: 'liability',
|
||||||
|
order: 50,
|
||||||
|
localNames: ['LiabilitiesCurrent']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'long-term-debt',
|
||||||
|
label: 'Long-Term Debt',
|
||||||
|
category: 'liability',
|
||||||
|
order: 60,
|
||||||
|
localNames: [
|
||||||
|
'LongTermDebtNoncurrent',
|
||||||
|
'LongTermDebt',
|
||||||
|
'DebtNoncurrent',
|
||||||
|
'LongTermDebtAndCapitalLeaseObligations'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'current-debt',
|
||||||
|
label: 'Current Debt',
|
||||||
|
category: 'liability',
|
||||||
|
order: 70,
|
||||||
|
localNames: ['DebtCurrent', 'ShortTermBorrowings', 'LongTermDebtCurrent']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'total-debt',
|
||||||
|
label: 'Total Debt',
|
||||||
|
category: 'liability',
|
||||||
|
order: 80,
|
||||||
|
localNames: ['DebtAndFinanceLeaseLiabilities', 'Debt'],
|
||||||
|
formula: (rowsByKey, periodIds) => {
|
||||||
|
const longTermDebt = rowsByKey.get('long-term-debt');
|
||||||
|
const currentDebt = rowsByKey.get('current-debt');
|
||||||
|
if (!longTermDebt || !currentDebt) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
values: Object.fromEntries(periodIds.map((periodId) => [
|
||||||
|
periodId,
|
||||||
|
sumValues(longTermDebt.values[periodId] ?? null, currentDebt.values[periodId] ?? null)
|
||||||
|
])),
|
||||||
|
resolvedSourceRowKeys: Object.fromEntries(periodIds.map((periodId) => [periodId, null]))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'total-equity',
|
||||||
|
label: 'Total Equity',
|
||||||
|
category: 'equity',
|
||||||
|
order: 90,
|
||||||
|
localNames: [
|
||||||
|
'StockholdersEquity',
|
||||||
|
'StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest',
|
||||||
|
'PartnersCapital'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
cash_flow: [
|
||||||
|
{
|
||||||
|
key: 'operating-cash-flow',
|
||||||
|
label: 'Operating Cash Flow',
|
||||||
|
category: 'cash-flow',
|
||||||
|
order: 10,
|
||||||
|
localNames: [
|
||||||
|
'NetCashProvidedByUsedInOperatingActivities',
|
||||||
|
'NetCashProvidedByUsedInOperatingActivitiesContinuingOperations'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'capital-expenditures',
|
||||||
|
label: 'Capital Expenditures',
|
||||||
|
category: 'cash-flow',
|
||||||
|
order: 20,
|
||||||
|
localNames: ['PaymentsToAcquirePropertyPlantAndEquipment', 'CapitalExpendituresIncurredButNotYetPaid']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'free-cash-flow',
|
||||||
|
label: 'Free Cash Flow',
|
||||||
|
category: 'cash-flow',
|
||||||
|
order: 30,
|
||||||
|
formula: (rowsByKey, periodIds) => {
|
||||||
|
const operatingCashFlow = rowsByKey.get('operating-cash-flow');
|
||||||
|
const capex = rowsByKey.get('capital-expenditures');
|
||||||
|
if (!operatingCashFlow || !capex) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
values: Object.fromEntries(periodIds.map((periodId) => [
|
||||||
|
periodId,
|
||||||
|
subtractValues(operatingCashFlow.values[periodId] ?? null, capex.values[periodId] ?? null)
|
||||||
|
])),
|
||||||
|
resolvedSourceRowKeys: Object.fromEntries(periodIds.map((periodId) => [periodId, null]))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
equity: [
|
||||||
|
{
|
||||||
|
key: 'total-equity',
|
||||||
|
label: 'Total Equity',
|
||||||
|
category: 'equity',
|
||||||
|
order: 10,
|
||||||
|
localNames: [
|
||||||
|
'StockholdersEquity',
|
||||||
|
'StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest',
|
||||||
|
'PartnersCapital'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
comprehensive_income: [
|
||||||
|
{
|
||||||
|
key: 'comprehensive-income',
|
||||||
|
label: 'Comprehensive Income',
|
||||||
|
category: 'profit',
|
||||||
|
order: 10,
|
||||||
|
localNames: ['ComprehensiveIncomeNetOfTax', 'ComprehensiveIncomeNetOfTaxIncludingPortionAttributableToNoncontrollingInterest']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
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 buildCanonicalRow(
|
||||||
|
definition: CanonicalRowDefinition,
|
||||||
|
matches: TaxonomyStatementRow[],
|
||||||
|
periodIds: string[]
|
||||||
|
) {
|
||||||
|
const sortedMatches = [...matches].sort((left, right) => {
|
||||||
|
if (left.order !== right.order) {
|
||||||
|
return left.order - right.order;
|
||||||
|
}
|
||||||
|
|
||||||
|
return left.label.localeCompare(right.label);
|
||||||
|
});
|
||||||
|
|
||||||
|
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> = {};
|
||||||
|
|
||||||
|
for (const periodId of periodIds) {
|
||||||
|
const match = sortedMatches.find((row) => periodId in row.values);
|
||||||
|
values[periodId] = match?.values[periodId] ?? null;
|
||||||
|
resolvedSourceRowKeys[periodId] = match?.key ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: definition.key,
|
||||||
|
label: definition.label,
|
||||||
|
category: definition.category,
|
||||||
|
order: definition.order,
|
||||||
|
values,
|
||||||
|
hasDimensions: sortedMatches.some((row) => row.hasDimensions),
|
||||||
|
sourceConcepts: [...sourceConcepts].sort((left, right) => left.localeCompare(right)),
|
||||||
|
sourceRowKeys: [...sourceRowKeys].sort((left, right) => left.localeCompare(right)),
|
||||||
|
sourceFactIds: [...sourceFactIds].sort((left, right) => left - right),
|
||||||
|
resolvedSourceRowKeys
|
||||||
|
} satisfies StandardizedStatementRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStandardizedRows(
|
||||||
|
rows: TaxonomyStatementRow[],
|
||||||
|
statement: FinancialStatementKind,
|
||||||
|
periods: FinancialStatementPeriod[]
|
||||||
|
) {
|
||||||
|
const definitions = STANDARDIZED_ROW_DEFINITIONS[statement] ?? [];
|
||||||
|
const periodIds = periods.map((period) => period.id);
|
||||||
|
const rowsByKey = new Map<string, StandardizedStatementRow>();
|
||||||
|
const matchedRowKeys = new Set<string>();
|
||||||
|
|
||||||
|
for (const definition of definitions) {
|
||||||
|
const matches = rows.filter((row) => matchesDefinition(row, definition));
|
||||||
|
if (matches.length === 0 && !definition.formula) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const row of matches) {
|
||||||
|
matchedRowKeys.add(row.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
const canonicalRow = buildCanonicalRow(definition, matches, periodIds);
|
||||||
|
rowsByKey.set(definition.key, canonicalRow);
|
||||||
|
|
||||||
|
const derived = definition.formula?.(rowsByKey, periodIds) ?? null;
|
||||||
|
if (derived) {
|
||||||
|
rowsByKey.set(definition.key, {
|
||||||
|
...canonicalRow,
|
||||||
|
values: derived.values,
|
||||||
|
resolvedSourceRowKeys: derived.resolvedSourceRowKeys
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unmatchedRows = rows
|
||||||
|
.filter((row) => !matchedRowKeys.has(row.key))
|
||||||
|
.map((row) => ({
|
||||||
|
key: `other:${row.key}`,
|
||||||
|
label: row.label,
|
||||||
|
category: 'other',
|
||||||
|
order: 10_000 + row.order,
|
||||||
|
values: { ...row.values },
|
||||||
|
hasDimensions: row.hasDimensions,
|
||||||
|
sourceConcepts: [row.qname],
|
||||||
|
sourceRowKeys: [row.key],
|
||||||
|
sourceFactIds: [...row.sourceFactIds],
|
||||||
|
resolvedSourceRowKeys: Object.fromEntries(
|
||||||
|
periodIds.map((periodId) => [periodId, periodId in row.values ? row.key : null])
|
||||||
|
)
|
||||||
|
} satisfies StandardizedStatementRow));
|
||||||
|
|
||||||
|
return [...rowsByKey.values(), ...unmatchedRows].sort((left, right) => {
|
||||||
|
if (left.order !== right.order) {
|
||||||
|
return left.order - right.order;
|
||||||
|
}
|
||||||
|
|
||||||
|
return left.label.localeCompare(right.label);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function buildDimensionBreakdown(
|
function buildDimensionBreakdown(
|
||||||
facts: Awaited<ReturnType<typeof listTaxonomyFactsByTicker>>['facts'],
|
facts: Awaited<ReturnType<typeof listTaxonomyFactsByTicker>>['facts'],
|
||||||
periods: FinancialStatementPeriod[]
|
periods: FinancialStatementPeriod[],
|
||||||
|
faithfulRows: TaxonomyStatementRow[],
|
||||||
|
standardizedRows: StandardizedStatementRow[]
|
||||||
) {
|
) {
|
||||||
const periodByFilingId = new Map<number, FinancialStatementPeriod>();
|
const periodByFilingId = new Map<number, FinancialStatementPeriod>();
|
||||||
for (const period of periods) {
|
for (const period of periods) {
|
||||||
periodByFilingId.set(period.filingId, period);
|
periodByFilingId.set(period.filingId, period);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const faithfulRowByKey = new Map(faithfulRows.map((row) => [row.key, row]));
|
||||||
|
const standardizedRowsBySource = new Map<string, StandardizedStatementRow[]>();
|
||||||
|
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 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) {
|
for (const fact of facts) {
|
||||||
if (fact.dimensions.length === 0) {
|
if (fact.dimensions.length === 0) {
|
||||||
@@ -244,10 +661,15 @@ function buildDimensionBreakdown(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const faithfulRow = faithfulRowByKey.get(fact.conceptKey) ?? null;
|
||||||
|
const standardizedMatches = standardizedRowsBySource.get(fact.conceptKey) ?? [];
|
||||||
|
|
||||||
for (const dimension of fact.dimensions) {
|
for (const dimension of fact.dimensions) {
|
||||||
const row: DimensionBreakdownRow = {
|
const faithfulDimensionRow: DimensionBreakdownRow = {
|
||||||
rowKey: fact.conceptKey,
|
rowKey: fact.conceptKey,
|
||||||
concept: fact.qname,
|
concept: fact.qname,
|
||||||
|
sourceRowKey: fact.conceptKey,
|
||||||
|
sourceLabel: faithfulRow?.label ?? null,
|
||||||
periodId: period.id,
|
periodId: period.id,
|
||||||
axis: dimension.axis,
|
axis: dimension.axis,
|
||||||
member: dimension.member,
|
member: dimension.member,
|
||||||
@@ -255,11 +677,13 @@ function buildDimensionBreakdown(
|
|||||||
unit: fact.unit
|
unit: fact.unit
|
||||||
};
|
};
|
||||||
|
|
||||||
const existing = map.get(fact.conceptKey);
|
pushRow(fact.conceptKey, faithfulDimensionRow);
|
||||||
if (existing) {
|
|
||||||
existing.push(row);
|
for (const standardizedRow of standardizedMatches) {
|
||||||
} else {
|
pushRow(standardizedRow.key, {
|
||||||
map.set(fact.conceptKey, [row]);
|
...faithfulDimensionRow,
|
||||||
|
rowKey: standardizedRow.key
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -305,7 +729,8 @@ export async function getCompanyFinancialTaxonomy(input: GetCompanyFinancialTaxo
|
|||||||
const financialFilings = filings.filter((filing) => isFinancialForm(filing.filing_type));
|
const financialFilings = filings.filter((filing) => isFinancialForm(filing.filing_type));
|
||||||
const selection = selectPrimaryPeriods(snapshotResult.snapshots, input.statement);
|
const selection = selectPrimaryPeriods(snapshotResult.snapshots, input.statement);
|
||||||
const periods = selection.periods;
|
const periods = selection.periods;
|
||||||
const rows = buildRows(snapshotResult.snapshots, input.statement, selection.selectedPeriodIds);
|
const faithfulRows = buildRows(snapshotResult.snapshots, input.statement, selection.selectedPeriodIds);
|
||||||
|
const standardizedRows = buildStandardizedRows(faithfulRows, input.statement, periods);
|
||||||
|
|
||||||
const factsResult = input.includeFacts
|
const factsResult = input.includeFacts
|
||||||
? await listTaxonomyFactsByTicker({
|
? await listTaxonomyFactsByTicker({
|
||||||
@@ -329,11 +754,11 @@ export async function getCompanyFinancialTaxonomy(input: GetCompanyFinancialTaxo
|
|||||||
const latestFiling = filings[0] ?? null;
|
const latestFiling = filings[0] ?? null;
|
||||||
const metrics = latestMetrics(snapshotResult.snapshots);
|
const metrics = latestMetrics(snapshotResult.snapshots);
|
||||||
const dimensionBreakdown = input.includeDimensions
|
const dimensionBreakdown = input.includeDimensions
|
||||||
? buildDimensionBreakdown(dimensionFacts.facts, periods)
|
? buildDimensionBreakdown(dimensionFacts.facts, periods, faithfulRows, standardizedRows)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const dimensionsCount = dimensionBreakdown
|
const dimensionsCount = input.includeDimensions
|
||||||
? Object.values(dimensionBreakdown).reduce((total, entries) => total + entries.length, 0)
|
? dimensionFacts.facts.reduce((total, fact) => total + fact.dimensions.length, 0)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
const factsCoverage = input.includeFacts
|
const factsCoverage = input.includeFacts
|
||||||
@@ -348,8 +773,18 @@ export async function getCompanyFinancialTaxonomy(input: GetCompanyFinancialTaxo
|
|||||||
},
|
},
|
||||||
statement: input.statement,
|
statement: input.statement,
|
||||||
window: input.window,
|
window: input.window,
|
||||||
|
defaultSurface: 'standardized',
|
||||||
periods,
|
periods,
|
||||||
rows,
|
surfaces: {
|
||||||
|
faithful: {
|
||||||
|
kind: 'faithful',
|
||||||
|
rows: faithfulRows
|
||||||
|
},
|
||||||
|
standardized: {
|
||||||
|
kind: 'standardized',
|
||||||
|
rows: standardizedRows
|
||||||
|
}
|
||||||
|
},
|
||||||
nextCursor: snapshotResult.nextCursor,
|
nextCursor: snapshotResult.nextCursor,
|
||||||
facts: input.includeFacts
|
facts: input.includeFacts
|
||||||
? {
|
? {
|
||||||
@@ -359,7 +794,7 @@ export async function getCompanyFinancialTaxonomy(input: GetCompanyFinancialTaxo
|
|||||||
: null,
|
: null,
|
||||||
coverage: {
|
coverage: {
|
||||||
filings: periods.length,
|
filings: periods.length,
|
||||||
rows: rows.length,
|
rows: faithfulRows.length,
|
||||||
dimensions: dimensionsCount,
|
dimensions: dimensionsCount,
|
||||||
facts: factsCoverage
|
facts: factsCoverage
|
||||||
},
|
},
|
||||||
@@ -378,7 +813,11 @@ export async function getCompanyFinancialTaxonomy(input: GetCompanyFinancialTaxo
|
|||||||
|
|
||||||
export const __financialTaxonomyInternals = {
|
export const __financialTaxonomyInternals = {
|
||||||
buildPeriods,
|
buildPeriods,
|
||||||
|
buildRows,
|
||||||
|
buildStandardizedRows,
|
||||||
|
buildDimensionBreakdown,
|
||||||
isInstantPeriod,
|
isInstantPeriod,
|
||||||
|
matchesDefinition,
|
||||||
periodDurationDays,
|
periodDurationDays,
|
||||||
selectPrimaryPeriods
|
selectPrimaryPeriods
|
||||||
};
|
};
|
||||||
|
|||||||
38
lib/types.ts
38
lib/types.ts
@@ -213,6 +213,21 @@ export type TaxonomyStatementRow = {
|
|||||||
sourceFactIds: number[];
|
sourceFactIds: number[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type FinancialStatementSurfaceKind = 'faithful' | 'standardized';
|
||||||
|
|
||||||
|
export type StandardizedStatementRow = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
category: string;
|
||||||
|
order: number;
|
||||||
|
values: Record<string, number | null>;
|
||||||
|
hasDimensions: boolean;
|
||||||
|
sourceConcepts: string[];
|
||||||
|
sourceRowKeys: string[];
|
||||||
|
sourceFactIds: number[];
|
||||||
|
resolvedSourceRowKeys: Record<string, string | null>;
|
||||||
|
};
|
||||||
|
|
||||||
export type TaxonomyFactRow = {
|
export type TaxonomyFactRow = {
|
||||||
id: number;
|
id: number;
|
||||||
snapshotId: number;
|
snapshotId: number;
|
||||||
@@ -256,16 +271,6 @@ export type MetricValidationResult = {
|
|||||||
validatedAt: string | null;
|
validatedAt: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type StandardizedStatementRow = {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
concept: string;
|
|
||||||
category: string;
|
|
||||||
sourceConcepts: string[];
|
|
||||||
values: Record<string, number | null>;
|
|
||||||
hasDimensions: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type FilingFaithfulStatementRow = {
|
export type FilingFaithfulStatementRow = {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -280,6 +285,8 @@ export type FilingFaithfulStatementRow = {
|
|||||||
export type DimensionBreakdownRow = {
|
export type DimensionBreakdownRow = {
|
||||||
rowKey: string;
|
rowKey: string;
|
||||||
concept: string | null;
|
concept: string | null;
|
||||||
|
sourceRowKey: string | null;
|
||||||
|
sourceLabel: string | null;
|
||||||
periodId: string;
|
periodId: string;
|
||||||
axis: string;
|
axis: string;
|
||||||
member: string;
|
member: string;
|
||||||
@@ -287,6 +294,11 @@ export type DimensionBreakdownRow = {
|
|||||||
unit: string | null;
|
unit: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type FinancialStatementSurface<Row> = {
|
||||||
|
kind: FinancialStatementSurfaceKind;
|
||||||
|
rows: Row[];
|
||||||
|
};
|
||||||
|
|
||||||
export type CompanyFinancialStatementsResponse = {
|
export type CompanyFinancialStatementsResponse = {
|
||||||
company: {
|
company: {
|
||||||
ticker: string;
|
ticker: string;
|
||||||
@@ -295,8 +307,12 @@ export type CompanyFinancialStatementsResponse = {
|
|||||||
};
|
};
|
||||||
statement: FinancialStatementKind;
|
statement: FinancialStatementKind;
|
||||||
window: FinancialHistoryWindow;
|
window: FinancialHistoryWindow;
|
||||||
|
defaultSurface: FinancialStatementSurfaceKind;
|
||||||
periods: FinancialStatementPeriod[];
|
periods: FinancialStatementPeriod[];
|
||||||
rows: TaxonomyStatementRow[];
|
surfaces: {
|
||||||
|
faithful: FinancialStatementSurface<TaxonomyStatementRow>;
|
||||||
|
standardized: FinancialStatementSurface<StandardizedStatementRow>;
|
||||||
|
};
|
||||||
nextCursor: string | null;
|
nextCursor: string | null;
|
||||||
facts: {
|
facts: {
|
||||||
rows: TaxonomyFactRow[];
|
rows: TaxonomyFactRow[];
|
||||||
|
|||||||
Reference in New Issue
Block a user