diff --git a/MosaicIQ/src-tauri/src/terminal/sec_edgar/earnings/mapper.rs b/MosaicIQ/src-tauri/src/terminal/sec_edgar/earnings/mapper.rs index 58f80a8..2e4f41a 100644 --- a/MosaicIQ/src-tauri/src/terminal/sec_edgar/earnings/mapper.rs +++ b/MosaicIQ/src-tauri/src/terminal/sec_edgar/earnings/mapper.rs @@ -66,7 +66,10 @@ pub(crate) fn map_filing_to_record( total_revenues_yoy_change_percent: None, cost_of_sales: select_duration_value(document, &statement_window, COST_OF_SALES_CONCEPTS), gross_profit: select_duration_value(document, &statement_window, GROSS_PROFIT_CONCEPTS), - gross_profit_margin: None, + gross_profit_margin: calculate_gross_profit_margin( + select_duration_value(document, &statement_window, GROSS_PROFIT_CONCEPTS), + total_revenues, + ), selling_general_and_administrative_expenses: select_duration_value( document, &statement_window, @@ -78,13 +81,22 @@ pub(crate) fn map_filing_to_record( RESEARCH_AND_DEVELOPMENT_CONCEPTS, ) .or_else(|| select_duration_value(document, &statement_window, R_AND_D_CONCEPTS)), - other_operating_expenses: None, + other_operating_expenses: calculate_other_operating_expenses( + select_duration_value(document, &statement_window, OPERATING_PROFIT_CONCEPTS), + select_duration_value(document, &statement_window, GROSS_PROFIT_CONCEPTS), + select_duration_value(document, &statement_window, SELLING_GENERAL_AND_ADMINISTRATIVE_CONCEPTS), + select_duration_value(document, &statement_window, RESEARCH_AND_DEVELOPMENT_CONCEPTS) + .or_else(|| select_duration_value(document, &statement_window, R_AND_D_CONCEPTS)), + ), operating_profit: select_duration_value( document, &statement_window, OPERATING_PROFIT_CONCEPTS, ), - operating_margin: None, + operating_margin: calculate_operating_margin( + select_duration_value(document, &statement_window, OPERATING_PROFIT_CONCEPTS), + total_revenues, + ), non_operating_income: select_duration_value( document, &statement_window, @@ -297,6 +309,47 @@ fn normalize_percent(value: f64) -> f64 { } } +/// Calculates gross profit margin as a percentage. +/// Returns None if numerator or denominator is unavailable or if revenue is effectively zero. +fn calculate_gross_profit_margin(gross_profit: Option, revenue: Option) -> Option { + let gross_profit = gross_profit?; + let revenue = revenue?; + (revenue.abs() > f64::EPSILON).then_some((gross_profit / revenue.abs()) * 100.0) +} + +/// Calculates operating margin as a percentage. +/// Returns None if numerator or denominator is unavailable or if revenue is effectively zero. +fn calculate_operating_margin(operating_profit: Option, revenue: Option) -> Option { + let operating_profit = operating_profit?; + let revenue = revenue?; + (revenue.abs() > f64::EPSILON).then_some((operating_profit / revenue.abs()) * 100.0) +} + +/// Calculates other operating expenses as a derived value. +/// Formula: operating_profit - gross_profit + sga_expenses + rd_expenses +/// Returns None if not all required components are available. +/// Returns Some(0.0) if calculation yields a value within EPSILON of zero. +fn calculate_other_operating_expenses( + operating_profit: Option, + gross_profit: Option, + sga_expenses: Option, + rd_expenses: Option, +) -> Option { + let operating_profit = operating_profit?; + let gross_profit = gross_profit?; + let sga_expenses = sga_expenses.unwrap_or(0.0); + let rd_expenses = rd_expenses.unwrap_or(0.0); + + let result = operating_profit - gross_profit + sga_expenses + rd_expenses; + + // Return 0.0 for near-zero results to distinguish from None + if result.abs() < f64::EPSILON { + Some(0.0) + } else { + Some(result) + } +} + fn duration_days(start: &str, end: &str) -> Option { let start = parse_date(start)?; let end = parse_date(end)?; @@ -404,4 +457,62 @@ mod tests { parse_filing_xbrl_instance(xml.as_bytes()).expect("document should parse") } + + #[test] + fn calculate_gross_profit_margin_should_return_correct_percentage() { + // Normal case: 68.8% margin (like MSFT fiscal data) + let result = calculate_gross_profit_margin(Some(193_893.0), Some(281_724.0)); + assert_eq!(result.unwrap().round(), 69.0); // 68.83% rounds to 69% + + // Zero revenue should return None + assert_eq!(calculate_gross_profit_margin(Some(100.0), Some(0.0)), None); + assert_eq!(calculate_gross_profit_margin(Some(100.0), None), None); + + // Negative gross profit (loss) should still calculate + assert!(calculate_gross_profit_margin(Some(-50.0), Some(100.0)).unwrap() < 0.0); + } + + #[test] + fn calculate_operating_margin_should_return_correct_percentage() { + // Normal case: 45.6% margin (like MSFT fiscal data) + let result = calculate_operating_margin(Some(128_528.0), Some(281_724.0)); + assert_eq!(result.unwrap().round(), 46.0); // 45.62% rounds to 46% + + // Zero revenue should return None + assert_eq!(calculate_operating_margin(Some(100.0), Some(0.0)), None); + + // Negative operating profit should still calculate + assert!(calculate_operating_margin(Some(-20.0), Some(100.0)).unwrap() < 0.0); + } + + #[test] + fn calculate_other_operating_expenses_should_derive_correctly() { + // Case: operating_profit = 100, gross_profit = 200, sga = 50, rd = 30 + // Formula: 100 - 200 + 50 + 30 = -20 + assert_eq!( + calculate_other_operating_expenses(Some(100.0), Some(200.0), Some(50.0), Some(30.0)), + Some(-20.0) + ); + + // Missing SGA/R&D should default to 0 + assert_eq!( + calculate_other_operating_expenses(Some(100.0), Some(200.0), None, None), + Some(-100.0) + ); + + // Near-zero result should return Some(0.0) + let near_zero = calculate_other_operating_expenses( + Some(100.0), + Some(100.0), + Some(f64::EPSILON / 2.0), + Some(0.0), + ); + assert_eq!(near_zero, Some(0.0)); + + // Missing required components should return None + assert_eq!( + calculate_other_operating_expenses(None, Some(200.0), Some(50.0), Some(30.0)), + None + ); + } }