Credit loan pool (level-pay)
Outputs below are real: the notebook runs against the
creditpack's benchmark model, which CFDL validates against an independent reference. To run it yourself, see the Python SDK guide.
A homogeneous level-pay loan pool with CPR prepayments, CDR defaults, loss severity, a recovery lag, a servicing strip and prepayment penalties — priced at a discount to par.
This notebook uses one of the benchmark models, which CFDL validates against an independent reference to the penny.
# On Colab, install the SDK and fetch the models this notebook reads.
# Inside a checkout both are already present and this cell does nothing.
import subprocess, sys
from pathlib import Path
REPO = "https://github.com/cfdl-dev/cfdl"
def repo_root() -> Path:
"""The checkout holding benchmarks/ and packs/, cloning it if need be.
Searching a bounded set of ancestors means a plain `python` run outside a
checkout fails with an explanation rather than walking to the filesystem
root. On a hosted runtime there is no checkout to find, so fetch one.
"""
here = Path.cwd().resolve()
for candidate in (here, *here.parents):
if (candidate / "Cargo.toml").exists() and (candidate / "packs").is_dir():
return candidate
if "google.colab" not in sys.modules:
raise RuntimeError(
f"No CFDL checkout found above {here}. This notebook reads a model "
f"from benchmarks/ and pack definitions from packs/, so run it "
f"inside a clone of {REPO}."
)
subprocess.run([sys.executable, "-m", "pip", "install", "-q", "cfdl-sdk[viz]"], check=True)
# Packs and benchmark models track the engine, so take the checkout at the
# tag matching the wheel pip just resolved. `main` runs ahead of the last
# release and its packs may use metric ops the released engine rejects.
import importlib
from importlib.metadata import PackageNotFoundError, version
importlib.invalidate_caches()
try:
tag = f"v{version('cfdl-sdk')}"
except PackageNotFoundError:
tag = None
clone = ["git", "clone", "--depth", "1", "-q", REPO]
target = Path("/content/cfdl")
if not target.exists():
pinned = tag is not None and not subprocess.run(
clone + ["--branch", tag, str(target)]
).returncode
if not pinned:
# A dev or pre-release wheel has no matching tag; main is the best
# available, and the notebook may fail if the two have diverged.
print(f"warning: no {tag} tag for this SDK build; falling back to main.")
subprocess.run(clone + [str(target)], check=True)
return target
ROOT = repo_root()
PACKS = ROOT / "packs"
import cfdl_sdkCompile
Compile the model directory to IR.
model_dir = ROOT / "benchmarks/credit/level_pay_pool"
model = cfdl_sdk.compile(model_dir, packs_dir=PACKS)
print("streams:", len(model.ir["streams"]))streams: 10
Run
Run with the benchmark's configuration and apply the credit pack's domain metrics.
results = model.run(
config=str(model_dir / "run.json"),
pack="credit",
)
print("status:", results.status, "| warnings:", len(results.warnings))status: ok | warnings: 0
Cash flows
The engine returns per-period signed cash flows; cashflows() gives a wide DataFrame indexed by period.
cf = results.cashflows()
print('shape:', cf.shape)
cf.head()shape: (126, 28)
account.asset.buyer.balance account.asset.buyer.balance.auto_a \
period
2026-01 2.463766e+07 2.463766e+07
2026-02 2.427892e+07 2.427892e+07
2026-03 2.392374e+07 2.392374e+07
2026-04 2.357208e+07 2.357208e+07
2026-05 2.322393e+07 2.322393e+07
asset.buyer.credit_loan_balance_lag_auto_a asset.buyer.credit_loan_payment_auto_a \
period
2026-01 25000000.0 283869.943050
2026-02 25000000.0 281426.809237
2026-03 25000000.0 279004.702317
2026-04 25000000.0 276603.441321
2026-05 25000000.0 274222.846838
asset.buyer.credit_loan_payment_lag_auto_a domain.credit.balance_outstanding \
period
2026-01 283869.94305 2.463766e+07
2026-02 283869.94305 2.427892e+07
2026-03 283869.94305 2.392374e+07
2026-04 283869.94305 2.357208e+07
2026-05 283869.94305 2.322393e+07
domain.credit.gross_collections domain.credit.net_collections \
period
2026-01 457194.867943 446795.723595
2026-02 452225.142236 441976.718401
2026-03 447301.537920 437202.339282
2026-04 442423.647210 432472.191964
2026-05 437591.065867 427785.885597
domain.credit.net_interest_collections domain.credit.original_balance ... \
period ...
2026-01 124789.732181 25000000.0 ...
2026-02 122981.086013 25000000.0 ...
2026-03 121190.383650 25000000.0 ...
2026-04 119417.462962 25000000.0 ...
2026-05 117662.163243 25000000.0 ...
stream.credit.loan.defaults.auto_a stream.credit.loan.dilution.auto_a \
period
2026-01 -42053.563818 0.0
2026-02 -41444.058407 0.0
2026-03 -40840.599975 0.0
2026-04 -40243.133886 0.0
2026-05 -39651.605981 0.0
stream.credit.loan.interest.auto_a stream.credit.loan.penalty.auto_a \
period
2026-01 135188.876529 1720.816184
2026-02 133229.509847 1695.759733
2026-03 131289.582287 1670.952092
2026-04 129368.918209 1646.391013
2026-05 127467.343513 1622.074267
stream.credit.loan.prepay.auto_a stream.credit.loan.recoveries.auto_a \
period
2026-01 172081.618419 0.0
2026-02 169575.973277 0.0
2026-03 167095.209193 0.0
2026-04 164639.101296 0.0
2026-05 162207.426682 0.0
stream.credit.loan.sched_principal.auto_a stream.credit.loan.servicing.auto_a \
period
2026-01 148203.556810 -10399.144348
2026-02 147723.899379 -10248.423834
2026-03 147245.794348 -10099.198637
2026-04 146769.236692 -9951.455247
2026-05 146294.221405 -9805.180270
stream.credit.loan.zero_balance.auto_a stream.credit.purchase.price.auto_a
period
2026-01 0.0 -24750000.0
2026-02 0.0 0.0
2026-03 0.0 0.0
2026-04 0.0 0.0
2026-05 0.0 0.0
[5 rows x 28 columns]
# Requires the [viz] extra (pip install cfdl-sdk[viz]).
results.plot.cumulative()<Axes: >

