Skip to main content

cdfi-benchmark 📊

CDFI and MDI peer benchmarking tool using FDIC call report data.

Pull call report financials for any FDIC-insured CDFI or MDI, compute key performance metrics, build a peer group of similar institutions, and generate a benchmarking report — using the free FDIC BankFind Suite API, no API key required.


Why cdfi-benchmark?

CDFI banks and MDIs benchmark their performance against peers manually — pulling call report data from FFIEC, computing ratios in Excel, and building comparison tables by hand. cdfi-benchmark automates the entire workflow in Python.


Installation

pip install cdfi-benchmark

Quickstart

from cdfibenchmark import (
    get_financials, build_peer_group,
    generate_report, summary_table,
)

# Pull call report data for City First Bank, N.A. (CERT 34352) — a real
# CDFI/MDI in Washington, DC. Look a CERT up with search_institutions()
# rather than copying one; a cert and a name are bound by the FDIC, not
# by this README.
institution = get_financials(cert=34352)

# Build peer group — the banks NEAREST the institution in assets, not the
# largest ones in its window. The group's real breadth is set by max_peers
# (50), NOT by the +/-50% asset window: for 98.1% of filers that window
# already holds more than 50 banks and does not bind at all. No API key
# needed. Peers are pinned to the institution's own report_date unless you
# pass another.
peers = build_peer_group(institution, same_state=True)

# How the group was chosen, in words, including that the window and the
# group size are this tool's own choices.
print(peers.selection_basis)
# Where the institution sits INSIDE its own peer group by assets. Near 50
# means the group brackets it; 0 or 100 means the comparison is size-skewed.
print("subject asset percentile:", peers.asset_percentile)

# Anything that makes the peer group less than ideal is on the group and
# is rendered on the report — a dropped same-state constraint, a group
# below min_peers, a mixed reporting period, a size-skewed group.
for caveat in peers.caveats:
    print("CAVEAT:", caveat)

# Generate benchmarking report
report = generate_report(institution, peers)
print(report)

# Get results as DataFrame — includes `basis` and `threshold_source`
df = summary_table(institution, peers)

Sample Data (No API Required)

Everything in this block is invented. The institution does not exist, its CERT is outside the FDIC's issued range, and build_sample_peer_group generates its peers by scaling these figures pseudo-randomly. No report built from it describes any real bank. Use it to see the output shape, never as data.

from cdfibenchmark import build_sample_peer_group
from cdfibenchmark.data.schema import InstitutionProfile

institution = InstitutionProfile(
    cert=99001,                                   # not an issued FDIC cert
    name="Riverstone Community Bank (SYNTHETIC)",
    city="Los Angeles",
    state="CA",
    report_date="20241231",                       # Q4 — a full-year period
    total_assets=655_000,
    total_deposits=520_000,
    net_loans=380_000,
    net_income=1_950,
    interest_income=28_000,
    interest_expense=8_000,
    non_interest_income=3_500,
    non_interest_expense=22_000,
    total_equity=48_000,
    tier1_ratio=12.2,
)

peers = build_sample_peer_group(institution)
report = generate_report(institution, peers)
print(report)

Because this profile carries no FDIC-published ratios, NIM/ROAA/ROAE fall back to the computed proxy — so the report shows their values with a Basis: line and grades NIM N/A. That is the intended behaviour, not a bug; see Period basis below.


Metrics Computed

Metric Source field Benchmark (Strong) Threshold provenance
NIM FDIC NIMY >= 3.5% HOUSE
Efficiency Ratio FDIC EEFFR <= 60% HOUSE
ROAA FDIC ROA >= 1.0% HOUSE
ROAE FDIC ROE >= 10% HOUSE
Tier 1 Leverage Ratio FDIC RBC1AAJ >= 9% before 2026-07-01, >= 8% from it 12 CFR 324.12 / 324.403
Loans-to-Deposits LNLSNET / DEP 50%–80% (band) HOUSE
NPL Ratio NCLNLS / LNLSGR <= 1.0% HOUSE
Reserve Coverage LNATRES / NCLNLS >= 100% HOUSE

Status is graded against thresholds, never against the peer group

status compares the institution's value to the fixed thresholds in the table above. It does not read the peer median or the percentiles, which are reported alongside it and answer a different question. A metric can grade STRONG while sitting below the peer median, and ADEQUATE while sitting entirely outside the peer range — both happen on real banks at 20260630. The rendered report carries the same sentence beside the table.

Comparisons to the peer median are stated in BOTH units

