from __future__ import annotations from dataclasses import dataclass from typing import Iterable, List, Sequence, Tuple from app.agents.form_auditor.models import ExtractedIrsForm990PfDataSchema from .models import TrendDirection, TrendMetric, TrendMetricPoint, YearlySnapshot @dataclass class SnapshotBundle: year: int extraction: ExtractedIrsForm990PfDataSchema def _safe_ratio(numerator: float, denominator: float) -> float | None: if denominator in (0, None): return None try: return numerator / denominator except ZeroDivisionError: return None def _growth(current: float, previous: float | None) -> float | None: if previous in (None, 0): return None try: return (current - previous) / previous except ZeroDivisionError: return None def _direction_from_points(values: Sequence[float | None]) -> TrendDirection: clean = [value for value in values if value is not None] if len(clean) < 2: return TrendDirection.STABLE start, end = clean[0], clean[-1] if start is None or end is None: return TrendDirection.STABLE delta = end - start tolerance = abs(start) * 0.02 if start else 0.01 if abs(delta) <= tolerance: return TrendDirection.STABLE if len(clean) > 2: swings = sum( 1 for idx in range(1, len(clean) - 1) if (clean[idx] - clean[idx - 1]) * (clean[idx + 1] - clean[idx]) < 0 ) if swings >= len(clean) // 2: return TrendDirection.VOLATILE return TrendDirection.IMPROVING if delta > 0 else TrendDirection.DECLINING def _cagr(start: float | None, end: float | None, periods: int) -> float | None: if start is None or end is None or start <= 0 or end <= 0 or periods <= 0: return None return (end / start) ** (1 / periods) - 1 def build_snapshots(bundles: Sequence[SnapshotBundle]) -> List[YearlySnapshot]: snapshots: List[YearlySnapshot] = [] previous_revenue = None previous_expenses = None for bundle in bundles: rev = bundle.extraction.revenue_breakdown.total_revenue exp = bundle.extraction.expenses_breakdown.total_expenses program = bundle.extraction.expenses_breakdown.program_services_expenses admin = bundle.extraction.expenses_breakdown.management_general_expenses fundraising = bundle.extraction.expenses_breakdown.fundraising_expenses snapshots.append( YearlySnapshot( year=bundle.year, total_revenue=rev, total_expenses=exp, revenue_growth=_growth(rev, previous_revenue), expense_growth=_growth(exp, previous_expenses), surplus=rev - exp, program_ratio=_safe_ratio(program, exp), admin_ratio=_safe_ratio(admin, exp), fundraising_ratio=_safe_ratio(fundraising, exp), net_margin=_safe_ratio(rev - exp, rev), ) ) previous_revenue = rev previous_expenses = exp return snapshots def _metric_from_series( name: str, unit: str, description: str, values: Iterable[Tuple[int, float | None]], ) -> TrendMetric: points = [ TrendMetricPoint(year=year, value=value or 0.0, growth=None) for year, value in values ] for idx in range(1, len(points)): prev = points[idx - 1].value curr = points[idx].value points[idx].growth = _growth(curr, prev) data_values = [point.value for point in points] direction = _direction_from_points(data_values) cagr = None if len(points) >= 2: cagr = _cagr(points[0].value, points[-1].value, len(points) - 1) return TrendMetric( name=name, unit=unit, description=description, points=points, cagr=cagr, direction=direction, ) def build_key_metrics(snapshots: Sequence[YearlySnapshot]) -> List[TrendMetric]: if not snapshots: return [] metrics = [ _metric_from_series( "Total Revenue", "USD", "Reported total revenue in Part I.", [(snap.year, snap.total_revenue) for snap in snapshots], ), _metric_from_series( "Total Expenses", "USD", "Reported total expenses in Part I.", [(snap.year, snap.total_expenses) for snap in snapshots], ), _metric_from_series( "Operating Surplus", "USD", "Difference between total revenue and total expenses.", [(snap.year, snap.surplus) for snap in snapshots], ), _metric_from_series( "Program Service Ratio", "Ratio", "Program service expenses divided by total expenses.", [ ( snap.year, snap.program_ratio if snap.program_ratio is not None else 0.0, ) for snap in snapshots ], ), _metric_from_series( "Administrative Ratio", "Ratio", "Management & general expenses divided by total expenses.", [ (snap.year, snap.admin_ratio if snap.admin_ratio is not None else 0.0) for snap in snapshots ], ), _metric_from_series( "Fundraising Ratio", "Ratio", "Fundraising expenses divided by total expenses.", [ ( snap.year, snap.fundraising_ratio if snap.fundraising_ratio is not None else 0.0, ) for snap in snapshots ], ), ] for metric in metrics: if metric.name.endswith("Ratio"): metric.notes = "Higher values indicate greater spending share." elif metric.name == "Operating Surplus": metric.notes = "Positive surplus implies revenues exceeded expenses." return metrics