diff --git a/src/admin_analytics/dashboard/pages/about.py b/src/admin_analytics/dashboard/pages/about.py index f459dc7..34b2412 100644 --- a/src/admin_analytics/dashboard/pages/about.py +++ b/src/admin_analytics/dashboard/pages/about.py @@ -32,8 +32,8 @@ reports detailed compensation for officers, directors, trustees, and key employees of tax-exempt organizations. The University of Delaware (EIN 516000297) and UD Research Foundation (EIN 516017306) are the filing entities. Titles are normalized to canonical roles (President, Provost, VP Finance, etc.) -using pattern matching. CAGR is computed as compound annual growth rate from -first to last available year. +using pattern matching. President and top-10 CAGRs are compound annual growth +rates over the most recent ten tax years of data. **Administrative Cost Ratios** use IPEDS finance survey data. "Institutional support" is the IPEDS functional expense category that most closely diff --git a/src/admin_analytics/dashboard/queries.py b/src/admin_analytics/dashboard/queries.py index a8ea2cf..0b61f53 100644 --- a/src/admin_analytics/dashboard/queries.py +++ b/src/admin_analytics/dashboard/queries.py @@ -8,6 +8,9 @@ import polars as pl from admin_analytics.config import UD_UNITID from admin_analytics.irs990.titles import normalize_title +# Compensation CAGRs cover the most recent ten tax years of Schedule J data +CAGR_WINDOW_YEARS = 10 + # Shared CTE for CPI adjustment _CPI_CTE = """ WITH annual_cpi AS ( @@ -125,19 +128,18 @@ def query_aggregate_comp(conn: duckdb.DuckDBPyConnection) -> pl.DataFrame: """).pl() -def query_aggregate_comp_cagr(conn: duckdb.DuckDBPyConnection) -> dict | None: - """CAGR of aggregate Schedule J compensation over the last 5 years of data.""" - df = query_aggregate_comp(conn) +def _windowed_cagr(df: pl.DataFrame, value_col: str) -> dict | None: + """CAGR over the most recent CAGR_WINDOW_YEARS tax years of ``df``.""" + if df.height == 0: + return None + end_year = df["tax_year"][-1] + df = df.filter(pl.col("tax_year") > end_year - CAGR_WINDOW_YEARS) if df.height < 2: return None - # Use last 5 years of available data - df = df.tail(min(5, df.height)) - start_year = df["tax_year"][0] - end_year = df["tax_year"][-1] - start_comp = float(df["total_comp"][0]) - end_comp = float(df["total_comp"][-1]) + start_comp = float(df[value_col][0]) + end_comp = float(df[value_col][-1]) n_years = end_year - start_year if n_years <= 0 or start_comp <= 0: @@ -148,13 +150,18 @@ def query_aggregate_comp_cagr(conn: duckdb.DuckDBPyConnection) -> dict | None: "cagr_pct": round(cagr, 1), "start_year": start_year, "end_year": end_year, - "start_comp": int(end_comp), + "start_comp": int(start_comp), "end_comp": int(end_comp), } +def query_aggregate_comp_cagr(conn: duckdb.DuckDBPyConnection) -> dict | None: + """CAGR of top-10 Schedule J compensation over the last ten tax years.""" + return _windowed_cagr(query_aggregate_comp(conn), "total_comp") + + def query_comp_cagr(conn: duckdb.DuckDBPyConnection) -> dict | None: - """Annualized growth rate (CAGR) of President compensation. + """Annualized growth rate (CAGR) of President compensation over the last ten tax years. Tracks the President role specifically using title normalization. Returns dict with cagr_pct, start_year, end_year, start_comp, end_comp, @@ -183,27 +190,7 @@ def query_comp_cagr(conn: duckdb.DuckDBPyConnection) -> dict | None: .sort("tax_year") ) - if df.height < 2: - return None - - start_year = df["tax_year"][0] - end_year = df["tax_year"][-1] - start_comp = df["top_comp"][0] - end_comp = df["top_comp"][-1] - n_years = end_year - start_year - - if n_years <= 0 or start_comp <= 0: - return None - - cagr = ((end_comp / start_comp) ** (1.0 / n_years) - 1) * 100 - - return { - "cagr_pct": round(cagr, 1), - "start_year": start_year, - "end_year": end_year, - "start_comp": start_comp, - "end_comp": end_comp, - } + return _windowed_cagr(df, "top_comp") def query_top_earners( diff --git a/tests/test_dashboard_queries.py b/tests/test_dashboard_queries.py index cdea9b8..b9db31b 100644 --- a/tests/test_dashboard_queries.py +++ b/tests/test_dashboard_queries.py @@ -7,6 +7,8 @@ from admin_analytics.dashboard.queries import ( query_admin_per_student, query_admin_faculty_ratio, query_top_earners, + query_comp_cagr, + query_aggregate_comp_cagr, query_comp_by_role, query_comp_vs_cpi, query_staff_composition, @@ -129,6 +131,23 @@ class TestTopEarners: assert df_empty.height == 0 +class TestCompCagr: + def test_uses_last_ten_tax_years(self, db_conn): + # 12 years of data; the window should start at 2015, not 2013 + for year in range(2013, 2025): + comp = 1_000_000 if year < 2015 else 500_000 * 2 ** ((year - 2015) / 9) + db_conn.execute( + "INSERT INTO raw_990_schedule_j (tax_year, person_name, title, total_compensation) " + "VALUES (?, 'P', 'PRESIDENT', ?)", + [year, round(comp)], + ) + for result in (query_comp_cagr(db_conn), query_aggregate_comp_cagr(db_conn)): + assert (result["start_year"], result["end_year"]) == (2015, 2024) + assert result["start_comp"] == 500_000 + assert result["end_comp"] == 1_000_000 + assert result["cagr_pct"] == 8.0 + + class TestCompByRole: def test_groups_by_role(self, db_conn): _seed_990(db_conn)