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:
2026-03-15 15:22:51 -04:00
parent ed4420b8db
commit 24aa8e33d4
11 changed files with 1453 additions and 123 deletions

View File

@@ -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,

View File

@@ -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());