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-leveldatakey absent,null, or not a list) or at the field level inside a record: a record missing itsCERTidentity, aCERTthat isn't int-coercible, or any core/optional financial field that is present but not numeric. A bad record is never coerced into a phantomcert=0bank or a fabricated0.0.- Legitimately empty is not an error. A successful request that returns zero rows
(
{"data": []}) returns the empty value for that fetcher —None, an emptyDataFrame, 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 ownorhouseappears 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.mdandpyproject.tomlpasses, 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)
| File | Size | Uploaded | |
|---|---|---|---|
| cdfi_benchmark-0.3.2.tar.gz | 188.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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 logRelease 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