diff --git a/.gitignore b/.gitignore index 27877ef..5241b53 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/docs/architecture/taxonomy.md b/docs/architecture/taxonomy.md new file mode 100644 index 0000000..c5a90d8 --- /dev/null +++ b/docs/architecture/taxonomy.md @@ -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/.surface.json` +2. Create `rust/taxonomy/fiscal/v1/.income-bridge.json` +3. Create `rust/taxonomy/fiscal/v1/.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 diff --git a/lib/financial-metrics.ts b/lib/financial-metrics.ts index 26c4ad7..8986840 100644 --- a/lib/financial-metrics.ts +++ b/lib/financial-metrics.ts @@ -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'; diff --git a/lib/server/financials/canonical-definitions.ts b/lib/server/financials/canonical-definitions.ts index 87338d1..6601cd8 100644 --- a/lib/server/financials/canonical-definitions.ts +++ b/lib/server/financials/canonical-definitions.ts @@ -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, 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) }; diff --git a/lib/server/financials/ratios.ts b/lib/server/financials/ratios.ts index 9877422..3fa52f9 100644 --- a/lib/server/financials/ratios.ts +++ b/lib/server/financials/ratios.ts @@ -5,7 +5,7 @@ import type { StandardizedFinancialRow } from '@/lib/types'; import { - RATIO_CATEGORY_ORDER, + RATIO_CATEGORIES, RATIO_DEFINITIONS } from '@/lib/financial-metrics'; diff --git a/lib/server/financials/trend-series.ts b/lib/server/financials/trend-series.ts index d3f3e4b..a67390e 100644 --- a/lib/server/financials/trend-series.ts +++ b/lib/server/financials/trend-series.ts @@ -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()]; diff --git a/package.json b/package.json index 4b4b224..c36efe5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/rust/fiscal-xbrl-core/src/lib.rs b/rust/fiscal-xbrl-core/src/lib.rs index 6d78e6d..7219ac3 100644 --- a/rust/fiscal-xbrl-core/src/lib.rs +++ b/rust/fiscal-xbrl-core/src/lib.rs @@ -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, + pub computed_definitions: Vec, pub contexts: Vec, 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, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub requires_external_data: Vec, +} + +#[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 Result = 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, +} + +#[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, + #[serde(default)] + pub requires_external_data: Vec, +} + +#[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 { 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 { Ok(file) } +pub fn load_computed_pack(pack: FiscalPack) -> Result { + 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::(&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 { 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()); diff --git a/rust/taxonomy/fiscal/v1/core.computed.json b/rust/taxonomy/fiscal/v1/core.computed.json new file mode 100644 index 0000000..99802e7 --- /dev/null +++ b/rust/taxonomy/fiscal/v1/core.computed.json @@ -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"] + } + ] +} diff --git a/scripts/generate-taxonomy.ts b/scripts/generate-taxonomy.ts new file mode 100644 index 0000000..176fbe9 --- /dev/null +++ b/scripts/generate-taxonomy.ts @@ -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 { + const packs = new Map(); + + 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 { + const packs = new Map(); + + 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 { + const packs = new Map(); + + 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>(); + + for (const surface of pack.surfaces) { + const keySet = keysByStatement.get(surface.statement) || new Set(); + 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, errors: string[]) { + const keys = new Set(); + + 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 { + 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(); + 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(); + + 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); +});