cdfi-stress-tester
Professional CDFI portfolio stress testing engine for Python.
Monte Carlo simulation with correlated shocks to NOI, interest rates, and property values. Compute VaR, CVaR, and capital adequacy across 2008-style recession, COVID, rate-spike, and regional CRE crash scenarios — all from pure numpy, no simulation libraries required.
Why cdfi-stress-tester?
CDFI Fund regulations and investor due diligence increasingly require stress-tested capital adequacy projections. Building Monte Carlo models in Excel is brittle and hard to audit. cdfi-stress-tester provides a reproducible, testable Python engine tuned to community development finance portfolio characteristics.
Installation
pip install cdfi-stress-tester
Requires: numpy (Python 3.9+). No other runtime dependency.
Quickstart
The snippet below and the output under it are generated together by
scripts/render_readme_block.py, which executes the snippet and pastes back what it
actually printed. CI re-renders and byte-diffs this region, so the numbers on this page
cannot drift away from what the code does.
These figures are reproducible, not stable by contract: they come from
numpy.random.Generator, which carries no bit-stream compatibility guarantee across numpy
releases. They were verified identical on numpy 1.26.4 and 2.2.6. If the byte-diff goes red
after a numpy upgrade with no source change, the stream moved — see
Known limitations item 5.
from cdfistress import (
generate_sample_portfolio,
from_standard,
MonteCarloEngine,
generate_stress_report,
scenario_comparison_table,
capital_adequacy_report,
)
# A reproducible 50-loan CDFI portfolio
loans = generate_sample_portfolio(n=50, seed=42)
# Monte Carlo engine with $5MM of loss-absorbing capital
engine = MonteCarloEngine(loans=loans, available_capital=5_000_000)
# Run a 2008-style recession
result = engine.run_simulation(from_standard("2008_recession"), n_iterations=1000, seed=42)
print(generate_stress_report(result))
# Capital adequacy against the loss distribution that run produced
print()
for key, value in capital_adequacy_report(5_000_000, engine.loss_distribution).items():
print(f" {key:<18} {value:>18,.4f}")
# Inspect a scenario's parameters
print()
print(from_standard("rate_spike").summary())
# Compare several scenarios side by side
scenarios = [from_standard(k) for k in ["mild_downturn", "rate_spike", "2008_recession"]]
results = [engine.run_simulation(s, n_iterations=500, seed=0) for s in scenarios]
print()
print(scenario_comparison_table(results))
Output (verbatim -- this block is generated by executing the snippet above):
============================================================
CDFI PORTFOLIO STRESS TEST REPORT
Scenario: 2008-Style Recession [SEVERE]
============================================================
SCENARIO PARAMETERS
NOI shock : -35.0%
Rate shock : +0 bps
Property value shock : -40.0%
Default mult : 4.0x baseline
LOSS METRICS
Expected Loss : $ 8,843,297
VaR (95%) : $ 15,033,139
VaR (99%) : $ 17,396,438
CAPITAL ADEQUACY
CAR vs Expected Loss : 0.57x
Simulation breaches : 858 / 1000 (85.8%)
STATUS: INSUFFICIENT
============================================================
available_capital 5,000,000.0000
expected_loss 8,843,296.9130
var_95 15,033,139.3438
var_99 17,396,437.8562
cvar_99 20,104,925.2590
car_vs_el 0.5654
car_vs_var99 0.2874
car_vs_cvar99 0.2487
breaches 858.0000
breach_rate 0.8580
Scenario: Rate Spike (+300 bps) [moderate]
NOI shock : -5.0%
Rate shock : +300 bps
Property shock : -20.0%
Default mult : 1.8x
Scenario Severity EL VaR95 VaR99 CAR Breaches
---------------------------------------------------------------------------------------------------------
Mild Cyclical Downturn mild $ 2,476,091 $ 6,029,387 $ 7,761,442 2.02x 52/500
Rate Spike (+300 bps) moderate $ 3,122,681 $ 7,171,131 $ 8,733,905 1.60x 94/500
2008-Style Recession severe $ 8,769,697 $ 15,572,653 $ 19,507,034 0.57x 429/500
Key Features
| Feature | Detail |
|---|---|
| Monte Carlo engine | MonteCarloEngine with seed-controlled reproducibility |
| Correlated shocks | Multivariate normal draws for NOI, rate, and property value (see Known limitations before relying on this) |
| Standard scenarios | 2008 recession, COVID, rate spike (+300 bps), regional CRE crash, mild downturn |
| Custom scenarios | create_recession_scenario(), create_rate_shock_scenario() |
| VaR / CVaR | 95th and 99th percentile VaR; Expected Shortfall (CVaR) |
| Capital adequacy | CAR vs EL, VaR, and CVaR; breach count and breach rate |
| Tier 1 under stress | Post-stress Tier 1 ratio given risk-weighted assets |
| Sample portfolio | Reproducible 50-loan CDFI portfolio with realistic parameters |
| LGD modeling | Per-loan LGD with collateral shortfall and 30% foreclosure-cost floor |
| Reports | generate_stress_report() and scenario_comparison_table() |
Scenarios
STANDARD_SCENARIOS = {
"2008_recession": {"noi_shock": -0.35, "rate_shock": 0.00, "property_value_shock": -0.40, "default_rate_multiplier": 4.0},
"covid_shock": {"noi_shock": -0.25, "rate_shock": -0.01, "property_value_shock": -0.15, "default_rate_multiplier": 2.5},
"rate_spike": {"noi_shock": -0.05, "rate_shock": +0.03, "property_value_shock": -0.20, "default_rate_multiplier": 1.8},
"regional_cre_crash":{"noi_shock": -0.20, "rate_shock": +0.01, "property_value_shock": -0.50, "default_rate_multiplier": 3.0},
"mild_downturn": {"noi_shock": -0.10, "rate_shock": +0.005,"property_value_shock": -0.08, "default_rate_multiplier": 1.4},
}
Use Cases
- CDFI Fund reporting — Demonstrate capital adequacy under regulatory stress scenarios.
- Rating agency submissions — Produce reproducible loss distributions for credit analysis.
- Board risk committees — Run scenario comparison tables for governance reporting.
- Investors / lenders — Stress-test CDFI portfolios during due diligence.
- Policy researchers — Analyze CDFI portfolio resilience under macro stress. (Shocks are portfolio-wide; the engine does not target a single sector or geography — see Known limitations.)
API Reference
# Data
generate_sample_portfolio(n=50, seed=42) # 50-loan CDFI portfolio
Loan(loan_id, borrower_name, outstanding_balance, noi, debt_service,
property_value, interest_rate, sector, state, ltv)
.dscr # noi / debt_service
.is_stressed # dscr < 1.0
StressScenario(name, noi_shock, rate_shock, property_value_shock,
default_rate_multiplier, severity)
.summary()
StressResult(scenario, expected_loss, var_95, var_99,
capital_adequacy_ratio, num_breaches, total_simulations)
.summary()
STANDARD_SCENARIOS # dict of the five prebuilt scenarios
CORRELATION_DEFAULTS # default pairwise risk-factor correlations
SECTOR_DEFAULT_RATES # house baseline annual default rate per sector
# Scenarios
from_standard("2008_recession")
create_recession_scenario(noi_shock, rate_shock, property_value_shock,
default_rate_multiplier, name, severity)
create_rate_shock_scenario(rate_shock, property_value_shock)
apply_shock_to_loan(loan, scenario) # returns stressed Loan
# Simulation
MonteCarloEngine(loans, available_capital, correlation_matrix)
.run_simulation(scenario, n_iterations=1000, seed=None) # → StressResult
.apply_correlated_shocks(scenario, n_iterations, seed) # → ndarray (n, 3)
.default_probabilities(scenario) # → {loan_id: pd}
.loss_distribution # → ndarray, last run's losses
# VaR / Capital
value_at_risk(losses, confidence=0.95)
conditional_var(losses, confidence=0.95)
expected_loss(losses)
tail_loss(losses, pct=0.01)
capital_adequacy(available_capital, expected_loss_amount)
tier1_under_stress(tier1_capital, losses, risk_weighted_assets, confidence=0.99)
buffer_breach_count(losses, capital_buffer)
capital_adequacy_report(available_capital, losses)
# Correlations
build_correlation_matrix(noi_rate, noi_property, rate_property)
default_correlations()
is_positive_semidefinite(matrix)
sector_correlation_boost(sector)
# Reports
generate_stress_report(result) # → str
scenario_comparison_table(results) # → str
Known limitations
These are measured against the 0.2.0 code, not inferred. Read them before putting output into a regulatory, board, or investor submission.
-
Scenario shocks are portfolio-wide — there is no sector or geographic targeting. A scenario's
noi_shock,rate_shock,property_value_shockanddefault_rate_multiplierare scalars with no per-segment override, andrun_simulationapplies them to every loan. 0.1.0 shippedcreate_sector_specific_scenario(sector, ...), butsectoronly built a display string: two different sectors produced byte-identical loss distributions. The function was removed in 0.2.0 rather than dressed up with invented per-sector calibrations. Do not name a scenario in a way that implies it is confined to one segment.What is missing is calibration, not mechanism. The engine already resolves each loan's sector into a per-loan baseline default rate via
SECTOR_DEFAULT_RATES, and that per-loan array is what the simulation scales — so a single run already carries sector-differentiated default probabilities. Segment-targeted stress would scale that existing per-loan array; it does not require an engine rewrite. What does not exist is a primary source for how much harder a retail book should be shocked than a multifamily one, so the per-sector factors would be invented. If you want this capability, scope a calibration source, not an engine. -
Only the NOI draw affects losses. Each Monte Carlo path draws a correlated triple (NOI, rate, property value), but only the NOI draw is consumed — it scales each loan's default probability. The rate draw is discarded. The property-value shock enters LGD only at its scenario mean, computed once outside the path loop, so it does not vary by path.
-
Consequently
correlation_matrixdoes not change the loss distribution. Averaged over 12 seeds at 4,000 iterations each on the sample portfolio under2008_recession, mean expected loss was $8,807,537 with the default correlations, $8,804,655 with the identity matrix, and $8,814,075 with all pairwise correlations at +0.99 — a spread well inside the ~$54,000 across-seed standard deviation. Passing a different matrix reshuffles the NOI sample; it does not change the distribution being sampled. -
The calibrations are house assumptions.
SECTOR_DEFAULT_RATES, the factor volatilities (NOI 15%, rate 0.5%, property value 20%), and the 30% LGD floor are the author's working assumptions, not published or regulator-endorsed calibrations. Substitute your own before relying on absolute loss levels. -
The committed figures are reproducible, not guaranteed stable across numpy releases. Every number on this page is generated by executing the code, and CI byte-diffs it. But the draws come from
numpy.random.Generator, whose documentation states "No Compatibility Guarantee … the bit stream may change", and they route through LAPACKgesdd, whose singular-vector signs are not a standardised convention across builds. The figures here were verified identical on numpy 1.26.4 and 2.2.6; the declared floor isnumpy>=1.22. If a golden gate goes red after a numpy or BLAS/LAPACK upgrade with no source change, the stream moved. Re-rendering alone is not the fix: re-render and update every document that hand-copies these figures — this section and CHANGELOG.md — in the same commit.tests/test_documented_figures.pygates those hand-copied duplicates so the two cannot silently disagree, which is precisely the 0.1.0 defect.
Items 2 and 3 describe existing engine behaviour. 0.2.0 documents them; it does not change the simulation math.
Changelog
See CHANGELOG.md. 0.2.0 corrects a 0.1.0 README that reported loss figures the engine never produced and reversed its own capital-adequacy verdict; the simulation math was not changed.
License
MIT © Jay Patel
Metadata
Release files for cdfi-stress-tester 0.2.0
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_stress_tester-0.2.0.tar.gz | 76.6 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| cdfi_stress_tester-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 98.4 kB
Release files / cdfi_stress_tester-0.2.0.tar.gz
| Download URL | cdfi_stress_tester-0.2.0.tar.gz |
|---|---|
| Size | 76.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
a007ff4f2c9408b20d650e4a29449dae11cfd5c80bc77b268499fc217a989a62
|
|
BLAKE2b-256 checksum How to use checksums |
ea486a81f5c1a2c3439b4dd18d1935a149d2fbb17cd0727bba8d5ead31e22d47
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.6
|
Release files / cdfi_stress_tester-0.2.0-py3-none-any.whl
| Download URL | cdfi_stress_tester-0.2.0-py3-none-any.whl |
|---|---|
| Size | 21.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
80fbef6279f77e5467b7428b44ad668f0c416c514cbccfe0fc4705bc02a8addc
|
|
BLAKE2b-256 checksum How to use checksums |
6f48b364fb41390b37438982770e5e3a09eb23270cc7841dbe97bbac09ef680f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.6
|