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 = "datasynth_core::serde_decimal::option"
34 )]
35 pub current_ratio: Option<Decimal>,
36 #[serde(
38 default,
39 skip_serializing_if = "Option::is_none",
40 with = "datasynth_core::serde_decimal::option"
41 )]
42 pub quick_ratio: Option<Decimal>,
43
44 #[serde(
47 default,
48 skip_serializing_if = "Option::is_none",
49 with = "datasynth_core::serde_decimal::option"
50 )]
51 pub dso: Option<Decimal>,
52 #[serde(
54 default,
55 skip_serializing_if = "Option::is_none",
56 with = "datasynth_core::serde_decimal::option"
57 )]
58 pub dpo: Option<Decimal>,
59 #[serde(
61 default,
62 skip_serializing_if = "Option::is_none",
63 with = "datasynth_core::serde_decimal::option"
64 )]
65 pub inventory_turnover: Option<Decimal>,
66
67 #[serde(
70 default,
71 skip_serializing_if = "Option::is_none",
72 with = "datasynth_core::serde_decimal::option"
73 )]
74 pub gross_margin: Option<Decimal>,
75 #[serde(
77 default,
78 skip_serializing_if = "Option::is_none",
79 with = "datasynth_core::serde_decimal::option"
80 )]
81 pub operating_margin: Option<Decimal>,
82 #[serde(
84 default,
85 skip_serializing_if = "Option::is_none",
86 with = "datasynth_core::serde_decimal::option"
87 )]
88 pub net_margin: Option<Decimal>,
89 #[serde(
91 default,
92 skip_serializing_if = "Option::is_none",
93 with = "datasynth_core::serde_decimal::option"
94 )]
95 pub roa: Option<Decimal>,
96 #[serde(
98 default,
99 skip_serializing_if = "Option::is_none",
100 with = "datasynth_core::serde_decimal::option"
101 )]
102 pub roe: Option<Decimal>,
103
104 #[serde(
107 default,
108 skip_serializing_if = "Option::is_none",
109 with = "datasynth_core::serde_decimal::option"
110 )]
111 pub debt_to_equity: Option<Decimal>,
112 #[serde(
114 default,
115 skip_serializing_if = "Option::is_none",
116 with = "datasynth_core::serde_decimal::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 = "datasynth_core::serde_decimal::option"
131 )]
132 pub value: Option<Decimal>,
133 #[serde(with = "datasynth_core::serde_decimal")]
135 pub industry_min: Decimal,
136 #[serde(with = "datasynth_core::serde_decimal")]
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)]
542mod tests {
543 use super::*;
544 use datasynth_core::models::{JournalEntry, JournalEntryHeader, JournalEntryLine};
545 use rust_decimal_macros::dec;
546
547 fn make_date() -> chrono::NaiveDate {
548 chrono::NaiveDate::from_ymd_opt(2024, 6, 30).unwrap()
549 }
550
551 fn je(
553 company: &str,
554 debit_account: &str,
555 credit_account: &str,
556 amount: Decimal,
557 ) -> JournalEntry {
558 let header = JournalEntryHeader::new(company.to_string(), make_date());
559 let doc_id = header.document_id;
560 let mut entry = JournalEntry::new(header);
561 entry.add_line(JournalEntryLine::debit(
562 doc_id,
563 1,
564 debit_account.to_string(),
565 amount,
566 ));
567 entry.add_line(JournalEntryLine::credit(
568 doc_id,
569 2,
570 credit_account.to_string(),
571 amount,
572 ));
573 entry
574 }
575
576 #[test]
577 fn test_current_ratio() {
578 let entries = vec![
581 je("C001", "1000", "3000", dec!(10000)),
582 je("C001", "6000", "2000", dec!(5000)),
583 ];
584 let ratios = compute_ratios(&entries, "C001");
585 let cr = ratios.current_ratio.unwrap();
586 assert!(
587 (cr - dec!(2.0)).abs() < dec!(0.01),
588 "Expected current_ratio ≈ 2.0, got {cr}"
589 );
590 }
591
592 #[test]
593 fn test_dso() {
594 let entries = vec![
598 je("C001", "1100", "4000", dec!(3650)), ];
600 let ratios = compute_ratios(&entries, "C001");
601 let dso = ratios.dso.unwrap();
602 assert!(dso > dec!(0), "DSO should be positive");
604 }
605
606 #[test]
607 fn test_gross_margin() {
608 let entries = vec![
611 je("C001", "1000", "4000", dec!(10000)), je("C001", "5000", "1000", dec!(6000)), ];
614 let ratios = compute_ratios(&entries, "C001");
615 let gm = ratios.gross_margin.unwrap();
616 assert!(
618 (gm - dec!(0.40)).abs() < dec!(0.01),
619 "Expected gross_margin ≈ 0.40, got {gm}"
620 );
621 }
622
623 #[test]
624 fn test_reasonableness_flags_out_of_bounds() {
625 let ratios = FinancialRatios {
627 current_ratio: Some(dec!(0.1)),
628 ..Default::default()
629 };
630 let checks = check_reasonableness(&ratios, "retail");
631 let cr_check = checks
632 .iter()
633 .find(|c| c.ratio_name == "current_ratio")
634 .unwrap();
635 assert!(
636 !cr_check.is_reasonable,
637 "current_ratio 0.1 should be flagged as unreasonable for retail"
638 );
639 }
640
641 #[test]
642 fn test_reasonableness_passes_within_bounds() {
643 let ratios = FinancialRatios {
644 current_ratio: Some(dec!(1.8)),
645 gross_margin: Some(dec!(0.35)),
646 ..Default::default()
647 };
648 let checks = check_reasonableness(&ratios, "retail");
649 for check in &checks {
650 if check.ratio_name == "current_ratio" || check.ratio_name == "gross_margin" {
651 assert!(
652 check.is_reasonable,
653 "{} should be reasonable",
654 check.ratio_name
655 );
656 }
657 }
658 }
659
660 #[test]
661 fn test_none_ratios_vacuously_pass() {
662 let ratios = FinancialRatios::default(); let checks = check_reasonableness(&ratios, "retail");
664 assert!(
665 checks.iter().all(|c| c.is_reasonable),
666 "All None ratios should vacuously pass"
667 );
668 }
669
670 #[test]
671 fn test_entity_filter() {
672 let entries = vec![
675 je("C001", "1000", "4000", dec!(5000)), je("C001", "5000", "1000", dec!(2000)), je("C002", "1000", "4000", dec!(5000)), je("C002", "5000", "1000", dec!(4500)), ];
680 let r1 = compute_ratios(&entries, "C001");
681 let r2 = compute_ratios(&entries, "C002");
682 assert_ne!(
684 r1.gross_margin, r2.gross_margin,
685 "Entity filter should isolate per-company data"
686 );
687 }
688
689 #[test]
690 fn test_debt_to_equity() {
691 let entries = vec![
693 je("C001", "6000", "2000", dec!(4000)), je("C001", "1000", "3000", dec!(2000)), ];
696 let ratios = compute_ratios(&entries, "C001");
697 if let (Some(dte), Some(dta)) = (ratios.debt_to_equity, ratios.debt_to_assets) {
698 assert!(dte > dec!(0), "D/E should be positive when liabilities > 0");
699 assert!(dta > dec!(0), "D/A should be positive when liabilities > 0");
700 }
701 }
702
703 #[test]
704 fn test_analyze_end_to_end() {
705 let entries = vec![
706 je("C001", "1000", "4000", dec!(10000)),
707 je("C001", "5000", "1000", dec!(6000)),
708 je("C001", "6000", "2000", dec!(2000)),
709 ];
710 let result = analyze(&entries, "C001", "2024-H1", "retail");
711 assert_eq!(result.entity_code, "C001");
712 assert_eq!(result.period, "2024-H1");
713 assert!(!result.reasonableness_checks.is_empty());
714 }
715
716 #[test]
717 fn test_industry_bounds_manufacturing() {
718 let ratios = FinancialRatios {
719 current_ratio: Some(dec!(2.0)), ..Default::default()
721 };
722 let checks = check_reasonableness(&ratios, "manufacturing");
723 let cr = checks
724 .iter()
725 .find(|c| c.ratio_name == "current_ratio")
726 .unwrap();
727 assert!(
728 cr.is_reasonable,
729 "2.0 is within manufacturing bounds 1.2–3.0"
730 );
731 }
732}