1use datasynth_core::models::JournalEntry;
7use rust_decimal::Decimal;
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct RatioAnalysisResult {
13 pub entity_code: String,
15 pub period: String,
17 pub ratios: FinancialRatios,
19 pub reasonableness_checks: Vec<RatioCheck>,
21 pub passes: bool,
23}
24
25#[derive(Debug, Clone, Default, Serialize, Deserialize)]
27pub struct FinancialRatios {
28 #[serde(
31 default,
32 skip_serializing_if = "Option::is_none",
33 with = "rust_decimal::serde::str_option"
34 )]
35 pub current_ratio: Option<Decimal>,
36 #[serde(
38 default,
39 skip_serializing_if = "Option::is_none",
40 with = "rust_decimal::serde::str_option"
41 )]
42 pub quick_ratio: Option<Decimal>,
43
44 #[serde(
47 default,
48 skip_serializing_if = "Option::is_none",
49 with = "rust_decimal::serde::str_option"
50 )]
51 pub dso: Option<Decimal>,
52 #[serde(
54 default,
55 skip_serializing_if = "Option::is_none",
56 with = "rust_decimal::serde::str_option"
57 )]
58 pub dpo: Option<Decimal>,
59 #[serde(
61 default,
62 skip_serializing_if = "Option::is_none",
63 with = "rust_decimal::serde::str_option"
64 )]
65 pub inventory_turnover: Option<Decimal>,
66
67 #[serde(
70 default,
71 skip_serializing_if = "Option::is_none",
72 with = "rust_decimal::serde::str_option"
73 )]
74 pub gross_margin: Option<Decimal>,
75 #[serde(
77 default,
78 skip_serializing_if = "Option::is_none",
79 with = "rust_decimal::serde::str_option"
80 )]
81 pub operating_margin: Option<Decimal>,
82 #[serde(
84 default,
85 skip_serializing_if = "Option::is_none",
86 with = "rust_decimal::serde::str_option"
87 )]
88 pub net_margin: Option<Decimal>,
89 #[serde(
91 default,
92 skip_serializing_if = "Option::is_none",
93 with = "rust_decimal::serde::str_option"
94 )]
95 pub roa: Option<Decimal>,
96 #[serde(
98 default,
99 skip_serializing_if = "Option::is_none",
100 with = "rust_decimal::serde::str_option"
101 )]
102 pub roe: Option<Decimal>,
103
104 #[serde(
107 default,
108 skip_serializing_if = "Option::is_none",
109 with = "rust_decimal::serde::str_option"
110 )]
111 pub debt_to_equity: Option<Decimal>,
112 #[serde(
114 default,
115 skip_serializing_if = "Option::is_none",
116 with = "rust_decimal::serde::str_option"
117 )]
118 pub debt_to_assets: Option<Decimal>,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct RatioCheck {
124 pub ratio_name: String,
126 #[serde(
128 default,
129 skip_serializing_if = "Option::is_none",
130 with = "rust_decimal::serde::str_option"
131 )]
132 pub value: Option<Decimal>,
133 #[serde(with = "rust_decimal::serde::str")]
135 pub industry_min: Decimal,
136 #[serde(with = "rust_decimal::serde::str")]
138 pub industry_max: Decimal,
139 pub is_reasonable: bool,
141}
142
143#[derive(Debug, Default)]
147struct GlTotals {
148 assets: Decimal,
150 current_assets: Decimal,
157 ar: Decimal,
159 inventory: Decimal,
161 liabilities: Decimal,
163 ap: Decimal,
165 equity: Decimal,
167 revenue: Decimal,
169 cogs: Decimal,
171 opex: Decimal,
173 tax_expense: Decimal,
179}
180
181fn account_prefix(account: &str) -> Option<u32> {
183 let digits: String = account.chars().filter(|c| c.is_ascii_digit()).collect();
184 if digits.len() >= 2 {
185 digits[..2].parse().ok()
186 } else if digits.len() == 1 {
187 digits[..1].parse().ok()
188 } else {
189 None
190 }
191}
192
193fn build_totals(entries: &[JournalEntry], entity_code: &str) -> GlTotals {
200 let mut t = GlTotals::default();
201
202 for entry in entries {
203 if entry.header.company_code != entity_code {
204 continue;
205 }
206 for line in &entry.lines {
207 let account = &line.gl_account;
208 let Some(prefix2) = account_prefix(account) else {
209 continue;
210 };
211 let prefix1 = prefix2 / 10; let net = line.debit_amount - line.credit_amount; match prefix1 {
215 1 => {
216 t.assets += net;
218 if (10..=13).contains(&prefix2) {
222 t.current_assets += net;
223 if prefix2 == 11 {
224 t.ar += net;
225 } else if prefix2 == 12 {
226 t.inventory += net;
227 }
228 }
229 }
230 2 => {
231 t.liabilities += -net;
233 if prefix2 == 21 || prefix2 == 20 {
234 t.ap += -net;
235 }
236 }
237 3 => {
238 t.equity += -net;
240 }
241 4 => {
242 t.revenue += -net;
244 }
245 5 => {
246 t.cogs += net;
248 }
249 6..=7 => {
250 t.opex += net;
252 }
253 8 => {
254 t.tax_expense += net;
257 }
258 _ => {}
259 }
260 }
261 }
262
263 t
264}
265
266pub fn compute_ratios(entries: &[JournalEntry], entity_code: &str) -> FinancialRatios {
273 let t = build_totals(entries, entity_code);
274
275 let d365 = Decimal::from(365u32);
276
277 let current_ratio = if t.liabilities > Decimal::ZERO && t.current_assets > Decimal::ZERO {
281 Some(t.current_assets / t.liabilities)
282 } else {
283 None
284 };
285
286 let current_assets_ex_inv = t.current_assets - t.inventory;
287 let quick_ratio = if t.liabilities > Decimal::ZERO && t.current_assets > Decimal::ZERO {
288 Some(current_assets_ex_inv / t.liabilities)
289 } else {
290 None
291 };
292
293 let dso = if t.revenue > Decimal::ZERO && t.ar >= Decimal::ZERO {
295 Some(t.ar / t.revenue * d365)
296 } else {
297 None
298 };
299
300 let dpo = if t.cogs > Decimal::ZERO && t.ap >= Decimal::ZERO {
301 Some(t.ap / t.cogs * d365)
302 } else {
303 None
304 };
305
306 let inventory_turnover = if t.inventory > Decimal::ZERO {
307 Some(t.cogs / t.inventory)
308 } else {
309 None
310 };
311
312 let gross_profit = t.revenue - t.cogs;
314 let gross_margin = if t.revenue > Decimal::ZERO {
315 Some(gross_profit / t.revenue)
316 } else {
317 None
318 };
319
320 let operating_income = t.revenue - t.cogs - t.opex;
321 let operating_margin = if t.revenue > Decimal::ZERO {
322 Some(operating_income / t.revenue)
323 } else {
324 None
325 };
326
327 let net_income = operating_income - t.tax_expense;
333 let net_margin = if t.revenue > Decimal::ZERO {
334 Some(net_income / t.revenue)
335 } else {
336 None
337 };
338
339 let roa = if t.assets > Decimal::ZERO {
340 Some(net_income / t.assets)
341 } else {
342 None
343 };
344
345 let roe = if t.equity > Decimal::ZERO {
346 Some(net_income / t.equity)
347 } else {
348 None
349 };
350
351 let debt_to_equity = if t.equity > Decimal::ZERO {
353 Some(t.liabilities / t.equity)
354 } else {
355 None
356 };
357
358 let debt_to_assets = if t.assets > Decimal::ZERO {
359 Some(t.liabilities / t.assets)
360 } else {
361 None
362 };
363
364 FinancialRatios {
365 current_ratio,
366 quick_ratio,
367 dso,
368 dpo,
369 inventory_turnover,
370 gross_margin,
371 operating_margin,
372 net_margin,
373 roa,
374 roe,
375 debt_to_equity,
376 debt_to_assets,
377 }
378}
379
380struct IndustryBounds {
384 current_ratio: (Decimal, Decimal),
385 quick_ratio: (Decimal, Decimal),
386 dso: (Decimal, Decimal),
387 dpo: (Decimal, Decimal),
388 inventory_turnover: (Decimal, Decimal),
389 gross_margin: (Decimal, Decimal),
390 operating_margin: (Decimal, Decimal),
391 net_margin: (Decimal, Decimal),
392 roa: (Decimal, Decimal),
393 roe: (Decimal, Decimal),
394 debt_to_equity: (Decimal, Decimal),
395 debt_to_assets: (Decimal, Decimal),
396}
397
398fn d(val: &str) -> Decimal {
399 val.parse().expect("hardcoded decimal literal")
400}
401
402fn bounds_for(industry: &str) -> IndustryBounds {
403 match industry.to_lowercase().as_str() {
404 "manufacturing" => IndustryBounds {
405 current_ratio: (d("1.2"), d("3.0")),
406 quick_ratio: (d("0.7"), d("2.0")),
407 dso: (d("20"), d("60")),
408 dpo: (d("30"), d("90")),
409 inventory_turnover: (d("3.0"), d("20.0")),
410 gross_margin: (d("0.15"), d("0.50")),
411 operating_margin: (d("0.03"), d("0.20")),
412 net_margin: (d("0.01"), d("0.15")),
413 roa: (d("-0.10"), d("0.20")),
414 roe: (d("-0.20"), d("0.40")),
415 debt_to_equity: (d("0.0"), d("2.5")),
416 debt_to_assets: (d("0.0"), d("0.70")),
417 },
418 "financial_services" | "financial" | "banking" => IndustryBounds {
419 current_ratio: (d("0.5"), d("2.0")),
420 quick_ratio: (d("0.4"), d("1.8")),
421 dso: (d("10"), d("50")),
422 dpo: (d("15"), d("60")),
423 inventory_turnover: (d("1.0"), d("50.0")),
424 gross_margin: (d("0.30"), d("0.80")),
425 operating_margin: (d("0.10"), d("0.40")),
426 net_margin: (d("0.05"), d("0.35")),
427 roa: (d("-0.05"), d("0.25")),
428 roe: (d("-0.10"), d("0.50")),
429 debt_to_equity: (d("0.0"), d("10.0")),
430 debt_to_assets: (d("0.0"), d("0.90")),
431 },
432 "technology" | "tech" => IndustryBounds {
433 current_ratio: (d("1.5"), d("5.0")),
434 quick_ratio: (d("1.0"), d("4.5")),
435 dso: (d("30"), d("75")),
436 dpo: (d("15"), d("60")),
437 inventory_turnover: (d("5.0"), d("50.0")),
438 gross_margin: (d("0.40"), d("0.90")),
439 operating_margin: (d("0.05"), d("0.40")),
440 net_margin: (d("0.02"), d("0.35")),
441 roa: (d("-0.20"), d("0.30")),
442 roe: (d("-0.30"), d("0.60")),
443 debt_to_equity: (d("0.0"), d("2.0")),
444 debt_to_assets: (d("0.0"), d("0.60")),
445 },
446 "healthcare" => IndustryBounds {
447 current_ratio: (d("1.0"), d("3.0")),
448 quick_ratio: (d("0.6"), d("2.5")),
449 dso: (d("40"), d("90")),
450 dpo: (d("20"), d("60")),
451 inventory_turnover: (d("5.0"), d("30.0")),
452 gross_margin: (d("0.25"), d("0.70")),
453 operating_margin: (d("0.03"), d("0.25")),
454 net_margin: (d("0.01"), d("0.20")),
455 roa: (d("-0.10"), d("0.20")),
456 roe: (d("-0.20"), d("0.40")),
457 debt_to_equity: (d("0.0"), d("2.0")),
458 debt_to_assets: (d("0.0"), d("0.65")),
459 },
460 _ => IndustryBounds {
462 current_ratio: (d("1.0"), d("2.5")),
463 quick_ratio: (d("0.4"), d("1.5")),
464 dso: (d("5"), d("45")),
465 dpo: (d("20"), d("70")),
466 inventory_turnover: (d("4.0"), d("30.0")),
467 gross_margin: (d("0.10"), d("0.50")),
468 operating_margin: (d("0.01"), d("0.15")),
469 net_margin: (d("0.005"), d("0.10")),
470 roa: (d("-0.10"), d("0.20")),
471 roe: (d("-0.20"), d("0.40")),
472 debt_to_equity: (d("0.0"), d("3.0")),
473 debt_to_assets: (d("0.0"), d("0.75")),
474 },
475 }
476}
477
478fn make_check(name: &str, value: Option<Decimal>, bounds: (Decimal, Decimal)) -> RatioCheck {
480 let is_reasonable = match value {
481 None => true, Some(v) => v >= bounds.0 && v <= bounds.1,
483 };
484 RatioCheck {
485 ratio_name: name.to_string(),
486 value,
487 industry_min: bounds.0,
488 industry_max: bounds.1,
489 is_reasonable,
490 }
491}
492
493pub fn check_reasonableness(ratios: &FinancialRatios, industry: &str) -> Vec<RatioCheck> {
495 let b = bounds_for(industry);
496 vec![
497 make_check("current_ratio", ratios.current_ratio, b.current_ratio),
498 make_check("quick_ratio", ratios.quick_ratio, b.quick_ratio),
499 make_check("dso", ratios.dso, b.dso),
500 make_check("dpo", ratios.dpo, b.dpo),
501 make_check(
502 "inventory_turnover",
503 ratios.inventory_turnover,
504 b.inventory_turnover,
505 ),
506 make_check("gross_margin", ratios.gross_margin, b.gross_margin),
507 make_check(
508 "operating_margin",
509 ratios.operating_margin,
510 b.operating_margin,
511 ),
512 make_check("net_margin", ratios.net_margin, b.net_margin),
513 make_check("roa", ratios.roa, b.roa),
514 make_check("roe", ratios.roe, b.roe),
515 make_check("debt_to_equity", ratios.debt_to_equity, b.debt_to_equity),
516 make_check("debt_to_assets", ratios.debt_to_assets, b.debt_to_assets),
517 ]
518}
519
520pub fn analyze(
522 entries: &[JournalEntry],
523 entity_code: &str,
524 period: &str,
525 industry: &str,
526) -> RatioAnalysisResult {
527 let ratios = compute_ratios(entries, entity_code);
528 let reasonableness_checks = check_reasonableness(&ratios, industry);
529 let passes = reasonableness_checks.iter().all(|c| c.is_reasonable);
530 RatioAnalysisResult {
531 entity_code: entity_code.to_string(),
532 period: period.to_string(),
533 ratios,
534 reasonableness_checks,
535 passes,
536 }
537}
538
539#[cfg(test)]
542#[allow(clippy::unwrap_used)]
543mod tests {
544 use super::*;
545 use datasynth_core::models::{JournalEntry, JournalEntryHeader, JournalEntryLine};
546 use rust_decimal_macros::dec;
547
548 fn make_date() -> chrono::NaiveDate {
549 chrono::NaiveDate::from_ymd_opt(2024, 6, 30).unwrap()
550 }
551
552 fn je(
554 company: &str,
555 debit_account: &str,
556 credit_account: &str,
557 amount: Decimal,
558 ) -> JournalEntry {
559 let header = JournalEntryHeader::new(company.to_string(), make_date());
560 let doc_id = header.document_id;
561 let mut entry = JournalEntry::new(header);
562 entry.add_line(JournalEntryLine::debit(
563 doc_id,
564 1,
565 debit_account.to_string(),
566 amount,
567 ));
568 entry.add_line(JournalEntryLine::credit(
569 doc_id,
570 2,
571 credit_account.to_string(),
572 amount,
573 ));
574 entry
575 }
576
577 #[test]
578 fn test_current_ratio() {
579 let entries = vec![
582 je("C001", "1000", "3000", dec!(10000)),
583 je("C001", "6000", "2000", dec!(5000)),
584 ];
585 let ratios = compute_ratios(&entries, "C001");
586 let cr = ratios.current_ratio.unwrap();
587 assert!(
588 (cr - dec!(2.0)).abs() < dec!(0.01),
589 "Expected current_ratio ≈ 2.0, got {cr}"
590 );
591 }
592
593 #[test]
594 fn test_dso() {
595 let entries = vec![
599 je("C001", "1100", "4000", dec!(3650)), ];
601 let ratios = compute_ratios(&entries, "C001");
602 let dso = ratios.dso.unwrap();
603 assert!(dso > dec!(0), "DSO should be positive");
605 }
606
607 #[test]
608 fn test_gross_margin() {
609 let entries = vec![
612 je("C001", "1000", "4000", dec!(10000)), je("C001", "5000", "1000", dec!(6000)), ];
615 let ratios = compute_ratios(&entries, "C001");
616 let gm = ratios.gross_margin.unwrap();
617 assert!(
619 (gm - dec!(0.40)).abs() < dec!(0.01),
620 "Expected gross_margin ≈ 0.40, got {gm}"
621 );
622 }
623
624 #[test]
625 fn test_reasonableness_flags_out_of_bounds() {
626 let ratios = FinancialRatios {
628 current_ratio: Some(dec!(0.1)),
629 ..Default::default()
630 };
631 let checks = check_reasonableness(&ratios, "retail");
632 let cr_check = checks
633 .iter()
634 .find(|c| c.ratio_name == "current_ratio")
635 .unwrap();
636 assert!(
637 !cr_check.is_reasonable,
638 "current_ratio 0.1 should be flagged as unreasonable for retail"
639 );
640 }
641
642 #[test]
643 fn test_reasonableness_passes_within_bounds() {
644 let ratios = FinancialRatios {
645 current_ratio: Some(dec!(1.8)),
646 gross_margin: Some(dec!(0.35)),
647 ..Default::default()
648 };
649 let checks = check_reasonableness(&ratios, "retail");
650 for check in &checks {
651 if check.ratio_name == "current_ratio" || check.ratio_name == "gross_margin" {
652 assert!(
653 check.is_reasonable,
654 "{} should be reasonable",
655 check.ratio_name
656 );
657 }
658 }
659 }
660
661 #[test]
662 fn test_none_ratios_vacuously_pass() {
663 let ratios = FinancialRatios::default(); let checks = check_reasonableness(&ratios, "retail");
665 assert!(
666 checks.iter().all(|c| c.is_reasonable),
667 "All None ratios should vacuously pass"
668 );
669 }
670
671 #[test]
672 fn test_entity_filter() {
673 let entries = vec![
676 je("C001", "1000", "4000", dec!(5000)), je("C001", "5000", "1000", dec!(2000)), je("C002", "1000", "4000", dec!(5000)), je("C002", "5000", "1000", dec!(4500)), ];
681 let r1 = compute_ratios(&entries, "C001");
682 let r2 = compute_ratios(&entries, "C002");
683 assert_ne!(
685 r1.gross_margin, r2.gross_margin,
686 "Entity filter should isolate per-company data"
687 );
688 }
689
690 #[test]
691 fn test_debt_to_equity() {
692 let entries = vec![
694 je("C001", "6000", "2000", dec!(4000)), je("C001", "1000", "3000", dec!(2000)), ];
697 let ratios = compute_ratios(&entries, "C001");
698 if let (Some(dte), Some(dta)) = (ratios.debt_to_equity, ratios.debt_to_assets) {
699 assert!(dte > dec!(0), "D/E should be positive when liabilities > 0");
700 assert!(dta > dec!(0), "D/A should be positive when liabilities > 0");
701 }
702 }
703
704 #[test]
705 fn test_analyze_end_to_end() {
706 let entries = vec![
707 je("C001", "1000", "4000", dec!(10000)),
708 je("C001", "5000", "1000", dec!(6000)),
709 je("C001", "6000", "2000", dec!(2000)),
710 ];
711 let result = analyze(&entries, "C001", "2024-H1", "retail");
712 assert_eq!(result.entity_code, "C001");
713 assert_eq!(result.period, "2024-H1");
714 assert!(!result.reasonableness_checks.is_empty());
715 }
716
717 #[test]
718 fn test_industry_bounds_manufacturing() {
719 let ratios = FinancialRatios {
720 current_ratio: Some(dec!(2.0)), ..Default::default()
722 };
723 let checks = check_reasonableness(&ratios, "manufacturing");
724 let cr = checks
725 .iter()
726 .find(|c| c.ratio_name == "current_ratio")
727 .unwrap();
728 assert!(
729 cr.is_reasonable,
730 "2.0 is within manufacturing bounds 1.2–3.0"
731 );
732 }
733}