A metric's distance from its peer median is reported twice on the same line, because the two numbers mean different things and one alone is misleading:

**vs Peer Median:** 0.85 pp below median (72.6% below)

0.85 pp is the arithmetic difference of the two percentages printed directly above it — percentage POINTS. 72.6% is that gap as a share of the peer median. Through 0.3.1 only the first was rendered, and it carried a % sign: a bank earning less than a third of its peer group's ROAA read as "0.85% below median", which a reader reasonably takes for a near-miss. Both figures are computed from the printed operands, so both can be reproduced from the page.

The relative figure is withheld, with the reason stated inline, whenever the peer median is not positive at the precision the page prints — and the reason given is the one true of that case, not a single sentence covering all three:

  • negative median — a percentage of it carries the opposite sign to the direction stated beside it, so the same line would say "above" and "below" at once. FDIC really does publish negative efficiency ratios and negative ROE.
  • zero median — the ratio is undefined. There is no sign to invert, and saying there is one is a false statement about the peer group.
  • a median that only rounds to zero — it is positive; a gap relative to 0.0024% simply is not informative. The line says the median rounds to zero at the printed precision rather than claiming it is not positive.

If the relative figure itself rounds to 0.0%, it is reported as rounding to zero rather than dressed as a direction: a magnitude of zero never carries an above or below.

above/below is a statement of fact and never of merit; whether above is good depends on the metric, and that is what Status answers.

Threshold provenance

tier1_ratio is the only metric whose thresholds come from a published regulatory standard. Bank capital has one; earnings, efficiency, funding and reserve-coverage ratios do not — the FDIC publishes these series and reports them against a peer group in the UBPR, but publishes no required or "well capitalized"-equivalent cut point for any of them.

Every other threshold in this package is therefore a HOUSE rule of thumb: this tool's own, marked "source": "HOUSE" in BENCHMARKS, defined by HOUSE_-prefixed constants, and rendered on every report as "this tool's own threshold (HOUSE), not a regulatory or supervisory standard". Treat them as a starting point to be argued with, not as a standard to be met.

Loans-to-deposits is graded as a band, not a ladder. Above the band is funding strain; below it is under-deployment, which for a CDFI bank is its own failure. All three boundaries are house numbers. Calibration note, measured against the 50 banks nearest CERT 34352 in assets at 20260630 — selected from the 763 in its +/-50% asset window, retrieved 2026-09-05: this band grades 13 of 50 WEAK, 11 of them for exceeding 95%, and the peer median of 85.19% grades ADEQUATE. The WEAK tail is therefore almost entirely the funding-strain edge, not the under-deployment floor — which is the band doing what it was added to do. The boundaries are deliberately conservative; they have not been fitted to any population.


Period basis — read this before comparing a quarter

FDIC call-report income items (INTINC, EINTEXP, NETINC) are year-to-date. At a Q1 REPDTE they cover three months. Dividing them by a point-in-time balance and grading the result against an annual-basis threshold reads roughly 4x low — a healthy bank grades WEAK. Measured for CERT 34352 at 20260331, computed versus FDIC's own published series: NIM 4.30x, ROAA 4.12x, ROAE 4.01x.

This package does not annualize an estimate. It prefers FDIC's own published ratios — NIMY, ROA, ROE, EEFFR — which are already annualized and computed over the correct average denominators, which is the basis the thresholds are calibrated to. That is a measurement, not a projection.

When a published ratio is absent (a hand-built InstitutionProfile, or a field the API omitted) the computed proxy is used, its basis is rendered on the report, and it is not graded:

Metric Computed fallback Graded?
NIM net interest income / total assets, YTD Never — the 3.5% threshold is calibrated to NIMY, which is over average earning assets. A larger denominator biases it low at every period, including Q4.
ROAA / ROAE YTD net income / period-end balances Only at a Q4 REPDTE, where the flow covers the full year.
Efficiency Ratio (NONIX - EAMINTAN) / ((INTINC - EINTEXP) + NONII) Always — numerator and denominator are YTD flows over the same period, so the period cancels exactly. Annualizing it would introduce an error.
Tier 1, L/D, NPL, Reserve Coverage period-end balances only Always — no flow item, no period error.

Labels follow the basis. "Return on Average Assets (ROAA)" is used only when the value is FDIC's published ROA; the computed fallback renders as "Return on Assets, period-end (ROAA)", because that is what was actually divided by what.

A value that is reported but not graded shows its measurement and an explicit Not graded: line. The number is never hidden — only the grade is withheld.


