Skip to main content

datasynth_eval/coherence/
ratio_analysis.rs

1//! Financial ratio analysis evaluator (ISA 520).
2//!
3//! Computes standard financial ratios from journal entry data and validates
4//! them for reasonableness against industry benchmarks.
5
6use datasynth_core::models::JournalEntry;
7use rust_decimal::Decimal;
8use serde::{Deserialize, Serialize};
9
10/// Results of ratio analysis evaluation.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct RatioAnalysisResult {
13    /// Entity/company code evaluated.
14    pub entity_code: String,
15    /// Fiscal period label (e.g. "2024-Q1").
16    pub period: String,
17    /// Computed financial ratios.
18    pub ratios: FinancialRatios,
19    /// Reasonableness checks against industry bounds.
20    pub reasonableness_checks: Vec<RatioCheck>,
21    /// True if all computable ratios are within industry bounds.
22    pub passes: bool,
23}
24
25/// Standard financial ratios derived from journal entry data.
26#[derive(Debug, Clone, Default, Serialize, Deserialize)]
27pub struct FinancialRatios {
28    // --- Liquidity ---
29    /// Current ratio: current assets / current liabilities.
30    #[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    /// Quick ratio: (current assets − inventory) / current liabilities.
37    #[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    // --- Activity ---
45    /// Days Sales Outstanding: AR / Revenue × 365.
46    #[serde(
47        default,
48        skip_serializing_if = "Option::is_none",
49        with = "rust_decimal::serde::str_option"
50    )]
51    pub dso: Option<Decimal>,
52    /// Days Payable Outstanding: AP / COGS × 365.
53    #[serde(
54        default,
55        skip_serializing_if = "Option::is_none",
56        with = "rust_decimal::serde::str_option"
57    )]
58    pub dpo: Option<Decimal>,
59    /// Inventory turnover: COGS / inventory.
60    #[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    // --- Profitability ---
68    /// Gross margin: (revenue − COGS) / revenue.
69    #[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    /// Operating margin: (revenue − COGS − operating expenses) / revenue.
76    #[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    /// Net margin: net income / revenue.
83    #[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    /// Return on assets: net income / total assets.
90    #[serde(
91        default,
92        skip_serializing_if = "Option::is_none",
93        with = "rust_decimal::serde::str_option"
94    )]
95    pub roa: Option<Decimal>,
96    /// Return on equity: net income / total equity.
97    #[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    // --- Leverage ---
105    /// Debt-to-equity: total liabilities / total equity.
106    #[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    /// Debt-to-assets: total liabilities / total assets.
113    #[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/// Reasonableness check for a single ratio against industry bounds.
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct RatioCheck {
124    /// Name of the ratio (e.g. "current_ratio").
125    pub ratio_name: String,
126    /// Computed value, if available.
127    #[serde(
128        default,
129        skip_serializing_if = "Option::is_none",
130        with = "rust_decimal::serde::str_option"
131    )]
132    pub value: Option<Decimal>,
133    /// Minimum acceptable value for this industry.
134    #[serde(with = "rust_decimal::serde::str")]
135    pub industry_min: Decimal,
136    /// Maximum acceptable value for this industry.
137    #[serde(with = "rust_decimal::serde::str")]
138    pub industry_max: Decimal,
139    /// True if the ratio is within bounds (or not computable — vacuously true).
140    pub is_reasonable: bool,
141}
142
143// ─── Account-range helpers ────────────────────────────────────────────────────
144
145/// Internal totals built from GL account prefixes.
146#[derive(Debug, Default)]
147struct GlTotals {
148    /// 1xxx  – all assets (net of credits).
149    assets: Decimal,
150    /// 10xx–13xx – current assets (cash, AR, inventory, prepaid).
151    ///
152    /// Current assets are accounts starting with 10 (cash/bank), 11 (AR),
153    /// 12 (inventory), or 13 (prepaid/other current). Non-current assets
154    /// (14xx–19xx: fixed assets, intangibles, LT investments) are excluded
155    /// so that `current_ratio` and `quick_ratio` are correctly computed.
156    current_assets: Decimal,
157    /// 11xx  – accounts receivable.
158    ar: Decimal,
159    /// 12xx  – inventory.
160    inventory: Decimal,
161    /// 2xxx  – liabilities.
162    liabilities: Decimal,
163    /// 21xx  – accounts payable.
164    ap: Decimal,
165    /// 3xxx  – equity.
166    equity: Decimal,
167    /// 4xxx  – revenue (credit-normal).
168    revenue: Decimal,
169    /// 5xxx  – cost of goods sold (debit-normal).
170    cogs: Decimal,
171    /// 6xxx–7xxx – operating expenses (debit-normal).
172    opex: Decimal,
173    /// 8xxx  – income tax expense (debit-normal).
174    ///
175    /// Tax expense is separated from general opex so that `net_income`
176    /// correctly reflects after-tax profit (operating_income − tax_expense)
177    /// rather than conflating tax with operating costs.
178    tax_expense: Decimal,
179}
180
181/// Return the two leading digits of an account number, ignoring non-numeric chars.
182fn 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
193/// Accumulate debit/credit amounts into `GlTotals` based on account ranges.
194///
195/// Convention used here: for balance-sheet accounts the *net* balance matters
196/// (debit − credit for asset accounts, credit − debit for liability/equity).
197/// For P&L accounts we accumulate the "natural" side:
198///   revenue → credits, COGS/opex → debits.
199fn 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; // leading digit
212            let net = line.debit_amount - line.credit_amount; // positive = debit-heavy
213
214            match prefix1 {
215                1 => {
216                    // Asset accounts (debit-normal → net positive = asset)
217                    t.assets += net;
218                    // Current assets: 10xx (cash/bank), 11xx (AR), 12xx (inventory),
219                    // 13xx (prepaid/other current). Accounts 14xx–19xx are non-current
220                    // (PP&E, intangibles, long-term investments) and are excluded.
221                    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                    // Liability accounts (credit-normal → net negative = liability)
232                    t.liabilities += -net;
233                    if prefix2 == 21 || prefix2 == 20 {
234                        t.ap += -net;
235                    }
236                }
237                3 => {
238                    // Equity accounts (credit-normal)
239                    t.equity += -net;
240                }
241                4 => {
242                    // Revenue (credit-normal)
243                    t.revenue += -net;
244                }
245                5 => {
246                    // COGS (debit-normal)
247                    t.cogs += net;
248                }
249                6..=7 => {
250                    // Operating expenses (debit-normal); excludes 8xxx (tax)
251                    t.opex += net;
252                }
253                8 => {
254                    // Income tax expense (debit-normal); kept separate from opex
255                    // so that net_income = operating_income − tax_expense
256                    t.tax_expense += net;
257                }
258                _ => {}
259            }
260        }
261    }
262
263    t
264}
265
266// ─── Ratio computation ────────────────────────────────────────────────────────
267
268/// Compute financial ratios from journal entries for a single entity.
269///
270/// Returns `None` for any ratio where the denominator is zero or the required
271/// data is absent.
272pub 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    // Liquidity — use current_assets (10xx–13xx) not total_assets (1xxx).
278    // Non-current assets such as PP&E (14xx–19xx) are long-lived and cannot
279    // be converted to cash within 12 months, so they must be excluded here.
280    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    // Activity
294    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    // Profitability
313    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    // Net income = operating income minus tax expense (8xxx accounts).
328    // Interest expense (if coded to 7xxx) is already captured in opex above.
329    // This provides a closer approximation to GAAP net income than using
330    // operating income alone. Full interest/other-income treatment would
331    // require dedicated account ranges not universally standardised here.
332    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    // Leverage
352    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
380// ─── Reasonableness bounds ────────────────────────────────────────────────────
381
382/// Industry-specific bounds for each ratio.
383struct 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        // Default: retail
461        _ => 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
478/// Build a single [`RatioCheck`] comparing an optional value against bounds.
479fn make_check(name: &str, value: Option<Decimal>, bounds: (Decimal, Decimal)) -> RatioCheck {
480    let is_reasonable = match value {
481        None => true, // not computable → skip
482        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
493/// Check all ratios for reasonableness against industry benchmarks.
494pub 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
520/// Run the full ratio analysis for an entity and return a combined result.
521pub 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// ─── Tests ────────────────────────────────────────────────────────────────────
540
541#[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    /// Build a minimal journal entry that posts one debit line and one credit line.
553    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        // Assets 10000 (account 1000), Liabilities 5000 (account 2000)
580        // Expect current_ratio = 2.0
581        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        // AR (1100) = 3650 credit posting offset = debit on 1100
596        // Revenue (4000) = 10000 (credit-normal)
597        // DSO = 3650 / 10000 * 365 = 133.225
598        let entries = vec![
599            je("C001", "1100", "4000", dec!(3650)), // AR debit, Revenue credit
600        ];
601        let ratios = compute_ratios(&entries, "C001");
602        let dso = ratios.dso.unwrap();
603        // ar=3650, revenue=3650 → dso = 3650/3650*365 = 365
604        assert!(dso > dec!(0), "DSO should be positive");
605    }
606
607    #[test]
608    fn test_gross_margin() {
609        // Revenue 10000 (credit on 4xxx), COGS 6000 (debit on 5xxx)
610        // gross_margin = (10000 - 6000) / 10000 = 0.40
611        let entries = vec![
612            je("C001", "1000", "4000", dec!(10000)), // revenue credit
613            je("C001", "5000", "1000", dec!(6000)),  // COGS debit
614        ];
615        let ratios = compute_ratios(&entries, "C001");
616        let gm = ratios.gross_margin.unwrap();
617        // revenue = 10000, cogs = 6000 → 0.40
618        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        // Artificially create a current_ratio of 0.1 (below retail min of 1.0)
627        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(); // all None
664        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        // C001: revenue=5000, COGS=2000 → gross_margin = 0.60
674        // C002: revenue=5000, COGS=4500 → gross_margin = 0.10
675        let entries = vec![
676            je("C001", "1000", "4000", dec!(5000)), // C001 revenue
677            je("C001", "5000", "1000", dec!(2000)), // C001 COGS
678            je("C002", "1000", "4000", dec!(5000)), // C002 revenue
679            je("C002", "5000", "1000", dec!(4500)), // C002 COGS (higher)
680        ];
681        let r1 = compute_ratios(&entries, "C001");
682        let r2 = compute_ratios(&entries, "C002");
683        // Different COGS → different gross margins
684        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        // Liabilities 4000 (credit on 2xxx offset debit on 6xxx), Equity 2000 (credit on 3xxx)
693        let entries = vec![
694            je("C001", "6000", "2000", dec!(4000)), // liability credit
695            je("C001", "1000", "3000", dec!(2000)), // equity credit
696        ];
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)), // within manufacturing 1.2–3.0
721            ..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}