Implement dual-surface financials and db bootstrap
This commit is contained in:
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 { dirname } from 'node:path';
|
||||
import { mkdirSync, readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { drizzle } from 'drizzle-orm/bun-sqlite';
|
||||
import { schema } from './schema';
|
||||
@@ -28,6 +28,71 @@ function getDatabasePath() {
|
||||
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() {
|
||||
if (!globalThis.__fiscalSqliteClient) {
|
||||
const databasePath = getDatabasePath();
|
||||
@@ -40,6 +105,7 @@ export function getSqliteClient() {
|
||||
client.exec('PRAGMA foreign_keys = ON;');
|
||||
client.exec('PRAGMA journal_mode = WAL;');
|
||||
client.exec('PRAGMA busy_timeout = 5000;');
|
||||
ensureLocalSqliteSchema(client);
|
||||
|
||||
globalThis.__fiscalSqliteClient = client;
|
||||
}
|
||||
@@ -56,3 +122,10 @@ export const db = globalThis.__fiscalDrizzleDb ?? createDb();
|
||||
if (!globalThis.__fiscalDrizzleDb) {
|
||||
globalThis.__fiscalDrizzleDb = db;
|
||||
}
|
||||
|
||||
export const __dbInternals = {
|
||||
ensureLocalSqliteSchema,
|
||||
getDatabasePath,
|
||||
hasColumn,
|
||||
hasTable
|
||||
};
|
||||
|
||||
@@ -1,26 +1,47 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { __financialTaxonomyInternals } from './financial-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 {
|
||||
key: 'us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax',
|
||||
label: 'Revenue From Contract With Customer Excluding Assessed Tax',
|
||||
conceptKey: 'us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax',
|
||||
qname: 'us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax',
|
||||
namespaceUri: 'http://fasb.org/us-gaap/2021-01-31',
|
||||
localName: 'RevenueFromContractWithCustomerExcludingAssessedTax',
|
||||
key: input.key ?? conceptKey,
|
||||
label: input.label ?? localName,
|
||||
conceptKey,
|
||||
qname,
|
||||
namespaceUri: 'http://fasb.org/us-gaap/2024',
|
||||
localName,
|
||||
isExtension: false,
|
||||
statement: 'income',
|
||||
roleUri: 'income',
|
||||
order: 1,
|
||||
depth: 0,
|
||||
statement: input.statement ?? 'income',
|
||||
roleUri: input.statement ?? 'income',
|
||||
order: input.order ?? 1,
|
||||
depth: input.depth ?? 0,
|
||||
parentKey: null,
|
||||
values: Object.fromEntries(periodIds.map((periodId, index) => [periodId, 100 + index])),
|
||||
units: Object.fromEntries(periodIds.map((periodId) => [periodId, 'iso4217:USD'])),
|
||||
hasDimensions: false,
|
||||
sourceFactIds: periodIds.map((_, index) => index + 1)
|
||||
values: input.values,
|
||||
units: Object.fromEntries(Object.keys(input.values).map((periodId) => [periodId, 'iso4217:USD'])),
|
||||
hasDimensions: input.hasDimensions ?? false,
|
||||
sourceFactIds: input.sourceFactIds ?? [1]
|
||||
};
|
||||
}
|
||||
|
||||
@@ -35,8 +56,12 @@ function createSnapshot(input: {
|
||||
periodLabel: string;
|
||||
}>;
|
||||
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 {
|
||||
id: input.filingId,
|
||||
@@ -58,9 +83,9 @@ function createSnapshot(input: {
|
||||
periodLabel: period.periodLabel
|
||||
})),
|
||||
statement_rows: {
|
||||
income: input.statement === 'income' ? [row] : [],
|
||||
balance: input.statement === 'balance' ? [{ ...row, statement: 'balance' }] : [],
|
||||
cash_flow: [],
|
||||
income: input.statement === 'income' ? (input.rows ?? [defaultRow]) : [],
|
||||
balance: input.statement === 'balance' ? (input.rows ?? [{ ...defaultRow, statement: 'balance' }]) : [],
|
||||
cash_flow: input.statement === 'cash_flow' ? (input.rows ?? [{ ...defaultRow, statement: 'cash_flow' }]) : [],
|
||||
equity: [],
|
||||
comprehensive_income: []
|
||||
},
|
||||
@@ -74,6 +99,64 @@ function createSnapshot(input: {
|
||||
} 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', () => {
|
||||
it('selects the primary quarter duration for 10-Q income statements', () => {
|
||||
const snapshot = createSnapshot({
|
||||
@@ -139,4 +222,144 @@ describe('financial taxonomy internals', () => {
|
||||
|
||||
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,
|
||||
FinancialStatementKind,
|
||||
FinancialStatementPeriod,
|
||||
StandardizedStatementRow,
|
||||
TaxonomyStatementRow
|
||||
} from '@/lib/types';
|
||||
import { listFilingsRecords } from '@/lib/server/repos/filings';
|
||||
@@ -28,6 +29,19 @@ type GetCompanyFinancialTaxonomyInput = {
|
||||
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) {
|
||||
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(
|
||||
facts: Awaited<ReturnType<typeof listTaxonomyFactsByTicker>>['facts'],
|
||||
periods: FinancialStatementPeriod[]
|
||||
periods: FinancialStatementPeriod[],
|
||||
faithfulRows: TaxonomyStatementRow[],
|
||||
standardizedRows: StandardizedStatementRow[]
|
||||
) {
|
||||
const periodByFilingId = new Map<number, FinancialStatementPeriod>();
|
||||
for (const period of periods) {
|
||||
periodByFilingId.set(period.filingId, period);
|
||||
}
|
||||
|
||||
const faithfulRowByKey = new Map(faithfulRows.map((row) => [row.key, row]));
|
||||
const standardizedRowsBySource = new Map<string, 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 pushRow = (key: string, row: DimensionBreakdownRow) => {
|
||||
const existing = map.get(key);
|
||||
if (existing) {
|
||||
existing.push(row);
|
||||
} else {
|
||||
map.set(key, [row]);
|
||||
}
|
||||
};
|
||||
|
||||
for (const fact of facts) {
|
||||
if (fact.dimensions.length === 0) {
|
||||
@@ -244,10 +661,15 @@ function buildDimensionBreakdown(
|
||||
continue;
|
||||
}
|
||||
|
||||
const faithfulRow = faithfulRowByKey.get(fact.conceptKey) ?? null;
|
||||
const standardizedMatches = standardizedRowsBySource.get(fact.conceptKey) ?? [];
|
||||
|
||||
for (const dimension of fact.dimensions) {
|
||||
const row: DimensionBreakdownRow = {
|
||||
const faithfulDimensionRow: DimensionBreakdownRow = {
|
||||
rowKey: fact.conceptKey,
|
||||
concept: fact.qname,
|
||||
sourceRowKey: fact.conceptKey,
|
||||
sourceLabel: faithfulRow?.label ?? null,
|
||||
periodId: period.id,
|
||||
axis: dimension.axis,
|
||||
member: dimension.member,
|
||||
@@ -255,11 +677,13 @@ function buildDimensionBreakdown(
|
||||
unit: fact.unit
|
||||
};
|
||||
|
||||
const existing = map.get(fact.conceptKey);
|
||||
if (existing) {
|
||||
existing.push(row);
|
||||
} else {
|
||||
map.set(fact.conceptKey, [row]);
|
||||
pushRow(fact.conceptKey, faithfulDimensionRow);
|
||||
|
||||
for (const standardizedRow of standardizedMatches) {
|
||||
pushRow(standardizedRow.key, {
|
||||
...faithfulDimensionRow,
|
||||
rowKey: standardizedRow.key
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -305,7 +729,8 @@ export async function getCompanyFinancialTaxonomy(input: GetCompanyFinancialTaxo
|
||||
const financialFilings = filings.filter((filing) => isFinancialForm(filing.filing_type));
|
||||
const selection = selectPrimaryPeriods(snapshotResult.snapshots, input.statement);
|
||||
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
|
||||
? await listTaxonomyFactsByTicker({
|
||||
@@ -329,11 +754,11 @@ export async function getCompanyFinancialTaxonomy(input: GetCompanyFinancialTaxo
|
||||
const latestFiling = filings[0] ?? null;
|
||||
const metrics = latestMetrics(snapshotResult.snapshots);
|
||||
const dimensionBreakdown = input.includeDimensions
|
||||
? buildDimensionBreakdown(dimensionFacts.facts, periods)
|
||||
? buildDimensionBreakdown(dimensionFacts.facts, periods, faithfulRows, standardizedRows)
|
||||
: null;
|
||||
|
||||
const dimensionsCount = dimensionBreakdown
|
||||
? Object.values(dimensionBreakdown).reduce((total, entries) => total + entries.length, 0)
|
||||
const dimensionsCount = input.includeDimensions
|
||||
? dimensionFacts.facts.reduce((total, fact) => total + fact.dimensions.length, 0)
|
||||
: 0;
|
||||
|
||||
const factsCoverage = input.includeFacts
|
||||
@@ -348,8 +773,18 @@ export async function getCompanyFinancialTaxonomy(input: GetCompanyFinancialTaxo
|
||||
},
|
||||
statement: input.statement,
|
||||
window: input.window,
|
||||
defaultSurface: 'standardized',
|
||||
periods,
|
||||
rows,
|
||||
surfaces: {
|
||||
faithful: {
|
||||
kind: 'faithful',
|
||||
rows: faithfulRows
|
||||
},
|
||||
standardized: {
|
||||
kind: 'standardized',
|
||||
rows: standardizedRows
|
||||
}
|
||||
},
|
||||
nextCursor: snapshotResult.nextCursor,
|
||||
facts: input.includeFacts
|
||||
? {
|
||||
@@ -359,7 +794,7 @@ export async function getCompanyFinancialTaxonomy(input: GetCompanyFinancialTaxo
|
||||
: null,
|
||||
coverage: {
|
||||
filings: periods.length,
|
||||
rows: rows.length,
|
||||
rows: faithfulRows.length,
|
||||
dimensions: dimensionsCount,
|
||||
facts: factsCoverage
|
||||
},
|
||||
@@ -378,7 +813,11 @@ export async function getCompanyFinancialTaxonomy(input: GetCompanyFinancialTaxo
|
||||
|
||||
export const __financialTaxonomyInternals = {
|
||||
buildPeriods,
|
||||
buildRows,
|
||||
buildStandardizedRows,
|
||||
buildDimensionBreakdown,
|
||||
isInstantPeriod,
|
||||
matchesDefinition,
|
||||
periodDurationDays,
|
||||
selectPrimaryPeriods
|
||||
};
|
||||
|
||||
38
lib/types.ts
38
lib/types.ts
@@ -213,6 +213,21 @@ export type TaxonomyStatementRow = {
|
||||
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 = {
|
||||
id: number;
|
||||
snapshotId: number;
|
||||
@@ -256,16 +271,6 @@ export type MetricValidationResult = {
|
||||
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 = {
|
||||
key: string;
|
||||
label: string;
|
||||
@@ -280,6 +285,8 @@ export type FilingFaithfulStatementRow = {
|
||||
export type DimensionBreakdownRow = {
|
||||
rowKey: string;
|
||||
concept: string | null;
|
||||
sourceRowKey: string | null;
|
||||
sourceLabel: string | null;
|
||||
periodId: string;
|
||||
axis: string;
|
||||
member: string;
|
||||
@@ -287,6 +294,11 @@ export type DimensionBreakdownRow = {
|
||||
unit: string | null;
|
||||
};
|
||||
|
||||
export type FinancialStatementSurface<Row> = {
|
||||
kind: FinancialStatementSurfaceKind;
|
||||
rows: Row[];
|
||||
};
|
||||
|
||||
export type CompanyFinancialStatementsResponse = {
|
||||
company: {
|
||||
ticker: string;
|
||||
@@ -295,8 +307,12 @@ export type CompanyFinancialStatementsResponse = {
|
||||
};
|
||||
statement: FinancialStatementKind;
|
||||
window: FinancialHistoryWindow;
|
||||
defaultSurface: FinancialStatementSurfaceKind;
|
||||
periods: FinancialStatementPeriod[];
|
||||
rows: TaxonomyStatementRow[];
|
||||
surfaces: {
|
||||
faithful: FinancialStatementSurface<TaxonomyStatementRow>;
|
||||
standardized: FinancialStatementSurface<StandardizedStatementRow>;
|
||||
};
|
||||
nextCursor: string | null;
|
||||
facts: {
|
||||
rows: TaxonomyFactRow[];
|
||||
|
||||
Reference in New Issue
Block a user