The Tier 1 Leverage Ratio thresholds follow bank-capital regulation, not an arbitrary target: Strong is the Community Bank Leverage Ratio (CBLR) qualifying level (12 CFR 324.12) and Adequate >= 5% is the leverage-ratio minimum for "well capitalized" under Prompt Corrective Action (12 CFR 324.403(b)(1)(i)(D)).

The CBLR level is selected for the institution's own report date, not applied to every period. It was lowered from 9% to 8% effective 2026-07-01 (91 FR 22973, published 2026-04-29), so a bank filing at 20260630 is graded against 9% and one filing at 20260930 against 8%. Through 0.3.1 both were graded against 8% — a threshold applied to a period one day before it existed. The rendered Benchmark: line states which level graded the report and when the other one takes effect, so the two are never ambiguous on the page. The PCA leg is period-invariant across this window: 12 CFR 324.403(b)(1)(i)(D) reads 5.0% at both the 2026-06-30 and 2026-09-04 eCFR snapshots.

This is the only threshold in the package with an effective date, because it is the only one citing a real instrument. A HOUSE rule of thumb has none.


Asset Size Buckets

These bands are this tool's own (HOUSE_ASSET_BUCKETS). They are not the FFIEC CRA small/intermediate/large-bank asset thresholds, not the FDIC community-bank definition, and not a UBPR peer-group band. "Large" in particular is a supervisory term that means specific, different things elsewhere; this bucket is not any of them.

  • micro — Under $50MM
  • small — $50MM to $250MM
  • medium — $250MM to $1B
  • large — $1B to $5B
  • mega — Over $5B

The report renders the bucket with its boundaries and this attribution beside it, so the word never travels alone. ASSET_BUCKETS remains as an alias.


Data Source

FDIC BankFind Suite API — free public API, no authentication required. Data covers all FDIC-insured institutions with quarterly call report data since 1934.

https://api.fdic.gov/banks

The historical host banks.data.fdic.gov/api now answers HTTP 301 and redirects here. Requests still succeed through the redirect, which is why the move went unnoticed; the package now calls the canonical host directly.


Error handling

The data layer fails loud. In an early-warning / anomaly-detection pipeline a silently-empty or fabricated result reads as "nothing anomalous" and masks the real problem, so the FDIC fetchers raise typed errors instead of swallowing failures:

  • FDICAPIError — a transport problem: the request never produced a usable response body. Network/timeout errors, non-2xx HTTP status, and JSON decode failures all raise this.
  • FDICResponseError — the response decoded but its structure is wrong, either at the envelope level (the top-level data key absent, null, or not a list) or at the field level inside a record: a record missing its CERT identity, a CERT that isn't int-coercible, or any core/optional financial field that is present but not numeric. A bad record is never coerced into a phantom cert=0 bank or a fabricated 0.0.
  • Legitimately empty is not an error. A successful request that returns zero rows ({"data": []}) returns the empty value for that fetcher — None, an empty DataFrame, or [] — and does not raise.

Missing-but-not-garbage fields inside an otherwise valid record are kept, not dropped: an absent core financial (e.g. ASSET) becomes NaN (unknown — it propagates to any metric computed from it rather than fabricating 0.0), and an absent optional ratio (e.g. RBCT1J) becomes None. A real present 0.0 is preserved as 0.0.

Both error types subclass CDFIBenchmarkError, so callers can catch the contract broadly or distinguish "the API is unreachable" (FDICAPIError) from "the API changed its shape" (FDICResponseError):

from cdfibenchmark import FDICAPIError, FDICResponseError

try:
    institution = get_financials(cert=34352)
except FDICAPIError:
    ...   # transport/HTTP/decode failure — retry or alert
except FDICResponseError:
    ...   # wrong-shape or present-but-garbage field — contract problem

Running Tests

PYTHONPATH=. pytest tests/ -v

Every gate in this suite was run RED before the fix it covers was written, and a gate that cannot be made to fail is treated as a defect in the gate rather than as coverage. Gates that had slipped past that rule are recorded here rather than quietly removed.

No count is given, and the previous wording's count was wrong. This section used to say that exactly one gate had slipped past. Two more were found afterwards, by sweeps that had not been run when that sentence was written — so the number was a claim about gates nobody had looked for yet, which is the kind of claim this project keeps getting wrong. What is recorded instead is the rule, the shape, and the instances actually found.