The pack's charts
The run publishes the charts its pack declares, each resolved by the engine: the series it sums, how its periods fold into a year, its unit. results.plot.chart draws one as the playground does, in the site's colors.
results.charts()[["id", "label", "kind", "origin"]] id label kind origin
0 collections Collections columns pack
1 pool_balance Pool balance lines pack
2 pool_factor Pool factor lines pack
3 capital_structure Capital structure stack pack
4 returns Returns bridge pack
5 available_funds Gross collections to available funds bridge pack
results.plot.chart() # the pack's default chart<Axes: title={'left': 'Collections'}>

# The pool's balance outstanding: a level, so a year shows its last month.
results.plot.chart("pool_balance", grain="annual")<Axes: title={'left': 'Pool balance'}>

Metrics
Core metrics (NPV/IRR/MOIC/...) plus the pack's domain metrics, with their source labeled.
results.metrics_frame() metric value currency source
0 domain.credit.collections 3.072748e+07 USD domain:credit
1 domain.credit.collections_multiple 1.241514e+00 domain:credit
2 domain.credit.interest 6.499895e+06 USD domain:credit
3 domain.credit.penalties 8.220768e+04 USD domain:credit
4 domain.credit.principal 2.297806e+07 USD domain:credit
5 domain.credit.purchase 2.475000e+07 USD domain:credit
6 domain.credit.recoveries 1.167320e+06 USD domain:credit
7 domain.credit.servicing 4.999919e+05 USD domain:credit
8 domain.credit.wal_years 4.056967e+00 domain:credit
9 entity.asset.buyer.total 5.477491e+06 USD core
10 metric.moic 1.225381e+00 core
11 model.irr 5.630900e-02 core
12 model.npv -2.959752e+05 USD core
13 model.payback_periods 8.000000e+01 core
14 model.payback_years 6.750000e+00 core
15 model.total 5.477491e+06 USD core
16 model.wal_years 3.843940e+00 core
17 run.annual_discount_rate 6.000000e-02 core
18 run.periods_per_year 1.200000e+01 core
19 stream.credit.loan.defaults.auto_a.t... -2.021940e+06 USD core
20 stream.credit.loan.dilution.auto_a.t... 0.000000e+00 USD core
21 stream.credit.loan.interest.auto_a.t... 6.499895e+06 USD core
22 stream.credit.loan.penalty.auto_a.total 8.220768e+04 USD core
23 stream.credit.loan.prepay.auto_a.total 8.220768e+06 USD core
24 stream.credit.loan.recoveries.auto_a... 1.167320e+06 USD core
25 stream.credit.loan.sched_principal.a... 1.475729e+07 USD core
26 stream.credit.loan.servicing.auto_a.... -4.999919e+05 USD core
27 stream.credit.loan.zero_balance.auto... 0.000000e+00 USD core
28 stream.credit.purchase.price.auto_a.... -2.475000e+07 USD core
What-if
Show the collections multiple and the principal-weighted WAL.
m = results.metrics()
print("collections multiple:", round(m["domain.credit.collections_multiple"], 4))
print("WAL (years):", round(m["domain.credit.wal_years"], 3))collections multiple: 1.2415
WAL (years): 4.057
The record behind the numbers
Every period of the run is recorded with its reason. The loan's lifecycle holds in current for the whole run, so the stream that pays only once the pool reaches a zero balance never runs: it is out_of_state, and the trace names the state.
results.trace(kind="lifecycle") kind actor from to reason state left edge tested entry
0 lifecycle contract.credit.loan.auto_a 0 125 held current None None [] None
results.trace(kind="stream").query("reason != 'ran'") kind actor from to reason state left edge \
8 stream credit.loan.zero_balance.auto_a 0 125 out_of_state current None None
10 stream credit.purchase.price.auto_a 1 125 after_schedule NaN None None
tested entry
8 [] None
10 [] None