Compute President and top-10 compensation CAGRs over a shared ten-year window

The top-10 CAGR was hardcoded to the last five years while the President
CAGR used the full range, so the two cards covered different periods.
Also fix start_comp, which returned the end value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X9jG4CUFcH6n4vFvgtwndY
This commit is contained in:
emfurst 2026-10-19 08:10:29 -04:00
commit 5c82c22aa2
3 changed files with 40 additions and 34 deletions

View file

@ -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

View file

@ -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(

View file

@ -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)