The shape is an assertion whose success does not depend on the thing it names. Found so far, all corrected in 0.3.1:

  • assert "credit union" not in text or "not" in text — the second limb is true of every README ever written, so the assertion had no red state it could have been run in. Deleted; the sibling gate below does the work.
  • a report gate asserting that this tool's own or house appears somewhere in the rendered page, standing in for "the threshold lines carry their attribution". Deleting the threshold attribution outright left the whole suite byte-identical, because a sentence about peer-group selection satisfies the same substring on every report. It now reads the **Benchmark:** lines and requires the literal attribution on each, with the number of lines that must carry it derived from the threshold table rather than typed in.
  • a threshold gate whose only assertion sat inside a filter that yields one entry today and would yield none if that one citation were dropped — passing while checking nothing. It now fails if the filter comes back empty.

The sibling gate that does the credit-union work line by line survives, and was strengthened in the same release. It requires this README to state the scope limit in prose, and requires every prose mention to say plainly that those institutions are not covered; a bare negation elsewhere in the sentence is no longer enough, which is what previously let "…with no setup required" and "nothing is excluded…" past it. What it does not do is parse a sentence: one that states the exclusion and offers the tool in the same breath still passes, and a mention hard-wrapped across two lines is not seen at all. Both limits are written into the gate beside the mutations that measured them.

Known issue in the 0.3.0 source tarball

Scope: the 0.3.0 sdist, and only when you run its suite from the tarball root. If you installed with pip, this does not affect you and there is nothing to do.

The suite that ships inside cdfi_benchmark-0.3.0.tar.gz fails when run from the unpacked tarball root — the invocation above — with two failures. Both are defects in the test gates themselves, not in the package: they required examples/, a directory MANIFEST.in deliberately prunes from the source distribution, so an unpacked sdist could never satisfy them. Nothing they check is actually wrong in 0.3.0.

Measured against the published 0.3.0 artifacts, so the boundary is exact:

  • The installed library is unaffected. The 0.3.0 wheel ships no test files at all, and its package code is byte-identical to 0.3.1's — same SHA-256 over every module. Every metric, grade, threshold, peer group and report 0.3.0 produces is correct as documented.
  • Running the shipped suite against the installed 0.3.0 wheel passes, no failures.
  • Running it from a directory holding tests/, README.md and pyproject.toml passes, no failures.

0.3.0 is not yanked. It fixes grading-direction, period-basis and peer-composition errors that 0.2.1 still carries, so pushing pinned users back to 0.2.1 would be the worse outcome. 0.3.1 fixes the tarball-root layout and the same command against it passes. CHANGELOG.md carries the reproduction and names the two failing gates.


Who This Is For

  • CDFI banks and MDIs benchmarking against peers (FDIC-insured banks only — credit unions are NCUA-regulated and are not covered by this API or this tool)
  • MDI management teams preparing board reports
  • CDFI Fund analysts reviewing institution performance
  • Impact investors evaluating CDFI bank investments
  • Researchers studying community banking performance trends

License

MIT 2026 Jaypatel1511

Release files for cdfi-benchmark 0.3.2

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for cdfi-benchmark 0.3.2
File Size Uploaded
cdfi_benchmark-0.3.2.tar.gz 188.6 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for cdfi-benchmark 0.3.2
File Interpreter ABI Platform
cdfi_benchmark-0.3.2-py3-none-any.whl Python 3 none any Details

Total release size: 241.7 kB

Release files / cdfi_benchmark-0.3.2.tar.gz

Download URL cdfi_benchmark-0.3.2.tar.gz
Size 188.6 kB
Tags Source
SHA-256 checksum
How to use checksums
9fd3daf7c67a1baa2784eeb2a1871bb81085b727a75535e8f7a0c2bcb172b6b9
BLAKE2b-256 checksum
How to use checksums
91a000c4a81aaa9f8e47a4a207dcb8c62c6817fc65eeb5f325fe26c3c9acff11
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.13

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 9, 2026.

Transparency log

Release files / cdfi_benchmark-0.3.2-py3-none-any.whl

Download URL cdfi_benchmark-0.3.2-py3-none-any.whl
Size 53.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
6dfdde918c5ed9c20e0724a28ecc11e3e50687f685af6d1ec2b3ed1e63796eca
BLAKE2b-256 checksum
How to use checksums
a36c724f6c43deef124677b3fb6f172516398da470e32e90655a0aecc20289d5
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/6.1.0 CPython/3.13.13

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 9, 2026.

Transparency log

Release history Release notifications | RSS feed

0.3.3

2 release files

This release

0.3.2 This release

2 release files

0.3.1

2 release files

0.3.0

2 release files

0.2.1

2 release files

0.2.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page