Consolidate metric definitions with Rust JSON as single source of truth
- Add core.computed.json with 32 ratio definitions (filing + market derived) - Add Rust types for ComputedDefinition and ComputationSpec - Create generate-taxonomy.ts to generate TypeScript from Rust JSON - Generate lib/generated/ (gitignored) with surfaces, computed, kpis - Update financial-metrics.ts to use generated definitions - Add build-time generation via 'bun run generate' - Add taxonomy architecture documentation Two-phase ratio computation: - Filing-derived: margins, returns, per-share, growth (Rust computes) - Market-derived: valuation ratios (TypeScript computes with price data) All 32 ratios defined in core.computed.json: - Margins: gross, operating, ebitda, net, fcf - Returns: roa, roe, roic, roce - Financial health: debt_to_equity, net_debt_to_ebitda, cash_to_debt, current_ratio - Per-share: revenue, fcf, book_value - Growth: yoy metrics + 3y/5y cagr - Valuation: market_cap, ev, p/e, p/fcf, p/b, ev/sales, ev/ebitda, ev/fcf
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -13,6 +13,7 @@ build/
|
||||
dist/
|
||||
.next/
|
||||
out/
|
||||
lib/generated/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
@@ -37,6 +38,9 @@ out/
|
||||
*.db
|
||||
*.sqlite
|
||||
|
||||
# Generated code
|
||||
lib/generated/
|
||||
|
||||
# Local app runtime state
|
||||
data/*.json
|
||||
data/*.sqlite
|
||||
|
||||
292
docs/architecture/taxonomy.md
Normal file
292
docs/architecture/taxonomy.md
Normal file
@@ -0,0 +1,292 @@
|
||||
# Taxonomy Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
The taxonomy system defines all financial surfaces, computed ratios, and KPIs used throughout the application. The Rust JSON files in `rust/taxonomy/` serve as the **single source of truth** for all financial definitions.
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ rust/taxonomy/fiscal/v1/ │
|
||||
│ │
|
||||
│ core.surface.json - Income/Balance/Cash Flow surfaces │
|
||||
│ core.computed.json - Ratio definitions │
|
||||
│ core.kpis.json - Sector-specific KPIs │
|
||||
│ core.income-bridge.json - Income statement mapping rules │
|
||||
│ │
|
||||
│ bank_lender.surface.json - Bank-specific surfaces │
|
||||
│ insurance.surface.json - Insurance-specific surfaces │
|
||||
│ reit_real_estate.surface.json - REIT-specific surfaces │
|
||||
│ broker_asset_manager.surface.json - Asset manager surfaces │
|
||||
└──────────────────────────┬──────────────────────────────────────┘
|
||||
│
|
||||
┌─────────────────┼─────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────┐ ┌────────────────┐ ┌──────────────┐
|
||||
│ Rust Sidecar│ │ TS Generator │ │ TypeScript │
|
||||
│ fiscal-xbrl │ │ scripts/ │ │ Runtime │
|
||||
│ │ │ generate- │ │ │
|
||||
│ Parses XBRL │ │ taxonomy.ts │ │ UI/API │
|
||||
│ Maps to │ │ │ │ │
|
||||
│ surfaces │ │ Generates TS │ │ Uses generated│
|
||||
│ Computes │ │ types & consts │ │ definitions │
|
||||
│ ratios │ │ │ │ │
|
||||
└──────┬──────┘ └───────┬────────┘ └──────┬───────┘
|
||||
│ │ │
|
||||
│ ▼ │
|
||||
│ ┌──────────────┐ │
|
||||
│ │ lib/generated│ │
|
||||
│ │ (gitignored) │ │
|
||||
│ │ │ │
|
||||
│ │ surfaces/ │ │
|
||||
│ │ computed/ │ │
|
||||
│ │ kpis/ │ │
|
||||
│ └──────┬───────┘ │
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ lib/financial-metrics.ts │
|
||||
│ │
|
||||
│ Thin wrapper that: │
|
||||
│ - Re-exports generated types │
|
||||
│ - Provides UI-specific types (GraphableFinancialSurface) │
|
||||
│ - Transforms surfaces to metric definitions │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
rust/taxonomy/fiscal/v1/
|
||||
├── core.surface.json # Core financial surfaces
|
||||
├── core.computed.json # Ratio definitions (32 ratios)
|
||||
├── core.income-bridge.json # Income statement XBRL mapping
|
||||
├── core.kpis.json # Core KPIs (mostly empty)
|
||||
├── universal_income.surface.json
|
||||
│
|
||||
├── bank_lender.surface.json
|
||||
├── bank_lender.income-bridge.json
|
||||
├── bank_lender.kpis.json
|
||||
│
|
||||
├── insurance.surface.json
|
||||
├── insurance.income-bridge.json
|
||||
├── insurance.kpis.json
|
||||
│
|
||||
├── reit_real_estate.surface.json
|
||||
├── reit_real_estate.income-bridge.json
|
||||
├── reit_real_estate.kpis.json
|
||||
│
|
||||
├── broker_asset_manager.surface.json
|
||||
├── broker_asset_manager.income-bridge.json
|
||||
├── broker_asset_manager.kpis.json
|
||||
│
|
||||
└── kpis/
|
||||
└── *.kpis.json
|
||||
|
||||
lib/generated/ # Auto-generated, gitignored
|
||||
├── index.ts
|
||||
├── types.ts
|
||||
├── surfaces/
|
||||
│ ├── index.ts
|
||||
│ ├── income.ts
|
||||
│ ├── balance.ts
|
||||
│ └── cash_flow.ts
|
||||
├── computed/
|
||||
│ ├── index.ts
|
||||
│ └── core.ts
|
||||
└── kpis/
|
||||
├── index.ts
|
||||
└── *.ts
|
||||
```
|
||||
|
||||
## Surface Definitions
|
||||
|
||||
Surfaces represent canonical financial line items. Each surface maps XBRL concepts to a standardized key.
|
||||
|
||||
```json
|
||||
{
|
||||
"surface_key": "revenue",
|
||||
"statement": "income",
|
||||
"label": "Revenue",
|
||||
"category": "surface",
|
||||
"order": 10,
|
||||
"unit": "currency",
|
||||
"rollup_policy": "direct_or_formula",
|
||||
"allowed_source_concepts": [
|
||||
"us-gaap:RevenueFromContractWithCustomerExcludingAssessedTax",
|
||||
"us-gaap:SalesRevenueNet"
|
||||
],
|
||||
"formula_fallback": null
|
||||
}
|
||||
```
|
||||
|
||||
### Surface Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `surface_key` | string | Unique identifier (snake_case) |
|
||||
| `statement` | enum | `income`, `balance`, `cash_flow`, `equity`, `comprehensive_income` |
|
||||
| `label` | string | Human-readable label |
|
||||
| `category` | string | Grouping category |
|
||||
| `order` | number | Display order |
|
||||
| `unit` | enum | `currency`, `percent`, `ratio`, `shares`, `count` |
|
||||
| `rollup_policy` | string | How to aggregate: `direct_only`, `direct_or_formula`, `aggregate_children`, `formula_only` |
|
||||
| `allowed_source_concepts` | string[] | XBRL concepts that map to this surface |
|
||||
| `formula_fallback` | object | Optional formula when no direct mapping |
|
||||
|
||||
## Computed Definitions
|
||||
|
||||
Computed definitions describe ratios and derived metrics. They are split into two phases:
|
||||
|
||||
### Phase 1: Filing-Derived (Rust computes)
|
||||
|
||||
Ratios computable from filing data alone:
|
||||
- **Margins**: gross_margin, operating_margin, ebitda_margin, net_margin, fcf_margin
|
||||
- **Returns**: roa, roe, roic, roce
|
||||
- **Financial Health**: debt_to_equity, net_debt_to_ebitda, cash_to_debt, current_ratio
|
||||
- **Per-Share**: revenue_per_share, fcf_per_share, book_value_per_share
|
||||
- **Growth**: revenue_yoy, net_income_yoy, eps_yoy, fcf_yoy, *_cagr
|
||||
|
||||
### Phase 2: Market-Derived (TypeScript computes)
|
||||
|
||||
Ratios requiring external price data:
|
||||
- **Valuation**: market_cap, enterprise_value, price_to_earnings, price_to_fcf, price_to_book, ev_to_*
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "gross_margin",
|
||||
"label": "Gross Margin",
|
||||
"category": "margins",
|
||||
"order": 10,
|
||||
"unit": "percent",
|
||||
"computation": {
|
||||
"type": "ratio",
|
||||
"numerator": "gross_profit",
|
||||
"denominator": "revenue"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "price_to_earnings",
|
||||
"label": "Price to Earnings",
|
||||
"category": "valuation",
|
||||
"order": 270,
|
||||
"unit": "ratio",
|
||||
"computation": {
|
||||
"type": "simple",
|
||||
"formula": "price / diluted_eps"
|
||||
},
|
||||
"requires_external_data": ["price"]
|
||||
}
|
||||
```
|
||||
|
||||
### Computation Types
|
||||
|
||||
| Type | Fields | Description |
|
||||
|------|--------|-------------|
|
||||
| `ratio` | numerator, denominator | Simple division |
|
||||
| `yoy_growth` | source | Year-over-year percentage change |
|
||||
| `cagr` | source, years | Compound annual growth rate |
|
||||
| `per_share` | source, shares_key | Divide by share count |
|
||||
| `simple` | formula | Custom formula expression |
|
||||
|
||||
## Pack Inheritance
|
||||
|
||||
Non-core packs inherit balance and cash_flow surfaces from core:
|
||||
|
||||
```rust
|
||||
// taxonomy_loader.rs
|
||||
if !matches!(pack, FiscalPack::Core) {
|
||||
// Inherit balance + cash_flow from core
|
||||
// Override with pack-specific definitions
|
||||
}
|
||||
```
|
||||
|
||||
This ensures consistency across packs while allowing sector-specific income statements.
|
||||
|
||||
## Build Pipeline
|
||||
|
||||
```bash
|
||||
# Generate TypeScript from Rust JSON
|
||||
bun run generate
|
||||
|
||||
# Build Rust sidecar (includes taxonomy)
|
||||
bun run build:sidecar
|
||||
|
||||
# Full build (generates + compiles)
|
||||
bun run build
|
||||
```
|
||||
|
||||
### package.json Scripts
|
||||
|
||||
| Script | Description |
|
||||
|--------|-------------|
|
||||
| `generate` | Run taxonomy generator |
|
||||
| `build:sidecar` | Build Rust binary |
|
||||
| `build` | Generate + Next.js build |
|
||||
| `lint` | Generate + TypeScript check |
|
||||
|
||||
## Validation
|
||||
|
||||
The generator validates:
|
||||
1. No duplicate surface keys within the same statement
|
||||
2. All ratio numerators/denominators reference existing surfaces
|
||||
3. Required fields present on all definitions
|
||||
4. Valid statement/unit/category values
|
||||
|
||||
Run validation:
|
||||
```bash
|
||||
bun run generate # Validates during generation
|
||||
```
|
||||
|
||||
## Extending the Taxonomy
|
||||
|
||||
### Adding a New Surface
|
||||
|
||||
1. Edit `rust/taxonomy/fiscal/v1/core.surface.json`
|
||||
2. Add surface definition with unique key
|
||||
3. Run `bun run generate` to regenerate TypeScript
|
||||
4. Run `bun run build:sidecar` to rebuild Rust
|
||||
|
||||
### Adding a New Ratio
|
||||
|
||||
1. Edit `rust/taxonomy/fiscal/v1/core.computed.json`
|
||||
2. Add computed definition with computation spec
|
||||
3. If market-derived, add `requires_external_data`
|
||||
4. Run `bun run generate`
|
||||
|
||||
### Adding a New Sector Pack
|
||||
|
||||
1. Create `rust/taxonomy/fiscal/v1/<pack>.surface.json`
|
||||
2. Create `rust/taxonomy/fiscal/v1/<pack>.income-bridge.json`
|
||||
3. Create `rust/taxonomy/fiscal/v1/<pack>.kpis.json` (if needed)
|
||||
4. Add pack to `PACK_ORDER` in `scripts/generate-taxonomy.ts`
|
||||
5. Add pack to `FiscalPack` enum in `rust/fiscal-xbrl-core/src/pack_selector.rs`
|
||||
6. Run `bun run generate && bun run build:sidecar`
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### Why Rust JSON as Source of Truth?
|
||||
|
||||
1. **Single definition**: XBRL mapping and TypeScript use the same definitions
|
||||
2. **Type safety**: Rust validates JSON at compile time
|
||||
3. **Performance**: No runtime JSON parsing in TypeScript
|
||||
4. **Consistency**: Impossible for Rust and TypeScript to drift
|
||||
|
||||
### Why Gitignore Generated Files?
|
||||
|
||||
1. **Single source of truth**: Forces changes through Rust JSON
|
||||
2. **No merge conflicts**: Generated code never conflicts
|
||||
3. **Smaller repo**: No large generated files in history
|
||||
4. **CI validation**: CI regenerates and validates
|
||||
|
||||
### Why Two-Phase Ratio Computation?
|
||||
|
||||
1. **Filing-derived ratios**: Can be computed at parse time by Rust
|
||||
2. **Market-derived ratios**: Require real-time price data
|
||||
3. **Separation of concerns**: Rust handles XBRL, TypeScript handles market data
|
||||
4. **Same definitions**: Both phases use the same computation specs
|
||||
@@ -4,6 +4,15 @@ import type {
|
||||
FinancialUnit,
|
||||
RatioRow
|
||||
} from '@/lib/types';
|
||||
import {
|
||||
type ComputedDefinition,
|
||||
type SurfaceDefinition,
|
||||
ALL_COMPUTED,
|
||||
INCOME_SURFACES,
|
||||
BALANCE_SURFACES,
|
||||
CASH_FLOW_SURFACES,
|
||||
RATIO_CATEGORIES
|
||||
} from '@/lib/generated';
|
||||
|
||||
export type GraphableFinancialSurfaceKind = Extract<
|
||||
FinancialSurfaceKind,
|
||||
@@ -16,8 +25,6 @@ export type StatementMetricDefinition = {
|
||||
category: string;
|
||||
order: number;
|
||||
unit: FinancialUnit;
|
||||
localNames?: readonly string[];
|
||||
labelIncludes?: readonly string[];
|
||||
};
|
||||
|
||||
export type RatioMetricDefinition = {
|
||||
@@ -37,113 +44,44 @@ export const GRAPHABLE_FINANCIAL_SURFACES: readonly GraphableFinancialSurfaceKin
|
||||
'ratios'
|
||||
] as const;
|
||||
|
||||
export const INCOME_STATEMENT_METRIC_DEFINITIONS: StatementMetricDefinition[] = [
|
||||
{ key: 'revenue', label: 'Revenue', category: 'revenue', order: 10, unit: 'currency', localNames: ['RevenueFromContractWithCustomerExcludingAssessedTax', 'Revenues', 'SalesRevenueNet', 'TotalRevenuesAndOtherIncome'], labelIncludes: ['revenue', 'sales'] },
|
||||
{ key: 'cost_of_revenue', label: 'Cost of Revenue', category: 'expense', order: 20, unit: 'currency', localNames: ['CostOfRevenue', 'CostOfGoodsSold', 'CostOfSales', 'CostOfProductsSold', 'CostOfServices'], labelIncludes: ['cost of revenue', 'cost of sales', 'cost of goods sold'] },
|
||||
{ key: 'gross_profit', label: 'Gross Profit', category: 'profit', order: 30, unit: 'currency', localNames: ['GrossProfit'] },
|
||||
{ key: 'gross_margin', label: 'Gross Margin', category: 'margin', order: 40, unit: 'percent', labelIncludes: ['gross margin'] },
|
||||
{ key: 'operating_expenses', label: 'Operating Expenses', category: 'opex', order: 50, unit: 'currency', localNames: ['OperatingExpenses'], labelIncludes: ['operating expenses'] },
|
||||
{ key: 'selling_general_and_administrative', label: 'Selling, General & Administrative', category: 'opex', order: 60, unit: 'currency', localNames: ['SellingGeneralAndAdministrativeExpense'], labelIncludes: ['selling, general', 'selling general'] },
|
||||
{ key: 'research_and_development', label: 'Research Expense', category: 'opex', order: 70, unit: 'currency', localNames: ['ResearchAndDevelopmentExpense'], labelIncludes: ['research and development', 'research expense'] },
|
||||
{ key: 'other_operating_expense', label: 'Other Expense', category: 'opex', order: 80, unit: 'currency', localNames: ['OtherOperatingExpense'], labelIncludes: ['other operating expense', 'other expense'] },
|
||||
{ key: 'operating_income', label: 'Operating Income', category: 'profit', order: 90, unit: 'currency', localNames: ['OperatingIncomeLoss', 'IncomeLossFromOperations'], labelIncludes: ['operating income'] },
|
||||
{ key: 'sales_and_marketing', label: 'Sales & Marketing', category: 'opex', order: 100, unit: 'currency', localNames: ['SellingAndMarketingExpense'], labelIncludes: ['sales and marketing', 'selling and marketing'] },
|
||||
{ key: 'general_and_administrative', label: 'General & Administrative', category: 'opex', order: 110, unit: 'currency', localNames: ['GeneralAndAdministrativeExpense'], labelIncludes: ['general and administrative'] },
|
||||
{ key: 'operating_margin', label: 'Operating Margin', category: 'margin', order: 120, unit: 'percent', labelIncludes: ['operating margin'] },
|
||||
{ key: 'interest_income', label: 'Interest Income', category: 'non_operating', order: 130, unit: 'currency', localNames: ['InterestIncomeExpenseNonoperatingNet', 'InterestIncomeOther', 'InvestmentIncomeInterest'], labelIncludes: ['interest income'] },
|
||||
{ key: 'interest_expense', label: 'Interest Expense', category: 'non_operating', order: 140, unit: 'currency', localNames: ['InterestExpense', 'InterestAndDebtExpense'], labelIncludes: ['interest expense'] },
|
||||
{ key: 'other_non_operating_income', label: 'Other Non-Operating Income', category: 'non_operating', order: 150, unit: 'currency', localNames: ['OtherNonoperatingIncomeExpense', 'NonoperatingIncomeExpense'], labelIncludes: ['other non-operating', 'non-operating income'] },
|
||||
{ key: 'pretax_income', label: 'Pretax Income', category: 'profit', order: 160, unit: 'currency', localNames: ['IncomeBeforeTaxExpenseBenefit', 'PretaxIncome'], labelIncludes: ['income before taxes', 'pretax income'] },
|
||||
{ key: 'income_tax_expense', label: 'Income Tax Expense', category: 'tax', order: 170, unit: 'currency', localNames: ['IncomeTaxExpenseBenefit'], labelIncludes: ['income tax'] },
|
||||
{ key: 'effective_tax_rate', label: 'Effective Tax Rate', category: 'tax', order: 180, unit: 'percent', labelIncludes: ['effective tax rate'] },
|
||||
{ key: 'net_income', label: 'Net Income', category: 'profit', order: 190, unit: 'currency', localNames: ['NetIncomeLoss', 'ProfitLoss'], labelIncludes: ['net income'] },
|
||||
{ key: 'diluted_eps', label: 'Diluted EPS', category: 'per_share', order: 200, unit: 'currency', localNames: ['EarningsPerShareDiluted', 'DilutedEarningsPerShare'], labelIncludes: ['diluted eps', 'diluted earnings per share'] },
|
||||
{ key: 'basic_eps', label: 'Basic EPS', category: 'per_share', order: 210, unit: 'currency', localNames: ['EarningsPerShareBasic', 'BasicEarningsPerShare'], labelIncludes: ['basic eps', 'basic earnings per share'] },
|
||||
{ key: 'diluted_shares', label: 'Diluted Shares', category: 'shares', order: 220, unit: 'shares', localNames: ['WeightedAverageNumberOfDilutedSharesOutstanding', 'WeightedAverageNumberOfShareOutstandingDiluted'], labelIncludes: ['diluted shares', 'weighted average diluted'] },
|
||||
{ key: 'basic_shares', label: 'Basic Shares', category: 'shares', order: 230, unit: 'shares', localNames: ['WeightedAverageNumberOfSharesOutstandingBasic', 'WeightedAverageNumberOfShareOutstandingBasicAndDiluted'], labelIncludes: ['basic shares', 'weighted average basic'] },
|
||||
{ key: 'depreciation_and_amortization', label: 'Depreciation & Amortization', category: 'non_cash', order: 240, unit: 'currency', localNames: ['DepreciationDepletionAndAmortization', 'DepreciationAmortizationAndAccretionNet', 'DepreciationAndAmortization'], labelIncludes: ['depreciation', 'amortization'] },
|
||||
{ key: 'ebitda', label: 'EBITDA', category: 'profit', order: 250, unit: 'currency', localNames: ['OperatingIncomeLoss'], labelIncludes: ['ebitda'] },
|
||||
{ key: 'stock_based_compensation', label: 'Stock-Based Compensation', category: 'non_cash', order: 260, unit: 'currency', localNames: ['ShareBasedCompensation', 'AllocatedShareBasedCompensationExpense'], labelIncludes: ['stock-based compensation', 'share-based compensation'] }
|
||||
] as const satisfies StatementMetricDefinition[];
|
||||
function surfaceToMetric(surface: SurfaceDefinition): StatementMetricDefinition {
|
||||
return {
|
||||
key: surface.surface_key,
|
||||
label: surface.label,
|
||||
category: surface.category,
|
||||
order: surface.order,
|
||||
unit: surface.unit
|
||||
};
|
||||
}
|
||||
|
||||
export const BALANCE_SHEET_METRIC_DEFINITIONS: StatementMetricDefinition[] = [
|
||||
{ key: 'cash_and_equivalents', label: 'Cash & Equivalents', category: 'asset', order: 10, unit: 'currency', localNames: ['CashAndCashEquivalentsAtCarryingValue', 'CashCashEquivalentsAndShortTermInvestments', 'CashAndShortTermInvestments'], labelIncludes: ['cash and cash equivalents'] },
|
||||
{ key: 'short_term_investments', label: 'Short-Term Investments', category: 'asset', order: 20, unit: 'currency', localNames: ['AvailableForSaleSecuritiesCurrent', 'ShortTermInvestments'], labelIncludes: ['short-term investments', 'marketable securities'] },
|
||||
{ key: 'accounts_receivable', label: 'Accounts Receivable', category: 'asset', order: 30, unit: 'currency', localNames: ['AccountsReceivableNetCurrent', 'ReceivablesNetCurrent'], labelIncludes: ['accounts receivable'] },
|
||||
{ key: 'inventory', label: 'Inventory', category: 'asset', order: 40, unit: 'currency', localNames: ['InventoryNet'], labelIncludes: ['inventory'] },
|
||||
{ key: 'other_current_assets', label: 'Other Current Assets', category: 'asset', order: 50, unit: 'currency', localNames: ['OtherAssetsCurrent'], labelIncludes: ['other current assets'] },
|
||||
{ key: 'current_assets', label: 'Current Assets', category: 'asset', order: 60, unit: 'currency', localNames: ['AssetsCurrent'], labelIncludes: ['current assets'] },
|
||||
{ key: 'property_plant_equipment', label: 'Property, Plant & Equipment', category: 'asset', order: 70, unit: 'currency', localNames: ['PropertyPlantAndEquipmentNet'], labelIncludes: ['property, plant and equipment', 'property and equipment'] },
|
||||
{ key: 'goodwill', label: 'Goodwill', category: 'asset', order: 80, unit: 'currency', localNames: ['Goodwill'], labelIncludes: ['goodwill'] },
|
||||
{ key: 'intangible_assets', label: 'Intangible Assets', category: 'asset', order: 90, unit: 'currency', localNames: ['FiniteLivedIntangibleAssetsNet', 'IndefiniteLivedIntangibleAssetsExcludingGoodwill', 'IntangibleAssetsNetExcludingGoodwill'], labelIncludes: ['intangible assets'] },
|
||||
{ key: 'total_assets', label: 'Total Assets', category: 'asset', order: 100, unit: 'currency', localNames: ['Assets'], labelIncludes: ['total assets'] },
|
||||
{ key: 'accounts_payable', label: 'Accounts Payable', category: 'liability', order: 110, unit: 'currency', localNames: ['AccountsPayableCurrent'], labelIncludes: ['accounts payable'] },
|
||||
{ key: 'accrued_liabilities', label: 'Accrued Liabilities', category: 'liability', order: 120, unit: 'currency', localNames: ['AccruedLiabilitiesCurrent'], labelIncludes: ['accrued liabilities'] },
|
||||
{ key: 'deferred_revenue_current', label: 'Deferred Revenue, Current', category: 'liability', order: 130, unit: 'currency', localNames: ['ContractWithCustomerLiabilityCurrent', 'DeferredRevenueCurrent'], labelIncludes: ['deferred revenue current', 'current deferred revenue'] },
|
||||
{ key: 'current_liabilities', label: 'Current Liabilities', category: 'liability', order: 140, unit: 'currency', localNames: ['LiabilitiesCurrent'], labelIncludes: ['current liabilities'] },
|
||||
{ key: 'long_term_debt', label: 'Long-Term Debt', category: 'liability', order: 150, unit: 'currency', localNames: ['LongTermDebtNoncurrent', 'LongTermDebt', 'DebtNoncurrent', 'LongTermDebtAndCapitalLeaseObligations'], labelIncludes: ['long-term debt'] },
|
||||
{ key: 'current_debt', label: 'Current Debt', category: 'liability', order: 160, unit: 'currency', localNames: ['DebtCurrent', 'ShortTermBorrowings', 'LongTermDebtCurrent'], labelIncludes: ['current debt', 'short-term debt'] },
|
||||
{ key: 'lease_liabilities', label: 'Lease Liabilities', category: 'liability', order: 170, unit: 'currency', localNames: ['OperatingLeaseLiabilityNoncurrent', 'FinanceLeaseLiabilityNoncurrent', 'LesseeOperatingLeaseLiability'], labelIncludes: ['lease liabilities'] },
|
||||
{ key: 'total_debt', label: 'Total Debt', category: 'liability', order: 180, unit: 'currency', localNames: ['DebtAndFinanceLeaseLiabilities', 'Debt'], labelIncludes: ['total debt'] },
|
||||
{ key: 'deferred_revenue_noncurrent', label: 'Deferred Revenue, Noncurrent', category: 'liability', order: 190, unit: 'currency', localNames: ['ContractWithCustomerLiabilityNoncurrent', 'DeferredRevenueNoncurrent'], labelIncludes: ['deferred revenue noncurrent'] },
|
||||
{ key: 'total_liabilities', label: 'Total Liabilities', category: 'liability', order: 200, unit: 'currency', localNames: ['Liabilities'], labelIncludes: ['total liabilities'] },
|
||||
{ key: 'retained_earnings', label: 'Retained Earnings', category: 'equity', order: 210, unit: 'currency', localNames: ['RetainedEarningsAccumulatedDeficit'], labelIncludes: ['retained earnings', 'accumulated deficit'] },
|
||||
{ key: 'total_equity', label: 'Total Equity', category: 'equity', order: 220, unit: 'currency', localNames: ['StockholdersEquity', 'StockholdersEquityIncludingPortionAttributableToNoncontrollingInterest', 'PartnersCapital'], labelIncludes: ['total equity', 'stockholders’ equity', 'stockholders equity'] },
|
||||
{ key: 'net_cash_position', label: 'Net Cash Position', category: 'liquidity', order: 230, unit: 'currency', labelIncludes: ['net cash position'] }
|
||||
] as const satisfies StatementMetricDefinition[];
|
||||
function computedToRatioMetric(computed: ComputedDefinition): RatioMetricDefinition {
|
||||
const denominatorKey = computed.computation.type === 'ratio'
|
||||
? computed.computation.denominator
|
||||
: computed.computation.type === 'per_share'
|
||||
? computed.computation.shares_key
|
||||
: null;
|
||||
|
||||
export const CASH_FLOW_STATEMENT_METRIC_DEFINITIONS: StatementMetricDefinition[] = [
|
||||
{ key: 'operating_cash_flow', label: 'Operating Cash Flow', category: 'cash_flow', order: 10, unit: 'currency', localNames: ['NetCashProvidedByUsedInOperatingActivities', 'NetCashProvidedByUsedInOperatingActivitiesContinuingOperations'], labelIncludes: ['operating cash flow'] },
|
||||
{ key: 'capital_expenditures', label: 'Capital Expenditures', category: 'cash_flow', order: 20, unit: 'currency', localNames: ['PaymentsToAcquirePropertyPlantAndEquipment', 'CapitalExpendituresIncurredButNotYetPaid'], labelIncludes: ['capital expenditures', 'capital expenditure'] },
|
||||
{ key: 'free_cash_flow', label: 'Free Cash Flow', category: 'cash_flow', order: 30, unit: 'currency', labelIncludes: ['free cash flow'] },
|
||||
{ key: 'stock_based_compensation', label: 'Stock-Based Compensation', category: 'non_cash', order: 40, unit: 'currency', localNames: ['ShareBasedCompensation', 'AllocatedShareBasedCompensationExpense'], labelIncludes: ['stock-based compensation', 'share-based compensation'] },
|
||||
{ key: 'acquisitions', label: 'Acquisitions', category: 'investing', order: 50, unit: 'currency', localNames: ['PaymentsToAcquireBusinessesNetOfCashAcquired'], labelIncludes: ['acquisitions'] },
|
||||
{ key: 'share_repurchases', label: 'Share Repurchases', category: 'financing', order: 60, unit: 'currency', localNames: ['PaymentsForRepurchaseOfCommonStock', 'PaymentsForRepurchaseOfEquity'], labelIncludes: ['share repurchases', 'repurchase of common stock'] },
|
||||
{ key: 'dividends_paid', label: 'Dividends Paid', category: 'financing', order: 70, unit: 'currency', localNames: ['PaymentsOfDividends', 'PaymentsOfDividendsCommonStock'], labelIncludes: ['dividends paid'] },
|
||||
{ key: 'debt_issued', label: 'Debt Issued', category: 'financing', order: 80, unit: 'currency', localNames: ['ProceedsFromIssuanceOfLongTermDebt'], labelIncludes: ['debt issued'] },
|
||||
{ key: 'debt_repaid', label: 'Debt Repaid', category: 'financing', order: 90, unit: 'currency', localNames: ['RepaymentsOfLongTermDebt', 'RepaymentsOfDebt'], labelIncludes: ['debt repaid', 'repayment of debt'] }
|
||||
] as const satisfies StatementMetricDefinition[];
|
||||
return {
|
||||
key: computed.key,
|
||||
label: computed.label,
|
||||
category: computed.category,
|
||||
order: computed.order,
|
||||
unit: computed.unit as RatioRow['unit'],
|
||||
denominatorKey,
|
||||
supportedCadences: computed.supported_cadences as readonly FinancialCadence[] | undefined
|
||||
};
|
||||
}
|
||||
|
||||
export const RATIO_DEFINITIONS: RatioMetricDefinition[] = [
|
||||
{ key: 'gross_margin', label: 'Gross Margin', category: 'margins', order: 10, unit: 'percent', denominatorKey: 'revenue' },
|
||||
{ key: 'operating_margin', label: 'Operating Margin', category: 'margins', order: 20, unit: 'percent', denominatorKey: 'revenue' },
|
||||
{ key: 'ebitda_margin', label: 'EBITDA Margin', category: 'margins', order: 30, unit: 'percent', denominatorKey: 'revenue' },
|
||||
{ key: 'net_margin', label: 'Net Margin', category: 'margins', order: 40, unit: 'percent', denominatorKey: 'revenue' },
|
||||
{ key: 'fcf_margin', label: 'FCF Margin', category: 'margins', order: 50, unit: 'percent', denominatorKey: 'revenue' },
|
||||
{ key: 'roa', label: 'ROA', category: 'returns', order: 60, unit: 'percent', denominatorKey: 'total_assets' },
|
||||
{ key: 'roe', label: 'ROE', category: 'returns', order: 70, unit: 'percent', denominatorKey: 'total_equity' },
|
||||
{ key: 'roic', label: 'ROIC', category: 'returns', order: 80, unit: 'percent', denominatorKey: 'average_invested_capital' },
|
||||
{ key: 'roce', label: 'ROCE', category: 'returns', order: 90, unit: 'percent', denominatorKey: 'average_capital_employed' },
|
||||
{ key: 'debt_to_equity', label: 'Debt to Equity', category: 'financial_health', order: 100, unit: 'ratio', denominatorKey: 'total_equity' },
|
||||
{ key: 'net_debt_to_ebitda', label: 'Net Debt to EBITDA', category: 'financial_health', order: 110, unit: 'ratio', denominatorKey: 'ebitda' },
|
||||
{ key: 'cash_to_debt', label: 'Cash to Debt', category: 'financial_health', order: 120, unit: 'ratio', denominatorKey: 'total_debt' },
|
||||
{ key: 'current_ratio', label: 'Current Ratio', category: 'financial_health', order: 130, unit: 'ratio', denominatorKey: 'current_liabilities' },
|
||||
{ key: 'revenue_per_share', label: 'Revenue per Share', category: 'per_share', order: 140, unit: 'currency', denominatorKey: 'diluted_shares' },
|
||||
{ key: 'fcf_per_share', label: 'FCF per Share', category: 'per_share', order: 150, unit: 'currency', denominatorKey: 'diluted_shares' },
|
||||
{ key: 'book_value_per_share', label: 'Book Value per Share', category: 'per_share', order: 160, unit: 'currency', denominatorKey: 'diluted_shares' },
|
||||
{ key: 'revenue_yoy', label: 'Revenue YoY', category: 'growth', order: 170, unit: 'percent', denominatorKey: 'revenue' },
|
||||
{ key: 'net_income_yoy', label: 'Net Income YoY', category: 'growth', order: 180, unit: 'percent', denominatorKey: 'net_income' },
|
||||
{ key: 'eps_yoy', label: 'EPS YoY', category: 'growth', order: 190, unit: 'percent', denominatorKey: 'diluted_eps' },
|
||||
{ key: 'fcf_yoy', label: 'FCF YoY', category: 'growth', order: 200, unit: 'percent', denominatorKey: 'free_cash_flow' },
|
||||
{ key: '3y_revenue_cagr', label: '3Y Revenue CAGR', category: 'growth', order: 210, unit: 'percent', denominatorKey: 'revenue', supportedCadences: ['annual'] },
|
||||
{ key: '5y_revenue_cagr', label: '5Y Revenue CAGR', category: 'growth', order: 220, unit: 'percent', denominatorKey: 'revenue', supportedCadences: ['annual'] },
|
||||
{ key: '3y_eps_cagr', label: '3Y EPS CAGR', category: 'growth', order: 230, unit: 'percent', denominatorKey: 'diluted_eps', supportedCadences: ['annual'] },
|
||||
{ key: '5y_eps_cagr', label: '5Y EPS CAGR', category: 'growth', order: 240, unit: 'percent', denominatorKey: 'diluted_eps', supportedCadences: ['annual'] },
|
||||
{ key: 'market_cap', label: 'Market Cap', category: 'valuation', order: 250, unit: 'currency', denominatorKey: null },
|
||||
{ key: 'enterprise_value', label: 'Enterprise Value', category: 'valuation', order: 260, unit: 'currency', denominatorKey: null },
|
||||
{ key: 'price_to_earnings', label: 'Price to Earnings', category: 'valuation', order: 270, unit: 'ratio', denominatorKey: 'diluted_eps' },
|
||||
{ key: 'price_to_fcf', label: 'Price to FCF', category: 'valuation', order: 280, unit: 'ratio', denominatorKey: 'free_cash_flow' },
|
||||
{ key: 'price_to_book', label: 'Price to Book', category: 'valuation', order: 290, unit: 'ratio', denominatorKey: 'total_equity' },
|
||||
{ key: 'ev_to_sales', label: 'EV to Sales', category: 'valuation', order: 300, unit: 'ratio', denominatorKey: 'revenue' },
|
||||
{ key: 'ev_to_ebitda', label: 'EV to EBITDA', category: 'valuation', order: 310, unit: 'ratio', denominatorKey: 'ebitda' },
|
||||
{ key: 'ev_to_fcf', label: 'EV to FCF', category: 'valuation', order: 320, unit: 'ratio', denominatorKey: 'free_cash_flow' }
|
||||
] as const satisfies RatioMetricDefinition[];
|
||||
export const INCOME_STATEMENT_METRIC_DEFINITIONS: StatementMetricDefinition[] =
|
||||
INCOME_SURFACES.map(surfaceToMetric);
|
||||
|
||||
export const RATIO_CATEGORY_ORDER = [
|
||||
'margins',
|
||||
'returns',
|
||||
'financial_health',
|
||||
'per_share',
|
||||
'growth',
|
||||
'valuation'
|
||||
] as const;
|
||||
export const BALANCE_SHEET_METRIC_DEFINITIONS: StatementMetricDefinition[] =
|
||||
BALANCE_SURFACES.map(surfaceToMetric);
|
||||
|
||||
export const CASH_FLOW_STATEMENT_METRIC_DEFINITIONS: StatementMetricDefinition[] =
|
||||
CASH_FLOW_SURFACES.map(surfaceToMetric);
|
||||
|
||||
export const RATIO_DEFINITIONS: RatioMetricDefinition[] =
|
||||
ALL_COMPUTED.map(computedToRatioMetric);
|
||||
|
||||
export { RATIO_CATEGORIES, type RatioCategory } from '@/lib/generated';
|
||||
|
||||
@@ -3,10 +3,10 @@ import type {
|
||||
FinancialUnit
|
||||
} from '@/lib/types';
|
||||
import {
|
||||
BALANCE_SHEET_METRIC_DEFINITIONS,
|
||||
CASH_FLOW_STATEMENT_METRIC_DEFINITIONS,
|
||||
INCOME_STATEMENT_METRIC_DEFINITIONS
|
||||
} from '@/lib/financial-metrics';
|
||||
INCOME_SURFACES,
|
||||
BALANCE_SURFACES,
|
||||
CASH_FLOW_SURFACES
|
||||
} from '@/lib/generated';
|
||||
|
||||
export type CanonicalRowDefinition = {
|
||||
key: string;
|
||||
@@ -14,12 +14,20 @@ export type CanonicalRowDefinition = {
|
||||
category: string;
|
||||
order: number;
|
||||
unit: FinancialUnit;
|
||||
localNames?: readonly string[];
|
||||
labelIncludes?: readonly string[];
|
||||
};
|
||||
|
||||
function toCanonicalRow(surface: { surface_key: string; label: string; category: string; order: number; unit: string }) {
|
||||
return {
|
||||
key: surface.surface_key,
|
||||
label: surface.label,
|
||||
category: surface.category,
|
||||
order: surface.order,
|
||||
unit: surface.unit as FinancialUnit
|
||||
};
|
||||
}
|
||||
|
||||
export const CANONICAL_ROW_DEFINITIONS: Record<Extract<FinancialStatementKind, 'income' | 'balance' | 'cash_flow'>, CanonicalRowDefinition[]> = {
|
||||
income: INCOME_STATEMENT_METRIC_DEFINITIONS,
|
||||
balance: BALANCE_SHEET_METRIC_DEFINITIONS,
|
||||
cash_flow: CASH_FLOW_STATEMENT_METRIC_DEFINITIONS
|
||||
income: INCOME_SURFACES.map(toCanonicalRow),
|
||||
balance: BALANCE_SURFACES.map(toCanonicalRow),
|
||||
cash_flow: CASH_FLOW_SURFACES.map(toCanonicalRow)
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
StandardizedFinancialRow
|
||||
} from '@/lib/types';
|
||||
import {
|
||||
RATIO_CATEGORY_ORDER,
|
||||
RATIO_CATEGORIES,
|
||||
RATIO_DEFINITIONS
|
||||
} from '@/lib/financial-metrics';
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
StructuredKpiRow,
|
||||
TrendSeries
|
||||
} from '@/lib/types';
|
||||
import { RATIO_CATEGORY_ORDER } from '@/lib/financial-metrics';
|
||||
import { RATIO_CATEGORIES } from '@/lib/generated';
|
||||
import { KPI_CATEGORY_ORDER } from '@/lib/server/financials/kpi-registry';
|
||||
|
||||
function toTrendSeriesRow(row: {
|
||||
@@ -31,7 +31,7 @@ export function buildFinancialCategories(rows: Array<{ category: string }>, surf
|
||||
}
|
||||
|
||||
const order = surfaceKind === 'ratios'
|
||||
? [...RATIO_CATEGORY_ORDER]
|
||||
? [...RATIO_CATEGORIES]
|
||||
: surfaceKind === 'segments_kpis'
|
||||
? [...KPI_CATEGORY_ORDER]
|
||||
: [...counts.keys()];
|
||||
|
||||
@@ -6,13 +6,14 @@
|
||||
"scripts": {
|
||||
"dev": "bun run scripts/dev.ts",
|
||||
"dev:next": "bun --bun next dev --turbopack",
|
||||
"generate": "bun run scripts/generate-taxonomy.ts",
|
||||
"build:sidecar": "cargo build --manifest-path rust/Cargo.toml --release --bin fiscal-xbrl",
|
||||
"build": "bun --bun next build --turbopack",
|
||||
"build": "bun run generate && bun --bun next build --turbopack",
|
||||
"bootstrap:prod": "bun run scripts/bootstrap-production.ts",
|
||||
"check:sidecar": "cargo check --manifest-path rust/Cargo.toml",
|
||||
"validate:taxonomy-packs": "bun run scripts/validate-taxonomy-packs.ts",
|
||||
"start": "bun --bun next start",
|
||||
"lint": "bun x tsc --noEmit",
|
||||
"lint": "bun run generate && bun x tsc --noEmit",
|
||||
"e2e:prepare": "bun run scripts/e2e-prepare.ts",
|
||||
"e2e:webserver": "bun run scripts/e2e-webserver.ts",
|
||||
"workflow:setup": "workflow-postgres-setup",
|
||||
|
||||
@@ -12,6 +12,8 @@ mod surface_mapper;
|
||||
mod taxonomy_loader;
|
||||
mod universal_income;
|
||||
|
||||
use taxonomy_loader::{ComputationSpec, ComputedDefinition};
|
||||
|
||||
#[cfg(feature = "with-crabrl")]
|
||||
use crabrl as _;
|
||||
|
||||
@@ -112,6 +114,7 @@ pub struct HydrateFilingResponse {
|
||||
pub surface_rows: SurfaceRowMap,
|
||||
pub detail_rows: DetailRowStatementMap,
|
||||
pub kpi_rows: Vec<KpiRowOutput>,
|
||||
pub computed_definitions: Vec<ComputedDefinitionOutput>,
|
||||
pub contexts: Vec<ContextOutput>,
|
||||
pub derived_metrics: FilingMetrics,
|
||||
pub validation_result: ValidationResultOutput,
|
||||
@@ -257,6 +260,86 @@ pub struct KpiRowOutput {
|
||||
pub has_dimensions: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ComputedDefinitionOutput {
|
||||
pub key: String,
|
||||
pub label: String,
|
||||
pub category: String,
|
||||
pub order: i64,
|
||||
pub unit: String,
|
||||
pub computation: ComputationSpecOutput,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub supported_cadences: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub requires_external_data: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ComputationSpecOutput {
|
||||
Ratio {
|
||||
numerator: String,
|
||||
denominator: String,
|
||||
},
|
||||
YoyGrowth {
|
||||
source: String,
|
||||
},
|
||||
Cagr {
|
||||
source: String,
|
||||
years: i64,
|
||||
},
|
||||
PerShare {
|
||||
source: String,
|
||||
shares_key: String,
|
||||
},
|
||||
Simple {
|
||||
formula: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl From<&ComputationSpec> for ComputationSpecOutput {
|
||||
fn from(spec: &ComputationSpec) -> Self {
|
||||
match spec {
|
||||
ComputationSpec::Ratio {
|
||||
numerator,
|
||||
denominator,
|
||||
} => ComputationSpecOutput::Ratio {
|
||||
numerator: numerator.clone(),
|
||||
denominator: denominator.clone(),
|
||||
},
|
||||
ComputationSpec::YoyGrowth { source } => ComputationSpecOutput::YoyGrowth {
|
||||
source: source.clone(),
|
||||
},
|
||||
ComputationSpec::Cagr { source, years } => ComputationSpecOutput::Cagr {
|
||||
source: source.clone(),
|
||||
years: *years,
|
||||
},
|
||||
ComputationSpec::PerShare { source, shares_key } => ComputationSpecOutput::PerShare {
|
||||
source: source.clone(),
|
||||
shares_key: shares_key.clone(),
|
||||
},
|
||||
ComputationSpec::Simple { formula } => ComputationSpecOutput::Simple {
|
||||
formula: formula.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&ComputedDefinition> for ComputedDefinitionOutput {
|
||||
fn from(def: &ComputedDefinition) -> Self {
|
||||
ComputedDefinitionOutput {
|
||||
key: def.key.clone(),
|
||||
label: def.label.clone(),
|
||||
category: def.category.clone(),
|
||||
order: def.order,
|
||||
unit: def.unit.clone(),
|
||||
computation: ComputationSpecOutput::from(&def.computation),
|
||||
supported_cadences: def.supported_cadences.clone(),
|
||||
requires_external_data: def.requires_external_data.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ConceptOutput {
|
||||
pub concept_key: String,
|
||||
@@ -435,6 +518,7 @@ pub fn hydrate_filing(input: HydrateFilingRequest) -> Result<HydrateFilingRespon
|
||||
surface_rows: empty_surface_rows,
|
||||
detail_rows: empty_detail_rows,
|
||||
kpi_rows: vec![],
|
||||
computed_definitions: vec![],
|
||||
contexts: vec![],
|
||||
derived_metrics: FilingMetrics::default(),
|
||||
validation_result,
|
||||
@@ -541,6 +625,18 @@ pub fn hydrate_filing(input: HydrateFilingRequest) -> Result<HydrateFilingRespon
|
||||
&compact_model.concept_mappings,
|
||||
);
|
||||
|
||||
let computed_pack = taxonomy_loader::load_computed_pack(pack_selection.pack)
|
||||
.ok()
|
||||
.or_else(|| taxonomy_loader::load_computed_pack(pack_selector::FiscalPack::Core).ok());
|
||||
let computed_definitions: Vec<ComputedDefinitionOutput> = computed_pack
|
||||
.map(|pack| {
|
||||
pack.computed
|
||||
.iter()
|
||||
.map(ComputedDefinitionOutput::from)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let has_rows = materialized
|
||||
.statement_rows
|
||||
.values()
|
||||
@@ -578,6 +674,7 @@ pub fn hydrate_filing(input: HydrateFilingRequest) -> Result<HydrateFilingRespon
|
||||
surface_rows: compact_model.surface_rows,
|
||||
detail_rows: compact_model.detail_rows,
|
||||
kpi_rows: kpi_result.rows,
|
||||
computed_definitions,
|
||||
contexts: parsed_instance.contexts,
|
||||
derived_metrics: metrics::derive_metrics(&facts),
|
||||
validation_result,
|
||||
|
||||
@@ -102,6 +102,50 @@ pub struct KpiDefinition {
|
||||
pub unit: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct ComputedPackFile {
|
||||
pub version: String,
|
||||
pub pack: String,
|
||||
pub computed: Vec<ComputedDefinition>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct ComputedDefinition {
|
||||
pub key: String,
|
||||
pub label: String,
|
||||
pub category: String,
|
||||
pub order: i64,
|
||||
pub unit: String,
|
||||
pub computation: ComputationSpec,
|
||||
#[serde(default)]
|
||||
pub supported_cadences: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub requires_external_data: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ComputationSpec {
|
||||
Ratio {
|
||||
numerator: String,
|
||||
denominator: String,
|
||||
},
|
||||
YoyGrowth {
|
||||
source: String,
|
||||
},
|
||||
Cagr {
|
||||
source: String,
|
||||
years: i64,
|
||||
},
|
||||
PerShare {
|
||||
source: String,
|
||||
shares_key: String,
|
||||
},
|
||||
Simple {
|
||||
formula: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct UniversalIncomeFile {
|
||||
pub version: String,
|
||||
@@ -222,7 +266,9 @@ pub fn load_surface_pack(pack: FiscalPack) -> Result<SurfacePackFile> {
|
||||
core_file
|
||||
.surfaces
|
||||
.into_iter()
|
||||
.filter(|surface| surface.statement == "balance" || surface.statement == "cash_flow")
|
||||
.filter(|surface| {
|
||||
surface.statement == "balance" || surface.statement == "cash_flow"
|
||||
})
|
||||
.filter(|surface| {
|
||||
!pack_inherited_keys
|
||||
.contains(&(surface.statement.clone(), surface.surface_key.clone()))
|
||||
@@ -297,6 +343,28 @@ pub fn load_kpi_pack(pack: FiscalPack) -> Result<KpiPackFile> {
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
pub fn load_computed_pack(pack: FiscalPack) -> Result<ComputedPackFile> {
|
||||
let taxonomy_dir = resolve_taxonomy_dir()?;
|
||||
let path = taxonomy_dir
|
||||
.join("fiscal")
|
||||
.join("v1")
|
||||
.join(format!("{}.computed.json", pack.as_str()));
|
||||
let raw = fs::read_to_string(&path).with_context(|| {
|
||||
format!(
|
||||
"taxonomy resolution failed: unable to read {}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
let file = serde_json::from_str::<ComputedPackFile>(&raw).with_context(|| {
|
||||
format!(
|
||||
"taxonomy resolution failed: unable to parse {}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
let _ = (&file.version, &file.pack);
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
pub fn load_universal_income_definitions() -> Result<UniversalIncomeFile> {
|
||||
let taxonomy_dir = resolve_taxonomy_dir()?;
|
||||
let path = taxonomy_dir
|
||||
@@ -359,6 +427,10 @@ mod tests {
|
||||
let kpi_pack = load_kpi_pack(FiscalPack::Core).expect("core kpi pack should load");
|
||||
assert_eq!(kpi_pack.pack, "core");
|
||||
|
||||
let computed_pack =
|
||||
load_computed_pack(FiscalPack::Core).expect("core computed pack should load");
|
||||
assert_eq!(computed_pack.pack, "core");
|
||||
|
||||
let universal_income =
|
||||
load_universal_income_definitions().expect("universal income config should load");
|
||||
assert!(!universal_income.rows.is_empty());
|
||||
|
||||
389
rust/taxonomy/fiscal/v1/core.computed.json
Normal file
389
rust/taxonomy/fiscal/v1/core.computed.json
Normal file
@@ -0,0 +1,389 @@
|
||||
{
|
||||
"version": "fiscal-v1",
|
||||
"pack": "core",
|
||||
"computed": [
|
||||
{
|
||||
"key": "gross_margin",
|
||||
"label": "Gross Margin",
|
||||
"category": "margins",
|
||||
"order": 10,
|
||||
"unit": "percent",
|
||||
"computation": {
|
||||
"type": "ratio",
|
||||
"numerator": "gross_profit",
|
||||
"denominator": "revenue"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "operating_margin",
|
||||
"label": "Operating Margin",
|
||||
"category": "margins",
|
||||
"order": 20,
|
||||
"unit": "percent",
|
||||
"computation": {
|
||||
"type": "ratio",
|
||||
"numerator": "operating_income",
|
||||
"denominator": "revenue"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "ebitda_margin",
|
||||
"label": "EBITDA Margin",
|
||||
"category": "margins",
|
||||
"order": 30,
|
||||
"unit": "percent",
|
||||
"computation": {
|
||||
"type": "ratio",
|
||||
"numerator": "ebitda",
|
||||
"denominator": "revenue"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "net_margin",
|
||||
"label": "Net Margin",
|
||||
"category": "margins",
|
||||
"order": 40,
|
||||
"unit": "percent",
|
||||
"computation": {
|
||||
"type": "ratio",
|
||||
"numerator": "net_income",
|
||||
"denominator": "revenue"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "fcf_margin",
|
||||
"label": "FCF Margin",
|
||||
"category": "margins",
|
||||
"order": 50,
|
||||
"unit": "percent",
|
||||
"computation": {
|
||||
"type": "ratio",
|
||||
"numerator": "free_cash_flow",
|
||||
"denominator": "revenue"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "roa",
|
||||
"label": "ROA",
|
||||
"category": "returns",
|
||||
"order": 60,
|
||||
"unit": "percent",
|
||||
"computation": {
|
||||
"type": "simple",
|
||||
"formula": "net_income / average(total_assets)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "roe",
|
||||
"label": "ROE",
|
||||
"category": "returns",
|
||||
"order": 70,
|
||||
"unit": "percent",
|
||||
"computation": {
|
||||
"type": "simple",
|
||||
"formula": "net_income / average(total_equity)"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "roic",
|
||||
"label": "ROIC",
|
||||
"category": "returns",
|
||||
"order": 80,
|
||||
"unit": "percent",
|
||||
"computation": {
|
||||
"type": "simple",
|
||||
"formula": "nopat / average_invested_capital"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "roce",
|
||||
"label": "ROCE",
|
||||
"category": "returns",
|
||||
"order": 90,
|
||||
"unit": "percent",
|
||||
"computation": {
|
||||
"type": "simple",
|
||||
"formula": "operating_income / average_capital_employed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "debt_to_equity",
|
||||
"label": "Debt to Equity",
|
||||
"category": "financial_health",
|
||||
"order": 100,
|
||||
"unit": "ratio",
|
||||
"computation": {
|
||||
"type": "ratio",
|
||||
"numerator": "total_debt",
|
||||
"denominator": "total_equity"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "net_debt_to_ebitda",
|
||||
"label": "Net Debt to EBITDA",
|
||||
"category": "financial_health",
|
||||
"order": 110,
|
||||
"unit": "ratio",
|
||||
"computation": {
|
||||
"type": "simple",
|
||||
"formula": "net_debt / ebitda"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "cash_to_debt",
|
||||
"label": "Cash to Debt",
|
||||
"category": "financial_health",
|
||||
"order": 120,
|
||||
"unit": "ratio",
|
||||
"computation": {
|
||||
"type": "simple",
|
||||
"formula": "(cash_and_equivalents + short_term_investments) / total_debt"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "current_ratio",
|
||||
"label": "Current Ratio",
|
||||
"category": "financial_health",
|
||||
"order": 130,
|
||||
"unit": "ratio",
|
||||
"computation": {
|
||||
"type": "ratio",
|
||||
"numerator": "current_assets",
|
||||
"denominator": "current_liabilities"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "revenue_per_share",
|
||||
"label": "Revenue per Share",
|
||||
"category": "per_share",
|
||||
"order": 140,
|
||||
"unit": "currency",
|
||||
"computation": {
|
||||
"type": "per_share",
|
||||
"source": "revenue",
|
||||
"shares_key": "diluted_shares"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "fcf_per_share",
|
||||
"label": "FCF per Share",
|
||||
"category": "per_share",
|
||||
"order": 150,
|
||||
"unit": "currency",
|
||||
"computation": {
|
||||
"type": "per_share",
|
||||
"source": "free_cash_flow",
|
||||
"shares_key": "diluted_shares"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "book_value_per_share",
|
||||
"label": "Book Value per Share",
|
||||
"category": "per_share",
|
||||
"order": 160,
|
||||
"unit": "currency",
|
||||
"computation": {
|
||||
"type": "per_share",
|
||||
"source": "total_equity",
|
||||
"shares_key": "diluted_shares"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "revenue_yoy",
|
||||
"label": "Revenue YoY",
|
||||
"category": "growth",
|
||||
"order": 170,
|
||||
"unit": "percent",
|
||||
"computation": {
|
||||
"type": "yoy_growth",
|
||||
"source": "revenue"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "net_income_yoy",
|
||||
"label": "Net Income YoY",
|
||||
"category": "growth",
|
||||
"order": 180,
|
||||
"unit": "percent",
|
||||
"computation": {
|
||||
"type": "yoy_growth",
|
||||
"source": "net_income"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "eps_yoy",
|
||||
"label": "EPS YoY",
|
||||
"category": "growth",
|
||||
"order": 190,
|
||||
"unit": "percent",
|
||||
"computation": {
|
||||
"type": "yoy_growth",
|
||||
"source": "diluted_eps"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "fcf_yoy",
|
||||
"label": "FCF YoY",
|
||||
"category": "growth",
|
||||
"order": 200,
|
||||
"unit": "percent",
|
||||
"computation": {
|
||||
"type": "yoy_growth",
|
||||
"source": "free_cash_flow"
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "3y_revenue_cagr",
|
||||
"label": "3Y Revenue CAGR",
|
||||
"category": "growth",
|
||||
"order": 210,
|
||||
"unit": "percent",
|
||||
"computation": {
|
||||
"type": "cagr",
|
||||
"source": "revenue",
|
||||
"years": 3
|
||||
},
|
||||
"supported_cadences": ["annual"]
|
||||
},
|
||||
{
|
||||
"key": "5y_revenue_cagr",
|
||||
"label": "5Y Revenue CAGR",
|
||||
"category": "growth",
|
||||
"order": 220,
|
||||
"unit": "percent",
|
||||
"computation": {
|
||||
"type": "cagr",
|
||||
"source": "revenue",
|
||||
"years": 5
|
||||
},
|
||||
"supported_cadences": ["annual"]
|
||||
},
|
||||
{
|
||||
"key": "3y_eps_cagr",
|
||||
"label": "3Y EPS CAGR",
|
||||
"category": "growth",
|
||||
"order": 230,
|
||||
"unit": "percent",
|
||||
"computation": {
|
||||
"type": "cagr",
|
||||
"source": "diluted_eps",
|
||||
"years": 3
|
||||
},
|
||||
"supported_cadences": ["annual"]
|
||||
},
|
||||
{
|
||||
"key": "5y_eps_cagr",
|
||||
"label": "5Y EPS CAGR",
|
||||
"category": "growth",
|
||||
"order": 240,
|
||||
"unit": "percent",
|
||||
"computation": {
|
||||
"type": "cagr",
|
||||
"source": "diluted_eps",
|
||||
"years": 5
|
||||
},
|
||||
"supported_cadences": ["annual"]
|
||||
},
|
||||
{
|
||||
"key": "market_cap",
|
||||
"label": "Market Cap",
|
||||
"category": "valuation",
|
||||
"order": 250,
|
||||
"unit": "currency",
|
||||
"computation": {
|
||||
"type": "simple",
|
||||
"formula": "price * diluted_shares"
|
||||
},
|
||||
"requires_external_data": ["price"]
|
||||
},
|
||||
{
|
||||
"key": "enterprise_value",
|
||||
"label": "Enterprise Value",
|
||||
"category": "valuation",
|
||||
"order": 260,
|
||||
"unit": "currency",
|
||||
"computation": {
|
||||
"type": "simple",
|
||||
"formula": "market_cap + total_debt - cash_and_equivalents - short_term_investments"
|
||||
},
|
||||
"requires_external_data": ["market_cap"]
|
||||
},
|
||||
{
|
||||
"key": "price_to_earnings",
|
||||
"label": "Price to Earnings",
|
||||
"category": "valuation",
|
||||
"order": 270,
|
||||
"unit": "ratio",
|
||||
"computation": {
|
||||
"type": "simple",
|
||||
"formula": "price / diluted_eps"
|
||||
},
|
||||
"requires_external_data": ["price"]
|
||||
},
|
||||
{
|
||||
"key": "price_to_fcf",
|
||||
"label": "Price to FCF",
|
||||
"category": "valuation",
|
||||
"order": 280,
|
||||
"unit": "ratio",
|
||||
"computation": {
|
||||
"type": "ratio",
|
||||
"numerator": "market_cap",
|
||||
"denominator": "free_cash_flow"
|
||||
},
|
||||
"requires_external_data": ["market_cap"]
|
||||
},
|
||||
{
|
||||
"key": "price_to_book",
|
||||
"label": "Price to Book",
|
||||
"category": "valuation",
|
||||
"order": 290,
|
||||
"unit": "ratio",
|
||||
"computation": {
|
||||
"type": "ratio",
|
||||
"numerator": "market_cap",
|
||||
"denominator": "total_equity"
|
||||
},
|
||||
"requires_external_data": ["market_cap"]
|
||||
},
|
||||
{
|
||||
"key": "ev_to_sales",
|
||||
"label": "EV to Sales",
|
||||
"category": "valuation",
|
||||
"order": 300,
|
||||
"unit": "ratio",
|
||||
"computation": {
|
||||
"type": "ratio",
|
||||
"numerator": "enterprise_value",
|
||||
"denominator": "revenue"
|
||||
},
|
||||
"requires_external_data": ["enterprise_value"]
|
||||
},
|
||||
{
|
||||
"key": "ev_to_ebitda",
|
||||
"label": "EV to EBITDA",
|
||||
"category": "valuation",
|
||||
"order": 310,
|
||||
"unit": "ratio",
|
||||
"computation": {
|
||||
"type": "ratio",
|
||||
"numerator": "enterprise_value",
|
||||
"denominator": "ebitda"
|
||||
},
|
||||
"requires_external_data": ["enterprise_value"]
|
||||
},
|
||||
{
|
||||
"key": "ev_to_fcf",
|
||||
"label": "EV to FCF",
|
||||
"category": "valuation",
|
||||
"order": 320,
|
||||
"unit": "ratio",
|
||||
"computation": {
|
||||
"type": "ratio",
|
||||
"numerator": "enterprise_value",
|
||||
"denominator": "free_cash_flow"
|
||||
},
|
||||
"requires_external_data": ["enterprise_value"]
|
||||
}
|
||||
]
|
||||
}
|
||||
529
scripts/generate-taxonomy.ts
Normal file
529
scripts/generate-taxonomy.ts
Normal file
@@ -0,0 +1,529 @@
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
type FinancialUnit = 'currency' | 'percent' | 'ratio' | 'shares' | 'count';
|
||||
type FinancialCadence = 'annual' | 'quarterly' | 'ltm';
|
||||
type FinancialStatementKind = 'income' | 'balance' | 'cash_flow' | 'equity' | 'comprehensive_income';
|
||||
type SignTransform = 'invert' | 'absolute';
|
||||
|
||||
type SurfaceDefinition = {
|
||||
surface_key: string;
|
||||
statement: FinancialStatementKind;
|
||||
label: string;
|
||||
category: string;
|
||||
order: number;
|
||||
unit: FinancialUnit;
|
||||
rollup_policy?: string;
|
||||
allowed_source_concepts: string[];
|
||||
allowed_authoritative_concepts?: string[];
|
||||
formula_fallback?: {
|
||||
op: 'sum' | 'subtract' | 'divide';
|
||||
sources: string[];
|
||||
treat_null_as_zero?: boolean;
|
||||
} | string | null;
|
||||
detail_grouping_policy?: string;
|
||||
materiality_policy?: string;
|
||||
include_in_output?: boolean;
|
||||
sign_transform?: 'invert';
|
||||
};
|
||||
|
||||
type SurfacePackFile = {
|
||||
version: string;
|
||||
pack: string;
|
||||
surfaces: SurfaceDefinition[];
|
||||
};
|
||||
|
||||
type ComputationSpec =
|
||||
| { type: 'ratio'; numerator: string; denominator: string }
|
||||
| { type: 'yoy_growth'; source: string }
|
||||
| { type: 'cagr'; source: string; years: number }
|
||||
| { type: 'per_share'; source: string; shares_key: string }
|
||||
| { type: 'simple'; formula: string };
|
||||
|
||||
type ComputedDefinition = {
|
||||
key: string;
|
||||
label: string;
|
||||
category: string;
|
||||
order: number;
|
||||
unit: FinancialUnit;
|
||||
computation: ComputationSpec;
|
||||
supported_cadences?: FinancialCadence[];
|
||||
requires_external_data?: string[];
|
||||
};
|
||||
|
||||
type ComputedPackFile = {
|
||||
version: string;
|
||||
pack: string;
|
||||
computed: ComputedDefinition[];
|
||||
};
|
||||
|
||||
type KpiDefinition = {
|
||||
key: string;
|
||||
label: string;
|
||||
unit: string;
|
||||
};
|
||||
|
||||
type KpiPackFile = {
|
||||
version: string;
|
||||
pack: string;
|
||||
kpis: KpiDefinition[];
|
||||
};
|
||||
|
||||
const TAXONOMY_DIR = join(process.cwd(), 'rust', 'taxonomy', 'fiscal', 'v1');
|
||||
const OUTPUT_DIR = join(process.cwd(), 'lib', 'generated');
|
||||
|
||||
const PACK_ORDER = ['core', 'bank_lender', 'insurance', 'reit_real_estate', 'broker_asset_manager'] as const;
|
||||
type PackName = (typeof PACK_ORDER)[number];
|
||||
|
||||
function log(message: string) {
|
||||
console.log(`[generate-taxonomy] ${message}`);
|
||||
}
|
||||
|
||||
function loadSurfacePacks(): Map<PackName, SurfacePackFile> {
|
||||
const packs = new Map<PackName, SurfacePackFile>();
|
||||
|
||||
for (const pack of PACK_ORDER) {
|
||||
const path = join(TAXONOMY_DIR, `${pack}.surface.json`);
|
||||
if (!existsSync(path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const raw = readFileSync(path, 'utf8');
|
||||
const file = JSON.parse(raw) as SurfacePackFile;
|
||||
packs.set(pack, file);
|
||||
}
|
||||
|
||||
return packs;
|
||||
}
|
||||
|
||||
function loadComputedPacks(): Map<PackName, ComputedPackFile> {
|
||||
const packs = new Map<PackName, ComputedPackFile>();
|
||||
|
||||
for (const pack of PACK_ORDER) {
|
||||
const path = join(TAXONOMY_DIR, `${pack}.computed.json`);
|
||||
if (!existsSync(path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const raw = readFileSync(path, 'utf8');
|
||||
const file = JSON.parse(raw) as ComputedPackFile;
|
||||
packs.set(pack, file);
|
||||
}
|
||||
|
||||
return packs;
|
||||
}
|
||||
|
||||
function loadKpiPacks(): Map<PackName, KpiPackFile> {
|
||||
const packs = new Map<PackName, KpiPackFile>();
|
||||
|
||||
for (const pack of PACK_ORDER) {
|
||||
const path = join(TAXONOMY_DIR, 'kpis', `${pack}.kpis.json`);
|
||||
if (!existsSync(path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const raw = readFileSync(path, 'utf8');
|
||||
const file = JSON.parse(raw) as KpiPackFile;
|
||||
packs.set(pack, file);
|
||||
}
|
||||
|
||||
return packs;
|
||||
}
|
||||
|
||||
function validateSurfacePack(pack: SurfacePackFile, errors: string[]) {
|
||||
const keysByStatement = new Map<string, Set<string>>();
|
||||
|
||||
for (const surface of pack.surfaces) {
|
||||
const keySet = keysByStatement.get(surface.statement) || new Set<string>();
|
||||
if (keySet.has(surface.surface_key)) {
|
||||
errors.push(`${pack.pack}: duplicate surface_key "${surface.surface_key}" in statement "${surface.statement}"`);
|
||||
}
|
||||
keySet.add(surface.surface_key);
|
||||
keysByStatement.set(surface.statement, keySet);
|
||||
|
||||
if (!surface.label) {
|
||||
errors.push(`${pack.pack}: surface "${surface.surface_key}" missing label`);
|
||||
}
|
||||
|
||||
const validStatements: FinancialStatementKind[] = ['income', 'balance', 'cash_flow', 'equity', 'comprehensive_income'];
|
||||
if (!validStatements.includes(surface.statement)) {
|
||||
errors.push(`${pack.pack}: surface "${surface.surface_key}" has invalid statement "${surface.statement}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateComputedPack(pack: ComputedPackFile, surfaceKeys: Set<string>, errors: string[]) {
|
||||
const keys = new Set<string>();
|
||||
|
||||
for (const computed of pack.computed) {
|
||||
if (keys.has(computed.key)) {
|
||||
errors.push(`${pack.pack}: duplicate computed key "${computed.key}"`);
|
||||
}
|
||||
keys.add(computed.key);
|
||||
|
||||
if (!computed.label) {
|
||||
errors.push(`${pack.pack}: computed "${computed.key}" missing label`);
|
||||
}
|
||||
|
||||
const spec = computed.computation;
|
||||
switch (spec.type) {
|
||||
case 'ratio':
|
||||
if (!surfaceKeys.has(spec.numerator) && !spec.numerator.includes('_')) {
|
||||
errors.push(`${pack.pack}: computed "${computed.key}" references unknown numerator "${spec.numerator}"`);
|
||||
}
|
||||
if (!surfaceKeys.has(spec.denominator) && !spec.denominator.includes('_')) {
|
||||
errors.push(`${pack.pack}: computed "${computed.key}" references unknown denominator "${spec.denominator}"`);
|
||||
}
|
||||
break;
|
||||
case 'yoy_growth':
|
||||
case 'cagr':
|
||||
if (!surfaceKeys.has(spec.source)) {
|
||||
errors.push(`${pack.pack}: computed "${computed.key}" references unknown source "${spec.source}"`);
|
||||
}
|
||||
break;
|
||||
case 'per_share':
|
||||
if (!surfaceKeys.has(spec.source)) {
|
||||
errors.push(`${pack.pack}: computed "${computed.key}" references unknown source "${spec.source}"`);
|
||||
}
|
||||
if (!surfaceKeys.has(spec.shares_key)) {
|
||||
errors.push(`${pack.pack}: computed "${computed.key}" references unknown shares_key "${spec.shares_key}"`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function generateTypesFile(): string {
|
||||
return `// Auto-generated by scripts/generate-taxonomy.ts
|
||||
// DO NOT EDIT MANUALLY - changes will be overwritten
|
||||
|
||||
export type FinancialUnit = 'currency' | 'percent' | 'ratio' | 'shares' | 'count';
|
||||
|
||||
export type FinancialCadence = 'annual' | 'quarterly' | 'ltm';
|
||||
|
||||
export type FinancialStatementKind = 'income' | 'balance' | 'cash_flow' | 'equity' | 'comprehensive_income';
|
||||
|
||||
export type SignTransform = 'invert' | 'absolute';
|
||||
|
||||
export type ComputationSpec =
|
||||
| { type: 'ratio'; numerator: string; denominator: string }
|
||||
| { type: 'yoy_growth'; source: string }
|
||||
| { type: 'cagr'; source: string; years: number }
|
||||
| { type: 'per_share'; source: string; shares_key: string }
|
||||
| { type: 'simple'; formula: string };
|
||||
|
||||
export type SurfaceDefinition = {
|
||||
surface_key: string;
|
||||
statement: FinancialStatementKind;
|
||||
label: string;
|
||||
category: string;
|
||||
order: number;
|
||||
unit: FinancialUnit;
|
||||
rollup_policy?: string;
|
||||
allowed_source_concepts: string[];
|
||||
allowed_authoritative_concepts?: string[];
|
||||
formula_fallback?: {
|
||||
op: 'sum' | 'subtract' | 'divide';
|
||||
sources: string[];
|
||||
treat_null_as_zero?: boolean;
|
||||
} | string | null;
|
||||
detail_grouping_policy?: string;
|
||||
materiality_policy?: string;
|
||||
include_in_output?: boolean;
|
||||
sign_transform?: SignTransform;
|
||||
};
|
||||
|
||||
export type ComputedDefinition = {
|
||||
key: string;
|
||||
label: string;
|
||||
category: string;
|
||||
order: number;
|
||||
unit: FinancialUnit;
|
||||
computation: ComputationSpec;
|
||||
supported_cadences?: FinancialCadence[];
|
||||
requires_external_data?: string[];
|
||||
};
|
||||
|
||||
export type KpiDefinition = {
|
||||
key: string;
|
||||
label: string;
|
||||
unit: string;
|
||||
};
|
||||
|
||||
export const RATIO_CATEGORIES = ['margins', 'returns', 'financial_health', 'per_share', 'growth', 'valuation'] as const;
|
||||
export type RatioCategory = (typeof RATIO_CATEGORIES)[number];
|
||||
`;
|
||||
}
|
||||
|
||||
function generateSurfaceFile(statement: string, surfaces: SurfaceDefinition[]): string {
|
||||
const sorted = [...surfaces].sort((a, b) => a.order - b.order);
|
||||
const constName = `${statement.toUpperCase()}_SURFACES`;
|
||||
|
||||
return `// Auto-generated by scripts/generate-taxonomy.ts
|
||||
// DO NOT EDIT MANUALLY - changes will be overwritten
|
||||
|
||||
import type { SurfaceDefinition } from '../types';
|
||||
|
||||
export const ${constName}: SurfaceDefinition[] = ${JSON.stringify(sorted, null, 2)};
|
||||
`;
|
||||
}
|
||||
|
||||
function generateSurfacesIndex(surfacesByStatement: Map<string, SurfaceDefinition[]>): string {
|
||||
const statements = [...surfacesByStatement.keys()].sort();
|
||||
|
||||
const imports = statements
|
||||
.map((s) => `import { ${s.toUpperCase()}_SURFACES } from './${s}';`)
|
||||
.join('\n');
|
||||
|
||||
const exports = statements.map((s) => ` ${s}: ${s.toUpperCase()}_SURFACES,`).join('\n');
|
||||
|
||||
return `// Auto-generated by scripts/generate-taxonomy.ts
|
||||
// DO NOT EDIT MANUALLY - changes will be overwritten
|
||||
|
||||
${imports}
|
||||
|
||||
export const ALL_SURFACES_BY_STATEMENT = {
|
||||
${exports}
|
||||
} as const;
|
||||
|
||||
export { ${statements.map((s) => `${s.toUpperCase()}_SURFACES`).join(', ')} };
|
||||
`;
|
||||
}
|
||||
|
||||
function generateComputedFile(
|
||||
name: string,
|
||||
definitions: ComputedDefinition[]
|
||||
): string {
|
||||
const sorted = [...definitions].sort((a, b) => a.order - b.order);
|
||||
const constName = name.toUpperCase().replace(/-/g, '_');
|
||||
|
||||
return `// Auto-generated by scripts/generate-taxonomy.ts
|
||||
// DO NOT EDIT MANUALLY - changes will be overwritten
|
||||
|
||||
import type { ComputedDefinition } from '../types';
|
||||
|
||||
export const ${constName}: ComputedDefinition[] = ${JSON.stringify(sorted, null, 2)};
|
||||
`;
|
||||
}
|
||||
|
||||
function generateComputedIndex(files: { name: string; definitions: ComputedDefinition[] }[]): string {
|
||||
const imports = files
|
||||
.map((f) => {
|
||||
const constName = f.name.toUpperCase().replace(/-/g, '_');
|
||||
return `import { ${constName} } from './${f.name}';`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
const allExports = files
|
||||
.map((f) => ` ...${f.name.toUpperCase().replace(/-/g, '_')},`)
|
||||
.join('\n');
|
||||
|
||||
const filingDerived = files
|
||||
.flatMap((f) => f.definitions)
|
||||
.filter((d) => !d.requires_external_data || d.requires_external_data.length === 0)
|
||||
.sort((a, b) => a.order - b.order);
|
||||
|
||||
const marketDerived = files
|
||||
.flatMap((f) => f.definitions)
|
||||
.filter((d) => d.requires_external_data && d.requires_external_data.length > 0)
|
||||
.sort((a, b) => a.order - b.order);
|
||||
|
||||
return `// Auto-generated by scripts/generate-taxonomy.ts
|
||||
// DO NOT EDIT MANUALLY - changes will be overwritten
|
||||
|
||||
import type { ComputedDefinition } from '../types';
|
||||
|
||||
${imports}
|
||||
|
||||
export const ALL_COMPUTED: ComputedDefinition[] = [
|
||||
${allExports}
|
||||
];
|
||||
|
||||
export const FILING_DERIVED_COMPUTED: ComputedDefinition[] = ${JSON.stringify(filingDerived, null, 2)};
|
||||
|
||||
export const MARKET_DERIVED_COMPUTED: ComputedDefinition[] = ${JSON.stringify(marketDerived, null, 2)};
|
||||
|
||||
export { ${files.map((f) => f.name.toUpperCase().replace(/-/g, '_')).join(', ')} };
|
||||
`;
|
||||
}
|
||||
|
||||
function generateKpiFile(pack: string, kpis: KpiDefinition[]): string {
|
||||
const constName = `${pack.toUpperCase().replace(/-/g, '_')}_KPIS`;
|
||||
|
||||
return `// Auto-generated by scripts/generate-taxonomy.ts
|
||||
// DO NOT EDIT MANUALLY - changes will be overwritten
|
||||
|
||||
import type { KpiDefinition } from '../types';
|
||||
|
||||
export const ${constName}: KpiDefinition[] = ${JSON.stringify(kpis, null, 2)};
|
||||
`;
|
||||
}
|
||||
|
||||
function generateKpiIndex(packs: { pack: string; kpis: KpiDefinition[] }[]): string {
|
||||
const imports = packs
|
||||
.map((p) => {
|
||||
const constName = p.pack.toUpperCase().replace(/-/g, '_');
|
||||
return `import { ${constName}_KPIS } from './${p.pack}';`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
const exports = packs.map((p) => ` ...${p.pack.toUpperCase().replace(/-/g, '_')}_KPIS,`).join('\n');
|
||||
|
||||
return `// Auto-generated by scripts/generate-taxonomy.ts
|
||||
// DO NOT EDIT MANUALLY - changes will be overwritten
|
||||
|
||||
import type { KpiDefinition } from '../types';
|
||||
|
||||
${imports}
|
||||
|
||||
export const ALL_KPIS: KpiDefinition[] = [
|
||||
${exports}
|
||||
];
|
||||
|
||||
export { ${packs.map((p) => `${p.pack.toUpperCase().replace(/-/g, '_')}_KPIS`).join(', ')} };
|
||||
`;
|
||||
}
|
||||
|
||||
function generateMainIndex(): string {
|
||||
return `// Auto-generated by scripts/generate-taxonomy.ts
|
||||
// DO NOT EDIT MANUALLY - changes will be overwritten
|
||||
|
||||
export type {
|
||||
FinancialUnit,
|
||||
FinancialCadence,
|
||||
FinancialStatementKind,
|
||||
ComputationSpec,
|
||||
SurfaceDefinition,
|
||||
ComputedDefinition,
|
||||
KpiDefinition,
|
||||
} from './types';
|
||||
|
||||
export { RATIO_CATEGORIES, type RatioCategory } from './types';
|
||||
|
||||
export {
|
||||
INCOME_SURFACES,
|
||||
BALANCE_SURFACES,
|
||||
CASH_FLOW_SURFACES,
|
||||
ALL_SURFACES_BY_STATEMENT,
|
||||
} from './surfaces';
|
||||
|
||||
export {
|
||||
ALL_COMPUTED,
|
||||
FILING_DERIVED_COMPUTED,
|
||||
MARKET_DERIVED_COMPUTED,
|
||||
CORE,
|
||||
} from './computed';
|
||||
|
||||
export { ALL_KPIS, CORE_KPIS } from './kpis';
|
||||
`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
log('Loading taxonomy files...');
|
||||
|
||||
const surfacePacks = loadSurfacePacks();
|
||||
const computedPacks = loadComputedPacks();
|
||||
const kpiPacks = loadKpiPacks();
|
||||
|
||||
log(`Loaded ${surfacePacks.size} surface packs, ${computedPacks.size} computed packs, ${kpiPacks.size} KPI packs`);
|
||||
|
||||
const errors: string[] = [];
|
||||
|
||||
log('Validating taxonomy files...');
|
||||
|
||||
for (const [, pack] of surfacePacks) {
|
||||
validateSurfacePack(pack, errors);
|
||||
}
|
||||
|
||||
const allSurfaceKeys = new Set<string>();
|
||||
for (const [, pack] of surfacePacks) {
|
||||
for (const surface of pack.surfaces) {
|
||||
allSurfaceKeys.add(surface.surface_key);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [, pack] of computedPacks) {
|
||||
validateComputedPack(pack, allSurfaceKeys, errors);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error('Validation errors:');
|
||||
for (const error of errors) {
|
||||
console.error(` - ${error}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
log('Creating output directories...');
|
||||
mkdirSync(join(OUTPUT_DIR, 'surfaces'), { recursive: true });
|
||||
mkdirSync(join(OUTPUT_DIR, 'computed'), { recursive: true });
|
||||
mkdirSync(join(OUTPUT_DIR, 'kpis'), { recursive: true });
|
||||
|
||||
log('Generating types...');
|
||||
writeFileSync(join(OUTPUT_DIR, 'types.ts'), generateTypesFile());
|
||||
|
||||
log('Generating surfaces...');
|
||||
const coreSurfaces = surfacePacks.get('core');
|
||||
if (coreSurfaces) {
|
||||
const surfacesByStatement = new Map<string, SurfaceDefinition[]>();
|
||||
|
||||
for (const surface of coreSurfaces.surfaces) {
|
||||
const existing = surfacesByStatement.get(surface.statement) || [];
|
||||
existing.push(surface);
|
||||
surfacesByStatement.set(surface.statement, existing);
|
||||
}
|
||||
|
||||
for (const [statement, surfaces] of surfacesByStatement) {
|
||||
writeFileSync(
|
||||
join(OUTPUT_DIR, 'surfaces', `${statement}.ts`),
|
||||
generateSurfaceFile(statement, surfaces)
|
||||
);
|
||||
}
|
||||
|
||||
writeFileSync(
|
||||
join(OUTPUT_DIR, 'surfaces', 'index.ts'),
|
||||
generateSurfacesIndex(surfacesByStatement)
|
||||
);
|
||||
}
|
||||
|
||||
log('Generating computed definitions...');
|
||||
const computedFiles: { name: string; definitions: ComputedDefinition[] }[] = [];
|
||||
|
||||
for (const [pack, file] of computedPacks) {
|
||||
computedFiles.push({ name: pack, definitions: file.computed });
|
||||
writeFileSync(
|
||||
join(OUTPUT_DIR, 'computed', `${pack}.ts`),
|
||||
generateComputedFile(pack, file.computed)
|
||||
);
|
||||
}
|
||||
|
||||
writeFileSync(join(OUTPUT_DIR, 'computed', 'index.ts'), generateComputedIndex(computedFiles));
|
||||
|
||||
log('Generating KPI definitions...');
|
||||
const kpiFiles: { pack: string; kpis: KpiDefinition[] }[] = [];
|
||||
|
||||
for (const [pack, file] of kpiPacks) {
|
||||
kpiFiles.push({ pack, kpis: file.kpis });
|
||||
writeFileSync(
|
||||
join(OUTPUT_DIR, 'kpis', `${pack}.ts`),
|
||||
generateKpiFile(pack, file.kpis)
|
||||
);
|
||||
}
|
||||
|
||||
writeFileSync(join(OUTPUT_DIR, 'kpis', 'index.ts'), generateKpiIndex(kpiFiles));
|
||||
|
||||
log('Generating main index...');
|
||||
writeFileSync(join(OUTPUT_DIR, 'index.ts'), generateMainIndex());
|
||||
|
||||
const surfaceCount = coreSurfaces?.surfaces.length || 0;
|
||||
const computedCount = computedFiles.reduce((sum, f) => sum + f.definitions.length, 0);
|
||||
const kpiCount = kpiFiles.reduce((sum, f) => sum + f.kpis.length, 0);
|
||||
|
||||
log(`Generated ${surfaceCount} surfaces, ${computedCount} computed definitions, ${kpiCount} KPIs`);
|
||||
log(`Output written to ${OUTPUT_DIR}`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Generation failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user