Expand financials surfaces with ratios, KPIs, and cadence support
- Add bundled financial modeling pipeline (ratios, KPI dimensions/notes, trend series, standardization) - Introduce company financial bundles storage (Drizzle migration + repo wiring) - Refactor financials page/API/query flow to use surfaceKind + cadence and new response shapes
This commit is contained in:
159
lib/server/financials/kpi-dimensions.ts
Normal file
159
lib/server/financials/kpi-dimensions.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import type {
|
||||
FinancialStatementPeriod,
|
||||
StructuredKpiRow,
|
||||
TaxonomyFactRow
|
||||
} from '@/lib/types';
|
||||
import type { KpiDefinition } from '@/lib/server/financials/kpi-registry';
|
||||
import { factMatchesPeriod } from '@/lib/server/financials/standardize';
|
||||
|
||||
function normalizeSegmentToken(value: string) {
|
||||
return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
|
||||
}
|
||||
|
||||
function humanizeMember(value: string) {
|
||||
const source = value.split(':').pop() ?? value;
|
||||
return source
|
||||
.replace(/Member$/i, '')
|
||||
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||
.replace(/_/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function factMatchesDefinition(fact: TaxonomyFactRow, definition: KpiDefinition) {
|
||||
if (definition.preferredConceptNames && !definition.preferredConceptNames.includes(fact.localName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!definition.preferredAxisIncludes || definition.preferredAxisIncludes.length === 0) {
|
||||
return fact.dimensions.length > 0;
|
||||
}
|
||||
|
||||
return fact.dimensions.some((dimension) => {
|
||||
const axisMatch = definition.preferredAxisIncludes?.some((token) => dimension.axis.toLowerCase().includes(token.toLowerCase())) ?? false;
|
||||
const memberMatch = definition.preferredMemberIncludes && definition.preferredMemberIncludes.length > 0
|
||||
? definition.preferredMemberIncludes.some((token) => dimension.member.toLowerCase().includes(token.toLowerCase()))
|
||||
: true;
|
||||
return axisMatch && memberMatch;
|
||||
});
|
||||
}
|
||||
|
||||
function categoryForDefinition(definition: KpiDefinition, axis: string) {
|
||||
if (definition.key === 'segment_revenue' && /geo|country|region|area/i.test(axis)) {
|
||||
return 'geographic_mix';
|
||||
}
|
||||
|
||||
return definition.category;
|
||||
}
|
||||
|
||||
export function extractStructuredKpisFromDimensions(input: {
|
||||
facts: TaxonomyFactRow[];
|
||||
periods: FinancialStatementPeriod[];
|
||||
definitions: KpiDefinition[];
|
||||
}) {
|
||||
const rowMap = new Map<string, StructuredKpiRow>();
|
||||
const orderByKey = new Map<string, number>();
|
||||
|
||||
input.definitions.forEach((definition, index) => {
|
||||
orderByKey.set(definition.key, (index + 1) * 10);
|
||||
});
|
||||
|
||||
for (const definition of input.definitions) {
|
||||
for (const fact of input.facts) {
|
||||
if (fact.dimensions.length === 0 || !factMatchesDefinition(fact, definition)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const matchedPeriod = input.periods.find((period) => period.filingId === fact.filingId && factMatchesPeriod(fact, period));
|
||||
if (!matchedPeriod) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const dimension of fact.dimensions) {
|
||||
const axis = dimension.axis;
|
||||
const member = dimension.member;
|
||||
const normalizedAxis = normalizeSegmentToken(axis);
|
||||
const normalizedMember = normalizeSegmentToken(member);
|
||||
const key = `${definition.key}__${normalizedAxis}__${normalizedMember}`;
|
||||
const labelSuffix = humanizeMember(member);
|
||||
const existing = rowMap.get(key);
|
||||
|
||||
if (existing) {
|
||||
existing.values[matchedPeriod.id] = fact.value;
|
||||
if (!existing.sourceConcepts.includes(fact.qname)) {
|
||||
existing.sourceConcepts.push(fact.qname);
|
||||
}
|
||||
if (!existing.sourceFactIds.includes(fact.id)) {
|
||||
existing.sourceFactIds.push(fact.id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
rowMap.set(key, {
|
||||
key,
|
||||
label: `${definition.label} - ${labelSuffix}`,
|
||||
category: categoryForDefinition(definition, axis),
|
||||
unit: definition.unit,
|
||||
order: orderByKey.get(definition.key) ?? 999,
|
||||
segment: labelSuffix || null,
|
||||
axis,
|
||||
member,
|
||||
values: { [matchedPeriod.id]: fact.value },
|
||||
sourceConcepts: [fact.qname],
|
||||
sourceFactIds: [fact.id],
|
||||
provenanceType: 'taxonomy',
|
||||
hasDimensions: true
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rows = [...rowMap.values()].sort((left, right) => {
|
||||
if (left.order !== right.order) {
|
||||
return left.order - right.order;
|
||||
}
|
||||
|
||||
return left.label.localeCompare(right.label);
|
||||
});
|
||||
|
||||
const marginRows = new Map<string, StructuredKpiRow>();
|
||||
for (const row of rows.filter((entry) => entry.category === 'segment_profit')) {
|
||||
const revenueKey = row.key.replace(/^segment_profit__/, 'segment_revenue__');
|
||||
const revenueRow = rowMap.get(revenueKey);
|
||||
if (!revenueRow) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const values: Record<string, number | null> = {};
|
||||
for (const period of input.periods) {
|
||||
const revenue = revenueRow.values[period.id] ?? null;
|
||||
const profit = row.values[period.id] ?? null;
|
||||
values[period.id] = revenue === null || profit === null || revenue === 0
|
||||
? null
|
||||
: profit / revenue;
|
||||
}
|
||||
|
||||
marginRows.set(row.key.replace(/^segment_profit__/, 'segment_margin__'), {
|
||||
key: row.key.replace(/^segment_profit__/, 'segment_margin__'),
|
||||
label: row.label.replace(/^Segment Profit/, 'Segment Margin'),
|
||||
category: 'segment_margin',
|
||||
unit: 'percent',
|
||||
order: 25,
|
||||
segment: row.segment,
|
||||
axis: row.axis,
|
||||
member: row.member,
|
||||
values,
|
||||
sourceConcepts: [...new Set([...row.sourceConcepts, ...revenueRow.sourceConcepts])],
|
||||
sourceFactIds: [...new Set([...row.sourceFactIds, ...revenueRow.sourceFactIds])],
|
||||
provenanceType: 'taxonomy',
|
||||
hasDimensions: true
|
||||
});
|
||||
}
|
||||
|
||||
return [...rows, ...marginRows.values()].sort((left, right) => {
|
||||
if (left.order !== right.order) {
|
||||
return left.order - right.order;
|
||||
}
|
||||
|
||||
return left.label.localeCompare(right.label);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user