Metadata-Version: 2.4
Name: cdfi-benchmark
Version: 0.3.1
Summary: CDFI and MDI peer benchmarking tool using FDIC call report data — NIM, efficiency ratio, ROAA, ROAE, Tier 1 leverage, and more
License: MIT
Project-URL: Homepage, https://github.com/Jaypatel1511/cdfi-benchmark
Requires-Python: >=3.9
Description-Content-Type: text/markdown
Requires-Dist: pandas>=1.4.0
Requires-Dist: numpy>=1.21.0
Requires-Dist: requests>=2.27.0

# 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` | >= 8% | 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.

### 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 `>= 8%` is the Community Bank Leverage Ratio (CBLR)
qualifying level (12 CFR 324.12, lowered from 9% effective 2026-07-01) and
Adequate `>= 5%` is the leverage-ratio minimum for "well capitalized" under
Prompt Corrective Action (12 CFR 324.403).

---

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