use datasynth_core::models::JournalEntry;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RatioAnalysisResult {
pub entity_code: String,
pub period: String,
pub ratios: FinancialRatios,
pub reasonableness_checks: Vec<RatioCheck>,
pub passes: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FinancialRatios {
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "rust_decimal::serde::str_option"
)]
pub current_ratio: Option<Decimal>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "rust_decimal::serde::str_option"
)]
pub quick_ratio: Option<Decimal>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "rust_decimal::serde::str_option"
)]
pub dso: Option<Decimal>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "rust_decimal::serde::str_option"
)]
pub dpo: Option<Decimal>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "rust_decimal::serde::str_option"
)]
pub inventory_turnover: Option<Decimal>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "rust_decimal::serde::str_option"
)]
pub gross_margin: Option<Decimal>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "rust_decimal::serde::str_option"
)]
pub operating_margin: Option<Decimal>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "rust_decimal::serde::str_option"
)]
pub net_margin: Option<Decimal>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "rust_decimal::serde::str_option"
)]
pub roa: Option<Decimal>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "rust_decimal::serde::str_option"
)]
pub roe: Option<Decimal>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "rust_decimal::serde::str_option"
)]
pub debt_to_equity: Option<Decimal>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "rust_decimal::serde::str_option"
)]
pub debt_to_assets: Option<Decimal>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RatioCheck {
pub ratio_name: String,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "rust_decimal::serde::str_option"
)]
pub value: Option<Decimal>,
#[serde(with = "rust_decimal::serde::str")]
pub industry_min: Decimal,
#[serde(with = "rust_decimal::serde::str")]
pub industry_max: Decimal,
pub is_reasonable: bool,
}
#[derive(Debug, Default)]
struct GlTotals {
assets: Decimal,
current_assets: Decimal,
ar: Decimal,
inventory: Decimal,
liabilities: Decimal,
ap: Decimal,
equity: Decimal,
revenue: Decimal,
cogs: Decimal,
opex: Decimal,
tax_expense: Decimal,
}
fn account_prefix(account: &str) -> Option<u32> {
let digits: String = account.chars().filter(|c| c.is_ascii_digit()).collect();
if digits.len() >= 2 {
digits[..2].parse().ok()
} else if digits.len() == 1 {
digits[..1].parse().ok()
} else {
None
}
}
fn build_totals(entries: &[JournalEntry], entity_code: &str) -> GlTotals {
let mut t = GlTotals::default();
for entry in entries {
if entry.header.company_code != entity_code {
continue;
}
for line in &entry.lines {
let account = &line.gl_account;
let Some(prefix2) = account_prefix(account) else {
continue;
};
let prefix1 = prefix2 / 10; let net = line.debit_amount - line.credit_amount;
match prefix1 {
1 => {
t.assets += net;
if (10..=13).contains(&prefix2) {
t.current_assets += net;
if prefix2 == 11 {
t.ar += net;
} else if prefix2 == 12 {
t.inventory += net;
}
}
}
2 => {
t.liabilities += -net;
if prefix2 == 21 || prefix2 == 20 {
t.ap += -net;
}
}
3 => {
t.equity += -net;
}
4 => {
t.revenue += -net;
}
5 => {
t.cogs += net;
}
6..=7 => {
t.opex += net;
}
8 => {
t.tax_expense += net;
}
_ => {}
}
}
}
t
}
pub fn compute_ratios(entries: &[JournalEntry], entity_code: &str) -> FinancialRatios {
let t = build_totals(entries, entity_code);
let d365 = Decimal::from(365u32);
let current_ratio = if t.liabilities > Decimal::ZERO && t.current_assets > Decimal::ZERO {
Some(t.current_assets / t.liabilities)
} else {
None
};
let current_assets_ex_inv = t.current_assets - t.inventory;
let quick_ratio = if t.liabilities > Decimal::ZERO && t.current_assets > Decimal::ZERO {
Some(current_assets_ex_inv / t.liabilities)
} else {
None
};
let dso = if t.revenue > Decimal::ZERO && t.ar >= Decimal::ZERO {
Some(t.ar / t.revenue * d365)
} else {
None
};
let dpo = if t.cogs > Decimal::ZERO && t.ap >= Decimal::ZERO {
Some(t.ap / t.cogs * d365)
} else {
None
};
let inventory_turnover = if t.inventory > Decimal::ZERO {
Some(t.cogs / t.inventory)
} else {
None
};
let gross_profit = t.revenue - t.cogs;
let gross_margin = if t.revenue > Decimal::ZERO {
Some(gross_profit / t.revenue)
} else {
None
};
let operating_income = t.revenue - t.cogs - t.opex;
let operating_margin = if t.revenue > Decimal::ZERO {
Some(operating_income / t.revenue)
} else {
None
};
let net_income = operating_income - t.tax_expense;
let net_margin = if t.revenue > Decimal::ZERO {
Some(net_income / t.revenue)
} else {
None
};
let roa = if t.assets > Decimal::ZERO {
Some(net_income / t.assets)
} else {
None
};
let roe = if t.equity > Decimal::ZERO {
Some(net_income / t.equity)
} else {
None
};
let debt_to_equity = if t.equity > Decimal::ZERO {
Some(t.liabilities / t.equity)
} else {
None
};
let debt_to_assets = if t.assets > Decimal::ZERO {
Some(t.liabilities / t.assets)
} else {
None
};
FinancialRatios {
current_ratio,
quick_ratio,
dso,
dpo,
inventory_turnover,
gross_margin,
operating_margin,
net_margin,
roa,
roe,
debt_to_equity,
debt_to_assets,
}
}
struct IndustryBounds {
current_ratio: (Decimal, Decimal),
quick_ratio: (Decimal, Decimal),
dso: (Decimal, Decimal),
dpo: (Decimal, Decimal),
inventory_turnover: (Decimal, Decimal),
gross_margin: (Decimal, Decimal),
operating_margin: (Decimal, Decimal),
net_margin: (Decimal, Decimal),
roa: (Decimal, Decimal),
roe: (Decimal, Decimal),
debt_to_equity: (Decimal, Decimal),
debt_to_assets: (Decimal, Decimal),
}
fn d(val: &str) -> Decimal {
val.parse().expect("hardcoded decimal literal")
}
fn bounds_for(industry: &str) -> IndustryBounds {
match industry.to_lowercase().as_str() {
"manufacturing" => IndustryBounds {
current_ratio: (d("1.2"), d("3.0")),
quick_ratio: (d("0.7"), d("2.0")),
dso: (d("20"), d("60")),
dpo: (d("30"), d("90")),
inventory_turnover: (d("3.0"), d("20.0")),
gross_margin: (d("0.15"), d("0.50")),
operating_margin: (d("0.03"), d("0.20")),
net_margin: (d("0.01"), d("0.15")),
roa: (d("-0.10"), d("0.20")),
roe: (d("-0.20"), d("0.40")),
debt_to_equity: (d("0.0"), d("2.5")),
debt_to_assets: (d("0.0"), d("0.70")),
},
"financial_services" | "financial" | "banking" => IndustryBounds {
current_ratio: (d("0.5"), d("2.0")),
quick_ratio: (d("0.4"), d("1.8")),
dso: (d("10"), d("50")),
dpo: (d("15"), d("60")),
inventory_turnover: (d("1.0"), d("50.0")),
gross_margin: (d("0.30"), d("0.80")),
operating_margin: (d("0.10"), d("0.40")),
net_margin: (d("0.05"), d("0.35")),
roa: (d("-0.05"), d("0.25")),
roe: (d("-0.10"), d("0.50")),
debt_to_equity: (d("0.0"), d("10.0")),
debt_to_assets: (d("0.0"), d("0.90")),
},
"technology" | "tech" => IndustryBounds {
current_ratio: (d("1.5"), d("5.0")),
quick_ratio: (d("1.0"), d("4.5")),
dso: (d("30"), d("75")),
dpo: (d("15"), d("60")),
inventory_turnover: (d("5.0"), d("50.0")),
gross_margin: (d("0.40"), d("0.90")),
operating_margin: (d("0.05"), d("0.40")),
net_margin: (d("0.02"), d("0.35")),
roa: (d("-0.20"), d("0.30")),
roe: (d("-0.30"), d("0.60")),
debt_to_equity: (d("0.0"), d("2.0")),
debt_to_assets: (d("0.0"), d("0.60")),
},
"healthcare" => IndustryBounds {
current_ratio: (d("1.0"), d("3.0")),
quick_ratio: (d("0.6"), d("2.5")),
dso: (d("40"), d("90")),
dpo: (d("20"), d("60")),
inventory_turnover: (d("5.0"), d("30.0")),
gross_margin: (d("0.25"), d("0.70")),
operating_margin: (d("0.03"), d("0.25")),
net_margin: (d("0.01"), d("0.20")),
roa: (d("-0.10"), d("0.20")),
roe: (d("-0.20"), d("0.40")),
debt_to_equity: (d("0.0"), d("2.0")),
debt_to_assets: (d("0.0"), d("0.65")),
},
_ => IndustryBounds {
current_ratio: (d("1.0"), d("2.5")),
quick_ratio: (d("0.4"), d("1.5")),
dso: (d("5"), d("45")),
dpo: (d("20"), d("70")),
inventory_turnover: (d("4.0"), d("30.0")),
gross_margin: (d("0.10"), d("0.50")),
operating_margin: (d("0.01"), d("0.15")),
net_margin: (d("0.005"), d("0.10")),
roa: (d("-0.10"), d("0.20")),
roe: (d("-0.20"), d("0.40")),
debt_to_equity: (d("0.0"), d("3.0")),
debt_to_assets: (d("0.0"), d("0.75")),
},
}
}
fn make_check(name: &str, value: Option<Decimal>, bounds: (Decimal, Decimal)) -> RatioCheck {
let is_reasonable = match value {
None => true, Some(v) => v >= bounds.0 && v <= bounds.1,
};
RatioCheck {
ratio_name: name.to_string(),
value,
industry_min: bounds.0,
industry_max: bounds.1,
is_reasonable,
}
}
pub fn check_reasonableness(ratios: &FinancialRatios, industry: &str) -> Vec<RatioCheck> {
let b = bounds_for(industry);
vec![
make_check("current_ratio", ratios.current_ratio, b.current_ratio),
make_check("quick_ratio", ratios.quick_ratio, b.quick_ratio),
make_check("dso", ratios.dso, b.dso),
make_check("dpo", ratios.dpo, b.dpo),
make_check(
"inventory_turnover",
ratios.inventory_turnover,
b.inventory_turnover,
),
make_check("gross_margin", ratios.gross_margin, b.gross_margin),
make_check(
"operating_margin",
ratios.operating_margin,
b.operating_margin,
),
make_check("net_margin", ratios.net_margin, b.net_margin),
make_check("roa", ratios.roa, b.roa),
make_check("roe", ratios.roe, b.roe),
make_check("debt_to_equity", ratios.debt_to_equity, b.debt_to_equity),
make_check("debt_to_assets", ratios.debt_to_assets, b.debt_to_assets),
]
}
pub fn analyze(
entries: &[JournalEntry],
entity_code: &str,
period: &str,
industry: &str,
) -> RatioAnalysisResult {
let ratios = compute_ratios(entries, entity_code);
let reasonableness_checks = check_reasonableness(&ratios, industry);
let passes = reasonableness_checks.iter().all(|c| c.is_reasonable);
RatioAnalysisResult {
entity_code: entity_code.to_string(),
period: period.to_string(),
ratios,
reasonableness_checks,
passes,
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use datasynth_core::models::{JournalEntry, JournalEntryHeader, JournalEntryLine};
use rust_decimal_macros::dec;
fn make_date() -> chrono::NaiveDate {
chrono::NaiveDate::from_ymd_opt(2024, 6, 30).unwrap()
}
fn je(
company: &str,
debit_account: &str,
credit_account: &str,
amount: Decimal,
) -> JournalEntry {
let header = JournalEntryHeader::new(company.to_string(), make_date());
let doc_id = header.document_id;
let mut entry = JournalEntry::new(header);
entry.add_line(JournalEntryLine::debit(
doc_id,
1,
debit_account.to_string(),
amount,
));
entry.add_line(JournalEntryLine::credit(
doc_id,
2,
credit_account.to_string(),
amount,
));
entry
}
#[test]
fn test_current_ratio() {
let entries = vec![
je("C001", "1000", "3000", dec!(10000)),
je("C001", "6000", "2000", dec!(5000)),
];
let ratios = compute_ratios(&entries, "C001");
let cr = ratios.current_ratio.unwrap();
assert!(
(cr - dec!(2.0)).abs() < dec!(0.01),
"Expected current_ratio ≈ 2.0, got {cr}"
);
}
#[test]
fn test_dso() {
let entries = vec![
je("C001", "1100", "4000", dec!(3650)), ];
let ratios = compute_ratios(&entries, "C001");
let dso = ratios.dso.unwrap();
assert!(dso > dec!(0), "DSO should be positive");
}
#[test]
fn test_gross_margin() {
let entries = vec![
je("C001", "1000", "4000", dec!(10000)), je("C001", "5000", "1000", dec!(6000)), ];
let ratios = compute_ratios(&entries, "C001");
let gm = ratios.gross_margin.unwrap();
assert!(
(gm - dec!(0.40)).abs() < dec!(0.01),
"Expected gross_margin ≈ 0.40, got {gm}"
);
}
#[test]
fn test_reasonableness_flags_out_of_bounds() {
let ratios = FinancialRatios {
current_ratio: Some(dec!(0.1)),
..Default::default()
};
let checks = check_reasonableness(&ratios, "retail");
let cr_check = checks
.iter()
.find(|c| c.ratio_name == "current_ratio")
.unwrap();
assert!(
!cr_check.is_reasonable,
"current_ratio 0.1 should be flagged as unreasonable for retail"
);
}
#[test]
fn test_reasonableness_passes_within_bounds() {
let ratios = FinancialRatios {
current_ratio: Some(dec!(1.8)),
gross_margin: Some(dec!(0.35)),
..Default::default()
};
let checks = check_reasonableness(&ratios, "retail");
for check in &checks {
if check.ratio_name == "current_ratio" || check.ratio_name == "gross_margin" {
assert!(
check.is_reasonable,
"{} should be reasonable",
check.ratio_name
);
}
}
}
#[test]
fn test_none_ratios_vacuously_pass() {
let ratios = FinancialRatios::default(); let checks = check_reasonableness(&ratios, "retail");
assert!(
checks.iter().all(|c| c.is_reasonable),
"All None ratios should vacuously pass"
);
}
#[test]
fn test_entity_filter() {
let entries = vec![
je("C001", "1000", "4000", dec!(5000)), je("C001", "5000", "1000", dec!(2000)), je("C002", "1000", "4000", dec!(5000)), je("C002", "5000", "1000", dec!(4500)), ];
let r1 = compute_ratios(&entries, "C001");
let r2 = compute_ratios(&entries, "C002");
assert_ne!(
r1.gross_margin, r2.gross_margin,
"Entity filter should isolate per-company data"
);
}
#[test]
fn test_debt_to_equity() {
let entries = vec![
je("C001", "6000", "2000", dec!(4000)), je("C001", "1000", "3000", dec!(2000)), ];
let ratios = compute_ratios(&entries, "C001");
if let (Some(dte), Some(dta)) = (ratios.debt_to_equity, ratios.debt_to_assets) {
assert!(dte > dec!(0), "D/E should be positive when liabilities > 0");
assert!(dta > dec!(0), "D/A should be positive when liabilities > 0");
}
}
#[test]
fn test_analyze_end_to_end() {
let entries = vec![
je("C001", "1000", "4000", dec!(10000)),
je("C001", "5000", "1000", dec!(6000)),
je("C001", "6000", "2000", dec!(2000)),
];
let result = analyze(&entries, "C001", "2024-H1", "retail");
assert_eq!(result.entity_code, "C001");
assert_eq!(result.period, "2024-H1");
assert!(!result.reasonableness_checks.is_empty());
}
#[test]
fn test_industry_bounds_manufacturing() {
let ratios = FinancialRatios {
current_ratio: Some(dec!(2.0)), ..Default::default()
};
let checks = check_reasonableness(&ratios, "manufacturing");
let cr = checks
.iter()
.find(|c| c.ratio_name == "current_ratio")
.unwrap();
assert!(
cr.is_reasonable,
"2.0 is within manufacturing bounds 1.2–3.0"
);
}
}