eqlx-portfolio
Deterministic credit portfolio risk: how much a loan book can lose in a bad year, how much capital that takes, and which borrowers and sectors drive it. And what a new loan has to earn to be worth that capital. A Python library, a command line tool and a read-only MCP server for AI agents.
Loans are assessed one by one, but they fail together: a hundred loans to a hundred industries and a hundred loans to one industry look the same loan by loan and are very different portfolios. This tool measures that difference.
It computes. It does not estimate default probabilities, recoveries or correlations: those are inputs. Same input, same output, and every figure comes with the steps that produced it.
- Two methods, one input. A seeded simulation of the asset value model (the approach of CreditMetrics) and the closed-form CreditRisk+ model. Use one to question the other.
- Checked against published numbers. The worked examples of the CreditMetrics Technical Document and of the CreditRisk+ document are reproduced in the test-suite; everything else is checked against closed forms, direct enumeration and brute-force simulation.
- Built for agents. The agent decides what to look at; the tool returns the number, the band around it and the steps taken. The MCP server only reads files in one folder and writes nothing.
Who it is for
Credit risk and capital planning teams who want an independent view of economic capital and concentration, credit committees asking what one more loan does to the book, validators who need a transparent benchmark for a vendor model, and people building AI agents for credit work.
Install
pip install eqlx-portfolio # library and command line
pip install "eqlx-portfolio[mcp]" # with the MCP server
Python 3.10 or newer. Depends on NumPy and SciPy only.
A first look
eqlx-portfolio sample demo # writes a made-up portfolio: demo/loans.csv, demo/settings.json
eqlx-portfolio run demo/loans.csv --config demo/settings.json
eqlx-portfolio 0.2.0 method: simulation
loans 621 borrowers 500 sectors 6 exposure 995,349,000
expected loss 12,716,172
loss std deviation 12,031,533
at 99.00% confidence
value-at-risk 57,233,350 95% band 56,635,700 to 57,801,100
expected shortfall 71,379,023
economic capital 44,517,178
at 99.90% confidence
value-at-risk 91,343,950 95% band 88,673,050 to 93,627,200
expected shortfall 104,348,278
economic capital 78,627,778
largest borrower 5.5% of exposure, ten largest 28.0%, effective number of borrowers 79
sector exposure share risk share
construction 41.1% 69.1%
services 19.4% 13.0%
manufacturing 14.8% 6.8%
retail 11.7% 4.7%
tourism 5.6% 3.5%
energy 7.4% 2.9%
borrower exposure share risk share
B0004 4.0% 12.7%
B0003 4.7% 10.6%
B0005 4.2% 8.8%
B0001 5.5% 7.4%
B0002 2.4% 3.8%
B0287 1.8% 3.6%
B0303 1.1% 2.7%
B0136 0.7% 2.6%
B0407 1.0% 2.1%
B0435 0.5% 2.0%
Construction is 41% of the exposure and 69% of the risk: five large borrowers were planted there.
The band next to each value-at-risk says how far simulation noise can move it. The same run with
--json adds the checks that were made and the steps taken.
What does one more loan do? Fifty million to a new construction borrower, then the same loan to an energy borrower:
eqlx-portfolio what-if demo/loans.csv --config demo/settings.json --new new_loan.csv
| New loan of 50,000,000 (pd 2%, lgd 45%) | Expected loss | Expected shortfall at 99.9% | Capital per unit lent |
|---|---|---|---|
| to construction | +450,000 | +8,530,008 | 17.1% |
| to energy | +450,000 | +3,824,232 | 7.6% |
Same loan, same expected loss, twice the capital: it lands where the book is already heavy. Both portfolios are valued on the same scenarios, so the difference is not simulation noise.
What does that loan have to earn? Say it runs three years with quarterly interest, funding costs 40%, the bank wants 45% on its capital after 30% tax, the capital itself earns 38%, operating costs are 1%, and 43% is on offer:
eqlx-portfolio price demo/loans.csv --config demo/settings.json --new new_loan.csv --terms terms.json
| The same loan, offered at 43% | Capital tied up | Hurdle rate | RAROC | Earns the target? |
|---|---|---|---|---|
| to construction, capital from this book | 16.2% | 43.76% | 41.7% | no |
| to energy, capital from this book | 6.7% | 42.82% | 46.9% | yes |
| either, capital from the Basel formula | 7.7% | 42.91% | 45.8% | yes |
| either, capital from a 100% risk weight at 12% | 12.0% | 43.34% | 43.0% | no |
The hurdle rate is funding cost (40.00%) + expected loss (1.11%) + operating cost (1.03%) + the cost of the capital tied up. Only the last part differs, and only the book itself can tell the two borrowers apart: a formula or a risk weight prices them the same.
Other commands: contributions (every borrower's share of the risk, as CSV), describe (what a
loan file contains), pair (joint default probability of two borrowers).
As a library
import numpy as np
from eqlx_portfolio import (
creditriskplus,
default_thresholds,
joint_default_probability,
large_portfolio_loss,
migration_thresholds,
sigma_from_asset_correlation,
simulate,
single_factor,
value_stats,
)
# One bond, rated BBB: where can it be in a year, and what is it worth there?
bbb = [0.0002, 0.0033, 0.0595, 0.8693, 0.0530, 0.0117, 0.0012, 0.0018] # AAA ... CCC, default
values = [109.37, 109.19, 108.66, 107.55, 102.02, 98.10, 83.64, 51.13]
s = value_stats(bbb, values)
round(s.mean, 2), round(s.sd, 2) # (107.09, 2.99)
np.round(migration_thresholds(bbb), 2) # [ 3.54 2.7 1.53 -1.49 -2.18 -2.75 -2.91]
# Two borrowers with 0.18% and 0.06% default probability and 30% asset correlation
joint_default_probability(0.0018, 0.0006, 0.30)[0] # 1.561e-05, 14.5 times the independent 1.08e-06
# 100 loans of 1,000,000, pd 2%, lgd 45%, one common factor with 20% asset correlation
pd = np.full(100, 0.02)
amount = np.full(100, 1_000_000 * 0.45)
sim = simulate(
single_factor(0.20, 100),
default_thresholds(pd),
np.column_stack((np.zeros(100), amount)),
scenarios=200_000,
seed=1,
tail_levels=[0.999],
)
tail = sim.tails[0]
round(tail.value_at_risk), round(tail.expected_shortfall) # (10800000, 13137313)
round(large_portfolio_loss(amount, pd, 0.20, 0.999)) # 10184076: the infinitely fine limit
# The same portfolio in CreditRisk+, with the sector volatility that matches the 20% correlation
sigma = sigma_from_asset_correlation(0.02, 0.20) # 1.323
res = creditriskplus(amount, pd, np.ones((100, 1)), [sigma], confidence=[0.999])
round(res.tails[0].value_at_risk), round(res.tails[0].expected_shortfall) # (9900000, 11375006)
For AI agents (MCP)
eqlx-portfolio mcp --root /path/to/folder
Client configuration (Claude Desktop, Claude Code and other MCP clients):
{"mcpServers": {"eqlx-portfolio": {"command": "eqlx-portfolio", "args": ["mcp", "--root", "/path/to/folder"]}}}
| Tool | What it returns |
|---|---|
list_files |
loan and settings files in the folder |
describe_loans |
loans, borrowers, sectors, exposure, expected loss, largest borrowers |
portfolio_risk |
expected loss, value-at-risk, expected shortfall, economic capital, concentration, steps |
risk_contributions |
borrowers ranked by their share of the risk, next to their share of exposure |
what_if_new_loans |
the portfolio before and after new loans, on the same scenarios |
price_new_loan |
hurdle rate and RAROC of a new loan, with capital measured from the book, the Basel formula and a risk weight |
loan_price |
cost components, hurdle rate and RAROC when the capital is already known |
joint_default |
probability that two borrowers default together |
joint_migration |
joint year-end rating probabilities of two borrowers |
exposure_value |
mean and standard deviation of one exposure's value |
large_portfolio_benchmark |
loss rate of a perfectly fine-grained portfolio in a bad year |
The server reads .csv and .json files inside --root and nothing else; .. and symlinks cannot
leave the folder. It writes nothing and sends nothing anywhere. When an input is refused the agent
gets the reason, not a bare error.
Input
Loan file (CSV), one row per loan:
| Column | Meaning |
|---|---|
loan_id |
unique |
exposure |
amount at risk if the borrower defaults |
pd |
default probability over the horizon, at least 0 and below 1 |
lgd |
share of the exposure lost in default, 0 to 1 |
borrower_id |
optional; loans of one borrower default together |
sector |
optional; needed for the sector correlation model |
lgd_sd |
optional; makes the loss in default uncertain (simulation only) |
Settings (JSON). The correlation block is required: it is the main assumption of a run.
{
"method": "simulation",
"confidence": [0.99, 0.999],
"scenarios": 200000,
"seed": 1,
"correlation": {
"model": "sector",
"within": {"construction": 0.24, "energy": 0.18},
"default_within": 0.15,
"default_between": 0.5,
"between": {"construction|energy": 0.3}
}
}
| Correlation model | Meaning |
|---|---|
single |
one common factor; rho is the asset correlation of any two borrowers |
basel_corporate |
one common factor; each borrower's correlation from the Basel corporate formula (12% to 24% by pd) |
sector |
one factor per sector; within a sector, and between the sector factors |
"method": "creditriskplus" solves the portfolio in closed form instead. It accepts the single
and sector models with independent sectors; the sector volatility is derived from the asset
correlation unless given in "creditriskplus": {"sector_sigma": {...}}.
How it is verified
Published figures, from the CreditMetrics Technical Document (J.P. Morgan, 1997):
| Example | Published | Reproduced |
|---|---|---|
| Value of a BBB bond after one year: mean, standard deviation (Table 2.5) | 107.09, 2.99 | 107.09, 2.99 |
| The same with uncertain recovery | 3.18 | 3.18 |
| Revaluation of the bond in each rating from the forward curves (Tables 1.2, 1.4) | 16 values | within 0.02 |
| Asset return thresholds of BB and single-A borrowers (Tables 8.5, 8.6) | 14 values | all 14 |
| Joint migration of two borrowers, 0.30 asset correlation (Table 3.2) | 64 cells | within 0.01 point |
| Joint migration of two borrowers, 0.20 asset correlation (Table 8.7) | 64 cells | within 0.05 point |
| Scenarios of asset returns mapped to ratings (Tables 10.4, 10.5) | 30 ratings | all 30 |
| First percentile of a two-bond portfolio | 204.40 | 204.40 |
| Three-bond portfolio: means and variances (chapter 9) | 7 values | all 7 |
Published figures, from the CreditRisk+ document (Credit Suisse First Boston, 1997), example 1A, 25 obligors in one sector:
| Figure | Published | Reproduced |
|---|---|---|
| Aggregate exposure, expected loss | 130,513,072 and 14,221,863 | the same |
| Standard deviation | 12,668,742 | 12,613,315 (0.4% lower) |
| 99th percentile loss | 55,311,503 | 55,240,000 (0.1% lower) |
| 99.9th percentile loss | 77,133,478 | 76,990,000 (0.2% lower) |
The document's spreadsheet groups exposures into bands by a rounding rule it does not spell out; the small gaps are that rounding.
Three figures printed in the CreditMetrics document do not follow from its own tables (a misprinted probability, a misprinted threshold, and covariances that the model cannot produce). The tests show where each comes from; see LIMITS.md.
Independent truth for everything else:
- the bivariate normal distribution against SciPy, to 12 decimals;
- simulated mean and standard deviation against exact pairwise formulas;
- a fine-grained portfolio against the large-portfolio formula behind the Basel risk weights;
- CreditRisk+ against the negative binomial and Poisson closed forms (to 14 decimals) and against a brute-force simulation of the same model, including every borrower's risk contribution;
- risk contributions add up to the expected shortfall, in both methods;
- a what-if run reproduces, to the last digit, the separate run of the portfolio without the new loans, also when the new loan brings a new sector or an uncertain loss given default;
- loan pricing against the closed form of the scheme it follows (Engelmann and Pham 2020, eq. 28), a one-year loan worked by hand, and the identity that RAROC at the hurdle rate is the target;
- every defect found by an independent review is pinned by a regression test.
188 tests. uv run pytest runs them in two to three minutes.
Limits
One period, default mode in portfolio runs, inputs are yours, the far tail of a simulation is thin, CreditRisk+ sectors are independent. The hurdle rate is a break-even point, not a recommended price, and loan pricing is verified by formula, not by published tables. No estimation of correlations, no granularity adjustment, no stress scenarios, not regulatory capital. Details in LIMITS.md.
References
- Gupton, G. M., Finger, C. C., Bhatia, M. (1997). CreditMetrics - Technical Document. J.P. Morgan.
- Credit Suisse Financial Products (1997). CreditRisk+: A Credit Risk Management Framework.
- Vasicek, O. (2002). The distribution of loan portfolio value. Risk, December.
- Gordy, M. B. (2000). A comparative anatomy of credit risk models. Journal of Banking & Finance 24.
- Gordy, M. B. (2003). A risk-factor model foundation for ratings-based bank capital rules. Journal of Financial Intermediation 12.
- Engelmann, B., Pham, H. (2020). A RAROC valuation scheme for loans and its application in loan origination. Risks 8(2), 63.
- European Banking Authority (2020). Guidelines on loan origination and monitoring (EBA/GL/2020/06), section 6.
- Haaf, H., Tasche, D. (2002). Calculating value-at-risk contributions in CreditRisk+. GARP Risk Review 7.
- Haaf, H., Reiss, O., Schoenmakers, J. (2004). Numerically stable computation of CreditRisk+. In CreditRisk+ in the Banking Industry, Springer.
- Basel Committee on Banking Supervision (2005). An Explanatory Note on the Basel II IRB Risk Weight Functions.
- Drezner, Z., Wesolowsky, G. O. (1990). On the computation of the bivariate normal integral. Journal of Statistical Computation and Simulation 35.
License
Metadata
Release files for eqlx-portfolio 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 | Provenance | Actions |
|---|---|---|---|---|
| eqlx_portfolio-0.2.0.tar.gz | 57.3 kB | With provenance | Details for eqlx_portfolio-0.2.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| eqlx_portfolio-0.2.0-py3-none-any.whl | Python 3 | none | any | Details for eqlx_portfolio-0.2.0-py3-none-any.whl |
Total release size: 117.9 kB
Release files / eqlx_portfolio-0.2.0.tar.gz
| Download URL | eqlx_portfolio-0.2.0.tar.gz |
|---|---|
| Size | 57.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
d18ae3aa9896e9856f40e3d7e44d23499ad02c6e1acc5c8240bbd81a3e72fd6e
|
|
BLAKE2b-256 checksum How to use checksums |
8c13e782a69e7ab3114669e709169c1c2818ba7c90bba9fbde4cbdfff813321b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
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 Nov 12, 2026.
Transparency logRelease files / eqlx_portfolio-0.2.0-py3-none-any.whl
| Download URL | eqlx_portfolio-0.2.0-py3-none-any.whl |
|---|---|
| Size | 60.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
281f8fb9d27a97a4aa79eafcec25742227980d1abe4763670723312b3fd5927d
|
|
BLAKE2b-256 checksum How to use checksums |
cf1bd063a97eb72353cd1dbad535d93288c6fefbf430abbab33e4de411f8dc13
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
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 Nov 12, 2026.
Transparency logSecurity for eqlx-portfolio 0.2.0
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.
Full provenance
Both files in this release have been uploaded with provenance metadata.
Attestation for eqlx_portfolio-0.2.0.tar.gz
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 Nov 12, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.